5 Commits
Author SHA1 Message Date
hnzio b80dded55d Release 0.6.0 2026-10-25 09:45:31 +01:00
hnzio fd47910d09 Refine live controls and compact show cards 2026-10-24 19:09:44 +02:00
hnzio 01b5606a3b Repair missing live timer schema 2026-10-24 14:40:03 +02:00
hnzio fa17187af4 Add persistent live controls and interaction notes 2026-10-24 14:31:33 +02:00
hnzio b1474bc33d Refine live control and show overview 2026-10-24 09:45:01 +02:00
50 changed files with 6658 additions and 1259 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk", "title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung", "tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.5.12", "version": "0.6.0",
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 3000, "httpPort": 3000,
"addons": { "addons": {
+106 -19
View File
@@ -424,32 +424,119 @@
## 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.
## Etappe 6
| Kriterium | Nachweis | Stand |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------- |
| Spätere Originaländerung verändert Historie nicht | Verhaltenstest ändert Piece und Abschnittskategorie nach Abschluss; historische Snapshots bleiben gleich | erfüllt am 25.10.2026 |
| Abschluss nicht doppelt; übersprungen ist nicht gesendet | idempotente Befehlswiederholung, fremde Befehls-ID und materialisierte Usage-Historie im Verhaltenstest | erfüllt am 25.10.2026 |
| Bewusste Auswahl archiviert nur gewählte Originale | Abschlusskandidaten und explizite Archivauswahl im Service; Dialog trennt gesendet, übersprungen und offen | erfüllt am 25.10.2026 |
| Archivierte Sendung stabil und duplizierbar | Service- und Browsertest prüfen Nur-Lese-Ansicht sowie neuen Entwurf ohne Live-, Historien- oder Freigabedaten | erfüllt am 25.10.2026 |
| Gast sieht genau eine Sendung und nur erlaubte Felder | anonymer Chromium-Kontext und Scope-Verhaltenstests für Ablauf, Moderation und Recherche | erfüllt am 25.10.2026 |
| Gast-Schreibzugriff und fremde IDs abgelehnt | anonymer direkter POST erhält 403; fremde Medien-ID erhält 404 | erfüllt am 25.10.2026 |
| Passwort, Ablauf und Widerruf schützen direkte Aufrufe | Service- und Browsertests prüfen Seite, Gast-Sitzung und direkten Medienrequest vor und nach Widerruf | erfüllt am 25.10.2026 |
| Barkeeper verwaltet Freigaben | Anlage, Bearbeitung und Löschung direkt in der Sendung sowie gruppiert in der Verwaltung im Browser geprüft | erfüllt am 25.10.2026 |
| Offene Sendungen sind sicher freigebbar | Service und anonymer Browser prüfen aktuellen Plan vor Abschluss und stabilen Stand danach | erfüllt am 25.10.2026 |
| Bestehende Abschlüsse werden einmalig übernommen | Upgrade-Test wendet `0015` auf einen Stand bis `0014` zweimal an und findet genau eine Historie | erfüllt am 25.10.2026 |
| Bestehende Freigaben bleiben bei Erweiterung erhalten | Migration `0016` ersetzt nur die Fremdschlüsselgrenze und kopiert Freigaben sowie Gast-Sitzungen | erfüllt am 25.10.2026 |
## Ausgeführte Prüfungen Etappe 6
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 52 Tests in 11 Dateien erfolgreich
- gezielter Chromium-Ablauf für Abschluss, Archiv, historische Ansicht,
offene und abgeschlossene Freigabe, Bearbeitung und Löschung in beiden
Barkeeper-Oberflächen, Passwort, privaten Scope, Reserve-Medienschutz, fremde
ID und Gast-POST: 1 von 1 erfolgreich
- `npm run test:e2e`: nach Korrektur eines bestehenden, auf den 24. Oktober
festgelegten „Heute“-Fixtures 18 von 18 Chromium-Browsertests erfolgreich
- Browserprüfung von Sendungsfreigabe und Gastansicht bei 390 × 844 Pixeln sowie
der Verwaltung bei 1280 × 900 Pixeln ohne horizontalen Überlauf; Screenshots
visuell kontrolliert
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run lint`: Prettier und ESLint erfolgreich
- Versionsgleichheit `0.6.0` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
+107 -24
View File
@@ -33,22 +33,22 @@ spätere API dieselben Regeln verwenden.
## Gesamtdatenmodell ## Gesamtdatenmodell
Die ersten fünf Etappen materialisieren Identität, Inhaltsverwaltung, Die ersten sechs Etappen materialisieren Identität, Inhaltsverwaltung,
Sendungsplanung und Live-Durchführung. Folgende Grenzen gelten für umgesetzte Sendungsplanung, Live-Durchführung, Historie und Lesefreigaben. Folgende Grenzen
und spätere Bereiche. gelten für umgesetzte und spätere Bereiche.
| Bereich | Geplante Entitäten und Beziehungen | | Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| 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 |
| API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel | | API/Audit | technische Berechtigungen, Idempotenzschlüssel und Audit-Ereignisse mit Akteur, Aktion und Ziel |
| Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie | | Betrieb | Anwendungseinstellungen und spätere Job-Zustände, keine Geheimnisse im Klartext ohne begründete Schutzstrategie |
### Historien- und Kopierregel ### Historien- und Kopierregel
@@ -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,14 @@ 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 schließt
laufende Intervalle und setzt `ended_at_utc`, finalisiert die Sendung aber noch
nicht. 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 +255,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
@@ -270,6 +294,52 @@ sichtbar; die Oberfläche kennzeichnet den Abbruch und versucht die Verbindung
mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses mit verlangsamtem Intervall erneut. Private Live-Daten werden nicht Teil dieses
Zustandsobjekts. Zustandsobjekts.
## Abschluss, Historie und Lesefreigaben
`src/lib/server/completion.ts` ist die Transaktionsgrenze des endgültigen
Sendungsabschlusses. Er verlangt einen beendeten Live-Lauf sowie passende Show-
und Live-Revisionen. Eine Befehls-ID in `show_finalizations` macht Wiederholungen
idempotent. In derselben Transaktion werden offene Intervalle geschlossen,
Abschnittskategorien eingefroren, ausschließlich erledigte Verwendungen außerhalb
der Reservebank in `piece_usage_history` materialisiert, bewusst gewählte
Fundus-Originale archiviert und die Sendung auf `completed` gesetzt. Jede normale
Planungs- und Live-Mutation sperrt diesen Zustand.
Die eigentlichen Sendungstexte, Quellen, Werkzeuge, Themen und Medien liegen
weiterhin in den beim Einplanen erzeugten Usage-Snapshots. Finalisierung friert
die zuvor noch katalogabhängige Abschnittskategorie ein. Abgeschlossene
Sendungen werden ausschließlich gelesen oder vollständig in einen neuen Entwurf
dupliziert. Das Duplikat erhält neue Show-, Abschnitts-, Usage- und Relations-IDs,
offene Usage-Status und weder Live-, Historien- noch Freigabedaten.
`src/lib/server/shares.ts` erzeugt für Barkeeper zufällige 256-Bit-Lesetokens;
SQLite speichert nur SHA-256-Hashes. Eine Freigabe verweist direkt auf eine
Sendung und darf deshalb bereits im Entwurf, im bereiten oder im laufenden
Zustand entstehen. Der Umfang ist eine Positivliste aus `rundown`, `moderation`
oder `research`. Optionale Passwörter liegen als Argon2id-Hash vor. Nach
erfolgreicher Prüfung entsteht eine höchstens zwölf Stunden gültige, ebenfalls
nur gehashte Gast-Sitzung; Fehlversuche werden pro Link und Clientadresse
begrenzt. Änderungen an Umfang, Passwort oder Ablauf invalidieren bestehende
Gast-Sitzungen. Ablauf und Löschung werden bei jedem HTML-, Seitendaten- und
Medienrequest erneut geprüft.
Die öffentliche Route `/freigabe/[token]` erhält keine interne Sendungs-ID und
keinen ungefilterten `Show`-Datensatz. Sie projiziert genau die am Token gebundene
Sendung und nur die Felder ihres Umfangs. Vor dem Abschluss werden aktuelle
Sendungs- und Usage-Snapshots bei jedem Aufruf neu gelesen; danach liefert
dieselbe Projektion die durch die Finalisierung stabilisierten Snapshots. Die
separate Medienroute akzeptiert ausschließlich das ausdrücklich gewählte
Präsentationsmedium einer eingeplanten Nicht-Reserve-Verwendung derselben
Sendung. Private `/media`-Dateien bleiben weiterhin an eine Redaktionssitzung
gebunden.
Die wiederverwendbare Barkeeper-Komponente `ShareManagement.svelte` liegt direkt
in der Sendungsbearbeitung und in der Verwaltung. Beide Routen verwenden
dieselben Servicefunktionen für Anlage, Bearbeitung und Löschung; die
Sendungsroute bindet Mutationen zusätzlich an ihre eigene Sendungs-ID. Die
Verwaltung gruppiert bestehende Freigaben nach Sendung und zeigt deren aktuellen
Status und Termin.
## Oberflächenstruktur ## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
@@ -279,6 +349,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 +366,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
+80 -5
View File
@@ -371,8 +371,83 @@ 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.
`ended_at_utc` 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.
## D-027: Abschlusszustand und Freigaben als eigene Sicherheitsgrenzen
Ein beendeter Live-Lauf ist wiederaufnehmbar und deshalb nicht identisch mit
einer historischen Sendung. Erst eine eigene transaktionale Finalisierung setzt
`shows.status` auf `completed`. Eine dauerhaft gespeicherte Befehls-ID verhindert
Doppelverbuchung; Usage-Historie enthält nur `done`, nie `skipped` oder Reserve.
Bestehende Usage-Snapshots bleiben Inhaltsquelle, während Abschnittskategorien
beim Abschluss zusätzlich eingefroren werden. Historische Sendungen werden nicht
erneut bearbeitet, sondern mit neuen IDs und zurückgesetztem Laufzustand kopiert.
Lesefreigaben sind keine abgeschwächten Redaktionssitzungen. Sie verwenden eine
eigene Positivprojektion mit drei kumulativen Umfängen. Roh-Tokens und
Gast-Sitzungstokens stehen nie in SQLite, optionale Passwörter verwenden Argon2id.
Passwort, Ablauf und Widerruf werden zentral vor Seite, Seitendaten und Medium
geprüft. Präsentationsmedien sind an Freigabe, Sendung und Usage-Snapshot
gebunden; beliebige Fundus-Dateien bleiben ausgeschlossen.
Freigaben verweisen auf `shows` statt ausschließlich auf Finalisierungen. Damit
kann ein Barkeeper bereits einen Entwurf oder eine laufende Planung teilen. Die
Gastprojektion liest bei jedem Aufruf die aktuellen Sendungssnapshots; der
spätere Abschluss stabilisiert genau diese bestehende Datenquelle, ohne Token
oder URL zu wechseln. Bearbeitungen behalten den Link, invalidieren aber
vorhandene Passwortsitzungen. Vollständiges Löschen ist der unmittelbare
Widerruf. Die Sendungsbearbeitung und die zentrale Verwaltung verwenden dieselbe
Geschäftslogik und bleiben Barkeepern vorbehalten.
+138 -22
View File
@@ -1,29 +1,90 @@
# Release Notes # Release Notes
## 0.5.12 - Kompakte Live-Regie ## 0.6.0 - Abschluss, Archiv und Freigaben
Veröffentlicht am 25. Oktober 2026
Sendewerk 0.6.0 trennt das wiederaufnehmbare Ende eines Live-Laufs vom
unveränderlichen redaktionellen Abschluss. Abgeschlossene Sendungen erhalten
eine stabile Historie, liegen im Archiv und können als neuer Entwurf dupliziert
werden. Barkeeper geben genau eine geplante, laufende oder historische Sendung
über widerrufbare, optional passwortgeschützte Leselinks frei.
### Abschluss und Historie
- Ein Abschlussdialog zeigt gesendete, übersprungene und offene Pieces getrennt
und bietet ausschließlich gesendete Originale zur bewussten Archivierung an.
- Finalisierung ist transaktional und idempotent. Übersprungene Pieces und die
Reservebank werden nicht als gesendet verbucht.
- Usage-Texte, Relationen, Kategorien und tatsächliche Zeiten bleiben nach dem
Abschluss stabil, auch wenn Fundus-Originale oder Kataloge später wechseln.
- Historische Sendungen sind intern nur lesbar. Die Duplikation erzeugt einen
unabhängigen Entwurf mit offenen Pieces und ohne früheren Lauf- oder
Freigabezustand.
### Archiv und Freigaben
- Das Archiv zeigt abgeschlossene Sendungen neben archivierten und gelöschten
Pieces und bietet die Duplikation direkt an.
- Barkeeper erstellen Leselinks direkt in der Sendungsbearbeitung oder zentral
in der Verwaltung für Ablauf, Moderation oder vollständige Recherche, optional
mit Passwort und Ablaufzeit.
- Noch nicht abgeschlossene Sendungen zeigen Gästen beim nächsten Aufruf den
aktuellen Plan. Nach dem Abschluss bleibt derselbe Link auf dem stabilen
historischen Stand.
- Freigaben lassen sich in nach Sendung gruppierten Verwaltungskarten bearbeiten
und endgültig löschen; Passwortänderungen beenden vorhandene Gast-Sitzungen.
- Tokens und Gast-Sitzungen liegen nur gehasht vor; Passwörter verwenden
Argon2id und Fehlversuche sind begrenzt.
- Gastseiten projizieren genau eine Sendung. Private Originaldaten, Nutzer,
interne IDs und Felder außerhalb des gewählten Umfangs werden nicht geliefert.
- Direkte Medienaufrufe prüfen dieselbe Freigabe, Passwortsitzung, Ablaufzeit und
den Widerruf erneut und erlauben nur das freigegebene Präsentationsmedium.
### Technik und Prüfung
- Migration `0015_completion_archive_and_shares.sql` übernimmt bestehende
abgeschlossene Sendungen und ergänzt Finalisierung, Historie,
Duplikationsbefehle, Freigaben, Gast-Sitzungen und
Passwortversuchsbegrenzung.
- Migration `0016_shares_for_active_shows.sql` erhält vorhandene Freigaben und
erweitert ihre Referenz auf alle Sendungszustände.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.6.0`.
- 52 Verhaltenstests und 18 Chromium-Abläufe decken den bisherigen Umfang sowie
Abschluss, Archiv und anonyme Lesefreigaben ab.
## 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 +97,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 +136,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.
+90 -12
View File
@@ -2,19 +2,82 @@
## Aktueller Umfang ## Aktueller Umfang
Etappe 1 bis Etappe 5 sind abgeschlossen. Architektur, Gesamtdatenmodell, Etappe 1 bis Etappe 6 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Etappe 6 wurde nicht begonnen. Archiv und sichere Lesefreigaben sind dokumentiert.
## Kompakte Live-Regie 0.5.12 ## Abschluss, Archiv und Freigaben 0.6.0
- das Ende eines Live-Laufs bleibt wiederaufnehmbar; erst der getrennte,
idempotente Abschluss friert Sendung, Kategorien, tatsächliche Verwendungen
und Zeiten unveränderlich ein
- der Abschlussdialog zählt gesendete, übersprungene und offene Pieces getrennt
und archiviert Fundus-Originale ausschließlich nach bewusster Auswahl
- die Verwendungshistorie wird beim Abschluss materialisiert; übersprungene
Verwendungen und die Reservebank zählen nicht als gesendet
- abgeschlossene Sendungen liegen getrennt im Archiv, sind intern nur lesbar und
lassen sich mit neuen IDs, offenen Verwendungen und ohne Live- oder
Freigabezustand in einen neuen Entwurf duplizieren
- Barkeeper verwalten gehashte Leselinks direkt in der Sendungsbearbeitung und
gesammelt in der Verwaltung; Umfang, Passwort und Ablauf lassen sich ändern,
Freigaben vollständig löschen
- Entwürfe, bereite und laufende Sendungen lassen sich bereits freigeben;
Planänderungen werden beim nächsten Gastaufruf sichtbar, während der Abschluss
denselben Link auf den unveränderlichen historischen Stand festlegt
- passwortgeschützte Freigaben verwenden kurzlebige, gehashte Gast-Sitzungen und
begrenzen Fehlversuche pro Link und Clientadresse
- Gastseiten liefern genau eine positiv projizierte Sendung ohne interne IDs,
Originaldaten oder nicht freigegebene Felder; geschützte Präsentationsmedien
prüfen Umfang, Passwortsitzung, Ablauf und Widerruf bei jedem Direktaufruf
- bestehende abgeschlossene Sendungen werden durch Migration `0015` mit
eingefrorener Historie übernommen; Migration `0016` erweitert bestehende
Freigaben verlustfrei auf noch nicht abgeschlossene Sendungen
Die lokale Prüfmatrix umfasst 52 Verhaltenstests und 18 Chromium-Abläufe.
Abschluss, Archiv, historische Nur-Lese-Ansicht, Passwortfreigabe,
Scope-Begrenzung, direkter Medienzugriff, fremde IDs, Gast-Schreibversuch und
Widerruf wurden zusätzlich in einem anonymen Browserkontext geprüft. Der
erweiterte Ablauf prüft außerdem offene Sendungen, aktuelle Planänderungen,
Bearbeiten und Löschen in beiden Barkeeper-Oberflächen sowie Desktop- und
Smartphone-Layout.
## 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 +85,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;
@@ -0,0 +1,115 @@
ALTER TABLE shows ADD COLUMN completed_at_utc TEXT;
ALTER TABLE show_sections ADD COLUMN category_name_snapshot TEXT;
ALTER TABLE show_sections ADD COLUMN category_color_snapshot TEXT;
CREATE TABLE show_finalizations (
show_id TEXT PRIMARY KEY REFERENCES shows(id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
finalized_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
finalized_at_utc TEXT NOT NULL,
UNIQUE (show_id, command_id)
);
CREATE TABLE show_finalization_archived_pieces (
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
PRIMARY KEY (show_id, piece_id)
);
CREATE TABLE piece_usage_history (
usage_id TEXT PRIMARY KEY REFERENCES piece_usages(id) ON DELETE RESTRICT,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE RESTRICT,
original_piece_id TEXT NOT NULL REFERENCES pieces(id) ON DELETE RESTRICT,
show_title_snapshot TEXT NOT NULL,
usage_title_snapshot TEXT NOT NULL,
used_at_utc TEXT NOT NULL,
actual_duration_seconds INTEGER NOT NULL CHECK (actual_duration_seconds >= 0)
);
CREATE INDEX idx_piece_usage_history_original
ON piece_usage_history(original_piece_id, used_at_utc DESC);
CREATE TABLE show_duplication_commands (
source_show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
command_id TEXT NOT NULL,
created_show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
PRIMARY KEY (source_show_id, command_id)
);
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES show_finalizations(show_id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
CREATE TABLE share_password_attempts (
id TEXT PRIMARY KEY,
share_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX idx_share_password_attempts_share
ON share_password_attempts(share_key, attempted_at_utc);
CREATE INDEX idx_share_password_attempts_address
ON share_password_attempts(address_key, attempted_at_utc);
UPDATE show_sections
SET category_name_snapshot = (
SELECT name FROM section_categories WHERE id = show_sections.category_id
),
category_color_snapshot = (
SELECT color FROM section_categories WHERE id = show_sections.category_id
)
WHERE show_id IN (SELECT id FROM shows WHERE status = 'completed');
UPDATE shows
SET completed_at_utc = COALESCE(
(SELECT ended_at_utc FROM show_live_states WHERE show_id = shows.id),
updated_at_utc
)
WHERE status = 'completed';
INSERT INTO show_finalizations (
show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc
)
SELECT id, 'migration-' || id, revision, NULL, completed_at_utc
FROM shows
WHERE status = 'completed';
INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, sh.completed_at_utc),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, sh.completed_at_utc)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.status = 'completed' AND s.is_reserve_bank = 0
AND u.status = 'done' AND u.original_piece_id IS NOT NULL;
@@ -0,0 +1,43 @@
DROP INDEX idx_show_share_sessions_share;
DROP INDEX idx_show_shares_show;
ALTER TABLE show_share_sessions RENAME TO show_share_sessions_v15;
ALTER TABLE show_shares RENAME TO show_shares_v15;
CREATE TABLE show_shares (
id TEXT PRIMARY KEY,
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('rundown', 'moderation', 'research')),
password_hash TEXT,
expires_at_utc TEXT,
revoked_at_utc TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_shares_show ON show_shares(show_id, created_at_utc DESC);
CREATE TABLE show_share_sessions (
token_hash TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES show_shares(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX idx_show_share_sessions_share ON show_share_sessions(share_id, expires_at_utc);
INSERT INTO show_shares (
id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
)
SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc
FROM show_shares_v15;
INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
SELECT token_hash, share_id, created_at_utc, expires_at_utc
FROM show_share_sessions_v15;
DROP TABLE show_share_sessions_v15;
DROP TABLE show_shares_v15;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sendewerk", "name": "sendewerk",
"version": "0.5.12", "version": "0.6.0",
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.2", "@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sendewerk", "name": "sendewerk",
"private": true, "private": true,
"version": "0.5.12", "version": "0.6.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.17 <25" "node": ">=22.17 <25"
+991 -125
View File
File diff suppressed because it is too large. Load diff
+29 -2
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js'; import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
@@ -14,16 +15,32 @@ const publicPaths = new Set([
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const presentationPath = event.url.pathname.startsWith('/heinzlive/'); const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname); const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE); const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token); event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies); if (token && !event.locals.user) deleteSession(event.cookies);
if (!presentationPath && !publicPaths.has(event.url.pathname) && !event.locals.user) { if (
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup'); redirect(303, hasUsers() ? '/login' : '/setup');
} }
const response = await resolve(event); const sharePageNonce = sharePath && !shareMediaPath ? randomBytes(16).toString('base64') : null;
const response = await resolve(
event,
sharePageNonce
? {
transformPageChunk: ({ html }) =>
html.replaceAll('<script', `<script nonce="${sharePageNonce}"`)
}
: undefined
);
response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
@@ -35,6 +52,16 @@ export const handle: Handle = async ({ event, resolve }) => {
"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'" "frame-src https://www.youtube-nocookie.com https://www.youtube.com https://www.tiktok.com; frame-ancestors 'none'"
); );
} }
if (sharePath) {
response.headers.set('Cache-Control', 'private, no-store');
response.headers.set('Referrer-Policy', 'same-origin');
if (!shareMediaPath) {
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${sharePageNonce}'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'`
);
}
}
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store'); response.headers.set('Cache-Control', 'no-store');
} }
+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
};
});
}
+249
View File
@@ -0,0 +1,249 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
id: string;
title: string;
startsAtUtc: string | null;
status: string;
};
type ShareItem = {
id: string;
showId: string;
showTitle: string;
showStatus: string;
showStartsAtUtc: string | null;
scope: 'rundown' | 'moderation' | 'research';
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
let {
shows,
shares,
fixedShowId = '',
form = null
}: {
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string; shareUrl?: string } | null;
} = $props();
let visibleShows = $derived(
fixedShowId
? shows.filter((show) => show.id === fixedShowId)
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
: fallback;
}
function berlinInput(value: string | null) {
if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(value));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
}
function statusLabel(status: string) {
if (status === 'draft') return 'Entwurf';
if (status === 'ready') return 'Bereit';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
function scopeLabel(scope: ShareItem['scope']) {
if (scope === 'rundown') return 'Nur Ablauf';
if (scope === 'moderation') return 'Ablauf und Moderation';
return 'Vollständig mit Recherche';
}
function shareState(share: ShareItem) {
if (share.revokedAtUtc) return 'Widerrufen';
if (share.expiresAtUtc && new Date(share.expiresAtUtc) <= new Date()) return 'Abgelaufen';
return 'Aktiv';
}
</script>
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && form.success}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a>
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
</select></label
>
{/if}
<label
>Informationsumfang<select name="scope">
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Optionales Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
</form>
{#if visibleShows.length}
<div class="share-show-list">
{#each visibleShows as show (show.id)}
<article class="share-show-card">
<header>
<div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small>
</div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header>
<div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row">
<summary>
<span class="share-row-icon"><AppIcon name="link" /></span>
<span>
<strong>{scopeLabel(share.scope)}</strong>
<small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc,
'ohne Ablauf'
)}</small
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
<form
method="POST"
action="?/deleteShare"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
@@ -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';
+20 -2
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);
} }
@@ -92,11 +110,11 @@ export async function verifyPassword(passwordHash: string, password: string): Pr
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex')); const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string { export function createToken(): string {
return randomBytes(32).toString('base64url'); return randomBytes(32).toString('base64url');
} }
function hashToken(token: string): string { export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
+204
View File
@@ -0,0 +1,204 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { finalizeShow, getCompletionSummary } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
addPieceUsage,
addShowSection,
createShow,
duplicateCompletedShow,
getPieceUsageContexts,
getShow
} from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'completion-actor',
email: 'completion@example.test',
displayName: 'Abschluss Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-completion-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(
actor.id,
actor.email,
actor.email,
actor.displayName,
...Array(3).fill(new Date().toISOString())
);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function endedShow() {
const created = createShow({ title: 'Finale Sendung', startsAtUtc: null, templateId: null });
const section = addShowSection(created.id, created.revision, {
name: 'Gesendeter Block',
categoryId: 'cat-jc',
explicitDurationSeconds: null
});
const done = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const skipped = addPieceUsage(created.id, done.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(done.id);
database.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?").run(skipped.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
return { showId: created.id, doneUsageId: done.id, revision: skipped.revision };
}
describe('show completion', () => {
it('finalizes once, records only sent usages and archives only the explicit selection', () => {
const prepared = endedShow();
expect(getCompletionSummary(prepared.showId)).toMatchObject({
doneCount: 1,
skippedCount: 1,
candidates: [{ pieceId: 'piece-console', usageCount: 1 }]
});
const first = finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor,
now: new Date('2026-10-25T19:05:00.000Z')
});
expect(first.duplicate).toBe(false);
expect(
finalizeShow({
showId: prepared.showId,
commandId: 'complete-once',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: ['piece-console'],
actor
}).duplicate
).toBe(true);
expect(() =>
finalizeShow({
showId: prepared.showId,
commandId: 'different-command',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
})
).toThrow('SHOW_COMPLETED');
const database = getDatabase();
expect(
(
database
.prepare('SELECT count(*) AS count FROM piece_usage_history WHERE show_id = ?')
.get(prepared.showId) as { count: number }
).count
).toBe(1);
expect(database.prepare('SELECT status FROM pieces WHERE id = ?').get('piece-console')).toEqual(
{ status: 'archived' }
);
expect(getPieceUsageContexts(['piece-console'])['piece-console'].sentHistory).toHaveLength(1);
});
it('keeps historical snapshots stable and duplicates them into an editable clean run', () => {
const prepared = endedShow();
finalizeShow({
showId: prepared.showId,
commandId: 'freeze',
expectedShowRevision: prepared.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
const historical = getShow(prepared.showId)!;
getDatabase()
.prepare("UPDATE section_categories SET name = 'Später geändert' WHERE id = 'cat-jc'")
.run();
getDatabase()
.prepare("UPDATE pieces SET title = 'Später geändert' WHERE id = 'piece-console'")
.run();
const unchanged = getShow(prepared.showId)!;
expect(unchanged.sections[0].category).toBe(historical.sections[0].category);
expect(unchanged.sections[0].usages[0].title).toBe(historical.sections[0].usages[0].title);
const copy = duplicateCompletedShow(prepared.showId, 'copy-once');
expect(duplicateCompletedShow(prepared.showId, 'copy-once').id).toBe(copy.id);
expect(getShow(copy.id)).toMatchObject({
status: 'draft',
startsAtUtc: null,
sections: [
{
category: historical.sections[0].category,
usages: [{ status: 'open' }, { status: 'open' }]
}
]
});
expect(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(copy.id)
).toBeUndefined();
getDatabase()
.prepare(
`UPDATE piece_usages SET status = 'done'
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
)
.run(copy.id);
getDatabase()
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
copy.id,
'2026-10-26T18:00:00.000Z',
'2026-10-26T19:00:00.000Z',
'2026-10-26T19:00:00.000Z'
);
finalizeShow({
showId: copy.id,
commandId: 'finalize-copy',
expectedShowRevision: 1,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
expect(getShow(copy.id)?.sections[0].category).toBe(historical.sections[0].category);
});
});
+210
View File
@@ -0,0 +1,210 @@
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type CompletionCandidate = {
pieceId: string;
title: string;
usageCount: number;
};
export type CompletionSummary = {
doneCount: number;
skippedCount: number;
openCount: number;
candidates: CompletionCandidate[];
};
export function isShowFinalized(showId: string): boolean {
return Boolean(
getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)
);
}
export function getCompletionSummary(showId: string): CompletionSummary {
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId))
throw new Error('NOT_FOUND');
const counts = database
.prepare(
`SELECT
sum(CASE WHEN u.status = 'done' THEN 1 ELSE 0 END) AS done_count,
sum(CASE WHEN u.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_count,
sum(CASE WHEN u.status IN ('open', 'current') THEN 1 ELSE 0 END) AS open_count
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0`
)
.get(showId) as {
done_count: number | null;
skipped_count: number | null;
open_count: number | null;
};
const candidates = database
.prepare(
`SELECT u.original_piece_id AS piece_id, p.title, count(*) AS usage_count
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN pieces p ON p.id = u.original_piece_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND p.status != 'archived' AND p.deleted_at_utc IS NULL
GROUP BY u.original_piece_id, p.title ORDER BY p.title COLLATE NOCASE`
)
.all(showId) as { piece_id: string; title: string; usage_count: number }[];
return {
doneCount: counts.done_count ?? 0,
skippedCount: counts.skipped_count ?? 0,
openCount: counts.open_count ?? 0,
candidates: candidates.map((candidate) => ({
pieceId: candidate.piece_id,
title: candidate.title,
usageCount: candidate.usage_count
}))
};
}
export function getHistoricalActualDurations(showId: string): Record<string, number> {
return Object.fromEntries(
(
getDatabase()
.prepare(
`SELECT usage_id, actual_duration_seconds FROM piece_usage_history WHERE show_id = ?`
)
.all(showId) as { usage_id: string; actual_duration_seconds: number }[]
).map((row) => [row.usage_id, row.actual_duration_seconds])
);
}
export function finalizeShow(input: {
showId: string;
commandId: string;
expectedShowRevision: number;
expectedLiveRevision: number;
archivePieceIds: string[];
actor: AuthUser;
now?: Date;
}) {
if (!input.commandId || input.commandId.length > 200) throw new Error('INVALID_COMMAND');
if (!Number.isInteger(input.expectedShowRevision) || input.expectedShowRevision < 1) {
throw new Error('INVALID_REVISION');
}
if (!Number.isInteger(input.expectedLiveRevision) || input.expectedLiveRevision < 0) {
throw new Error('INVALID_REVISION');
}
const archivePieceIds = [...new Set(input.archivePieceIds.filter(Boolean))];
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare('SELECT command_id FROM show_finalizations WHERE show_id = ?')
.get(input.showId) as { command_id: string } | undefined;
if (existing) {
if (existing.command_id !== input.commandId) throw new Error('SHOW_COMPLETED');
return { duplicate: true };
}
const state = database
.prepare(
`SELECT sh.revision AS show_revision, ls.revision AS live_revision, ls.ended_at_utc
FROM shows sh JOIN show_live_states ls ON ls.show_id = sh.id WHERE sh.id = ?`
)
.get(input.showId) as
{ show_revision: number; live_revision: number; ended_at_utc: string | null } | undefined;
if (!state) throw new Error('NOT_FOUND');
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
if (
state.show_revision !== input.expectedShowRevision ||
state.live_revision !== input.expectedLiveRevision
) {
throw new Error('CONFLICT');
}
const candidates = getCompletionSummary(input.showId).candidates.map(
(candidate) => candidate.pieceId
);
if (archivePieceIds.some((pieceId) => !candidates.includes(pieceId))) {
throw new Error('INVALID_ARCHIVE_SELECTION');
}
const now = (input.now ?? new Date()).toISOString();
database
.prepare(
`UPDATE live_usage_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_show_timer_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timer_runs SET ended_at_utc = ?
WHERE ended_at_utc IS NULL AND timer_id IN (
SELECT id FROM live_section_timers WHERE show_id = ?
)`
)
.run(now, input.showId);
database
.prepare(
`UPDATE live_section_timers SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL`
)
.run(now, input.showId);
database
.prepare(
`UPDATE show_sections SET
category_name_snapshot = COALESCE(category_name_snapshot,
(SELECT name FROM section_categories WHERE id = show_sections.category_id)),
category_color_snapshot = COALESCE(category_color_snapshot,
(SELECT color FROM section_categories WHERE id = show_sections.category_id))
WHERE show_id = ?`
)
.run(input.showId);
database
.prepare(
`INSERT INTO piece_usage_history (
usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds
)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
COALESCE(sh.starts_at_utc, ?),
COALESCE((
SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, ?)) -
julianday(r.started_at_utc)) * 86400 AS INTEGER)))
FROM live_usage_runs r WHERE r.usage_id = u.id
), 0)
FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE sh.id = ? AND s.is_reserve_bank = 0 AND u.status = 'done'
AND u.original_piece_id IS NOT NULL`
)
.run(now, now, input.showId);
const archivePiece = database.prepare(
`UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND status != 'archived' AND deleted_at_utc IS NULL`
);
for (const pieceId of archivePieceIds) {
archivePiece.run(now, pieceId);
}
const updated = database
.prepare(
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1,
updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'completed'`
)
.run(now, now, input.showId, input.expectedShowRevision);
if (updated.changes !== 1) throw new Error('CONFLICT');
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.showId, input.commandId, input.expectedShowRevision, input.actor.id, now);
const recordArchivedPiece = database.prepare(
'INSERT INTO show_finalization_archived_pieces (show_id, piece_id) VALUES (?, ?)'
);
for (const pieceId of archivePieceIds) {
recordArchivedPiece.run(input.showId, pieceId);
}
return { duplicate: false };
})();
}
+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,
+95 -2
View File
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 11, migrations: 16,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
@@ -90,4 +90,97 @@ describe('database setup', () => {
expect(show.totalDurationSeconds).toBe(5100); expect(show.totalDurationSeconds).toBe(5100);
expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540); expect(show.sections[1].usages.find((usage) => usage.isReserve)?.durationSeconds).toBe(540);
}); });
it('upgrades existing completed shows into stable finalizations exactly once', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-upgrade-test-'));
temporaryDirectories.push(directory);
const databasePath = path.join(directory, 'test.sqlite');
const environment = {
...process.env,
SENDEWERK_DB_PATH: databasePath,
SENDEWERK_UPLOAD_DIR: path.join(directory, 'uploads')
};
const database = new Database(databasePath);
database.pragma('foreign_keys = ON');
database.exec(
'CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at_utc TEXT NOT NULL)'
);
const migrationDirectory = path.resolve('migrations');
const oldMigrations = readdirSync(migrationDirectory)
.filter(
(name) =>
/^\d+.+\.sql$/.test(name) && !name.startsWith('0015_') && !name.startsWith('0016_')
)
.sort();
for (const name of oldMigrations) {
database.exec(readFileSync(path.join(migrationDirectory, name), 'utf8'));
database
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(name, new Date().toISOString());
}
database.close();
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: environment });
const legacy = new Database(databasePath);
legacy.pragma('foreign_keys = ON');
const usage = legacy
.prepare(
`SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = 'show-october' AND s.is_reserve_bank = 0 LIMIT 1`
)
.get() as { id: string };
legacy.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
legacy.prepare("UPDATE shows SET status = 'completed' WHERE id = 'show-october'").run();
const completionMigration = '0015_completion_archive_and_shares.sql';
legacy.exec(readFileSync(path.join(migrationDirectory, completionMigration), 'utf8'));
legacy
.prepare('INSERT INTO schema_migrations (name, applied_at_utc) VALUES (?, ?)')
.run(completionMigration, new Date().toISOString());
const now = new Date().toISOString();
legacy
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES ('upgrade-barkeeper', 'upgrade@example.test', 'upgrade@example.test',
'Upgrade', 'unused', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
legacy
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc, revoked_at_utc,
created_by_user_id, created_at_utc)
VALUES ('upgrade-share', 'show-october', 'upgrade-token-hash', 'rundown', NULL,
NULL, NULL, 'upgrade-barkeeper', ?)`
)
.run(now);
legacy
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES ('upgrade-session-hash', 'upgrade-share', ?, ?)`
)
.run(now, new Date(Date.now() + 60_000).toISOString());
legacy.close();
for (let run = 0; run < 2; run += 1) {
execFileSync(process.execPath, ['scripts/migrate.mjs'], {
cwd: process.cwd(),
env: environment
});
}
const upgraded = new Database(databasePath, { readonly: true });
expect(
upgraded
.prepare(
`SELECT
(SELECT count(*) FROM show_finalizations WHERE show_id = 'show-october') AS finalizations,
(SELECT count(*) FROM piece_usage_history WHERE show_id = 'show-october') AS history,
(SELECT count(*) FROM show_shares WHERE id = 'upgrade-share') AS shares,
(SELECT count(*) FROM show_share_sessions WHERE token_hash = 'upgrade-session-hash') AS sessions`
)
.get()
).toEqual({ finalizations: 1, history: 1, shares: 1, sessions: 1 });
expect(upgraded.pragma('foreign_key_check')).toEqual([]);
upgraded.close();
});
}); });
+413 -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,52 @@ 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 +227,24 @@ 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 +315,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 +330,10 @@ 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 (database.prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId)) {
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 +389,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 +434,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 +443,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 +496,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 +510,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 +537,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 +547,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 +566,267 @@ 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 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 +840,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 +892,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 +928,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) {
+266
View File
@@ -0,0 +1,266 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { finalizeShow } from './completion.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateShare,
createShare,
deleteShare,
getSharedMedia,
getSharedShow,
revokeShare,
updateShare
} from './shares.js';
import { addPieceUsage, addShowSection, createShow } from './shows.js';
let directory: string;
const actor: AuthUser = {
id: 'share-actor',
email: 'share@example.test',
displayName: 'Freigabe Test',
role: 'barkeeper',
lockedAtUtc: null
};
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shares-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, 'unused', 'barkeeper', ?, ?, ?)`
)
.run(actor.id, actor.email, actor.email, actor.displayName, now, now, now);
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function finalizedShow() {
const created = createShow({
title: 'Freigegebene Sendung',
startsAtUtc: null,
templateId: null
});
const section = addShowSection(created.id, created.revision, {
name: 'Freigabeblock',
categoryId: null,
explicitDurationSeconds: null
});
const usage = addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const reserveUsage = addPieceUsage(created.id, usage.revision, {
pieceId: 'piece-console',
sectionId: null
});
const database = getDatabase();
database.prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES (?, 0, ?, ?, 0, ?)`
)
.run(
created.id,
'2026-10-25T18:00:00.000Z',
'2026-10-25T19:00:00.000Z',
'2026-10-25T19:00:00.000Z'
);
finalizeShow({
showId: created.id,
commandId: `finalize-${created.id}`,
expectedShowRevision: reserveUsage.revision,
expectedLiveRevision: 0,
archivePieceIds: [],
actor
});
return { showId: created.id, usageId: usage.id, reserveUsageId: reserveUsage.id };
}
describe('show shares', () => {
it('shares an unfinished show and keeps its current plan visible', async () => {
const created = createShow({
title: 'Offene Freigabe',
startsAtUtc: null,
templateId: null
});
const shared = await createShare(actor, {
showId: created.id,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const initiallyEmpty = getSharedShow(shared.token);
expect(initiallyEmpty.status).toBe('authorized');
if (initiallyEmpty.status !== 'authorized') throw new Error('share unavailable');
expect(initiallyEmpty.share.isCompleted).toBe(false);
expect(initiallyEmpty.share.sections).toEqual([]);
const section = addShowSection(created.id, created.revision, {
name: 'Nachträglich geplant',
categoryId: null,
explicitDurationSeconds: null
});
addPieceUsage(created.id, section.revision, {
pieceId: 'piece-console',
sectionId: section.id
});
const updatedPlan = getSharedShow(shared.token);
expect(updatedPlan.status).toBe('authorized');
if (updatedPlan.status !== 'authorized') throw new Error('share unavailable');
expect(updatedPlan.share.sections[0]).toMatchObject({
name: 'Nachträglich geplant',
usages: [{ title: 'Die kleine Konsole mit dem großen Dreh' }]
});
await updateShare(actor, shared.id, {
scope: 'moderation',
passwordMode: 'replace',
password: 'neues-freigabe-passwort',
expiresAtUtc: null
});
expect(getSharedShow(shared.token).status).toBe('password');
const session = await authenticateShare(shared.token, 'neues-freigabe-passwort', '192.0.2.20');
expect(getSharedShow(shared.token, session.token).status).toBe('authorized');
deleteShare(actor, shared.id, created.id);
expect(getSharedShow(shared.token, session.token).status).toBe('unavailable');
});
it('projects exactly the selected information scope', async () => {
const prepared = finalizedShow();
const rundown = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: null
});
const basic = getSharedShow(rundown.token);
expect(basic.status).toBe('authorized');
if (basic.status !== 'authorized') throw new Error('share unavailable');
const basicUsage = basic.share.sections[0].usages[0];
expect(basicUsage).not.toHaveProperty('moderationHtml');
expect(basicUsage).not.toHaveProperty('researchHtml');
expect(JSON.stringify(basic)).not.toContain('originalPieceId');
expect(JSON.stringify(basic)).not.toContain(prepared.showId);
getDatabase()
.prepare(
`INSERT INTO usage_tool_snapshots
(id, usage_id, original_tool_id, name, start_url, work_url, note, position)
VALUES ('private-share-tool', ?, NULL, 'Schnittwerkzeug',
'https://private.example/start', 'https://private.example/work?secret=1',
'Freigegebene Werkzeugnotiz', 0)`
)
.run(prepared.usageId);
const research = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: '',
expiresAtUtc: null
});
const full = getSharedShow(research.token);
expect(full.status).toBe('authorized');
if (full.status !== 'authorized') throw new Error('share unavailable');
expect(full.share.sections[0].usages[0]).toHaveProperty('moderationHtml');
expect(full.share.sections[0].usages[0]).toHaveProperty('researchHtml');
expect(full.share.sections[0].usages[0]).not.toHaveProperty('ownNotesHtml');
expect(JSON.stringify(full)).not.toContain('private.example');
expect(JSON.stringify(full)).toContain('Freigegebene Werkzeugnotiz');
});
it('enforces password, expiry and revocation for page and direct media access', async () => {
const prepared = finalizedShow();
const database = getDatabase();
mkdirSync(process.env.SENDEWERK_UPLOAD_DIR!, { recursive: true });
writeFileSync(path.join(process.env.SENDEWERK_UPLOAD_DIR!, 'shared.png'), Buffer.from('image'));
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('share-media', 'piece-console', 'shared.png', 'shared.png', 'image/png', 5, 'Freigegeben', 99, ?)`
)
.run(new Date().toISOString());
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('share-snapshot', ?, 'share-media', 'Freigegeben', 99, 1)`
)
.run(prepared.usageId);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('reserve-share-snapshot', ?, 'share-media', 'Private Reserve', 99, 1)`
)
.run(prepared.reserveUsageId);
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'research',
password: 'ein-sicheres-passwort',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
expect(getSharedShow(created.token).status).toBe('password');
expect(getSharedShow(created.token)).not.toHaveProperty('share');
expect(await getSharedMedia(created.token, undefined, 'share-snapshot')).toBeNull();
await expect(authenticateShare(created.token, 'falsch', '127.0.0.1')).rejects.toThrow(
'INVALID_PASSWORD'
);
const session = await authenticateShare(created.token, 'ein-sicheres-passwort', '127.0.0.1');
expect(getSharedShow(created.token, session.token).status).toBe('authorized');
expect((await getSharedMedia(created.token, session.token, 'share-snapshot'))?.byteSize).toBe(
5
);
expect(await getSharedMedia(created.token, session.token, 'foreign-media')).toBeNull();
expect(await getSharedMedia(created.token, session.token, 'reserve-share-snapshot')).toBeNull();
revokeShare(actor, created.id);
expect(getSharedShow(created.token, session.token).status).toBe('unavailable');
expect(await getSharedMedia(created.token, session.token, 'share-snapshot')).toBeNull();
const expired = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: '',
expiresAtUtc: new Date(Date.now() + 60_000).toISOString()
});
database
.prepare('UPDATE show_shares SET expires_at_utc = ? WHERE id = ?')
.run('2000-01-01T00:00:00.000Z', expired.id);
expect(getSharedShow(expired.token).status).toBe('unavailable');
expect(await getSharedMedia(expired.token, undefined, 'share-snapshot')).toBeNull();
});
it('limits repeated password attempts per share', async () => {
const prepared = finalizedShow();
const created = await createShare(actor, {
showId: prepared.showId,
scope: 'rundown',
password: 'noch-ein-sicheres-passwort',
expiresAtUtc: null
});
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(authenticateShare(created.token, 'falsch', '192.0.2.10')).rejects.toThrow(
'INVALID_PASSWORD'
);
}
await expect(
authenticateShare(created.token, 'noch-ein-sicheres-passwort', '192.0.2.10')
).rejects.toThrow('RATE_LIMITED');
});
});
+430
View File
@@ -0,0 +1,430 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
createToken,
hashPassword,
hashToken,
validPassword,
verifyPassword,
type AuthUser
} from './auth.js';
import { getServerPaths } from './config.js';
import { getDatabase } from './database.js';
import { renderMarkdown } from './markdown.js';
import type { ShowStatus } from './shows.js';
export const SHARE_SESSION_COOKIE = 'sendewerk_share_session';
export const SHARE_SCOPES = ['rundown', 'moderation', 'research'] as const;
export type ShareScope = (typeof SHARE_SCOPES)[number];
const PASSWORD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const SHARE_PASSWORD_ATTEMPT_LIMIT = 5;
const ADDRESS_PASSWORD_ATTEMPT_LIMIT = 20;
type ShareRow = {
id: string;
show_id: string;
token_hash: string;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
};
export type ShareListItem = {
id: string;
showId: string;
showTitle: string;
showStatus: ShowStatus;
showStartsAtUtc: string | null;
scope: ShareScope;
isPasswordProtected: boolean;
expiresAtUtc: string | null;
revokedAtUtc: string | null;
createdAtUtc: string;
};
function requireBarkeeperActor(actor: AuthUser): void {
if (actor.role !== 'barkeeper') throw new Error('FORBIDDEN');
}
function validExpiry(value: string | null): string | null {
if (!value) return null;
const expiry = new Date(value);
if (Number.isNaN(expiry.valueOf()) || expiry.valueOf() <= Date.now()) {
throw new Error('INVALID_EXPIRY');
}
return expiry.toISOString();
}
function activeShare(rawToken: string): ShareRow | null {
if (!rawToken) return null;
const now = new Date().toISOString();
return (
(getDatabase()
.prepare(
`SELECT id, show_id, token_hash, scope, password_hash, expires_at_utc,
revoked_at_utc, created_at_utc FROM show_shares
WHERE token_hash = ? AND revoked_at_utc IS NULL
AND (expires_at_utc IS NULL OR expires_at_utc > ?)`
)
.get(hashToken(rawToken), now) as ShareRow | undefined) ?? null
);
}
export function listShares(actor: AuthUser, showId?: string): ShareListItem[] {
requireBarkeeperActor(actor);
const where = showId ? 'WHERE ss.show_id = ?' : '';
return (
getDatabase()
.prepare(
`SELECT ss.id, ss.show_id, sh.title AS show_title, sh.status AS show_status,
sh.starts_at_utc AS show_starts_at_utc, ss.scope, ss.password_hash,
ss.expires_at_utc, ss.revoked_at_utc, ss.created_at_utc
FROM show_shares ss JOIN shows sh ON sh.id = ss.show_id
${where}
ORDER BY ss.created_at_utc DESC`
)
.all(...(showId ? [showId] : [])) as {
id: string;
show_id: string;
show_title: string;
show_status: ShowStatus;
show_starts_at_utc: string | null;
scope: ShareScope;
password_hash: string | null;
expires_at_utc: string | null;
revoked_at_utc: string | null;
created_at_utc: string;
}[]
).map((share) => ({
id: share.id,
showId: share.show_id,
showTitle: share.show_title,
showStatus: share.show_status,
showStartsAtUtc: share.show_starts_at_utc,
scope: share.scope,
isPasswordProtected: Boolean(share.password_hash),
expiresAtUtc: share.expires_at_utc,
revokedAtUtc: share.revoked_at_utc,
createdAtUtc: share.created_at_utc
}));
}
export async function createShare(
actor: AuthUser,
input: { showId: string; scope: string; password: string; expiresAtUtc: string | null }
) {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (input.password && !validPassword(input.password)) throw new Error('INVALID_PASSWORD');
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const database = getDatabase();
if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(input.showId)) {
throw new Error('NOT_FOUND');
}
const token = createToken();
const id = randomUUID();
const now = new Date().toISOString();
const passwordHash = input.password ? await hashPassword(input.password) : null;
database
.prepare(
`INSERT INTO show_shares
(id, show_id, token_hash, scope, password_hash, expires_at_utc,
created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.showId,
hashToken(token),
input.scope,
passwordHash,
expiresAtUtc,
actor.id,
now
);
return { id, token };
}
export async function updateShare(
actor: AuthUser,
shareId: string,
input: {
scope: string;
passwordMode: string;
password: string;
expiresAtUtc: string | null;
},
expectedShowId?: string
): Promise<void> {
requireBarkeeperActor(actor);
if (!SHARE_SCOPES.includes(input.scope as ShareScope)) throw new Error('INVALID_SCOPE');
if (!['keep', 'remove', 'replace'].includes(input.passwordMode)) {
throw new Error('INVALID_PASSWORD_MODE');
}
if (input.passwordMode === 'replace' && !validPassword(input.password)) {
throw new Error('INVALID_PASSWORD');
}
const expiresAtUtc = validExpiry(input.expiresAtUtc);
const existing = getDatabase()
.prepare(
'SELECT show_id, password_hash FROM show_shares WHERE id = ? AND revoked_at_utc IS NULL'
)
.get(shareId) as { show_id: string; password_hash: string | null } | undefined;
if (!existing || (expectedShowId && existing.show_id !== expectedShowId))
throw new Error('NOT_FOUND');
const passwordHash =
input.passwordMode === 'keep'
? existing.password_hash
: input.passwordMode === 'replace'
? await hashPassword(input.password)
: null;
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare(
`UPDATE show_shares SET scope = ?, password_hash = ?, expires_at_utc = ?
WHERE id = ? AND revoked_at_utc IS NULL`
)
.run(input.scope, passwordHash, expiresAtUtc, shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function deleteShare(actor: AuthUser, shareId: string, expectedShowId?: string): void {
requireBarkeeperActor(actor);
const result = getDatabase()
.prepare(`DELETE FROM show_shares WHERE id = ?${expectedShowId ? ' AND show_id = ?' : ''}`)
.run(...(expectedShowId ? [shareId, expectedShowId] : [shareId]));
if (result.changes !== 1) throw new Error('NOT_FOUND');
}
export function revokeShare(actor: AuthUser, shareId: string): void {
requireBarkeeperActor(actor);
const database = getDatabase();
database.transaction(() => {
const result = database
.prepare('UPDATE show_shares SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL')
.run(new Date().toISOString(), shareId);
if (result.changes !== 1) throw new Error('NOT_FOUND');
database.prepare('DELETE FROM show_share_sessions WHERE share_id = ?').run(shareId);
})();
}
export function shareAccessState(rawToken: string, sessionToken?: string) {
const share = activeShare(rawToken);
if (!share) return { status: 'unavailable' as const };
if (!share.password_hash) return { status: 'authorized' as const, share };
if (!sessionToken) return { status: 'password' as const };
const session = getDatabase()
.prepare(
`SELECT 1 FROM show_share_sessions WHERE token_hash = ? AND share_id = ?
AND expires_at_utc > ?`
)
.get(hashToken(sessionToken), share.id, new Date().toISOString());
return session ? { status: 'authorized' as const, share } : { status: 'password' as const };
}
export async function authenticateShare(rawToken: string, password: string, address: string) {
const share = activeShare(rawToken);
if (!share?.password_hash) throw new Error('SHARE_UNAVAILABLE');
const database = getDatabase();
const now = new Date();
const since = new Date(now.getTime() - PASSWORD_ATTEMPT_WINDOW_MS).toISOString();
const shareKey = hashToken(rawToken);
const addressKey = hashToken(address || 'unknown');
database.prepare('DELETE FROM share_password_attempts WHERE attempted_at_utc < ?').run(since);
const shareAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE share_key = ? AND attempted_at_utc >= ?`
)
.get(shareKey, since) as { count: number };
const addressAttempts = database
.prepare(
`SELECT count(*) AS count FROM share_password_attempts
WHERE address_key = ? AND attempted_at_utc >= ?`
)
.get(addressKey, since) as { count: number };
if (
shareAttempts.count >= SHARE_PASSWORD_ATTEMPT_LIMIT ||
addressAttempts.count >= ADDRESS_PASSWORD_ATTEMPT_LIMIT
) {
throw new Error('RATE_LIMITED');
}
database
.prepare(
`INSERT INTO share_password_attempts
(id, share_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)`
)
.run(randomUUID(), shareKey, addressKey, now.toISOString());
if (!(await verifyPassword(share.password_hash, password))) throw new Error('INVALID_PASSWORD');
const token = createToken();
const defaultExpiry = now.getTime() + 12 * 60 * 60 * 1000;
const shareExpiry = share.expires_at_utc
? new Date(share.expires_at_utc).getTime()
: defaultExpiry;
const expires = new Date(Math.min(defaultExpiry, shareExpiry));
database.prepare('DELETE FROM share_password_attempts WHERE share_key = ?').run(shareKey);
database
.prepare('DELETE FROM show_share_sessions WHERE expires_at_utc <= ?')
.run(now.toISOString());
database
.prepare(
`INSERT INTO show_share_sessions (token_hash, share_id, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?)`
)
.run(hashToken(token), share.id, now.toISOString(), expires.toISOString());
return { token, expires };
}
export function getSharedShow(rawToken: string, sessionToken?: string) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return access;
const share = access.share;
const database = getDatabase();
const show = database
.prepare('SELECT title, starts_at_utc, status, completed_at_utc FROM shows WHERE id = ?')
.get(share.show_id) as {
title: string;
starts_at_utc: string | null;
status: ShowStatus;
completed_at_utc: string | null;
};
const sections = database
.prepare(
`SELECT s.id, s.name, s.category_name_snapshot, s.category_color_snapshot,
c.name AS category_name, c.color AS category_color,
s.explicit_duration_seconds, s.position FROM show_sections s
LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY s.position`
)
.all(share.show_id) as {
id: string;
name: string;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
category_name: string | null;
category_color: string | null;
explicit_duration_seconds: number | null;
position: number;
}[];
const usageStatement = database.prepare(
`SELECT u.id, u.title_snapshot, u.output_title_snapshot, u.status, u.duration_seconds,
u.intro_snapshot, u.research_snapshot, u.moderation_snapshot, u.interaction_snapshot,
u.transition_snapshot, u.own_notes,
COALESCE((SELECT actual_duration_seconds FROM piece_usage_history WHERE usage_id = u.id), 0)
AS actual_duration_seconds
FROM piece_usages u WHERE u.show_section_id = ? ORDER BY u.position`
);
const sourceStatement = database.prepare(
'SELECT label, url, timecode_seconds FROM usage_source_snapshots WHERE usage_id = ? ORDER BY position'
);
const toolStatement = database.prepare(
`SELECT name, start_url, work_url, note FROM usage_tool_snapshots
WHERE usage_id = ? ORDER BY position`
);
const mediaStatement = database.prepare(
`SELECT id, alt_text FROM usage_media_snapshots
WHERE usage_id = ? AND is_presentation = 1`
);
const includesModeration = share.scope === 'moderation' || share.scope === 'research';
const includesResearch = share.scope === 'research';
return {
status: 'authorized' as const,
share: {
scope: share.scope,
title: show.title,
startsAtUtc: show.starts_at_utc,
completedAtUtc: show.completed_at_utc,
isCompleted: show.status === 'completed',
sections: sections.map((section) => {
const usages = usageStatement.all(section.id) as {
id: string;
title_snapshot: string;
output_title_snapshot: string;
status: string;
duration_seconds: number;
actual_duration_seconds: number;
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string;
own_notes: string;
}[];
return {
name: section.name,
category:
show.status === 'completed' ? section.category_name_snapshot : section.category_name,
categoryColor:
show.status === 'completed' ? section.category_color_snapshot : section.category_color,
explicitDurationSeconds: section.explicit_duration_seconds,
usages: usages.map((usage) => ({
title: usage.title_snapshot,
outputTitle: usage.output_title_snapshot,
status: usage.status,
durationSeconds: usage.duration_seconds,
actualDurationSeconds: usage.actual_duration_seconds,
media: mediaStatement.get(usage.id) as { id: string; alt_text: string } | undefined,
...(includesModeration
? {
introHtml: renderMarkdown(usage.intro_snapshot),
moderationHtml: renderMarkdown(usage.moderation_snapshot),
interactionHtml: renderMarkdown(usage.interaction_snapshot),
transitionHtml: renderMarkdown(usage.transition_snapshot)
}
: {}),
...(includesResearch
? {
researchHtml: renderMarkdown(usage.research_snapshot),
sources: sourceStatement.all(usage.id) as {
label: string;
url: string;
timecode_seconds: number | null;
}[],
tools: (
toolStatement.all(usage.id) as {
name: string;
start_url: string;
work_url: string;
note: string;
}[]
).map((tool) => ({ name: tool.name, note: tool.note }))
}
: {})
}))
};
})
}
};
}
export async function getSharedMedia(
rawToken: string,
sessionToken: string | undefined,
snapshotMediaId: string
) {
const access = shareAccessState(rawToken, sessionToken);
if (access.status !== 'authorized' || !access.share) return null;
const row = getDatabase()
.prepare(
`SELECT pm.storage_name, pm.mime_type, pm.byte_size
FROM usage_media_snapshots ums
JOIN piece_usages u ON u.id = ums.usage_id
JOIN show_sections s ON s.id = u.show_section_id
JOIN piece_media pm ON pm.id = ums.original_media_id
WHERE ums.id = ? AND ums.is_presentation = 1 AND s.show_id = ?
AND s.is_reserve_bank = 0`
)
.get(snapshotMediaId, access.share.show_id) as
{ storage_name: string; mime_type: string; byte_size: number } | undefined;
if (!row) return null;
return {
data: await readFile(path.join(getServerPaths().uploads, row.storage_name)),
mimeType: row.mime_type,
byteSize: row.byte_size
};
}
+23 -1
View File
@@ -253,7 +253,29 @@ describe('show builder data layer', () => {
getDatabase() getDatabase()
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?") .prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
.run(skipped.id); .run(skipped.id);
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(completed.id); getDatabase()
.prepare(
"UPDATE shows SET status = 'completed', completed_at_utc = starts_at_utc WHERE id = ?"
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc)
SELECT id, 'test-finalization', revision, starts_at_utc FROM shows WHERE id = ?`
)
.run(completed.id);
getDatabase()
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds)
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
sh.starts_at_utc, 0 FROM piece_usages u
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id WHERE u.id = ?`
)
.run(done.id);
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null }); const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
const activeSection = addShowSection(active.id, active.revision, { const activeSection = addShowSection(active.id, active.revision, {
+181 -24
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;
@@ -119,6 +120,7 @@ export type Show = {
revision: number; revision: number;
createdAtUtc: string; createdAtUtc: string;
updatedAtUtc: string; updatedAtUtc: string;
completedAtUtc: string | null;
sections: ShowSection[]; sections: ShowSection[];
reserve: PieceUsage[]; reserve: PieceUsage[];
totalDurationSeconds: number; totalDurationSeconds: number;
@@ -157,6 +159,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;
@@ -191,6 +194,7 @@ type ShowRow = {
revision: number; revision: number;
created_at_utc: string; created_at_utc: string;
updated_at_utc: string; updated_at_utc: string;
completed_at_utc: string | null;
}; };
type SectionRow = { type SectionRow = {
@@ -199,6 +203,8 @@ type SectionRow = {
category_id: string | null; category_id: string | null;
category: string | null; category: string | null;
category_color: string | null; category_color: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number; position: number;
explicit_duration_seconds: number | null; explicit_duration_seconds: number | null;
is_reserve_bank: number; is_reserve_bank: number;
@@ -216,6 +222,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 +452,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,
@@ -709,7 +717,11 @@ function copyUsageRelations(database: Database.Database, sourceUsageId: string,
.run(usageId, sourceUsageId); .run(usageId, sourceUsageId);
} }
export function listShows(): ShowListItem[] { export function listShows(statuses?: ShowStatus[]): ShowListItem[] {
const requestedStatuses = statuses?.filter((status) => SHOW_STATUSES.includes(status));
const where = requestedStatuses?.length
? `WHERE sh.status IN (${requestedStatuses.map(() => '?').join(', ')})`
: '';
return ( return (
getDatabase() getDatabase()
.prepare( .prepare(
@@ -720,10 +732,11 @@ export function listShows(): ShowListItem[] {
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id)) (SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
ELSE 0 END), 0) AS total_duration_seconds ELSE 0 END), 0) AS total_duration_seconds
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
${where}
GROUP BY sh.id GROUP BY sh.id
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC` ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
) )
.all() as { .all(...(requestedStatuses ?? [])) as {
id: string; id: string;
title: string; title: string;
starts_at_utc: string | null; starts_at_utc: string | null;
@@ -777,13 +790,14 @@ export function getShow(id: string): Show | null {
const show = database const show = database
.prepare( .prepare(
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision, `SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
created_at_utc, updated_at_utc FROM shows WHERE id = ?` created_at_utc, updated_at_utc, completed_at_utc FROM shows WHERE id = ?`
) )
.get(id) as ShowRow | undefined; .get(id) as ShowRow | undefined;
if (!show) return null; if (!show) return null;
const sectionRows = database const sectionRows = database
.prepare( .prepare(
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color, `SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
s.category_name_snapshot, s.category_color_snapshot,
s.position, s.explicit_duration_seconds, s.is_reserve_bank s.position, s.explicit_duration_seconds, s.is_reserve_bank
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
WHERE s.show_id = ? ORDER BY s.position` WHERE s.show_id = ? ORDER BY s.position`
@@ -793,7 +807,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,
@@ -817,8 +831,14 @@ export function getShow(id: string): Show | null {
id: row.id, id: row.id,
name: row.name, name: row.name,
categoryId: row.category_id, categoryId: row.category_id,
category: row.category, category:
categoryColor: row.category_color, show.status === 'completed'
? row.category_name_snapshot
: (row.category_name_snapshot ?? row.category),
categoryColor:
show.status === 'completed'
? row.category_color_snapshot
: (row.category_color_snapshot ?? row.category_color),
position: row.position, position: row.position,
explicitDurationSeconds: row.explicit_duration_seconds, explicitDurationSeconds: row.explicit_duration_seconds,
durationSeconds: durationSeconds:
@@ -838,6 +858,7 @@ export function getShow(id: string): Show | null {
revision: show.revision, revision: show.revision,
createdAtUtc: show.created_at_utc, createdAtUtc: show.created_at_utc,
updatedAtUtc: show.updated_at_utc, updatedAtUtc: show.updated_at_utc,
completedAtUtc: show.completed_at_utc,
sections, sections,
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [], reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
@@ -864,14 +885,9 @@ export function getPieceUsageContexts(
const database = getDatabase(); const database = getDatabase();
const history = database const history = database
.prepare( .prepare(
`SELECT u.id AS usage_id, u.original_piece_id, sh.id AS show_id, `SELECT usage_id, original_piece_id, show_id,
sh.title AS show_title, COALESCE(sh.starts_at_utc, sh.updated_at_utc) AS used_at_utc show_title_snapshot AS show_title, used_at_utc
FROM piece_usages u FROM piece_usage_history ORDER BY used_at_utc DESC, usage_id DESC`
JOIN show_sections s ON s.id = u.show_section_id
JOIN shows sh ON sh.id = s.show_id
WHERE u.original_piece_id IS NOT NULL AND u.status = 'done'
AND sh.status = 'completed' AND s.is_reserve_bank = 0
ORDER BY used_at_utc DESC, u.id DESC`
) )
.all() as { .all() as {
usage_id: string; usage_id: string;
@@ -972,6 +988,138 @@ export function createShow(input: CreateShowInput): CreatedMutationResult {
return { id, revision: 1, updatedAtUtc: now }; return { id, revision: 1, updatedAtUtc: now };
} }
export function duplicateCompletedShow(
sourceShowId: string,
commandId: string
): CreatedMutationResult {
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
const database = getDatabase();
return database.transaction(() => {
const duplicate = database
.prepare(
`SELECT created_show_id FROM show_duplication_commands
WHERE source_show_id = ? AND command_id = ?`
)
.get(sourceShowId, commandId) as { created_show_id: string } | undefined;
if (duplicate) {
const show = database
.prepare('SELECT revision, updated_at_utc FROM shows WHERE id = ?')
.get(duplicate.created_show_id) as { revision: number; updated_at_utc: string };
return {
id: duplicate.created_show_id,
revision: show.revision,
updatedAtUtc: show.updated_at_utc
};
}
const source = database
.prepare(
`SELECT sh.title, sh.twitch_title, sh.announcement_text
FROM shows sh JOIN show_finalizations f ON f.show_id = sh.id WHERE sh.id = ?`
)
.get(sourceShowId) as
{ title: string; twitch_title: string; announcement_text: string } | undefined;
if (!source) throw new Error('NOT_FOUND');
const id = randomUUID();
const now = new Date().toISOString();
const title = `${source.title} (Kopie)`.slice(0, 200);
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
status, revision, created_at_utc, updated_at_utc)
VALUES (?, ?, NULL, ?, ?, 'draft', 1, ?, ?)`
)
.run(id, title, source.twitch_title, source.announcement_text, now, now);
const sections = database
.prepare(
`SELECT id, name, category_id, category_name_snapshot, category_color_snapshot,
position, explicit_duration_seconds, is_reserve_bank
FROM show_sections WHERE show_id = ? ORDER BY position`
)
.all(sourceShowId) as {
id: string;
name: string;
category_id: string | null;
category_name_snapshot: string | null;
category_color_snapshot: string | null;
position: number;
explicit_duration_seconds: number | null;
is_reserve_bank: number;
}[];
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, category_name_snapshot,
category_color_snapshot, position, explicit_duration_seconds, is_reserve_bank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const usages = database.prepare(
'SELECT * FROM piece_usages WHERE show_section_id = ? ORDER BY position'
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const section of sections) {
const sectionId = randomUUID();
insertSection.run(
sectionId,
id,
section.name,
section.category_id,
section.category_name_snapshot,
section.category_color_snapshot,
section.position,
section.explicit_duration_seconds,
section.is_reserve_bank
);
for (const usage of usages.all(section.id) as (UsageRow & { is_reserve: number })[]) {
const usageId = randomUUID();
insertUsage.run(
usageId,
sectionId,
usage.original_piece_id,
usage.position,
usage.duration_seconds,
usage.title_snapshot,
usage.output_title_snapshot,
usage.intro_snapshot,
usage.research_snapshot,
usage.moderation_snapshot,
usage.interaction_snapshot,
usage.transition_snapshot,
usage.presentation_snapshot,
usage.presentation_mode_snapshot,
usage.presentation_source_label_snapshot,
usage.presentation_source_url_snapshot,
usage.presentation_source_timecode_snapshot,
usage.presentation_title_meta_snapshot,
usage.presentation_description_snapshot,
usage.presentation_image_url_snapshot,
usage.presentation_site_name_snapshot,
usage.presentation_provider_snapshot,
usage.presentation_provider_id_snapshot,
usage.own_notes,
usage.is_reserve
);
copyUsageRelations(database, usage.id, usageId);
}
}
database
.prepare(
`INSERT INTO show_duplication_commands
(source_show_id, command_id, created_show_id, created_at_utc) VALUES (?, ?, ?, ?)`
)
.run(sourceShowId, commandId, id, now);
return { id, revision: 1, updatedAtUtc: now };
})();
}
export function saveShowMetadata( export function saveShowMetadata(
showId: string, showId: string,
expectedRevision: number, expectedRevision: number,
@@ -1051,7 +1199,8 @@ export function updateShowSection(
sectionBelongsToShow(database, showId, sectionId); sectionBelongsToShow(database, showId, sectionId);
database database
.prepare( .prepare(
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ? `UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?,
category_name_snapshot = NULL, category_color_snapshot = NULL
WHERE id = ?` WHERE id = ?`
) )
.run(name, input.categoryId, duration, sectionId); .run(name, input.categoryId, duration, sectionId);
@@ -1126,7 +1275,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 +1292,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 +1328,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 +1347,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 +1435,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 +1458,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 +1477,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 +1512,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 +1531,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 +1550,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 +1565,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 +1606,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 +1629,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 +1643,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,
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { berlinLocalToUtc } from './time.js';
describe('Berlin local time conversion', () => {
it('converts valid civil time and rejects normalized or nonexistent values', () => {
expect(berlinLocalToUtc('2026-10-25T20:00')).toBe('2026-10-25T19:00:00.000Z');
expect(berlinLocalToUtc('')).toBeNull();
expect(() => berlinLocalToUtc('2026-02-31T12:00')).toThrow('INVALID_TIMESTAMP');
expect(() => berlinLocalToUtc('2026-03-29T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
expect(() => berlinLocalToUtc('2026-10-25T02:30', 'INVALID_EXPIRY')).toThrow('INVALID_EXPIRY');
});
});
+44
View File
@@ -0,0 +1,44 @@
export function berlinLocalToUtc(value: string, errorCode = 'INVALID_TIMESTAMP'): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error(errorCode);
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const localParts = (date: Date) => {
const formatted = formatter.formatToParts(date);
const read = (type: Intl.DateTimeFormatPartTypes) =>
formatted.find((part) => part.type === type)?.value ?? '';
return {
key: `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`,
milliseconds: Date.UTC(
Number(read('year')),
Number(read('month')) - 1,
Number(read('day')),
Number(read('hour')),
Number(read('minute'))
)
};
};
for (let attempt = 0; attempt < 2; attempt += 1) {
candidate += localMilliseconds - localParts(new Date(candidate)).milliseconds;
}
const result = new Date(candidate);
if (localParts(result).key !== value) throw new Error(errorCode);
if (
localParts(new Date(candidate - 60 * 60 * 1000)).key === value ||
localParts(new Date(candidate + 60 * 60 * 1000)).key === value
) {
throw new Error(errorCode);
}
return result.toISOString();
}
+5 -1
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({ export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, user:
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
? null
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'), presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
liveControllerMode: url.pathname === '/live' liveControllerMode: url.pathname === '/live'
}); });
+11
View File
@@ -34,6 +34,17 @@
{#if data.presentationMode} {#if data.presentationMode}
{@render children()} {@render children()}
{:else if data.guestMode}
<div class="guest-shell">
<header class="auth-header">
<span class="brand"
><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span
>Sendewerk</span
></span
><ThemeToggle />
</header>
{@render children()}
</div>
{:else if data.user && data.liveControllerMode} {:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main> <main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user} {:else if data.user}
+19 -1
View File
@@ -1,11 +1,15 @@
import { fail } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js'; import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
import { duplicateCompletedShow, listShows } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user); requireUser(locals.user);
return { return {
completedShows: listShows(['completed']),
duplicateCommandId: randomUUID(),
pieces: listPieces({ statuses: ['archived'] }), pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({ deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'], statuses: ['inbox', 'preparing', 'ready', 'archived'],
@@ -34,5 +38,19 @@ export const actions = {
} catch { } catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' }); return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
} }
},
duplicateShow: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(
String(data.get('showId') || ''),
String(data.get('commandId') || '')
);
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
}
} }
} satisfies Actions; } satisfies Actions;
+22 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
</script> </script>
@@ -9,10 +11,29 @@
<div class="page-wrap narrow-page"> <div class="page-wrap narrow-page">
<header class="page-heading"> <header class="page-heading">
<h1>Archiv</h1> <h1>Archiv</h1>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p> <p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p>
</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}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} {#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list"> {#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article> {#each data.pieces as piece (piece.id)}<article>
<div> <div>
@@ -0,0 +1,31 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getAuthConfig } from '#lib/server/config.js';
import { authenticateShare, getSharedShow, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const load: PageServerLoad = ({ params, cookies }) => {
return getSharedShow(params.token, cookies.get(SHARE_SESSION_COOKIE));
};
export const actions = {
password: async ({ params, request, cookies, getClientAddress }) => {
const data = await request.formData();
try {
const session = await authenticateShare(
params.token,
String(data.get('password') || ''),
getClientAddress()
);
cookies.set(SHARE_SESSION_COOKIE, session.token, {
path: `/freigabe/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: getAuthConfig().secureCookies,
expires: session.expires
});
return { success: true };
} catch {
return fail(401, { error: 'Passwort falsch oder Freigabe nicht mehr verfügbar.' });
}
}
} satisfies Actions;
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { page } from '$app/state';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin nicht angegeben';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Lesefreigabe</p>
<h1>Nicht verfügbar</h1>
<p>Dieser Link ist ungültig, abgelaufen oder wurde widerrufen.</p>
</section>
{:else if data.status === 'password'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Geschützte Lesefreigabe</p>
<h1>Passwort erforderlich</h1>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/password" class="compact-form">
<label
>Passwort<input
name="password"
type="password"
required
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
</header>
<div class="shared-rundown">
{#each data.share.sections as section (section)}
<section class="quiet-panel shared-section">
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
{#if section.explicitDurationSeconds !== null}<span
>{formatDuration(section.explicitDurationSeconds)}</span
>{/if}
</header>
{#each section.usages as usage (usage)}
<article class="shared-usage">
<div class="shared-usage-heading">
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen'}</small
>
<h3>{usage.title}</h3>
</div>
<span>{formatDuration(usage.actualDurationSeconds || usage.durationSeconds)}</span>
</div>
{#if usage.media}<img
src={`${page.url.pathname}/media/${usage.media.id}`}
alt={usage.media.alt_text}
/>{/if}
{#if 'introHtml' in usage && usage.introHtml}<div class="shared-copy">
<strong>Einleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.introHtml}</div>
</div>{/if}
{#if 'moderationHtml' in usage && usage.moderationHtml}<div class="shared-copy">
<strong>Moderation</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.moderationHtml}</div>
</div>{/if}
{#if 'interactionHtml' in usage && usage.interactionHtml}<div class="shared-copy">
<strong>Interaktion</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.interactionHtml}</div>
</div>{/if}
{#if 'transitionHtml' in usage && usage.transitionHtml}<div class="shared-copy">
<strong>Überleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.transitionHtml}</div>
</div>{/if}
{#if 'researchHtml' in usage && usage.researchHtml}<details>
<summary>Recherche</summary>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="shared-copy">{@html usage.researchHtml}</div>
</details>{/if}
{#if 'sources' in usage && usage.sources?.length}<details>
<summary>Quellen</summary>
<ul>
{#each usage.sources ?? [] as source (source)}<li>
<!-- External source URLs are validated and stored server-side. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} rel="noreferrer">{source.label || source.url}</a>
</li>{/each}
</ul>
</details>{/if}
{#if 'tools' in usage && usage.tools?.length}<details>
<summary>Werkzeuge</summary>
<ul>
{#each usage.tools ?? [] as tool (tool)}<li>
<strong>{tool.name}</strong>{#if tool.note}
· {tool.note}{/if}
</li>{/each}
</ul>
</details>{/if}
</article>
{/each}
</section>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSharedMedia, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getSharedMedia(params.token, cookies.get(SHARE_SESSION_COOKIE), params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
+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"
+103 -1
View File
@@ -2,17 +2,26 @@ import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js'; import { requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.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 +50,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.' };
} }
@@ -59,7 +75,13 @@ export const load: PageServerLoad = ({ locals, url }) => {
const showId = url.searchParams.get('show') || listShows()[0]?.id; const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden'); if (!showId) error(404, 'Keine Sendung vorhanden');
try { try {
return { live: getLiveView(showId), commandId: randomUUID() }; const live = getLiveView(showId);
return {
live,
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
};
} catch (cause) { } catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND') if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden'); error(404, 'Sendung nicht gefunden');
@@ -75,6 +97,86 @@ 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')))
);
},
finalize: async ({ locals, request }) => {
const actor = requireUser(locals.user);
const data = await request.formData();
return attempt('finalize', () =>
finalizeShow({
showId: text(data, 'showId'),
commandId: text(data, 'commandId'),
expectedShowRevision: Number(data.get('showRevision')),
expectedLiveRevision: Number(data.get('revision')),
archivePieceIds: data.getAll('archivePieceIds').map(String),
actor
})
);
},
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();
+575 -174
View File
@@ -11,23 +11,41 @@
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 completionDialog = $state<HTMLDialogElement>();
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 +69,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 +96,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;
@@ -98,6 +145,10 @@
}); });
}); });
$effect(() => {
if (!data.completion && completionDialog?.open) completionDialog.close();
});
onMount(() => { onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`; const key = `sendewerk-live-${data.live.show.id}`;
try { try {
@@ -105,6 +156,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 +175,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 +227,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 +265,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 +326,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 +352,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 +370,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 +400,340 @@
<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> {#if data.live.show.status === 'completed'}
<strong>Sendung</strong> <p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
{#if needsResume}<form <a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
{:else}
<form method="POST" action={liveAction('resumeEnded')} 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="play" /> Lauf fortsetzen</button
>
</form>
<form
method="POST" method="POST"
action={liveAction('start')} action={liveAction('startNewRun')}
use:enhance={enhanceCommand} 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="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"
</form>{/if} ><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })} >
>Liveansicht verlassen · Zur Planung</a </form>
<button
class="primary-button"
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
</button>
{/if}
{:else}
<form
method="POST"
action={liveAction(data.live.isPaused ? 'resume' : 'pause')}
use:enhance={enhanceCommand}
> >
</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="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"
action={liveAction('resetSectionTimer')}
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="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Abschnittszeit zurücksetzen</button
>
</form>{/if}
<form
method="POST"
action={liveAction('resetTotalTimer')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) 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="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">
@@ -383,8 +742,51 @@
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if} {#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div> </div>
<dialog bind:this={completionDialog} class="completion-dialog">
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
gesendet.
</p>
{#if data.completion}
<div class="completion-counts">
<span><strong>{data.completion.doneCount}</strong> gesendet</span><span
><strong>{data.completion.skippedCount}</strong> übersprungen</span
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
><strong>{candidate.title}</strong>{#if candidate.usageCount > 1}<small
>{candidate.usageCount} gesendete Verwendungen</small
>{/if}</span
></label
>{/each}
{#if !data.completion.candidates.length}<p class="quiet-copy">
Keine archivierbaren Originale vorhanden.
</p>{/if}
</fieldset>
{/if}
<input type="hidden" name="showId" value={data.live.show.id} />
<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} />
<footer>
<button class="text-button" type="button" onclick={() => completionDialog?.close()}
>Abbrechen</button
><button class="primary-button" type="submit">Historischen Stand abschließen</button>
</footer>
</form>
</dialog>
<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 +794,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 +847,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 +863,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 +930,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 +961,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 +1041,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 +1141,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 +1163,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
+31 -35
View File
@@ -1,45 +1,41 @@
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';
import { berlinLocalToUtc } from '#lib/server/time.js';
function berlinLocalToUtc(value: string): string | null { export const load: PageServerLoad = ({ locals, url }) => {
if (!value) return null; const user = requireUser(locals.user);
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); const today = new Date();
if (!match) throw new Error('INVALID_TIMESTAMP'); const todayMonth = berlinMonthKey(today);
const parts = match.slice(1).map(Number); const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]); return {
let candidate = localMilliseconds; shows: listShows(['draft', 'ready', 'live']),
for (let attempt = 0; attempt < 2; attempt += 1) { templates: listShowTemplates(),
const formatted = new Intl.DateTimeFormat('en-CA', { view: getShowOverviewView(user.id),
timeZone: 'Europe/Berlin', selectedMonth,
year: 'numeric', previousMonth: shiftMonth(selectedMonth, -1),
month: '2-digit', nextMonth: shiftMonth(selectedMonth, 1),
day: '2-digit', todayMonth,
hour: '2-digit', todayDate: berlinDateKey(today)
minute: '2-digit', };
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
}; };
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>
+96 -29
View File
@@ -1,13 +1,17 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit'; import { error, 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 { requireBarkeeper, requireUser } from '#lib/server/auth.js';
import { getHistoricalActualDurations } from '#lib/server/completion.js';
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { import {
addPieceUsage, addPieceUsage,
addShowSection, addShowSection,
createAndAddPieceUsage, createAndAddPieceUsage,
duplicatePieceUsage, duplicatePieceUsage,
duplicateCompletedShow,
getShow, getShow,
getPieceUsageContexts, getPieceUsageContexts,
movePieceUsage, movePieceUsage,
@@ -35,32 +39,6 @@ function pieceDurationInSeconds(data: FormData) {
return Math.round(Number(data.get('durationMinutes')) * 60); return Math.round(Number(data.get('durationMinutes')) * 60);
} }
function berlinLocalToUtc(value: string): string | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error('INVALID_TIMESTAMP');
const parts = match.slice(1).map(Number);
const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
let candidate = localMilliseconds;
for (let attempt = 0; attempt < 2; attempt += 1) {
const formatted = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(new Date(candidate));
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(formatted.find((part) => part.type === type)?.value);
candidate +=
localMilliseconds -
Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute'));
}
return new Date(candidate).toISOString();
}
function berlinInput(value: string | null) { function berlinInput(value: string | null) {
if (!value) return ''; if (!value) return '';
const parts = new Intl.DateTimeFormat('en-CA', { const parts = new Intl.DateTimeFormat('en-CA', {
@@ -104,6 +82,12 @@ function actionError(cause: unknown) {
}; };
if (code === 'INVALID_DURATION') if (code === 'INVALID_DURATION')
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
if (code === 'INVALID_PASSWORD')
return { status: 400, error: 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.' };
if (code === 'INVALID_EXPIRY')
return { status: 400, error: 'Der Ablaufzeitpunkt muss in der Zukunft liegen.' };
if (code === 'INVALID_SCOPE' || code === 'INVALID_PASSWORD_MODE')
return { status: 400, error: 'Die Einstellungen der Freigabe sind ungültig.' };
if (code.startsWith('INVALID_')) if (code.startsWith('INVALID_'))
return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' };
return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' }; return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' };
@@ -118,9 +102,26 @@ function attempt(intent: string, operation: () => unknown) {
} }
export const load: PageServerLoad = ({ locals, params, url }) => { export const load: PageServerLoad = ({ locals, params, url }) => {
requireUser(locals.user); const actor = requireUser(locals.user);
const show = getShow(params.id); const show = getShow(params.id);
if (!show) error(404, 'Sendung nicht gefunden'); if (!show) error(404, 'Sendung nicht gefunden');
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
if (show.status === 'completed') {
return {
show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: true,
actualDurations: getHistoricalActualDurations(show.id),
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc),
pieces: [],
taxonomy: getTaxonomy(),
pieceTemplates: [],
createCommandId: randomUUID(),
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
};
}
const pieces = listPieces({ const pieces = listPieces({
statuses: ['ready'], statuses: ['ready'],
query: url.searchParams.get('q') ?? '', query: url.searchParams.get('q') ?? '',
@@ -132,6 +133,11 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
); );
return { return {
show, show,
shares,
canManageShares: actor.role === 'barkeeper',
historical: false,
actualDurations: {} as Record<string, number>,
duplicateCommandId: randomUUID(),
startsAtLocal: berlinInput(show.startsAtUtc), startsAtLocal: berlinInput(show.startsAtUtc),
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
taxonomy: getTaxonomy(), taxonomy: getTaxonomy(),
@@ -147,6 +153,66 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
}; };
export const actions = { export const actions = {
createShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: params.id,
scope: text(data, 'scope'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(actionError(cause).status, { intent: 'createShare', ...actionError(cause) });
}
},
updateShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(
actor,
text(data, 'shareId'),
{
scope: text(data, 'scope'),
passwordMode: text(data, 'passwordMode'),
password: text(data, 'password'),
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
},
params.id
);
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
}
},
deleteShare: async ({ locals, params, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, text(data, 'shareId'), params.id);
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(actionError(cause).status, { intent: 'deleteShare', ...actionError(cause) });
}
},
duplicateShow: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(params.id, text(data, 'commandId'));
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(actionError(cause).status, { intent: 'duplicateShow', ...actionError(cause) });
}
},
saveMetadata: async ({ locals, params, request }) => { saveMetadata: async ({ locals, params, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
@@ -237,6 +303,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'],
File diff suppressed because it is too large. Load diff
+53
View File
@@ -8,6 +8,9 @@ import {
type TaxonomyKind type TaxonomyKind
} from '#lib/server/content.js'; } from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js'; import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js'; import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { import {
inviteUser, inviteUser,
@@ -28,6 +31,8 @@ export const load: PageServerLoad = ({ locals }) => {
users: listUsers(), users: listUsers(),
invitations: listOpenInvitations(), invitations: listOpenInvitations(),
tools: listTools(true), tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
mailTransport: getMailConfig().transport mailTransport: getMailConfig().transport
}; };
}; };
@@ -50,6 +55,10 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_TOOL_FORMAT') if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.'; return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.'; if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
if (code === 'INVALID_PASSWORD') return 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.';
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
return 'Die Aktion konnte nicht ausgeführt werden.'; return 'Die Aktion konnte nicht ausgeführt werden.';
} }
@@ -65,6 +74,50 @@ function toolInput(data: FormData): ToolInput {
} }
export const actions = { export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: String(data.get('showId') || ''),
scope: String(data.get('scope') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return {
intent: 'createShare',
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
shareUrl: `/freigabe/${share.token}`
};
} catch (cause) {
return fail(400, { intent: 'createShare', error: actionError(cause) });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, String(data.get('shareId') || ''), {
scope: String(data.get('scope') || ''),
passwordMode: String(data.get('passwordMode') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, String(data.get('shareId') || ''));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(400, { intent: 'deleteShare', error: actionError(cause) });
}
},
invite: async ({ locals, request }) => { invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user); const actor = requireBarkeeper(locals.user);
const data = await request.formData(); const data = await request.formData();
+14 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte'; import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte'; import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
@@ -18,8 +19,19 @@
<h1>Verwaltung</h1> <h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p> <p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if} class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<section class="quiet-panel account-panel"> <section class="quiet-panel account-panel">
<div class="panel-heading"> <div class="panel-heading">
+473 -23
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from '@playwright/test'; import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const adminEmail = 'barkeeper@example.test'; const adminEmail = 'barkeeper@example.test';
@@ -85,7 +85,8 @@ function addSentHistoryFixture(pieceId: string) {
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
const insertShow = database.prepare( const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)` revision, created_at_utc, updated_at_utc, completed_at_utc)
VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?, ?)`
); );
const insertSection = database.prepare( const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position, `INSERT INTO show_sections (id, show_id, name, category_id, position,
@@ -104,9 +105,24 @@ function addSentHistoryFixture(pieceId: string) {
] as const) { ] as const) {
const showId = `e2e-history-${suffix}`; const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`; const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date); insertShow.run(showId, title, date, date, date, date);
insertSection.run(sectionId, showId); insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId); const usageId = `e2e-history-usage-${suffix}`;
insertUsage.run(usageId, sectionId, pieceId);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)`
)
.run(showId, `fixture-${suffix}`, date);
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot,
usage_title_snapshot, used_at_utc, actual_duration_seconds)
VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)`
)
.run(usageId, showId, pieceId, title, date);
} }
})(); })();
database.close(); database.close();
@@ -117,10 +133,120 @@ function removeSentHistoryFixture() {
if (!dataDirectory) return; if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON'); database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run();
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run(); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close(); database.close();
} }
function addCompletionFixture() {
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'));
database.pragma('foreign_keys = ON');
const now = '2026-10-25T19:00:00.000Z';
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true });
writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png);
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-completion', 'E2E Abschluss', ?, '', '', 'live', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES
('e2e-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0),
('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)`
);
insertUsage.run(
'e2e-completion-done',
'piece-console',
0,
'done',
'Gesendeter Abschlussbeitrag',
'FREIGABE_EINLEITUNG',
'PRIVATE_FREIGABE_RECHERCHE',
'FREIGABE_MODERATION',
'FREIGABE_INTERAKTION',
'FREIGABE_UEBERLEITUNG',
'PRIVATE_FREIGABE_NOTIZ'
);
insertUsage.run(
'e2e-completion-skipped',
'piece-effects',
1,
'skipped',
'Übersprungener Abschlussbeitrag',
'',
'PRIVATE_SKIPPED_RESEARCH',
'',
'',
'',
''
);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES ('e2e-completion', 0, ?, ?, 0, ?)`
)
.run('2026-10-25T18:00:00.000Z', now, now);
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png',
'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)`
)
.run(png.byteLength, now);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media',
'Freigegebenes Abschlussbild', 0, 1)`
)
.run();
})();
database.close();
}
function removeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
database
.prepare(
`DELETE FROM show_share_sessions WHERE share_id IN (
SELECT id FROM show_shares WHERE show_id = 'e2e-completion'
)`
)
.run();
database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run();
})();
database.close();
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addPresentationFixture() { function addPresentationFixture() {
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.');
@@ -196,6 +322,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-25T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09: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 +653,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 +1291,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 +1375,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 +1404,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 +1434,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 +1509,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 +1576,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 +1601,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 +1628,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 });
@@ -1363,6 +1658,161 @@ test('live control persists state and exposes only explicit presentation data',
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
}); });
test('completion, archive and protected guest sharing work end to end', async ({
page,
browser
}, testInfo) => {
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click();
const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href');
expect(planningSharePath).toMatch(/^\/freigabe\//);
await page.screenshot({
path: testInfo.outputPath('share-editor-desktop.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1280, height: 900 });
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Sendungsplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
.locator('html')
.getAttribute('data-theme-preference');
await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click();
await expect(planningGuestPage.locator('html')).not.toHaveAttribute(
'data-theme-preference',
guestThemePreference!
);
const planningShareRow = planningShares.locator('.share-row').first();
await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click();
await planningShareRow.getByLabel('Informationsumfang').selectOption('research');
await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace');
await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort');
await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await planningGuestPage.reload();
await expect(
planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' })
).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await planningShares
.locator('.share-row')
.first()
.locator('summary')
.getByText('Vollständig mit Recherche')
.click();
await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click();
await planningGuestPage.reload();
await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
await planningGuest.close();
await page.goto('/live?show=e2e-completion');
await page.getByRole('button', { name: 'Menü', exact: true }).click();
await page.getByRole('button', { name: 'Sendung abschließen' }).click();
const completion = page.locator('.completion-dialog');
await expect(completion).toBeVisible();
await expect(completion).toContainText('1 gesendet');
await expect(completion).toContainText('1 übersprungen');
await expect(completion.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(completion.getByText('Warum praktische Effekte wieder auffallen')).toHaveCount(0);
await completion.getByRole('button', { name: 'Historischen Stand abschließen' }).click();
await expect(completion).toBeHidden();
await page.goto('/archiv');
await expect(page.getByRole('link', { name: 'E2E Abschluss' })).toBeVisible();
await page.getByRole('link', { name: 'E2E Abschluss' }).click();
await expect(page.getByText('Unveränderliche Sendungshistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sendungsdaten speichern' })).toHaveCount(0);
await page.goto('/verwaltung');
const shareManagement = page.locator('.share-management');
await shareManagement.getByLabel('Sendung').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
const shareLink = page.locator('.share-result a');
await expect(shareLink).toBeVisible();
const sharePath = await shareLink.getAttribute('href');
expect(sharePath).toMatch(/^\/freigabe\//);
await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true });
const guest = await browser.newContext();
const guestPage = await guest.newPage();
await guestPage.goto(sharePath!);
await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible();
const passwordHtml = await guestPage.content();
expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Sendung öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
const imagePath = await guestPage
.getByAltText('Freigegebenes Abschlussbild')
.getAttribute('src');
const sharedImage = await guest.request.get(imagePath!);
expect(sharedImage.status()).toBe(200);
expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox");
expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404);
const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', {
maxRedirects: 0,
form: { title: 'Verboten', revision: '2', status: 'draft' }
});
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
await shareRow.locator('summary').getByText('Ablauf und Moderation').click();
await shareRow.getByLabel('Informationsumfang').selectOption('rundown');
await shareRow.locator('select[name="passwordMode"]').selectOption('remove');
await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0);
page.once('dialog', (dialog) => dialog.accept());
const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await updatedCard
.locator('.share-row')
.first()
.locator('summary')
.getByText('Nur Ablauf')
.click();
await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
expect((await guest.request.get(imagePath!)).status()).toBe(404);
await guest.close();
} finally {
removeCompletionFixture();
}
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page, page,
browser browser