diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 1e9a829..2027760 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -424,30 +424,40 @@ ## Kompakte Live-Regie 0.5.12 -| Kriterium | Nachweis | Stand | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- | -| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 | -| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 | -| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 | -| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 | -| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 | -| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 | -| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 | -| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 | -| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 | -| 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 | +| Kriterium | Nachweis | Stand | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------- | +| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 | +| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 | +| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 | +| Zweizeiliger Kopf gewinnt Inhaltsfläche | Chromium begrenzt die Kopfhöhe bei 670 × 450 und prüft die unveränderte feste Aktionsleiste | erfüllt am 24.10.2026 | +| Lange Kopftitel bleiben kontrolliert | Abschnitt und Folgetitel kürzen einzeilig und öffnen vollständig per Klick, Enter und Escape | erfüllt am 24.10.2026 | +| Regiemenü bleibt sichtbar und erreichbar | Browser prüft Viewportgrenzen, Scrollzugang, Sendungsname und Aktionen bei 1280, 670 und 390 Pixeln | erfüllt am 24.10.2026 | +| Menü schließt zugänglich | Escape und Außenklick schließen das Popover und führen den Fokus zum Auslöser zurück | erfüllt am 24.10.2026 | +| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 | +| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 | +| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 | +| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 | +| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 | +| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 | +| Zurück ist reine Betrachtungsnavigation | Browser prüft unverändertes aktuelles Piece, Countdown und Ausgabe sowie gesperrte Fortschrittsaktionen | erfüllt am 24.10.2026 | +| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 | +| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 | +| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 | +| Liste und Kalender nutzen dieselben Daten | Chromium wechselt zwischen beiden Ansichten; Sendungslinks bleiben identisch erreichbar | erfüllt am 24.10.2026 | +| Kalenderpräferenz bleibt am Konto | Service- und Browsertest prüfen Standardwert, Speicherung und Reload | erfüllt am 24.10.2026 | +| Monat und Berliner Tage sind korrekt | Unit-Tests prüfen Monatsgrenzen und UTC-Zuordnung; Browser prüft Monatswechsel und heutigen Tag | erfüllt am 24.10.2026 | +| Mehrere und terminlose Sendungen bleiben sichtbar | Desktopkalender und mobile Agenda zeigen zwei Termine am Tag; eigener Bereich zeigt offenen Termin | erfüllt am 24.10.2026 | ## Ausgeführte Prüfungen 0.5.12 - `npm run lint`: erfolgreich - `npm run check`: 0 Fehler und 0 Warnungen -- `npm test`: 40 Tests in 7 Dateien erfolgreich -- gezielter Live-Regie-Lauf: 1 von 1 Chromium-Test erfolgreich -- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich -- Screenshots der Live-Regie bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln - einschließlich mobilem Ablauf-Sheet visuell kontrolliert +- `npm test`: 44 Tests in 8 Dateien erfolgreich +- gezielte Kalender- und Live-Regie-Läufe: 2 von 2 Chromium-Tests erfolgreich +- `npm run test:e2e`: 17 von 17 Chromium-Browsertests erfolgreich +- Browserprüfungen der Live-Regie bei 1440 × 900, 900 × 450, 670 × 450 und + 390 × 700 Pixeln sowie des Kalenders bei 1440 × 1000 und 390 × 844 Pixeln + erfolgreich - `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft - `bash -n start.sh` und `git diff --check`: erfolgreich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a75508..193980e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -192,6 +192,13 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können die Vorlage deshalb nicht verändern. +Die Sendungsübersicht leitet Liste, Desktop-Monatsraster und mobile Monatsagenda +aus derselben `listShows()`-Abfrage ab. UTC-Zeitpunkte werden erst für Gruppierung +und Anzeige nach `Europe/Berlin` in zivile Kalendertage übersetzt. Sendungen ohne +Startzeit bleiben in einem eigenen Bereich sichtbar. `users.show_overview_view` +speichert ausschließlich die kontobezogene Darstellungspräferenz; der gewählte +Monat bleibt URL-Zustand und verändert keine Sendungsdaten. + Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs @@ -241,10 +248,13 @@ Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die Grenze passieren. -Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den -Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den -gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen -erst nach einer ausdrücklichen Snapshot-Aktualisierung. +Lokale Betrachtungsnavigation, Fortschaltung oder Wiederaufnahme verändern den +Präsentationsstand nicht. Zurück und die Auswahl einer Ablaufzeile wechseln nur +den im Browser betrachteten Verwendungssnapshot; tatsächliches Live-Piece, +Status, Timer und Ausgabe bleiben serverseitig unverändert. Die öffentliche +Projektion liest ausschließlich den ausdrücklich präsentierten +Verwendungssnapshot; spätere Änderungen am Original erscheinen erst nach einer +ausdrücklichen Snapshot-Aktualisierung. Linkmetadaten werden nur auf ausdrückliche Aktion durch `src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost @@ -279,6 +289,10 @@ die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. +Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen +Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges +Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare, +touchfreundliche Agenda desselben Monats. Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht ohne interne Anwendungsnavigation. @@ -292,9 +306,18 @@ Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten, ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand -aufgehoben. Dieser Layoutmodus ändert weder die Live-Actions noch die getrennte -öffentliche `/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige -Verwendung gebunden und werden nicht zwischen Pieces übertragen. +aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst +Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten +Zustand. Die öffentliche `/heinzlive`-Projektion bleibt unverändert. Lokale +Lesepositionen sind an die jeweilige Verwendung gebunden und werden nicht +zwischen Pieces übertragen. + +Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes +Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen +Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport +positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem +Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context; +seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar. Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter `static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index dad3946..c33fa9f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -371,8 +371,34 @@ breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. Die Höhe steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße und Gesamtoberfläche werden nicht skaliert. -Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl, -Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte -serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein -Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche -Präsentationsaction verändert die getrennte öffentliche Ausgabe. +Der Kopf verwendet genau zwei kompakte Informationszeilen. Lange Kontexttitel +bleiben im Ruhezustand einzeilig und werden auf bewusste Klick- oder +Tastaturaktion lokal vollständig sichtbar. Das Regiemenü ist kein verschachteltes +`details` innerhalb der Glasfläche, sondern ein explizit gesteuertes, +viewportgebundenes Popover. So bleiben Z-Index, Maximalhöhe, Escape, +Außenklick und Fokus-Rückkehr unabhängig von Overflow und Filter-Stacking- +Contexts kontrollierbar. + +Diese Darstellung ist keine neue Live-Zustandsgrenze. Zurück und die direkte +Auswahl im Ablauf sind reine lokale Betrachtungsnavigation. Sie verändern weder +aktuelles Live-Piece noch Status, Zeitintervalle, Abschnittstimer oder Ausgabe. +Während ein anderes Piece betrachtet wird, bleiben Fortschrittsaktionen +gesperrt; Präsentieren darf dagegen weiterhin ausdrücklich genau das betrachtete +Piece ausgeben. Start, Erledigen, Überspringen, Reserveeinfügung und Präsentation +bleiben serverseitige Actions mit ihren bisherigen Revisions- und +Idempotenzregeln. + +## D-025: Gemeinsame Sendungsdaten für Liste und Kalender + +Listenansicht, Desktop-Monatsraster und mobile Monatsagenda werden aus derselben +kompakten Sendungsliste abgeleitet. Es gibt kein zweites Kalenderdatenmodell und +keine Bibliothek für Terminverwaltung. UTC-Zeitpunkte werden mit +`Europe/Berlin` einem zivilen Tag zugeordnet; der ausgewählte Monat bleibt als +URL-Parameter navigierbar. Mehrere Sendungen eines Tages werden vollständig und +chronologisch gezeigt, Sendungen ohne Startzeit separat. + +Die Präferenz `list` oder `calendar` gehört zum angemeldeten Konto und wird als +kleines geprüftes Feld an `users` gespeichert. Sie ist weder Bestandteil einer +Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält +bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die +bestehende serverseitig validierte Sendungsbearbeitung erfolgt. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index c77f4bf..3129656 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,29 +1,33 @@ # Release Notes -## 0.5.12 - Kompakte Live-Regie +## 0.5.12 - Live-Regie und Sendungskalender Veröffentlicht am 24. Oktober 2026 Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht -für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Das -aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die -wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche -heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt. +für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Die +Sendungsübersicht erhält zusätzlich eine kontobezogen gespeicherte Listen- und +Kalenderansicht. Die öffentliche heinzlive-Ausgabe und die gespeicherten +Livezustände bleiben technisch getrennt. ### Highlights - Die Live-Regie verwendet die gesamte Fensterfläche ohne normale App-Seitenleiste, mobilen App-Header oder Bottomnavigation. -- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den - tatsächlich präsentierten Ausgabezustand. +- Zwei kompakte Kopfzeilen halten Abschnitt, „Danach“, Menü, Ausgabe, + Abschnittscountdown und Ablauf gleichzeitig erreichbar. Der Sendungsname ist + in das Menü gewandert. +- Lange Abschnitts- und Folgetitel bleiben einzeilig gekürzt und lassen sich per + Klick oder Tastatur vollständig aufklappen, ohne benachbarte Bedienelemente zu + überlagern. - Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar. - Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren. -- Die feste Fußleiste bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und - „Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende - eindeutig benannt. +- Die feste Fußleiste ist auf „Zurück“, „Präsentieren“, „Überspringen“ und + „Erledigen & weiter“ reduziert. Das nächste Piece steht in der Kopfzeile, das + Leeren der Präsentation direkt am Ausgabezustand. - Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet, während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber. @@ -36,21 +40,25 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt werden nur bei Bedarf geöffnet. - Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal gespeichert. Texte werden nicht automatisch verkleinert. +- Piece-Position, Piece-Titel und Zeit stehen in einer gemeinsamen kompakten + Kopfzeile; dadurch beginnt die Einleitung weiter oben. ### Ablauf und Liveverhalten - Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und das aktive Piece bleibt in der Ablaufspalte sichtbar. -- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch - textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags. +- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und + ausgegebenes Piece getrennt. Jeder Eintrag lässt sich zur Betrachtung wählen, + einschließlich des ersten Eintrags. - Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über eine kompakte Aktion am jeweiligen Eintrag erreichbar. - Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben. -- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts. - Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche - Ausgabe. +- „Zurück“ und direkte Ablaufwahl verändern weder aktuelles Live-Piece noch + Erledigungsstatus, Timer oder Ausgabe. Bei einem nur betrachteten früheren + Piece bleiben Überspringen und Erledigen gesperrt. Nur „Präsentieren“ oder + „Präsentation leeren“ verändert die öffentliche Ausgabe. - Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme verwenden unverändert die vorhandenen persistenten Zustände. - „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich @@ -60,21 +68,41 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt - Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln, Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar. +- Das Regiemenü erscheint als viewportgebundenes Popover sicher über den + Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt + mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück. + +### Sendungsübersicht + +- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer + Monatsansicht um; die Auswahl bleibt am angemeldeten Konto gespeichert. +- Auf dem Desktop zeigt ein vollständiges Montag-bis-Sonntag-Raster alle Termine + des Monats. Mehrere Sendungen pro Tag bleiben vollständig sichtbar und nach + Uhrzeit sortiert. +- Auf dem Smartphone wird derselbe Monat als chronologische Agenda dargestellt. + Vorheriger Monat, nächster Monat und der Sprung zu heute bleiben erreichbar. +- Die Gruppierung verwendet `Europe/Berlin`. Sendungen ohne Startzeit stehen in + einem eigenen Bereich und verschwinden weder aus Liste noch Kalender. +- Der Kalender ist eine Navigationsansicht. Termine werden weiterhin nur über + die bestehende Sendungsbearbeitung geändert; Termin-Drag-and-drop wurde nicht + ergänzt. ### Update und Kompatibilität -- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der - Konfiguration. +- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene + Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`. +- Es ist keine Änderung der Konfiguration erforderlich. - Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und Präsentationssnapshots bleiben erhalten. - Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`. ### Prüfung -- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe sind erfolgreich. -- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit - langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft; - die erzeugten Screenshots wurden visuell kontrolliert. +- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich. +- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln + mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und + mobilem Ablauf-Sheet geprüft; der Kalender zusätzlich bei 1440 × 1000 und + 390 × 844 Pixeln. - Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät standen nicht zur Verfügung. diff --git a/docs/STATUS.md b/docs/STATUS.md index e16083c..b15b66b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,14 +7,19 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Etappe 6 wurde nicht begonnen. -## Kompakte Live-Regie 0.5.12 +## Live-Regie und Sendungskalender 0.5.12 - fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und Ausgabekanal bleiben über das kompakte Regiemenü erreichbar -- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und - öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece, - Präsentation, Überspringen und kontextgerechtem Erledigen +- zweizeilige feste Kopfzeile: Abschnitt und „Danach“ teilen sich die erste + Zeile mit dem Menü; Ausgabe, Abschnittscountdown und Ablaufschalter bilden die + zweite Zeile. Der Sendungsname steht platzsparend im Menü +- lange Abschnitts- und Folgetitel sind regulär einzeilig gekürzt und lassen + sich per Klick oder Tastatur vollständig aufklappen +- feste, auf vier Kernaktionen reduzierte Fußleiste mit Zurück, Präsentieren, + Überspringen und kontextgerechtem Erledigen; das Leeren der Präsentation liegt + direkt beim Ausgabezustand - aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen @@ -22,22 +27,33 @@ Etappe 6 wurde nicht begonnen. verfügbaren Höhe verdichtet, ohne Text oder Gesamtoberfläche zu skalieren - auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen -- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter - Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und +- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, lokaler + Betrachtung jedes Pieces, automatisch sichtbarem aktuellen Eintrag und zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion erreichbar, dauerhafte Sortierpfeile entfallen - die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece beginnt oben statt an der Position des vorherigen Textes +- Piece-Position, Titel und Zeit teilen sich einen kompakten Piece-Kopf, ohne die + einstellbare Moderationsschrift zu verkleinern +- das Regiemenü liegt als viewportgebundenes Popover sicher über den Glasflächen, + bleibt scrollbar erreichbar und schließt per Escape oder Außenklick mit + Fokus-Rückkehr zum Auslöser - Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf; leere Moderationsbereiche werden nicht dargestellt -- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation - verwenden unverändert ihre getrennten persistenten Zustände +- Zurück und direkte Ablaufwahl verändern weder tatsächliches Live-Piece noch + Status, Timer oder öffentliche Ausgabe; Überspringen und Erledigen bleiben bei + einem nur betrachteten früheren Piece gesperrt +- Liste und Kalender teilen dieselben Sendungsdaten; der Desktop zeigt ein + vollständiges Monatsraster, das Smartphone eine chronologische Monatsagenda +- Monatswechsel, heutiger Tag, mehrere Termine pro Tag und Sendungen ohne Termin + bleiben sichtbar; die bevorzugte Listen- oder Kalenderansicht wird je Konto + gespeichert - die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem Live-Stand statt während eines laufenden Pieces Etappe 6 bleibt unverändert unbegonnen. -Die lokale Prüfmatrix umfasst 40 Verhaltenstests und 16 Chromium-Abläufe. +Die lokale Prüfmatrix umfasst 44 Verhaltenstests und 17 Chromium-Abläufe. Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät bleiben mangels verfügbarer Umgebung offen. diff --git a/migrations/0012_show_overview_view.sql b/migrations/0012_show_overview_view.sql new file mode 100644 index 0000000..29238ab --- /dev/null +++ b/migrations/0012_show_overview_view.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN show_overview_view TEXT NOT NULL DEFAULT 'list' +CHECK (show_overview_view IN ('list', 'calendar')); diff --git a/src/app.css b/src/app.css index 22d4d6d..0c79e58 100644 --- a/src/app.css +++ b/src/app.css @@ -1002,11 +1002,9 @@ textarea:focus { display: grid; position: relative; z-index: 60; - grid-template-columns: minmax(12rem, 1fr) auto minmax(8rem, 0.55fr) auto; - align-items: center; - gap: 0.65rem; + gap: 0.4rem; min-width: 0; - padding: 0.55rem 0.65rem; + padding: 0.45rem 0.55rem; border: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line)); border-radius: 0.9rem; background: var(--glass); @@ -1014,22 +1012,41 @@ textarea:focus { backdrop-filter: blur(20px) saturate(1.08); } -.live-section-context, -.live-output-state { +.live-context-row, +.live-status-row { + display: grid; + min-width: 0; + align-items: center; +} + +.live-context-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; +} + +.live-status-row { + grid-template-columns: minmax(12rem, 1fr) auto auto; + gap: 0.45rem; + padding-top: 0.35rem; + border-top: 1px solid var(--line); +} + +.live-output-state, +.live-context-summary, +.live-next-inline { display: flex; min-width: 0; align-items: center; gap: 0.55rem; } -.live-section-context > div, .live-output-state > div { display: grid; min-width: 0; } -.live-section-context small, .live-output-state small, +.live-next-inline small, .section-countdown span { color: var(--text-faint); font-size: 0.59rem; @@ -1038,7 +1055,6 @@ textarea:focus { text-transform: uppercase; } -.live-section-context strong, .live-output-state strong { overflow: hidden; font-size: 0.75rem; @@ -1046,6 +1062,67 @@ textarea:focus { white-space: nowrap; } +.live-context-summary { + gap: 0.45rem; +} + +.live-context-title { + display: block; + min-width: 0; + max-width: min(34rem, 42vw); + padding: 0.28rem 0.34rem; + border: 0; + border-radius: 0.42rem; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + font-weight: 760; + text-align: left; +} + +.live-context-title span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-context-title:hover, +.live-context-title:focus-visible, +.live-context-title.expanded { + background: var(--panel-soft); +} + +.live-context-title:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.live-context-title.expanded span { + overflow: visible; + white-space: normal; +} + +.live-next-inline { + flex: 1 1 20rem; + gap: 0.2rem; + padding-left: 0.45rem; + border-left: 1px solid var(--line); +} + +.live-next-inline small { + flex: 0 0 auto; + letter-spacing: 0; + text-transform: none; +} + +.live-next-inline .live-context-title { + flex: 1 1 auto; + font-size: 0.7rem; +} + .live-pulse { width: 0.48rem; height: 0.48rem; @@ -1089,23 +1166,69 @@ textarea:focus { background: var(--text-faint); } -.live-output-state.active > span { +.live-output-control.active .live-output-state > span { background: var(--accent); box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent); } -.live-mobile-run-button { - display: none; +.live-output-control { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.55rem; } -.live-menu { - position: relative; - z-index: 40; - width: fit-content; - justify-self: end; +.live-output-control .live-output-state { + min-width: 0; } -.live-menu > summary { +.live-output-control form { + display: flex; + flex: 0 0 auto; +} + +.live-output-control button { + padding: 0.32rem 0.48rem; + font-size: 0.62rem; +} + +.live-run-button { + display: flex; + min-height: 2rem; + align-items: center; + gap: 0.38rem; + padding: 0.35rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--panel-soft); + color: var(--text); + cursor: pointer; + font-size: 0.67rem; + font-weight: 740; +} + +.live-run-button:hover, +.live-run-button.active { + border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); + background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft)); + color: var(--accent-strong); +} + +.live-run-button:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.live-run-button .app-icon:last-child { + transition: transform 140ms ease; +} + +.live-run-button.active .app-icon:last-child { + transform: rotate(180deg); +} + +.live-menu-trigger { width: fit-content; padding: 0.48rem 0.65rem; border: 1px solid var(--line); @@ -1114,17 +1237,27 @@ textarea:focus { cursor: pointer; font-size: 0.7rem; font-weight: 750; - list-style: none; +} + +.live-menu-trigger:hover, +.live-menu-trigger[aria-expanded='true'] { + border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); + background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft)); +} + +.live-menu-trigger:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; } .live-menu-popover { - position: absolute; - z-index: 50; - top: calc(100% + 0.4rem); - right: 0; + position: fixed; + z-index: 200; + top: max(3.5rem, calc(env(safe-area-inset-top) + 3rem)); + right: max(0.65rem, env(safe-area-inset-right)); display: grid; width: min(22rem, calc(100vw - 1.3rem)); - max-height: calc(100dvh - 5rem); + max-height: calc(100dvh - 4rem - env(safe-area-inset-top)); overflow-y: auto; padding: 0.7rem; border: 1px solid var(--line-strong); @@ -1132,6 +1265,29 @@ textarea:focus { background: var(--glass-strong); box-shadow: var(--shadow-large); backdrop-filter: blur(24px); + overscroll-behavior: contain; +} + +.live-menu-show small, +.live-menu-show strong { + display: block; +} + +.live-menu-show small { + color: var(--text-faint); + font-size: 0.6rem; + font-weight: 720; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.live-menu-popover .live-menu-show > strong { + margin-top: 0.15rem; + color: var(--text); + font-size: 0.78rem; + letter-spacing: normal; + overflow-wrap: anywhere; + text-transform: none; } .live-menu-popover section { @@ -1231,39 +1387,42 @@ textarea:focus { } .live-piece-heading { - padding-bottom: 0.85rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; + gap: 0.35rem 0.65rem; + padding-bottom: 0.5rem; } -.live-kicker { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; +.live-piece-position { color: var(--accent); font-size: 0.65rem; font-weight: 750; letter-spacing: 0.055em; text-transform: uppercase; + white-space: nowrap; } -.live-kicker strong { +.live-piece-time { color: var(--text-muted); + font-size: 0.68rem; font-variant-numeric: tabular-nums; + white-space: nowrap; } .live-current h1 { - max-width: 30ch; - margin: 0.45rem 0 0; - font-size: clamp(1.45rem, 2.7vw, 2.55rem); + max-width: none; + margin: 0; + font-size: clamp(1.2rem, 2.2vw, 1.9rem); font-weight: 690; - letter-spacing: -0.045em; - line-height: 1.04; + letter-spacing: -0.035em; + line-height: 1.08; overflow-wrap: anywhere; } .live-script-section { max-width: 58rem; - padding: 1rem 0; + padding: 0.75rem 0; border-top: 1px solid var(--line); } @@ -1510,6 +1669,14 @@ textarea:focus { background: color-mix(in srgb, var(--gold) 10%, var(--panel)); } +.live-run-item.selected .live-run-select { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.live-run-item.presented { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent); +} + .live-run-item.done { opacity: 0.68; } @@ -1519,7 +1686,7 @@ textarea:focus { opacity: 0.62; } -.live-run-item > form:first-child { +.live-run-item > .live-run-select { min-width: 0; } @@ -1602,6 +1769,15 @@ textarea:focus { opacity: 1; } +.live-run-select:not(:disabled):hover { + background: color-mix(in srgb, var(--accent) 10%, var(--panel-soft)); +} + +.live-run-select:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} + .live-run-select strong, .live-reserve-item strong { font-size: 0.67rem; @@ -1673,9 +1849,8 @@ textarea:focus { display: flex; min-width: 0; align-items: center; - justify-content: space-between; - gap: 0.8rem; - padding: 0.55rem 0.65rem; + justify-content: flex-end; + padding: 0.4rem 0.55rem; border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); border-radius: 0.9rem; background: var(--glass-strong); @@ -1683,30 +1858,12 @@ textarea:focus { backdrop-filter: blur(22px) saturate(1.08); } -.live-next-summary { - display: grid; - min-width: 0; -} - -.live-next-summary span { - color: var(--accent); - font-size: 0.58rem; - font-weight: 760; - letter-spacing: 0.07em; - text-transform: uppercase; -} - -.live-next-summary strong { - overflow: hidden; - font-size: 0.72rem; - text-overflow: ellipsis; - white-space: nowrap; -} - .live-footer-actions { - display: flex; + display: grid; + width: min(100%, 42rem); align-items: center; - justify-content: flex-end; + grid-template-columns: repeat(4, minmax(0, auto)); + justify-content: end; gap: 0.4rem; } @@ -1715,6 +1872,7 @@ textarea:focus { } .live-footer-actions button { + min-height: 2.2rem; white-space: nowrap; } @@ -1748,7 +1906,7 @@ textarea:focus { } .live-piece-heading { - padding-bottom: 0.55rem; + padding-bottom: 0.4rem; } .live-script-section { @@ -1764,50 +1922,54 @@ textarea:focus { } .live-command-header { - grid-template-areas: - 'context menu' - 'countdown output' - 'run run'; + gap: 0.2rem; + padding: 0.32rem 0.4rem; + } + + .live-context-row { grid-template-columns: minmax(0, 1fr) auto; gap: 0.4rem; - padding: 0.45rem; } - .live-section-context { - grid-area: context; + .live-context-summary { + gap: 0.25rem; } - .section-countdown { - grid-area: countdown; - min-width: 0; - text-align: left; + .live-context-title { + max-width: 34vw; + padding-inline: 0.25rem; } - .live-output-state { - grid-area: output; - min-width: 0; + .live-next-inline { + gap: 0.12rem; + padding-left: 0.25rem; + } + + .live-status-row { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.3rem; + padding-top: 0.2rem; + } + + .live-output-state strong { + max-width: 8rem; + } + + .live-output-control { + gap: 0.3rem; + } + + .live-output-control button { padding-inline: 0.35rem; } - .live-menu { - grid-area: menu; + .section-countdown { + min-width: 5.5rem; + padding: 0.28rem 0.4rem; } - .live-mobile-run-button { - display: block; - grid-area: run; - width: 100%; - padding: 0.42rem 0.55rem; - border: 1px solid var(--line); - border-radius: 0.6rem; - background: var(--panel-soft); - color: var(--text); - font-size: 0.67rem; - font-weight: 740; - } - - .live-desktop-rail-toggle { - display: none; + .live-run-button { + padding-inline: 0.45rem; } .live-workspace, @@ -1855,7 +2017,11 @@ textarea:focus { } .live-current h1 { - font-size: clamp(1.35rem, 7vw, 2rem); + font-size: clamp(1.15rem, 5.5vw, 1.65rem); + } + + .live-piece-heading { + gap: 0.3rem 0.45rem; } .live-support-actions { @@ -1869,18 +2035,13 @@ textarea:focus { } .live-action-bar { - display: grid; - gap: 0.4rem; - padding: 0.45rem; - } - - .live-next-summary strong { - font-size: 0.68rem; + padding: 0.35rem; } .live-footer-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + grid-template-columns: repeat(4, minmax(0, 1fr)); + justify-content: stretch; gap: 0.3rem; } @@ -1897,16 +2058,41 @@ textarea:focus { font-size: 0.64rem; } - .live-clear-form { - grid-column: 1 / -1; - } - .live-finished-label { grid-column: 1 / -1; text-align: center; } } +@media (max-width: 23rem) { + .live-context-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + } + + .live-context-title { + max-width: none; + } + + .live-next-inline { + grid-column: 2; + padding-left: 0; + border-left: 0; + } + + .live-status-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .live-output-control { + grid-column: 1 / -1; + } + + .live-footer-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .heinzlive-output { min-height: 100vh; padding: clamp(2rem, 6vw, 6rem); @@ -3941,6 +4127,42 @@ textarea:focus { max-width: 78rem; } +.shows-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; +} + +.show-view-toggle { + display: flex; + padding: 0.2rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--panel-soft); +} + +.show-view-toggle button { + display: flex; + min-height: 2.2rem; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.65rem; + border: 0; + border-radius: 0.58rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 0.7rem; + font-weight: 740; +} + +.show-view-toggle button[aria-pressed='true'] { + background: var(--panel); + box-shadow: var(--shadow-soft); + color: var(--gold-strong); +} + .show-create-panel, .show-library, .show-settings { @@ -4157,6 +4379,237 @@ textarea:focus { font-size: 0.78rem; } +.show-month-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.7rem; +} + +.show-month-toolbar h3 { + margin: 0.15rem 0 0; + font-size: 1.15rem; +} + +.show-month-navigation { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.show-month-navigation a { + display: grid; + min-width: 2.2rem; + min-height: 2.2rem; + place-items: center; + padding: 0.35rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--panel-soft); + font-size: 0.68rem; + font-weight: 730; +} + +.show-calendar-weekdays, +.show-calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.show-calendar-weekdays { + border: 1px solid var(--line); + border-bottom: 0; + border-radius: 0.8rem 0.8rem 0 0; + background: var(--panel-soft); +} + +.show-calendar-weekdays span { + padding: 0.45rem; + color: var(--text-faint); + font-size: 0.62rem; + font-weight: 750; + text-align: center; +} + +.show-calendar-grid { + overflow: hidden; + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); + border-radius: 0 0 0.8rem 0.8rem; +} + +.show-calendar-day { + min-width: 0; + min-height: 7.5rem; + padding: 0.4rem; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--panel-soft) 75%, transparent); +} + +.show-calendar-day.outside { + min-height: 3.25rem; + background: color-mix(in srgb, var(--panel-soft) 32%, transparent); + color: var(--text-faint); +} + +.show-calendar-day > strong { + display: grid; + width: 1.55rem; + height: 1.55rem; + place-items: center; + border-radius: 50%; + font-size: 0.68rem; +} + +.show-calendar-day.today > strong { + background: var(--gold); + color: #152238; +} + +.show-calendar-events { + display: grid; + gap: 0.3rem; + margin-top: 0.35rem; +} + +.show-calendar-event { + display: grid; + min-width: 0; + gap: 0.08rem; + padding: 0.38rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--panel); +} + +.show-calendar-event:hover { + border-color: var(--accent); + background: var(--panel-strong); +} + +.show-calendar-event.live, +.show-calendar-event.ready { + border-left: 0.2rem solid var(--accent); +} + +.show-calendar-event time, +.show-calendar-event small { + color: var(--text-faint); + font-size: 0.54rem; +} + +.show-calendar-event span { + font-size: 0.65rem; + font-weight: 730; + overflow-wrap: anywhere; +} + +.show-calendar-event small { + text-transform: uppercase; +} + +.show-agenda { + display: none; +} + +.show-undated { + margin-top: 0.8rem; +} + +@media (max-width: 52rem) { + .shows-heading { + align-items: stretch; + flex-direction: column; + } + + .show-view-toggle, + .show-view-toggle button { + width: 100%; + } + + .show-view-toggle button { + justify-content: center; + } + + .show-calendar { + display: none; + } + + .show-agenda { + display: grid; + gap: 0.65rem; + } + + .show-agenda-day { + display: grid; + grid-template-columns: minmax(7rem, 0.35fr) minmax(0, 1fr); + gap: 0.65rem; + padding: 0.65rem 0; + border-top: 1px solid var(--line); + } + + .show-agenda-day h3 { + margin: 0; + font-size: 0.78rem; + text-transform: capitalize; + } + + .show-agenda-day > div { + display: grid; + gap: 0.35rem; + } + + .show-agenda-event { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.6rem; + padding: 0.65rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--panel-soft); + } + + .show-agenda-event time { + color: var(--accent-text); + font-size: 0.7rem; + font-weight: 740; + } + + .show-agenda-event span, + .show-agenda-event small { + display: grid; + min-width: 0; + } + + .show-agenda-event strong { + overflow-wrap: anywhere; + } + + .show-agenda-event small { + margin-top: 0.15rem; + color: var(--text-faint); + font-size: 0.58rem; + text-transform: uppercase; + } +} + +@media (max-width: 32rem) { + .show-month-toolbar { + align-items: stretch; + flex-direction: column; + } + + .show-month-navigation { + justify-content: space-between; + } + + .show-agenda-day { + grid-template-columns: minmax(0, 1fr); + } +} + .show-settings { margin-bottom: 0.75rem; } diff --git a/src/lib/calendar.spec.ts b/src/lib/calendar.spec.ts new file mode 100644 index 0000000..1d620bf --- /dev/null +++ b/src/lib/calendar.spec.ts @@ -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'); + }); +}); diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts new file mode 100644 index 0000000..11a4050 --- /dev/null +++ b/src/lib/calendar.ts @@ -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 + }; + }); +} diff --git a/src/lib/server/auth.spec.ts b/src/lib/server/auth.spec.ts index eca9637..60d7fad 100644 --- a/src/lib/server/auth.spec.ts +++ b/src/lib/server/auth.spec.ts @@ -70,6 +70,21 @@ afterEach(async () => { }); describe('authentication', () => { + it('stores the show overview preference per user', async () => { + const { createFirstAdmin, getShowOverviewView, setShowOverviewView } = + await import('./auth.js'); + const user = await createFirstAdmin({ + setupToken: 'a'.repeat(32), + email: 'calendar@example.test', + displayName: 'Kalender Test', + password: 'a-valid-password-123' + }); + expect(getShowOverviewView(user.id)).toBe('list'); + expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar'); + expect(getShowOverviewView(user.id)).toBe('calendar'); + expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW'); + }); + it('creates the first Barkeeper once and stores no plaintext secret', async () => { const { createFirstAdmin } = await import('./auth.js'); const password = 'Ein-sehr-langes-Testpasswort'; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 9083c85..fd142ae 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = { } as const; export type Role = 'barkeeper' | 'thekencrew'; +export type ShowOverviewView = 'list' | 'calendar'; export type AuthUser = { id: string; email: string; @@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } +export function getShowOverviewView(userId: string): ShowOverviewView { + const row = getDatabase() + .prepare('SELECT show_overview_view FROM users WHERE id = ?') + .get(userId) as { show_overview_view: ShowOverviewView } | undefined; + if (!row) throw new Error('NOT_FOUND'); + return row.show_overview_view; +} + +export function setShowOverviewView(userId: string, view: string): ShowOverviewView { + if (view !== 'list' && view !== 'calendar') throw new Error('INVALID_VIEW'); + const result = getDatabase() + .prepare('UPDATE users SET show_overview_view = ?, updated_at_utc = ? WHERE id = ?') + .run(view, nowUtc(), userId); + if (!result.changes) throw new Error('NOT_FOUND'); + return view; +} + export function validEmail(email: string): boolean { return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 409fa76..3b3a176 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -62,7 +62,7 @@ describe('database setup', () => { database.close(); expect(counts).toEqual({ - migrations: 11, + migrations: 12, pieces: 6, shows: 2, usages: 5, diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index 74f2e65..fdb58a2 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -11,23 +11,38 @@ let promptSize = $state(1.55); let showRail = $state(true); let mobileRailOpen = $state(false); + let isMobile = $state(false); let showTimes = $state(true); + let menuOpen = $state(false); + let expandedContext = $state<'section' | 'next' | null>(null); let popupMessage = $state(''); let now = $state(Date.now()); let loadedAt = $state(Date.now()); let currentId = $state(untrack(() => data.live.current?.id ?? null)); let loadedRevision = $state(untrack(() => data.live.liveRevision)); + let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null)); let pieceScroller = $state(); let railScroller = $state(); let liveRail = $state(); let mobileRunButton = $state(); let railCloseButton = $state(); + let liveMenuTrigger = $state(); + let liveMenuPopover = $state(); let scrollPositions: Record = {}; let outputWindow: Window | null = null; let allUsages = $derived(data.live.sections.flatMap((section) => section.usages)); + let viewedUsage = $derived( + allUsages.find((usage) => usage.id === viewedUsageId) ?? data.live.current + ); let currentPosition = $derived( data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0 ); + let viewedPosition = $derived( + viewedUsage ? allUsages.findIndex((usage) => usage.id === viewedUsage?.id) + 1 : 0 + ); + let previousUsage = $derived(viewedPosition > 1 ? allUsages[viewedPosition - 2] : null); + let viewingLiveCurrent = $derived(viewedUsage?.id === data.live.current?.id); + let runExpanded = $derived(isMobile ? mobileRailOpen : showRail); let currentSection = $derived( data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null ); @@ -51,10 +66,19 @@ data.live.completedCount === data.live.totalCount ) ); + let currentSectionTitle = $derived( + currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie') + ); + let nextTitle = $derived( + data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece') + ); let currentActualSeconds = $derived( (data.live.current?.actualDurationSeconds ?? 0) + (data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0) ); + let viewedActualSeconds = $derived( + viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0) + ); let sectionElapsedSeconds = $derived( (data.live.sectionTimer?.elapsedSeconds ?? 0) + (data.live.sectionTimer?.runningSinceUtc @@ -80,6 +104,7 @@ loadedRevision = data.live.liveRevision; loadedAt = Date.now(); if (changedPiece) { + viewedUsageId = nextCurrentId; void tick().then(() => { if (pieceScroller) pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0; @@ -121,19 +146,36 @@ localStorage.removeItem(key); } const mobileBreakpoint = matchMedia('(max-width: 46rem)'); + isMobile = mobileBreakpoint.matches; const handleBreakpointChange = (event: MediaQueryListEvent) => { + isMobile = event.matches; if (!event.matches) void closeMobileRail(false); }; + const handleDocumentClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (menuOpen && !liveMenuPopover?.contains(target) && !liveMenuTrigger?.contains(target)) { + void closeLiveMenu(); + } + if ( + expandedContext && + !(target instanceof Element && target.closest('.live-context-title')) + ) { + expandedContext = null; + } + }; mobileBreakpoint.addEventListener('change', handleBreakpointChange); + document.addEventListener('click', handleDocumentClick); const interval = setInterval(() => (now = Date.now()), 1000); return () => { mobileBreakpoint.removeEventListener('change', handleBreakpointChange); + document.removeEventListener('click', handleDocumentClick); clearInterval(interval); }; }); function persistSettings() { - if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop; + if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop; localStorage.setItem( `sendewerk-live-${data.live.show.id}`, JSON.stringify({ @@ -155,7 +197,31 @@ persistSettings(); } + function toggleRunPanel() { + void closeLiveMenu(false); + if (isMobile) { + if (mobileRailOpen) void closeMobileRail(); + else void openMobileRail(); + return; + } + toggleDesktopRail(); + } + + function viewUsage(usageId: string) { + if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop; + viewedUsageId = usageId; + void tick().then(() => { + if (pieceScroller) pieceScroller.scrollTop = scrollPositions[usageId] ?? 0; + }); + void closeMobileRail(); + } + + function viewPreviousUsage() { + if (previousUsage) viewUsage(previousUsage.id); + } + async function openMobileRail() { + await closeLiveMenu(false); mobileRailOpen = true; await tick(); railCloseButton?.focus(); @@ -169,12 +235,43 @@ } function handleWindowKeydown(event: KeyboardEvent) { - if (mobileRailOpen && event.key === 'Escape') { + if (event.key !== 'Escape') return; + if (menuOpen) { + event.preventDefault(); + void closeLiveMenu(); + } else if (expandedContext) { + event.preventDefault(); + expandedContext = null; + } else if (mobileRailOpen) { event.preventDefault(); void closeMobileRail(); } } + async function toggleLiveMenu() { + if (menuOpen) { + await closeLiveMenu(); + return; + } + menuOpen = true; + expandedContext = null; + await tick(); + liveMenuPopover + ?.querySelector('button:not(:disabled), a[href]') + ?.focus({ preventScroll: true }); + } + + async function closeLiveMenu(restoreFocus = true) { + if (!menuOpen) return; + menuOpen = false; + await tick(); + if (restoreFocus) liveMenuTrigger?.focus({ preventScroll: true }); + } + + function toggleContext(name: 'section' | 'next') { + expandedContext = expandedContext === name ? null : name; + } + function trapRailFocus(event: KeyboardEvent) { if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return; const focusable = Array.from( @@ -220,7 +317,7 @@ } const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => { - formElement.closest('.live-menu')?.removeAttribute('open'); + void closeLiveMenu(); const submittedFromRail = Boolean(formElement.closest('.live-rail')); const keepRailOpen = formElement.dataset.keepRailOpen === 'true'; const command = formElement.elements.namedItem('commandId'); @@ -238,8 +335,8 @@ }; }; - const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => { - formElement.closest('.live-menu')?.removeAttribute('open'); + const enhanceOutput: SubmitFunction = ({ submitter }) => { + void closeLiveMenu(); popupMessage = ''; outputWindow = window.open('about:blank', 'heinzlive-output'); if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.'; @@ -268,114 +365,167 @@
-
- -
- {data.live.show.title} - {currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')} -
-
- -
= 0} - class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0} - class="section-countdown" - aria-live="off" - > - Abschnitt - - {#if hasActiveSectionTimer} - {sectionRemainingSeconds < 0 - ? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}` - : formatCountdown(sectionRemainingSeconds)} - {:else}Kein Countdown{/if} - -
- -
- -
- Ausgabe - {presentedUsage?.title ?? 'Leer'} -
-
- - - -
- Menü -
-
- Ansicht -
- - -
- +
+
+ + +
+ Danach: - -
-
- Ausgabekanal -
- - -
-
- - -
-
- -
-
-
- Sendung - {#if needsResume}
- - - - -
{/if} - Liveansicht verlassen · Zur Planung -
+
- + + + +
+
+
+ +
+ Ausgabe + {presentedUsage?.title ?? 'Leer'} +
+
+ {#if data.live.presentationUsageId}
+ + + + + +
{/if} +
+ +
= 0} + class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0} + class="section-countdown" + aria-live="off" + > + Abschnitt + + {#if hasActiveSectionTimer} + {sectionRemainingSeconds < 0 + ? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}` + : formatCountdown(sectionRemainingSeconds)} + {:else}Kein Countdown{/if} + +
+ + +
+ {#if menuOpen}{/if} +
{#if form?.error}
- {#if data.live.current}
-
- Piece {currentPosition} von {data.live.totalCount} - {#if showTimes} - {formatClock(currentActualSeconds)} / {formatClock( - data.live.current.durationSeconds - )} - {/if} -
-

{data.live.current.title}

+ Piece {viewedPosition}/{data.live.totalCount} +

{viewedUsage.title}

+ {#if showTimes} + {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)} + {/if}
- {#if data.live.current.introHtml}
+ {#if viewedUsage.introHtml}
Einleitung -
{@html data.live.current.introHtml}
+
{@html viewedUsage.introHtml}
{/if} - {#if data.live.current.moderationHtml}
+ {#if viewedUsage.moderationHtml}
Moderationsstichpunkte -
{@html data.live.current.moderationHtml}
+
{@html viewedUsage.moderationHtml}
{/if} - {#if data.live.current.transitionHtml}
+ {#if viewedUsage.transitionHtml}
Überleitung -
{@html data.live.current.transitionHtml}
+
{@html viewedUsage.transitionHtml}
{/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}
- {#if data.live.current.researchHtml}
+ {#if viewedUsage.researchHtml}
Recherche -
{@html data.live.current.researchHtml}
+
{@html viewedUsage.researchHtml}
{/if} - {#if data.live.current.sources.length}
- Quellen · {data.live.current.sources.length} + {#if viewedUsage.sources.length}
+ Quellen · {viewedUsage.sources.length}
    - {#each data.live.current.sources as source (source.id)}
  • + {#each viewedUsage.sources as source (source.id)}
  • {/each}
{/if} - {#if data.live.current.tools.length}
- Werkzeuge · {data.live.current.tools.length} + {#if viewedUsage.tools.length}
+ Werkzeuge · {viewedUsage.tools.length}
{/if} - {#if data.live.current.ownNotes}
+ {#if viewedUsage.ownNotes}
Eigene Notizen -

{data.live.current.ownNotes}

+

{viewedUsage.ownNotes}

{/if}
{/if} - {#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml} + {#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.transitionHtml}

Für dieses Piece sind keine Moderationstexte hinterlegt.

{/if}
@@ -561,26 +703,27 @@ {#each section.usages as usage, usageIndex (usage.id)}
-
- - - - - -
+
{#if usage.status === 'open'}