4 Commits
26 changed files with 2632 additions and 457 deletions

No files matched your search

+72 -19
View File
@@ -424,32 +424,85 @@
## 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
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem - reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
## Erweiterte Live-Steuerung 0.5.12
| Kriterium | Stand |
| ---------------------------------------------- | ---------------------------- |
| Gemeinsame persistente Pause | implementiert, nicht geprüft |
| Abschnitts- und Gesamtreset bleiben unabhängig | implementiert, nicht geprüft |
| Gesamt-/Restzeit ohne Doppelerfassung | implementiert, nicht geprüft |
| Ende, Wiederaufnahme und neuer Lauf getrennt | implementiert, nicht geprüft |
| Privates Feld „Interaktion“ durch alle Kopien | implementiert, nicht geprüft |
| Öffentliche Positivliste unverändert | implementiert, nicht geprüft |
| Classic-Regular-Icons und Drawer-Schließen | gezielt im Browser geprüft |
Für die ursprüngliche Erweiterung wurden auf ausdrücklichen Auftrag zunächst
keine Prüfungen ausgeführt. Die nachfolgenden Korrekturen weisen inzwischen
Migration, Typen, Build, bestehendes Liveverhalten und die betroffenen
Oberflächen gezielt nach; die übrigen neuen Timer- und Statusfälle bleiben ohne
vollständige erneute Matrix.
## Live-500-Korrektur 0.5.12
- Ursache auf einer schreibgeschützt untersuchten lokalen Datenbank bestätigt:
`0013` war protokolliert, `live_show_timer_runs` fehlte jedoch.
- `0014_live_show_timer_repair.sql` auf einer Kopie dieser Datenbank angewendet
und erneut ausgeführt; Tabelle und Indizes wurden angelegt, 13 bestehende
Intervalle genau einmal übernommen und ein offenes Intervall erhalten.
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
## Live-Icons und kompakte Sendungskarten 0.5.12
- `npm run check`: 0 Fehler und 0 Warnungen.
- `npm run test:unit -- --run src/lib/server/live.spec.ts`: 6 Tests erfolgreich.
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
- Gezielter Chromium-Ablauf der Sendungsübersicht mit Desktopkalender, mobiler
Agenda und Rückkehr zur Liste: erfolgreich.
- Vollständiger bestehender Live-Regie-Ablauf einschließlich 900 × 450,
670 × 450 und 390 × 700 Pixeln: erfolgreich; Iconleiste, Kopfhöhe,
Viewportgrenzen und horizontaler Überlauf geprüft.
- Zusätzliche visuelle Chromium-Prüfung bei 656 × 400 und 390 × 700 Pixeln:
Sendungskarte unter 130 Pixel hoch, Menü-/Ausgabe-/Timericons sichtbar und
Pausebutton wechselt bedienbar zu Fortsetzen.
+48 -10
View File
@@ -43,7 +43,7 @@ und spätere Bereiche.
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft | | Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots | | Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen | | Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe | | Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnitts-/Gesamttimer und getrennte Präsentationsausgabe |
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | | Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit | | Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen | | RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
@@ -68,6 +68,10 @@ Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt i
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit. selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
Der Gesamttimer speichert ebenfalls mehrere Laufintervalle. Eine gemeinsame
Pause schließt die offenen Piece-, Abschnitts- und Gesamtintervalle; Fortsetzen
öffnet neue Intervalle, ohne die Pause als Laufzeit zu zählen. Abschnitts- und
Gesamtreset ersetzen ausschließlich die jeweilige Zeitbasis.
## Anmeldung und Rechte ## Anmeldung und Rechte
@@ -156,7 +160,7 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt. weitere Bearbeitung geschützt.
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen Interaktion, Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke, Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
@@ -192,6 +196,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
@@ -226,6 +237,15 @@ liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
geplanten Dauer nachvollziehbar. geplanten Dauer nachvollziehbar.
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den
Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene
Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten
und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die
Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die
positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie
zusammen.
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
@@ -236,15 +256,20 @@ rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
erneutes Öffnen behält ihn bei. erneutes Öffnen behält ihn bei.
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen Das private Markdownfeld „Interaktion“ wird am Original und als
Verwendungssnapshot gespeichert und ausschließlich in der authentifizierten
Live-Regie gerendert. Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und 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 +304,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 +321,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
+54 -5
View File
@@ -371,8 +371,57 @@ 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.
## D-026: Gemeinsame Live-Pause und getrennte Zeitbasen
Piece-Laufzeit, Abschnittszeit und Gesamtzeit bleiben getrennte persistente
Intervallmengen. Pause schließt in einer Live-Transaktion alle offenen
Intervalle, Fortsetzen eröffnet sie am gespeicherten Fortschritt erneut. Dadurch
zählt weder Browserwechsel noch eine beliebig lange Pause zur Sendezeit. Ein
Abschnittsreset ersetzt nur den aktiven Abschnittstimer; ein Gesamtreset löscht
nur die Gesamtlaufintervalle. Beide ändern weder Piece-Status noch Reihenfolge,
Restplanung oder Präsentationsstand.
Die Restzeit ist eine Ableitung, kein zweiter Ablaufplan. Für jeden verbleibenden
Abschnitt gilt entweder dessen positive explizite Dauer oder die Summe seiner
verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen,
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
nicht die geplante Zeit kommender Inhalte.
`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
Präsentations-Positivliste.
+85 -22
View File
@@ -1,29 +1,37 @@
# 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
- Ausgabe, Gesamtzeit, Abschnittszeit und Menü tragen in der Live-Kopfleiste
jetzt die passenden lokalen Classic-Regular-Icons. Pause beziehungsweise
Fortsetzen ist zusätzlich als direkter Iconbutton neben der Abschnittszeit
erreichbar.
- 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 +44,36 @@ 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
- Pause und Fortsetzen steuern Piece-, Abschnitts- und Gesamtzeit gemeinsam und
zählen Pausen auch nach Reload oder Navigation nicht mit.
- Gelaufene Gesamtzeit und geschätzte Restzeit lassen sich direkt umschalten;
die Auswahl bleibt lokal erhalten. Ungeplante verbleibende Abschnitte sind
sichtbar, ohne Abschnitts- und Piece-Dauern doppelt zu zählen.
- Abschnitts- und Gesamtzeit lassen sich getrennt nach Bestätigung zurücksetzen.
Ablaufposition, Piece-Status, Restplanung und Präsentation bleiben dabei
unverändert.
- Sendungen lassen sich ausdrücklich vorbereiten, starten, pausieren und
beenden. Nach dem Ende sind „Lauf fortsetzen“ und „Neuer Durchlauf“ getrennte
Aktionen; offene Pieces werden beim Beenden nicht automatisch erledigt.
- 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 +83,61 @@ 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.
### Interaktion
- „Interaktion“ ergänzt Piece und Sendungsfassung als optionales privates
Markdownfeld zwischen Moderationsstichpunkten und Überleitung.
- Vorlagen, Duplikate, Einplanungen und die bewusste Aktualisierung aus dem
Fundus übernehmen das Feld jeweils als unabhängigen Snapshot.
- Die private Live-Regie zeigt das Feld nur bei Inhalt. Die öffentliche
heinzlive-Ausgabe übernimmt es nicht.
### Sendungsübersicht
- Sendungskarten bleiben auf kleinen Bildschirmen kompakt: Status, Titel und
Termin stehen neben den horizontal angeordneten Abschnitts- und Zeitangaben,
statt die Karte durch vollständiges Stapeln unnötig zu erhöhen.
- „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`.
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und - Es ist keine Änderung der Konfiguration erforderlich.
- `0013_live_controls_and_interaction.sql` ergänzt das private Interaktionsfeld
und persistente Gesamtzeitintervalle. Bestehende Piece-Laufintervalle werden
als Gesamtzeit übernommen.
- Bestehende Sendungen, Livezustände, Abschnittstimer, 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. - Die ergänzte Iconleiste, Pause/Fortsetzen und die kompakte Sendungskarte wurden
- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit gezielt in Chromium bei 670 × 450, 390 × 700 und 656 × 400 Pixeln geprüft.
langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft; Typprüfung, sechs Live-Verhaltenstests und Produktionsbuild sind erfolgreich.
die erzeugten Screenshots wurden visuell kontrolliert. - Die vollständige Matrix wurde für diese Ergänzung nicht erneut ausgeführt; die
folgenden Gesamtnachweise stammen vom vorherigen 0.5.12-Stand.
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
mobilem Ablauf-Sheet geprüft; der Kalender zusätzlich bei 1440 × 1000 und
390 × 844 Pixeln.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät - Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung. standen nicht zur Verfügung.
+52 -9
View File
@@ -7,14 +7,42 @@ 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
- die Live-Kopfleiste zeigt Classic-Regular-Icons für Ausgabe, Gesamtzeit,
Abschnitt, Menü und Ablauf; Pause/Fortsetzen liegt zusätzlich als direkter,
beschrifteter Iconbutton neben der Abschnittszeit
- Sendungskarten ordnen Fakten auf kleinen Bildschirmen wieder horizontal an und
vermeiden dadurch die zuvor unnötig hohe Kartenfläche
- Reparaturmigration `0014_live_show_timer_repair.sql` ergänzt fehlende
Gesamttimertabellen auch dann, wenn `0013` bereits in einem früheren
Zwischenstand protokolliert wurde; der 500-Fehler beim Laden von `/live` ist
damit behoben
- gemeinsame persistente Pause für Piece-, Abschnitts- und Gesamttimer;
Fortsetzen zählt Pausenzeit weder nach Navigation noch nach Reload mit
- getrennte bestätigungspflichtige Resets für Abschnitts- und Gesamtzeit, ohne
Piece-Status, Ablaufposition, Restplanung oder Präsentationsstand zu verändern
- umschaltbare, lokal gemerkte Anzeige von gelaufener Gesamtzeit und geschätzter
Restzeit; Abschnitte ohne eigene Planung werden kenntlich gemacht und
Abschnitts- sowie Piece-Dauern nie doppelt addiert
- explizite Sendungssteuerung für Entwurf, Bereit, Start, Pause, Ende,
Wiederaufnahme eines beendeten Laufs und klar getrennten neuen Durchlauf
- optionales privates Markdownfeld „Interaktion“ zwischen Moderation und
Überleitung; Original, Vorlage, Sendungsfassung, Duplikation und bewusste
Fundus-Aktualisierung führen jeweils unabhängige Snapshots
- „Interaktion“, Timer- und Sendungssteuerung bleiben aus der öffentlichen
heinzlive-Projektion ausgeschlossen
- 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 +50,37 @@ 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 bestehende lokale Prüfmatrix umfasst 44 Verhaltenstests und 17
Chromium-Abläufe. Die nachträgliche Icon-, Pause- und Kartenkorrektur wurde
zusätzlich mit Typprüfung, sechs Live-Verhaltenstests, Produktionsbuild und
gezielten Chromium-Abläufen bei 670 × 450, 390 × 700 und 656 × 400 Pixeln
geprüft. Die vollständige Matrix wurde dafür nicht erneut ausgeführt.
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'));
@@ -0,0 +1,21 @@
ALTER TABLE pieces ADD COLUMN interaction_text TEXT NOT NULL DEFAULT '';
ALTER TABLE piece_usages ADD COLUMN interaction_snapshot TEXT NOT NULL DEFAULT '';
CREATE TABLE live_show_timer_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE UNIQUE INDEX idx_one_open_show_timer_run
ON live_show_timer_runs(show_id)
WHERE ended_at_utc IS NULL;
CREATE INDEX idx_live_show_timer_runs_show
ON live_show_timer_runs(show_id, started_at_utc);
INSERT INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
FROM live_usage_runs;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS live_show_timer_runs (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
started_at_utc TEXT NOT NULL,
ended_at_utc TEXT,
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_show_timer_run
ON live_show_timer_runs(show_id)
WHERE ended_at_utc IS NULL;
CREATE INDEX IF NOT EXISTS idx_live_show_timer_runs_show
ON live_show_timer_runs(show_id, started_at_utc);
INSERT OR IGNORE INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
FROM live_usage_runs;
+668 -125
View File
File diff suppressed because it is too large. Load diff
+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
};
});
}
@@ -108,6 +108,13 @@
rows={10} rows={10}
bind:value={draft.moderation} bind:value={draft.moderation}
/> />
<MarkdownEditorField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField <MarkdownEditorField
name="transition" name="transition"
label="Überleitung" label="Überleitung"
+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);
} }
+30 -10
View File
@@ -46,6 +46,7 @@ export type PieceInput = {
intro: string; intro: string;
researchText: string; researchText: string;
moderationNotes: string; moderationNotes: string;
interactionText?: string;
transitionText: string; transitionText: string;
presentationText: string; presentationText: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -83,7 +84,10 @@ export type PieceListItem = {
isFavorite: boolean; isFavorite: boolean;
}; };
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & { export type PieceEditor = Omit<
PieceInput,
'status' | 'tools' | 'presentationMediaId' | 'interactionText'
> & {
id: string; id: string;
status: PieceStatus; status: PieceStatus;
revision: number; revision: number;
@@ -92,9 +96,12 @@ export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMed
media: PieceMedia[]; media: PieceMedia[];
presentationMediaId: string | null; presentationMediaId: string | null;
isFavorite: boolean; isFavorite: boolean;
interactionText: string;
tools: PieceTool[]; tools: PieceTool[];
}; };
type ValidatedPieceInput = PieceInput;
export type PieceMedia = { export type PieceMedia = {
id: string; id: string;
originalName: string; originalName: string;
@@ -187,7 +194,7 @@ function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories',
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY'); if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
} }
function validatePieceInput(input: PieceInput): PieceInput { function validatePieceInput(input: PieceInput): ValidatedPieceInput {
const title = cleanText(input.title, 200, 'title'); const title = cleanText(input.title, 200, 'title');
if (!title) throw new Error('INVALID_TITLE'); if (!title) throw new Error('INVALID_TITLE');
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS'); if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
@@ -252,6 +259,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
researchText: cleanText(input.researchText, 50_000, 'research'), researchText: cleanText(input.researchText, 50_000, 'research'),
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'), moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
interactionText:
input.interactionText === undefined
? undefined
: cleanText(input.interactionText, 50_000, 'interaction'),
transitionText: cleanText(input.transitionText, 50_000, 'transition'), transitionText: cleanText(input.transitionText, 50_000, 'transition'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'), presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode, presentationMode: input.presentationMode,
@@ -402,6 +413,7 @@ type PieceRow = {
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -428,7 +440,7 @@ export function getPiece(id: string): PieceEditor | null {
const row = database const row = database
.prepare( .prepare(
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text, `SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
moderation_notes, transition_text, presentation_text, presentation_mode, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual, presentation_provider_id, presentation_title_manual, presentation_description_manual,
@@ -452,6 +464,7 @@ export function getPiece(id: string): PieceEditor | null {
intro: row.intro, intro: row.intro,
researchText: row.research_text, researchText: row.research_text,
moderationNotes: row.moderation_notes, moderationNotes: row.moderation_notes,
interactionText: row.interaction_text,
transitionText: row.transition_text, transitionText: row.transition_text,
presentationText: row.presentation_text, presentationText: row.presentation_text,
presentationMode: row.presentation_mode, presentationMode: row.presentation_mode,
@@ -600,7 +613,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const result = database const result = database
.prepare( .prepare(
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?, `UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?, intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?, presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?, presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?, presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
@@ -619,6 +632,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.intro, input.intro,
input.researchText, input.researchText,
input.moderationNotes, input.moderationNotes,
input.interactionText ?? null,
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
input.presentationMode, input.presentationMode,
@@ -650,7 +664,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
return { revision: expectedRevision + 1, updatedAtUtc }; return { revision: expectedRevision + 1, updatedAtUtc };
} }
function blankPieceInput(): PieceInput { function blankPieceInput(): ValidatedPieceInput {
return { return {
title: 'Neues Piece', title: 'Neues Piece',
outputTitle: '', outputTitle: '',
@@ -661,6 +675,7 @@ function blankPieceInput(): PieceInput {
intro: '', intro: '',
researchText: '', researchText: '',
moderationNotes: '', moderationNotes: '',
interactionText: '',
transitionText: '', transitionText: '',
presentationText: '', presentationText: '',
presentationMode: 'text', presentationMode: 'text',
@@ -682,7 +697,7 @@ function blankPieceInput(): PieceInput {
}; };
} }
function templateInput(templateId: string): PieceInput { function templateInput(templateId: string): ValidatedPieceInput {
if (templateId === 'blank') return blankPieceInput(); if (templateId === 'blank') return blankPieceInput();
const row = getDatabase() const row = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?') .prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
@@ -739,8 +754,8 @@ export function createPiece(template = 'blank'): string {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, created_at_utc, research_text, moderation_notes, interaction_text, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .run(
id, id,
@@ -753,6 +768,7 @@ export function createPiece(template = 'blank'): string {
input.intro, input.intro,
input.researchText, input.researchText,
input.moderationNotes, input.moderationNotes,
input.interactionText ?? '',
input.transitionText, input.transitionText,
input.presentationText, input.presentationText,
now, now,
@@ -902,13 +918,16 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database database
.prepare( .prepare(
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro, `INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
research_text, moderation_notes, transition_text, presentation_text, presentation_mode, research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
presentation_source_position, presentation_title, presentation_description, presentation_source_position, presentation_title, presentation_description,
presentation_image_url, presentation_site_name, presentation_provider, presentation_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual, presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision) presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)`
) )
.run( .run(
id, id,
@@ -920,6 +939,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.intro, source.intro,
source.researchText, source.researchText,
source.moderationNotes, source.moderationNotes,
source.interactionText,
source.transitionText, source.transitionText,
source.presentationText, source.presentationText,
source.presentationMode, source.presentationMode,
+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,
+411 -17
View File
@@ -11,6 +11,7 @@ import {
movePieceUsage, movePieceUsage,
type MovePieceUsageInput, type MovePieceUsageInput,
type PieceUsage, type PieceUsage,
type ShowStatus,
type ShowSection type ShowSection
} from './shows.js'; } from './shows.js';
@@ -41,6 +42,7 @@ export type LiveUsage = PieceUsage & {
introHtml: string; introHtml: string;
researchHtml: string; researchHtml: string;
moderationHtml: string; moderationHtml: string;
interactionHtml: string;
transitionHtml: string; transitionHtml: string;
}; };
@@ -55,7 +57,10 @@ export type LiveView = {
totalDurationSeconds: number; totalDurationSeconds: number;
}; };
liveRevision: number; liveRevision: number;
calculatedAtUtc: string;
startedAtUtc: string | null; startedAtUtc: string | null;
endedAtUtc: string | null;
isPaused: boolean;
updatedAtUtc: string; updatedAtUtc: string;
presentationUsageId: string | null; presentationUsageId: string | null;
presentationRevision: number; presentationRevision: number;
@@ -72,6 +77,15 @@ export type LiveView = {
elapsedSeconds: number; elapsedSeconds: number;
runningSinceUtc: string | null; runningSinceUtc: string | null;
}; };
totalTimer: {
elapsedSeconds: number;
runningSinceUtc: string | null;
};
remainingEstimate: {
seconds: number;
activeCountdownSeconds: number;
unplannedSectionCount: number;
};
}; };
export type PresentationView = { export type PresentationView = {
@@ -152,6 +166,55 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
.run(now, showId); .run(now, showId);
} }
function closeShowTimerRun(database: Database.Database, showId: string, now: string) {
database
.prepare(
`UPDATE live_show_timer_runs SET ended_at_utc = ?
WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, showId);
}
function openShowTimerRun(database: Database.Database, showId: string, now: string) {
const active = database
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId);
if (!active) {
database
.prepare(
`INSERT INTO live_show_timer_runs (id, show_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), showId, now);
}
}
function showTimerRunning(database: Database.Database, showId: string) {
return Boolean(
database
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId)
);
}
function elapsedRuns(
runs: { started_at_utc: string; ended_at_utc: string | null }[],
now: string
) {
return runs.reduce(
(total, run) =>
total +
Math.max(
0,
Math.floor(
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
1_000
)
),
0
);
}
function closeSectionTimer(database: Database.Database, showId: string, now: string) { function closeSectionTimer(database: Database.Database, showId: string, now: string) {
const timer = database const timer = database
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL') .prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
@@ -167,6 +230,26 @@ function closeSectionTimer(database: Database.Database, showId: string, now: str
.run(now, timer.id); .run(now, timer.id);
} }
function resumeSectionTimerRun(database: Database.Database, showId: string, now: string) {
const timer = database
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId) as { id: string } | undefined;
if (!timer) return;
const running = database
.prepare(
'SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL'
)
.get(timer.id);
if (!running) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timer.id, now);
}
}
function syncSectionTimer( function syncSectionTimer(
database: Database.Database, database: Database.Database,
showId: string, showId: string,
@@ -237,7 +320,8 @@ function executeCommand(
expectedRevision: number, expectedRevision: number,
action: string, action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void, operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date nowValue?: Date,
allowCompleted = false
) { ) {
requireCommandId(commandId); requireCommandId(commandId);
const database = getDatabase(); const database = getDatabase();
@@ -251,7 +335,7 @@ function executeCommand(
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as { const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
status: string; status: string;
}; };
if (show.status === 'completed') throw new Error('SHOW_COMPLETED'); if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
if (state.revision !== expectedRevision) throw new Error('CONFLICT'); if (state.revision !== expectedRevision) throw new Error('CONFLICT');
operation(database, state, now); operation(database, state, now);
const updated = database const updated = database
@@ -307,6 +391,7 @@ function liveUsage(
introHtml: renderMarkdown(usage.intro), introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research), researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation), moderationHtml: renderMarkdown(usage.moderation),
interactionHtml: renderMarkdown(usage.interaction),
transitionHtml: renderMarkdown(usage.transition) transitionHtml: renderMarkdown(usage.transition)
}; };
} }
@@ -351,18 +436,7 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
) )
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[]; .all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
const running = runs.find((run) => run.ended_at_utc === null) ?? null; const running = runs.find((run) => run.ended_at_utc === null) ?? null;
const elapsedSeconds = runs.reduce( const elapsedSeconds = elapsedRuns(runs, now);
(total, run) =>
total +
Math.max(
0,
Math.floor(
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
1_000
)
),
0
);
sectionTimer = { sectionTimer = {
sectionId: timer.section_id, sectionId: timer.section_id,
sectionName: timer.section_name, sectionName: timer.section_name,
@@ -371,6 +445,50 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
runningSinceUtc: running?.started_at_utc ?? null runningSinceUtc: running?.started_at_utc ?? null
}; };
} }
const totalTimerRuns = database
.prepare(
`SELECT started_at_utc, ended_at_utc FROM live_show_timer_runs
WHERE show_id = ? ORDER BY started_at_utc`
)
.all(showId) as { started_at_utc: string; ended_at_utc: string | null }[];
const runningTotalTimer = totalTimerRuns.find((run) => run.ended_at_utc === null) ?? null;
const totalElapsedSeconds = elapsedRuns(totalTimerRuns, now);
const remainingAnchor = current ?? next;
let reachedRemaining = !remainingAnchor;
let remainingSeconds = 0;
let activeCountdownSeconds = 0;
let unplannedSectionCount = 0;
for (const section of sections) {
if (section.id === remainingAnchor?.sectionId) reachedRemaining = true;
if (!reachedRemaining) continue;
const remainingUsages = section.usages.filter(
(usage) => usage.status === 'open' || usage.status === 'current'
);
if (remainingUsages.length === 0) continue;
if (section.id === current?.sectionId) {
if (section.durationSeconds > 0 && sectionTimer?.sectionId === section.id) {
activeCountdownSeconds = Math.max(0, section.durationSeconds - sectionTimer.elapsedSeconds);
remainingSeconds += activeCountdownSeconds;
} else {
remainingSeconds += remainingUsages.reduce((total, usage) => {
const elapsed = usage.id === current?.id ? usage.actualDurationSeconds : 0;
const remainder = Math.max(0, usage.durationSeconds - elapsed);
if (usage.id === current?.id) activeCountdownSeconds = remainder;
return total + remainder;
}, 0);
if (section.durationSeconds <= 0) unplannedSectionCount += 1;
}
} else if (section.durationSeconds > 0) {
remainingSeconds += section.durationSeconds;
} else {
remainingSeconds += remainingUsages.reduce(
(total, usage) => total + usage.durationSeconds,
0
);
unplannedSectionCount += 1;
}
}
return { return {
show: { show: {
id: show.id, id: show.id,
@@ -380,7 +498,10 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
totalDurationSeconds: show.totalDurationSeconds totalDurationSeconds: show.totalDurationSeconds
}, },
liveRevision: state.revision, liveRevision: state.revision,
calculatedAtUtc: now,
startedAtUtc: state.started_at_utc, startedAtUtc: state.started_at_utc,
endedAtUtc: state.ended_at_utc,
isPaused: Boolean(state.started_at_utc && !state.ended_at_utc && !runningTotalTimer),
updatedAtUtc: state.updated_at_utc, updatedAtUtc: state.updated_at_utc,
presentationUsageId: state.presentation_usage_id, presentationUsageId: state.presentation_usage_id,
presentationRevision: state.presentation_revision, presentationRevision: state.presentation_revision,
@@ -391,7 +512,16 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped') completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
.length, .length,
totalCount: usages.length, totalCount: usages.length,
sectionTimer sectionTimer,
totalTimer: {
elapsedSeconds: totalElapsedSeconds,
runningSinceUtc: runningTotalTimer?.started_at_utc ?? null
},
remainingEstimate: {
seconds: remainingSeconds,
activeCountdownSeconds,
unplannedSectionCount
}
}; };
} }
@@ -409,7 +539,7 @@ export function startOrResumeLive(
(database, state, now) => { (database, state, now) => {
database database
.prepare( .prepare(
`UPDATE shows SET status = 'live', updated_at_utc = ? `UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ?
WHERE id = ? AND status != 'completed'` WHERE id = ? AND status != 'completed'`
) )
.run(now, showId); .run(now, showId);
@@ -419,10 +549,14 @@ export function startOrResumeLive(
WHERE show_id = ?` WHERE show_id = ?`
) )
.run(now, showId); .run(now, showId);
openShowTimerRun(database, showId, now);
const activeRun = database const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL') .prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.get(showId); .get(showId);
if (state.current_usage_id && activeRun) return; if (state.current_usage_id && activeRun) {
resumeSectionTimerRun(database, showId, now);
return;
}
const current = state.current_usage_id const current = state.current_usage_id
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id) ? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: undefined; : undefined;
@@ -434,6 +568,263 @@ export function startOrResumeLive(
); );
} }
export function pauseLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'pause',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (!showTimerRunning(database, showId)) throw new Error('ALREADY_PAUSED');
closeShowTimerRun(database, showId, now);
closeActiveRun(database, showId, now);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
) AND ended_at_utc IS NULL`
)
.run(now, showId);
},
nowValue
);
}
export function resumeLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
if (showTimerRunning(database, showId)) throw new Error('ALREADY_RUNNING');
openShowTimerRun(database, showId, now);
if (state.current_usage_id) {
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, state.current_usage_id, now);
}
resumeSectionTimerRun(database, showId, now);
},
nowValue
);
}
export function resetSectionTimer(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'reset-section-timer',
(database, _state, now) => {
const timer = database
.prepare(
`SELECT section_id, planned_duration_seconds
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
)
.get(showId) as
| { section_id: string; planned_duration_seconds: number }
| undefined;
if (!timer) throw new Error('NO_SECTION_TIMER');
const running = showTimerRunning(database, showId);
closeSectionTimer(database, showId, now);
const timerId = randomUUID();
database
.prepare(
`INSERT INTO live_section_timers
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(timerId, showId, timer.section_id, timer.planned_duration_seconds, now);
if (running) {
database
.prepare(
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
VALUES (?, ?, ?)`
)
.run(randomUUID(), timerId, now);
}
},
nowValue
);
}
export function resetTotalTimer(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'reset-total-timer',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
const running = showTimerRunning(database, showId);
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
if (running) openShowTimerRun(database, showId, now);
},
nowValue
);
}
export function setShowStatus(
showId: string,
commandId: string,
expectedRevision: number,
expectedShowRevision: number,
status: Extract<ShowStatus, 'draft' | 'ready'>,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
`status-${status}`,
(database, state, now) => {
if (state.started_at_utc) throw new Error('ALREADY_STARTED');
const result = database
.prepare(
'UPDATE shows SET status = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?'
)
.run(status, now, showId, expectedShowRevision);
if (result.changes !== 1) throw new Error('CONFLICT');
},
nowValue
);
}
export function endLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'end',
(database, state, now) => {
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
closeShowTimerRun(database, showId, now);
closeActiveRun(database, showId, now);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
) AND ended_at_utc IS NULL`
)
.run(now, showId);
database
.prepare(
`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`
)
.run(now, showId);
database
.prepare(`UPDATE shows SET status = 'completed', revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
.run(now, showId);
},
nowValue
);
}
export function resumeEndedLive(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'resume-ended',
(database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
database
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
.run(showId);
openShowTimerRun(database, showId, now);
if (state.current_usage_id) {
database
.prepare(
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), showId, state.current_usage_id, now);
resumeSectionTimerRun(database, showId, now);
}
},
nowValue,
true
);
}
export function startNewLiveRun(
showId: string,
commandId: string,
expectedRevision: number,
nowValue?: Date
) {
return executeCommand(
showId,
commandId,
expectedRevision,
'start-new-run',
(database, state, now) => {
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
database.prepare('DELETE FROM live_usage_runs WHERE show_id = ?').run(showId);
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
database.prepare('DELETE FROM live_section_timers WHERE show_id = ?').run(showId);
database
.prepare(
`UPDATE piece_usages SET status = 'open'
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
)
.run(showId);
database
.prepare(
`UPDATE show_live_states
SET current_usage_id = NULL, started_at_utc = ?, ended_at_utc = NULL
WHERE show_id = ?`
)
.run(now, showId);
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
openShowTimerRun(database, showId, now);
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
if (target) activateUsage(database, showId, target.id, now);
},
nowValue,
true
);
}
function advanceCurrent( function advanceCurrent(
showId: string, showId: string,
commandId: string, commandId: string,
@@ -447,6 +838,7 @@ function advanceCurrent(
expectedRevision, expectedRevision,
status, status,
(database, state, now) => { (database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
if (!state.current_usage_id) throw new Error('NO_CURRENT'); if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId); const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id); const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
@@ -498,6 +890,7 @@ export function goBack(
expectedRevision, expectedRevision,
'back', 'back',
(database, state, now) => { (database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const ordered = orderedUsages(database, showId); const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id) ? ordered.findIndex((usage) => usage.id === state.current_usage_id)
@@ -533,6 +926,7 @@ export function selectUsage(
'select', 'select',
(database, state, now) => { (database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED'); if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId); usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return; if (state.current_usage_id === usageId) return;
if (state.current_usage_id) { if (state.current_usage_id) {
+22 -10
View File
@@ -47,6 +47,7 @@ export type PieceUsage = {
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
interaction: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -157,6 +158,7 @@ export type PieceUsageUpdate = {
intro: string; intro: string;
research: string; research: string;
moderation: string; moderation: string;
interaction?: string;
transition: string; transition: string;
presentation: string; presentation: string;
presentationMode: PresentationMode; presentationMode: PresentationMode;
@@ -216,6 +218,7 @@ type UsageRow = {
intro_snapshot: string; intro_snapshot: string;
research_snapshot: string; research_snapshot: string;
moderation_snapshot: string; moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string; transition_snapshot: string;
presentation_snapshot: string; presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode; presentation_mode_snapshot: PresentationMode;
@@ -445,6 +448,7 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
intro: row.intro_snapshot, intro: row.intro_snapshot,
research: row.research_snapshot, research: row.research_snapshot,
moderation: row.moderation_snapshot, moderation: row.moderation_snapshot,
interaction: row.interaction_snapshot,
transition: row.transition_snapshot, transition: row.transition_snapshot,
presentation: row.presentation_snapshot, presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot, presentationMode: row.presentation_mode_snapshot,
@@ -793,7 +797,7 @@ export function getShow(id: string): Show | null {
.prepare( .prepare(
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot, u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
u.presentation_mode_snapshot, u.presentation_source_label_snapshot, u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot, u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot, u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -1126,7 +1130,7 @@ export function addPieceUsage(
const piece = database const piece = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
p.transition_text, p.presentation_text, p.status, p.presentation_mode, p.interaction_text, p.transition_text, p.presentation_text, p.status, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -1143,6 +1147,7 @@ export function addPieceUsage(
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -1178,13 +1183,13 @@ export function addPieceUsage(
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
) )
.run( .run(
id, id,
@@ -1197,6 +1202,7 @@ export function addPieceUsage(
piece.intro, piece.intro,
piece.research_text, piece.research_text,
piece.moderation_notes, piece.moderation_notes,
piece.interaction_text,
piece.transition_text, piece.transition_text,
piece.presentation_text, piece.presentation_text,
piece.presentation_mode, piece.presentation_mode,
@@ -1284,7 +1290,7 @@ export function duplicatePieceUsage(
const source = database const source = database
.prepare( .prepare(
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, `SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
@@ -1307,13 +1313,13 @@ export function duplicatePieceUsage(
.prepare( .prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, transition_snapshot, presentation_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot, presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve) presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
id, id,
@@ -1326,6 +1332,7 @@ export function duplicatePieceUsage(
source.intro_snapshot, source.intro_snapshot,
source.research_snapshot, source.research_snapshot,
source.moderation_snapshot, source.moderation_snapshot,
source.interaction_snapshot,
source.transition_snapshot, source.transition_snapshot,
source.presentation_snapshot, source.presentation_snapshot,
source.presentation_mode_snapshot, source.presentation_mode_snapshot,
@@ -1360,7 +1367,7 @@ export function refreshPieceUsageFromOriginal(
const source = database const source = database
.prepare( .prepare(
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, `SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode, p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.presentation_mode,
COALESCE(ps.label, '') AS presentation_source_label, COALESCE(ps.label, '') AS presentation_source_label,
COALESCE(ps.url, '') AS presentation_source_url, COALESCE(ps.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode, ps.timecode_seconds AS presentation_source_timecode,
@@ -1379,6 +1386,7 @@ export function refreshPieceUsageFromOriginal(
intro: string; intro: string;
research_text: string; research_text: string;
moderation_notes: string; moderation_notes: string;
interaction_text: string;
transition_text: string; transition_text: string;
presentation_text: string; presentation_text: string;
presentation_mode: PresentationMode; presentation_mode: PresentationMode;
@@ -1397,7 +1405,7 @@ export function refreshPieceUsageFromOriginal(
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?, research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
presentation_snapshot = ?, presentation_mode_snapshot = ?, presentation_snapshot = ?, presentation_mode_snapshot = ?,
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
@@ -1412,6 +1420,7 @@ export function refreshPieceUsageFromOriginal(
source.intro, source.intro,
source.research_text, source.research_text,
source.moderation_notes, source.moderation_notes,
source.interaction_text,
source.transition_text, source.transition_text,
source.presentation_text, source.presentation_text,
source.presentation_mode, source.presentation_mode,
@@ -1452,6 +1461,8 @@ export function updatePieceUsage(
intro: cleanText(input.intro, 50_000, 'intro'), intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'), research: cleanText(input.research, 50_000, 'research'),
moderation: cleanText(input.moderation, 50_000, 'moderation'), moderation: cleanText(input.moderation, 50_000, 'moderation'),
interaction:
input.interaction === undefined ? null : cleanText(input.interaction, 50_000, 'interaction'),
transition: cleanText(input.transition, 50_000, 'transition'), transition: cleanText(input.transition, 50_000, 'transition'),
presentation: cleanText(input.presentation, 50_000, 'presentation'), presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes') ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
@@ -1473,7 +1484,7 @@ export function updatePieceUsage(
database database
.prepare( .prepare(
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, `UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?, presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?, presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?, presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
@@ -1487,6 +1498,7 @@ export function updatePieceUsage(
values.intro, values.intro,
values.research, values.research,
values.moderation, values.moderation,
values.interaction,
values.transition, values.transition,
values.presentation, values.presentation,
presentation.mode, presentation.mode,
+8 -1
View File
@@ -24,13 +24,19 @@ import { listTools } from '#lib/server/tools.js';
function renderedFields( function renderedFields(
piece: Pick< piece: Pick<
PieceInput, PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText' | 'intro'
| 'researchText'
| 'moderationNotes'
| 'interactionText'
| 'transitionText'
| 'presentationText'
> >
) { ) {
return { return {
intro: renderMarkdown(piece.intro), intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText), research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes), moderation: renderMarkdown(piece.moderationNotes),
interaction: renderMarkdown(piece.interactionText ?? ''),
transition: renderMarkdown(piece.transitionText), transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText) presentation: renderMarkdown(piece.presentationText)
}; };
@@ -73,6 +79,7 @@ function pieceInput(data: FormData): PieceInput {
intro: text(data, 'intro'), intro: text(data, 'intro'),
researchText: text(data, 'researchText'), researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'), moderationNotes: text(data, 'moderationNotes'),
interactionText: text(data, 'interactionText'),
transitionText: text(data, 'transitionText'), transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'), presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'], presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
+7
View File
@@ -308,6 +308,13 @@
rows={10} rows={10}
bind:value={draft.moderationNotes} bind:value={draft.moderationNotes}
/> />
<MarkdownEditorField
name="interactionText"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interactionText}
/>
<MarkdownEditorField <MarkdownEditorField
name="transitionText" name="transitionText"
label="Überleitung" label="Überleitung"
+81
View File
@@ -5,14 +5,22 @@ import { requireUser } from '#lib/server/auth.js';
import { import {
completeCurrent, completeCurrent,
clearPresentationChannel, clearPresentationChannel,
endLive,
getLiveView, getLiveView,
goBack, goBack,
issuePresentationAccess, issuePresentationAccess,
moveOpenUsage, moveOpenUsage,
pauseLive,
presentUsage, presentUsage,
rotatePresentationChannelKey, rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeEndedLive,
resumeLive,
selectUsage, selectUsage,
setShowStatus,
skipCurrent, skipCurrent,
startNewLiveRun,
startOrResumeLive startOrResumeLive
} from '#lib/server/live.js'; } from '#lib/server/live.js';
import { listShows } from '#lib/server/shows.js'; import { listShows } from '#lib/server/shows.js';
@@ -41,6 +49,13 @@ function actionError(cause: unknown) {
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' }; return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
if (code === 'NOT_FOUND') if (code === 'NOT_FOUND')
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' }; return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
if (code === 'NO_SECTION_TIMER')
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' }; return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
} }
@@ -75,6 +90,72 @@ export const actions = {
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
); );
}, },
pause: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('pause', () =>
pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resume: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resume', () =>
resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resetSectionTimer: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resetSectionTimer', () =>
resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resetTotalTimer: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resetTotalTimer', () =>
resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
setStatus: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const status = text(data, 'status');
if (status !== 'draft' && status !== 'ready') {
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
}
return attempt('setStatus', () =>
setShowStatus(
text(data, 'showId'),
text(data, 'commandId'),
Number(data.get('revision')),
Number(data.get('showRevision')),
status
)
);
},
end: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('end', () =>
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
resumeEnded: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('resumeEnded', () =>
resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
startNewRun: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
return attempt('startNewRun', () =>
startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
complete: async ({ locals, request }) => { complete: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+512 -173
View File
@@ -11,23 +11,40 @@
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 timerDisplay = $state<'elapsed' | 'remaining'>('elapsed');
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 calculatedAtUtc = $state(untrack(() => data.live.calculatedAtUtc));
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,9 +68,20 @@
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 && data.live.totalTimer.runningSinceUtc
? 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) +
@@ -67,19 +95,37 @@
let hasActiveSectionTimer = $derived( let hasActiveSectionTimer = $derived(
Boolean( Boolean(
data.live.sectionTimer && data.live.sectionTimer &&
data.live.sectionTimer.plannedDurationSeconds > 0 &&
data.live.current && data.live.current &&
data.live.sectionTimer.sectionId === data.live.current.sectionId data.live.sectionTimer.sectionId === data.live.current.sectionId
) )
); );
let runningOffsetSeconds = $derived(
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
);
let totalElapsedSeconds = $derived(data.live.totalTimer.elapsedSeconds + runningOffsetSeconds);
let remainingSeconds = $derived(
Math.max(
0,
data.live.remainingEstimate.seconds -
Math.min(runningOffsetSeconds, data.live.remainingEstimate.activeCountdownSeconds)
)
);
$effect(() => { $effect(() => {
const nextCurrentId = data.live.current?.id ?? null; const nextCurrentId = data.live.current?.id ?? null;
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) { if (
nextCurrentId !== currentId ||
data.live.liveRevision !== loadedRevision ||
data.live.calculatedAtUtc !== calculatedAtUtc
) {
const changedPiece = nextCurrentId !== currentId; const changedPiece = nextCurrentId !== currentId;
currentId = nextCurrentId; currentId = nextCurrentId;
loadedRevision = data.live.liveRevision; loadedRevision = data.live.liveRevision;
calculatedAtUtc = data.live.calculatedAtUtc;
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;
@@ -105,6 +151,9 @@
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize; if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail; if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes; if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
if (settings.timerDisplay === 'elapsed' || settings.timerDisplay === 'remaining') {
timerDisplay = settings.timerDisplay;
}
if (settings.scrollPositions && typeof settings.scrollPositions === 'object') { if (settings.scrollPositions && typeof settings.scrollPositions === 'object') {
scrollPositions = Object.fromEntries( scrollPositions = Object.fromEntries(
Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] => Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] =>
@@ -121,25 +170,43 @@
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({
promptSize, promptSize,
showRail, showRail,
showTimes, showTimes,
timerDisplay,
scrollPositions scrollPositions
}) })
); );
@@ -155,7 +222,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 +260,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(
@@ -199,6 +321,11 @@
persistSettings(); persistSettings();
} }
function toggleTimerDisplay() {
timerDisplay = timerDisplay === 'elapsed' ? 'remaining' : 'elapsed';
persistSettings();
}
function formatCountdown(totalSeconds: number) { function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -220,7 +347,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 +365,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,113 +395,326 @@
<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:paused={data.live.isPaused} 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> </div>
<section> <button
<strong>Ausgabekanal</strong> bind:this={liveMenuTrigger}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}> class="live-menu-trigger"
type="button"
aria-haspopup="dialog"
aria-controls="live-menu-popover"
aria-expanded={menuOpen}
onclick={toggleLiveMenu}
>
<AppIcon name="bars" />
<span>Menü</span>
</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 class="live-output-icon" aria-hidden="true"><AppIcon name="display" /></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="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button> <input type="hidden" name="usageId" value="" />
</form> <input type="hidden" name="revision" value={data.live.liveRevision} />
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}> <input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit" disabled={Boolean(data.live.endedAtUtc)}
>Präsentation leeren</button
>
</form>{/if}
</div>
<button
class="total-countdown"
type="button"
onclick={toggleTimerDisplay}
aria-label={timerDisplay === 'elapsed'
? 'Gelaufene Gesamtzeit, zu Restzeit wechseln'
: 'Geschätzte Restzeit, zu gelaufener Zeit wechseln'}
>
<span class="timer-label"
><AppIcon name="timer" /> {timerDisplay === 'elapsed' ? 'Gelaufen' : 'Restzeit'}</span
>
<strong
>{formatCountdown(
timerDisplay === 'elapsed' ? totalElapsedSeconds : remainingSeconds
)}</strong
>
{#if timerDisplay === 'remaining' && data.live.remainingEstimate.unplannedSectionCount}
<small title="Verbleibende Abschnitte ohne geplante Abschnittsdauer"
>+ {data.live.remainingEstimate.unplannedSectionCount} ungeplant</small
>
{/if}
</button>
<div class="section-timer-control">
<div
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span class="timer-label"><AppIcon name="timer" /> Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
{#if data.live.startedAtUtc && !data.live.endedAtUtc}<form
method="POST"
action={liveAction(data.live.isPaused ? 'resume' : 'pause')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class:paused={data.live.isPaused}
class="live-timer-toggle"
type="submit"
aria-label={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
title={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
>
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button>
</form>{/if}
</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>
{#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>Sendungssteuerung</strong>
<small>Status: {data.live.show.status}</small>
{#if !data.live.startedAtUtc}
<form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input
type="hidden"
name="status"
value={data.live.show.status === 'ready' ? 'draft' : 'ready'}
/>
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">
{data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'}
</button>
</form> </form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Sendung starten</button
>
</form> </form>
</section> {:else if data.live.endedAtUtc}
<section> <form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<strong>Sendung</strong> <input type="hidden" name="showId" value={data.live.show.id} />
{#if needsResume}<form <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button
>
</form>
<form
method="POST"
action={liveAction('startNewRun')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (
!confirm(
'Neuen Durchlauf starten? Fortschritt und Zeitmessungen werden zurückgesetzt.'
)
)
event.preventDefault();
}}
>
<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"
><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
>
</form>
{:else}
<form
method="POST"
action={liveAction(data.live.isPaused ? 'resume' : 'pause')}
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="secondary-button" type="submit">
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
{data.live.isPaused ? 'Fortsetzen' : 'Pausieren'}
</button>
</form>
{#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<form
method="POST" method="POST"
action={liveAction('start')} action={liveAction('resetSectionTimer')}
use:enhance={enhanceCommand} use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
}}
> >
<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="text-button" type="submit">Live-Stand wiederaufnehmen</button> <button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Abschnittszeit zurücksetzen</button
>
</form>{/if} </form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })} <form
>Liveansicht verlassen · Zur Planung</a method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
}}
> >
</section> <input type="hidden" name="showId" value={data.live.show.id} />
</div> <input type="hidden" name="revision" value={data.live.liveRevision} />
</details> <input type="hidden" name="commandId" value={data.commandId} />
</header> <button class="text-button" type="submit"
><AppIcon name="timer" /> Gesamtzeit zurücksetzen</button
>
</form>
<form
method="POST"
action={liveAction('end')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Sendung wirklich beenden? Offene Pieces bleiben offen.'))
event.preventDefault();
}}
>
<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 danger-text" type="submit"
><AppIcon name="stop" /> Sendung beenden</button
>
</form>
{/if}
{#if needsResume && !data.live.endedAtUtc}<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">
@@ -384,7 +724,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 +732,48 @@
> >
<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.interactionHtml}<section class="live-script-section">
class="live-script-section transition-card" <p class="eyebrow">Interaktion</p>
> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html viewedUsage.interactionHtml}</div>
</section>{/if}
{#if viewedUsage.transitionHtml}<section 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 +785,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 +801,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.interactionHtml && !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>
@@ -531,6 +868,7 @@
</div> </div>
<button <button
bind:this={railCloseButton} bind:this={railCloseButton}
class="dialog-close"
type="button" type="button"
aria-label="Ablauf schließen" aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
@@ -561,26 +899,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`}
@@ -640,7 +979,10 @@
<input type="hidden" name="usageId" value={usage.id} /> <input type="hidden" name="usageId" value={usage.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 type="submit" aria-label={`${usage.title} präsentieren`} <button
type="submit"
disabled={Boolean(data.live.endedAtUtc)}
aria-label={`${usage.title} präsentieren`}
><AppIcon name="display" /></button ><AppIcon name="display" /></button
> >
</form> </form>
@@ -737,37 +1079,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="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button> <button class="secondary-button" type="submit" disabled={Boolean(data.live.endedAtUtc)}>
</form>{/if} {data.live.presentationUsageId === viewedUsage.id
{#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="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
? 'Erneut präsentieren' ? 'Erneut präsentieren'
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
@@ -776,13 +1101,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 || data.live.isPaused || Boolean(data.live.endedAtUtc)}
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 || data.live.isPaused || Boolean(data.live.endedAtUtc)}
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>
@@ -237,6 +237,7 @@ export const actions = {
intro: text(data, 'intro'), intro: text(data, 'intro'),
research: text(data, 'research'), research: text(data, 'research'),
moderation: text(data, 'moderation'), moderation: text(data, 'moderation'),
interaction: text(data, 'interaction'),
transition: text(data, 'transition'), transition: text(data, 'transition'),
presentation: text(data, 'presentation'), presentation: text(data, 'presentation'),
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'], presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
+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 });