diff --git a/CloudronManifest.json b/CloudronManifest.json index cc6cf09..d0d15d9 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.3.0", + "version": "0.4.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index dc8f54b..49e25f4 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst -Etappe 1 bis 3: technisches Grundgerüst, Anmeldung und Rollen sowie einen -persistenten Fundus mit Eingang, Editor, Taxonomie, privaten Bildern und -Konfliktschutz. Sendungsbau und Live-Steuerung folgen in späteren Etappen. +Etappe 1 bis 4: technisches Grundgerüst, Anmeldung und Rollen, einen +persistenten Fundus sowie den vollständigen Sendungsbaukasten. Live-Steuerung +und Präsentationsausgabe folgen in späteren Etappen. ## Voraussetzungen @@ -83,6 +83,21 @@ Elemente und Links reduziert. Bilder dürfen höchstens 5 MB groß sein und müs als JPEG, PNG, WebP oder GIF erkannt werden. Sie liegen im konfigurierten Uploadverzeichnis und sind nur über eine angemeldete Route erreichbar. +## Sendungen planen + +Unter **Sendungen** lässt sich ein neuer Stream leer, mit dem Standardablauf +oder als Spieleabend anlegen. Abschnitte sind frei benennbar und können eine +explizite Dauer erhalten; andernfalls ergibt sich ihre Dauer aus den +eingeplanten Pieces. Die Reservebank zählt nie zur Planzeit. + +Der sendefertige Fundus steht neben dem Ablauf. Pieces können mehrfach +eingeplant und je Verwendung mit eigenem Titel, eigener Dauer und eigenen +Redaktionstexten bearbeitet werden, ohne das Original zu verändern. Abschnitte +und Verwendungen lassen sich per Drag-and-drop sowie gleichwertig mit sichtbaren +Schaltflächen und Auswahlfeldern verschieben. Twitch-Titel und Ankündigung sind +kopierbar. Gleichzeitige Änderungen werden über die Revision der gesamten +Sendung erkannt und nicht still überschrieben. + ## Befehle | Befehl | Zweck | diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 84576d6..5f7f403 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -91,3 +91,32 @@ ungewollten horizontalen Überlauf - Mobile Darkmode-Aufnahme des Piece-Editors visuell kontrolliert - `bash -n start.sh` und `git diff --check`: erfolgreich + +## Etappe 4 + +| Kriterium | Nachweis | Stand | +| ----------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------- | +| Spieleabend und mehrere JC-/Spieleblöcke | persistierte Spieleabend-Vorlage, Seed-Sendung und Browserrundlauf mit Standardablauf | erfüllt am 21.10.2026 | +| Reihenfolge und Texte bleiben nach Reload | Verhaltenstests sowie Browsertest für Tastatur- und Drag-and-drop-Sortierung | erfüllt am 21.10.2026 | +| Original bleibt unverändert | Service- und Browsertest planen dasselbe Piece mehrfach mit eigenen Titeln, Notizen, Dauern | erfüllt am 21.10.2026 | +| Keine doppelte Dauer; Reserve zählt nicht | explizite und summierte Abschnitte sowie Reserve in Verhaltenstest und Browser geprüft | erfüllt am 21.10.2026 | +| Entfernen betrifft nur Einplanung | Verhaltenstest löscht Verwendung und Abschnittskopie, nicht das Original | erfüllt am 21.10.2026 | +| Sendungsänderung verändert keine Vorlage | tiefe Vorlagenkopie vor und nach Abschnittsänderung verglichen | erfüllt am 21.10.2026 | +| Smartphone-Planungsablauf | Abschnittsanlage, Pane-Umschaltung und Überlauf bei 390 Pixeln geprüft | erfüllt am 21.10.2026 | +| Gleichwertige Bedienwege | Drag-and-drop, Enter auf Verschiebeschaltfläche sowie klick-/touchbare Zielauswahl geprüft | erfüllt am 21.10.2026 | +| Lokale Planungsaktualisierung | Quelle und Reserve per Drag-and-drop ohne Dokumentreload; lokaler Speicherzustand geprüft | erfüllt am 21.10.2026 | +| Sichtbare Einfügeposition | Abschnitt, Fundusquelle und Reserve zeigen vor dem Drop Vorschau und transparentes Original | erfüllt am 21.10.2026 | +| Piece-Dauer in Minuten | Fundus- und Verwendungseditor rechnen Minuten serverseitig in ganze Sekunden um | erfüllt am 21.10.2026 | +| Gleichzeitige Änderungen | veraltete showweite Revision rollt die gesamte Mutation zurück | erfüllt am 21.10.2026 | + +## Ausgeführte Prüfungen Etappe 4 + +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 18 Tests in 4 Dateien erfolgreich +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- `npm run test:e2e`: 11 von 11 Browsertests erfolgreich +- Browserprüfung bei 390, 768, 960, 1280 und 1440 Pixeln in beiden Themes ohne + ungewollten horizontalen Überlauf +- Mobile Aufnahme des vollständigen Sendungsbaukastens visuell kontrolliert +- `bash -n start.sh` und `git diff --check`: erfolgreich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c85bc9..21282ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,23 +31,23 @@ spätere API dieselben Regeln verwenden. - Zeitpunkte werden als ISO-8601-UTC-Text gespeichert. Dauern sind nichtnegative ganze Sekunden. Zeitzonendarstellung ist `Europe/Berlin`. -## Geplantes Gesamtdatenmodell +## Gesamtdatenmodell -Etappe 1 materialisiert nur den für echte Vorschau- und Seed-Daten benötigten -Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt. +Die ersten vier Etappen materialisieren Identität, Inhaltsverwaltung und +Sendungsplanung. Folgende Grenzen gelten für umgesetzte und spätere Bereiche. -| Bereich | Geplante Entitäten und Beziehungen | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | -| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | -| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte | -| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | -| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | -| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | -| 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 | -| 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 | +| Bereich | Geplante Entitäten und Beziehungen | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | +| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | +| Inhalt | `pieces` mit Status, Dauer, Revision, getrennten Textbereichen, Quellen und privaten Medien; Vorlagen sind definierte Startwerte | +| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen sind umgesetzt; Original-ID bleibt Referenz | +| Live | expliziter Live-Stand, aktuelles Element, Status je Verwendung, tatsächliche Zeiten und getrennte Präsentationsausgabe | +| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet | +| 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 | +| 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 | ### Historien- und Kopierregel @@ -102,6 +102,9 @@ Medienänderungen verwenden ein bedingtes Update auf die erwartete Revision. Eine veraltete Bearbeitung liefert einen Konflikt und verändert weder Piece noch Zuordnungen. Archivieren merkt sich den vorherigen Arbeitsstatus für die Wiederherstellung. Duplikate erhalten neue IDs und unabhängige Bilddateien. +Piece-Dauern werden in den Fundus- und Verwendungseditoren als Minuten erfasst, +an der Servergrenze auf volle Sekunden gerundet und ausschließlich als ganze +Sekunden persistiert. Markdown wird roh gespeichert, mit Marked serverseitig gerendert und danach mit sanitize-html auf eine kleine Allowlist reduziert. Bilder werden nicht über @@ -111,6 +114,41 @@ sind zufällige IDs; Originalnamen werden nie als Pfad verwendet. `/media/[id]` prüft die Anmeldung und liefert private Antworten mit `no-store`, `nosniff`, Same-Origin-Ressourcenrichtlinie und restriktiver CSP aus. +## Sendungsbau + +Etappe 4 bündelt alle Planungsmutationen in `src/lib/server/shows.ts`. Eine +Sendung ist die Konflikt- und Transaktionsgrenze: Jede Änderung an Metadaten, +Abschnitten, Verwendungen oder Reihenfolge vergleicht die erwartete Revision und +erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen +weitere Bearbeitung geschützt. + +Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation, +Überleitung und Präsentation aus dem sendefertigen Piece kopiert. Eigene Notizen +liegen ausschließlich an der Verwendung. Die Referenz zum Original bleibt für +Historie und Nachvollziehbarkeit erhalten, wird aber nicht live gespiegelt. + +Jede Sendung besitzt intern genau einen ausgeblendeten Reserveabschnitt. Dadurch +verwenden Plan und Reserve dieselbe geprüfte Verwendungslogik, während die +Reserve in der öffentlichen Struktur und allen Dauerberechnungen getrennt +bleibt. Positionen werden nach jeder Transaktion kollisionsfrei normalisiert. +Eine explizite Abschnittsdauer ersetzt die Summe der Verwendungen vollständig. + +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 Editorformulare bleiben progressive SvelteKit-Form-Actions, werden im +Browser aber mit `use:enhance` gesendet. Dadurch bleibt das Dokument bei +Planungsmutationen bestehen; nur die serverseitigen Seitendaten werden +aktualisiert. Ladeanzeige und FLIP-Animation liegen am betroffenen Abschnitt +oder an der betroffenen Verwendung. Eindeutige Drag-Griffe übertragen entweder +eine Quellenkopie, einen Abschnitt oder eine Verwendung auf dieselben +serverseitig geprüften Actions wie die alternativen Schaltflächen. Während des +Ziehens bleibt das Original transparent an seinem Ausgangsort; eine flüchtige +Vorschau in der Liste belegt bereits die anhand der Zeigerposition berechnete +Zielhöhe und verdrängt dadurch die benachbarten Elemente. Persistiert wird erst +beim Loslassen. + ## Oberflächenstruktur Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. @@ -123,9 +161,9 @@ Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht. -Etappe 1 zeigt Fundus, Sendungsbau und Live-Modus als sichtbare -`Gestaltungsvorschau`. Die Daten kommen bereits aus SQLite, mutierende -Fachabläufe werden jedoch nicht vorgetäuscht. +Fundus und Sendungsbau sind produktive Arbeitsansichten. Der Live-Modus bleibt +bis Etappe 5 eine sichtbare Gestaltungsvorschau, kann aber bereits gezielt die +gewählte Sendung und deren kopierte Texte lesen. ## Sicherheitsgrenzen ab den Folgeetappen diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fcdb416..182fced 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -172,3 +172,33 @@ mit sicherheitsrelevanten Headern aus. Das Adapter-Requestlimit liegt mit 6 MB knapp oberhalb des fachlichen Dateilimits. Quelle: + +## D-014: Sendung als transaktionales Aggregat + +Eine ganzzahlige Revision auf `shows` schützt in Etappe 4 sämtliche Metadaten, +Abschnitte, Verwendungen und Reihenfolgen gemeinsam. Jede Mutation erhöht die +Revision in derselben SQLite-Transaktion. Damit können voneinander abhängige +Listenänderungen nicht teilweise gespeichert oder durch einen veralteten +Browserstand unbemerkt überschrieben werden. + +Verwendungen speichern vollständige redaktionelle Text- und Dauerkopien. Die +Original-ID bleibt Referenz, ist aber keine Live-Datenquelle. Eine interne, +eindeutige Reservesection pro Sendung erlaubt dieselben geprüften Verschiebe- +und Löschoperationen für Plan und Reserve; Dauerabfragen schließen sie aus. + +## D-015: Drag-and-drop nur als zusätzliche Bedienung + +Abschnitte, Verwendungen und gefilterte Quellen können über eindeutige Griffe +gezogen werden. Dieselben Ergebnisse sind mit sichtbaren +Hoch-/Runter-Schaltflächen und Zielauswahl erreichbar. Diese Bedienelemente +funktionieren mit einfachem Klick, Touch und Tastatur auch ohne Drag-and-drop +oder clientseitige Spezialgesten. Das folgt WCAG 2.2 SC 2.5.7; +Tastaturzugang allein wäre keine ausreichende Zeigeralternative. Die +zugehörigen Form-Actions werden progressiv verbessert: Ohne JavaScript bleiben +normale POSTs möglich, mit JavaScript erfolgt die Aktualisierung ohne +Dokumentreload und mit lokalem Speicherzustand. + +Quellen: + +- +- diff --git a/docs/STATUS.md b/docs/STATUS.md index 5db9253..69f479c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,9 @@ ## Aktueller Umfang -Etappe 1 bis Etappe 3 sind abgeschlossen. Architektur, Gesamtdatenmodell, -Anmeldung, Rollen, persistente Inhaltsbearbeitung und lokaler Betriebsweg sind -dokumentiert. Etappe 4 wurde nicht begonnen. +Etappe 1 bis Etappe 4 sind abgeschlossen. Architektur, Gesamtdatenmodell, +Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung und lokaler +Betriebsweg sind dokumentiert. Etappe 5 wurde nicht begonnen. ## Umgesetzter Stand Etappe 1 @@ -37,8 +37,8 @@ umgebrochen. ## Noch nicht umgesetzt -Sendungsplanung, echte Live-Steuerung, Präsentationsfenster, Archivabschluss, -Freigaben, RSS und API. Die bestehenden Vorschauen dürfen nicht als +Echte Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und +API. Die verbleibenden Vorschauen dürfen nicht als Implementierung dieser späteren Funktionen verstanden werden. ## Umgesetzter Stand Etappe 2 @@ -56,8 +56,8 @@ Implementierung dieser späteren Funktionen verstanden werden. - persistente Piece-Neuanlage aus leerem Entwurf oder drei redaktionellen Vorlagen - Schnellaufnahme von Gedanken und HTTP-/HTTPS-Links im Eingang -- vollständiger Editor für Quellen, Dauer, Status und getrennte Einleitung, - Recherche, Moderation, Überleitung und Präsentation +- vollständiger Editor für Quellen, Dauer in Minuten, Status und getrennte + Einleitung, Recherche, Moderation, Überleitung und Präsentation - serverseitig bereinigte Markdown-Vorschau - unabhängige Themen, Formate und Abschnittskategorien mit Barkeeper-Verwaltung - serverseitige Suche und Filter nach Status, Format, Thema und Abschnitt @@ -68,6 +68,28 @@ Implementierung dieser späteren Funktionen verstanden werden. - private Rasterbilder mit Größen-/Signaturprüfung und geschützter Auslieferung - erweiterter Seed mit Eingangs-, Archiv-, Markdown- und Recherchebeispielen +## Umgesetzter Stand Etappe 4 + +- Sendungsübersicht mit leerem Start, Standardablauf und Spieleabend-Vorlage +- frei benennbare, kategorisierte und sortierbare Abschnitte einschließlich + eigenständiger Spieleblöcke +- Einplanung ausschließlich sendefertiger Pieces als unabhängige Text- und + Dauerkopien; Mehrfachverwendung desselben Originals ist möglich +- showweite Reservebank, die nicht in Abschnitts- oder Gesamtdauer einfließt +- explizite Abschnittsdauer ersetzt die Piece-Summe statt sie zu addieren +- Bearbeiten, Entfernen sowie Verschieben innerhalb und zwischen Abschnitten +- Piece-Dauern werden im Editor in Minuten eingegeben und intern weiterhin als + ganze Sekunden gespeichert +- Drag-and-drop über eindeutige Griffe, auch direkt aus gefilterten Quellen, + mit positionsgenauer Einfügevorschau, transparentem Original und + gleichwertigen Klick-, Touch- und Tastaturaktionen +- Planungsmutationen ohne Dokumentreload mit lokalem Speicherzustand und + animierter Neusortierung der betroffenen Elemente +- kopierbare Twitch-Titel und Ankündigungstexte +- showweite optimistische Revision schützt Metadaten, Reihenfolge und Inhalte + vor unbemerktem parallelem Überschreiben +- realistischer Seed mit Standardablauf und eigenständigem Spieleabend + ## Bekannte offene Betriebsprüfung Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten diff --git a/migrations/0004_show_builder.sql b/migrations/0004_show_builder.sql new file mode 100644 index 0000000..7dd3537 --- /dev/null +++ b/migrations/0004_show_builder.sql @@ -0,0 +1,152 @@ +CREATE TABLE shows_v4 ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + starts_at_utc TEXT, + twitch_title TEXT NOT NULL DEFAULT '', + announcement_text TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'live', 'completed')), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE show_sections_v4 ( + id TEXT PRIMARY KEY, + show_id TEXT NOT NULL REFERENCES shows_v4(id) ON DELETE CASCADE, + name TEXT NOT NULL, + category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL, + position INTEGER NOT NULL CHECK (position >= 0), + explicit_duration_seconds INTEGER CHECK ( + explicit_duration_seconds IS NULL OR + (explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400) + ), + is_reserve_bank INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve_bank IN (0, 1)), + UNIQUE (show_id, position) +); + +CREATE TABLE piece_usages_v4 ( + id TEXT PRIMARY KEY, + show_section_id TEXT NOT NULL REFERENCES show_sections_v4(id) ON DELETE CASCADE, + original_piece_id TEXT REFERENCES pieces(id) ON DELETE SET NULL, + position INTEGER NOT NULL CHECK (position >= 0), + status TEXT NOT NULL CHECK (status IN ('open', 'current', 'done', 'skipped')), + duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0 AND duration_seconds <= 86400), + title_snapshot TEXT NOT NULL, + intro_snapshot TEXT NOT NULL DEFAULT '', + research_snapshot TEXT NOT NULL DEFAULT '', + moderation_snapshot TEXT NOT NULL DEFAULT '', + transition_snapshot TEXT NOT NULL DEFAULT '', + presentation_snapshot TEXT NOT NULL DEFAULT '', + own_notes TEXT NOT NULL DEFAULT '', + is_reserve INTEGER NOT NULL DEFAULT 0 CHECK (is_reserve IN (0, 1)), + UNIQUE (show_section_id, position) +); + +INSERT INTO shows_v4 ( + id, title, starts_at_utc, twitch_title, announcement_text, status, revision, + created_at_utc, updated_at_utc +) +SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, 1, + created_at_utc, updated_at_utc +FROM shows; + +INSERT INTO show_sections_v4 ( + id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank +) +SELECT id, show_id, name, category_id, position, explicit_duration_seconds, 0 +FROM show_sections; + +INSERT INTO show_sections_v4 ( + id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank +) +SELECT 'reserve-bank-' || id, id, 'Reservebank', NULL, + COALESCE((SELECT max(position) + 1 FROM show_sections WHERE show_id = shows.id), 0), NULL, 1 +FROM shows; + +INSERT INTO piece_usages_v4 ( + id, show_section_id, original_piece_id, position, status, duration_seconds, + title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, + transition_snapshot, presentation_snapshot, own_notes, is_reserve +) +SELECT + u.id, + CASE WHEN u.is_reserve = 1 THEN 'reserve-bank-' || s.show_id ELSE u.show_section_id END, + u.original_piece_id, + CASE WHEN u.is_reserve = 1 THEN + ( + SELECT count(*) - 1 + FROM piece_usages earlier + JOIN show_sections earlier_section ON earlier_section.id = earlier.show_section_id + WHERE earlier.is_reserve = 1 AND earlier_section.show_id = s.show_id + AND (earlier_section.position < s.position OR + (earlier_section.position = s.position AND earlier.position < u.position) OR + (earlier_section.position = s.position AND earlier.position = u.position AND earlier.id <= u.id)) + ) + ELSE u.position END, + u.status, + u.duration_seconds, + u.title_snapshot, + u.intro_snapshot, + COALESCE(p.research_text, ''), + u.moderation_snapshot, + u.transition_snapshot, + u.presentation_snapshot, + '', + u.is_reserve +FROM piece_usages u +JOIN show_sections s ON s.id = u.show_section_id +LEFT JOIN pieces p ON p.id = u.original_piece_id; + +DROP TABLE piece_usages; +DROP TABLE show_sections; +DROP TABLE shows; + +ALTER TABLE shows_v4 RENAME TO shows; +ALTER TABLE show_sections_v4 RENAME TO show_sections; +ALTER TABLE piece_usages_v4 RENAME TO piece_usages; + +CREATE INDEX idx_shows_status_start ON shows(status, starts_at_utc); +CREATE INDEX idx_sections_show ON show_sections(show_id, position); +CREATE INDEX idx_usages_section ON piece_usages(show_section_id, position); +CREATE UNIQUE INDEX idx_one_reserve_bank_per_show + ON show_sections(show_id) WHERE is_reserve_bank = 1; + +CREATE TABLE show_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE show_template_sections ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL REFERENCES show_templates(id) ON DELETE CASCADE, + name TEXT NOT NULL, + category_id TEXT REFERENCES section_categories(id) ON DELETE SET NULL, + position INTEGER NOT NULL CHECK (position >= 0), + explicit_duration_seconds INTEGER CHECK ( + explicit_duration_seconds IS NULL OR + (explicit_duration_seconds >= 0 AND explicit_duration_seconds <= 86400) + ), + UNIQUE (template_id, position) +); + +CREATE INDEX idx_show_template_sections_template + ON show_template_sections(template_id, position); + +INSERT INTO show_templates (id, name, description) VALUES + ('template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'), + ('template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.'); + +INSERT INTO show_template_sections ( + id, template_id, name, category_id, position, explicit_duration_seconds +) VALUES + ('template-default-start', 'template-default', 'Start', NULL, 0, 300), + ('template-default-jc-reaction', 'template-default', 'JC / Reaction', NULL, 1, NULL), + ('template-default-game-1', 'template-default', 'Spiel 1', NULL, 2, 3600), + ('template-default-jc', 'template-default', 'JC', NULL, 3, NULL), + ('template-default-game-2', 'template-default', 'Spiel 2', NULL, 4, 3600), + ('template-games-start', 'template-games', 'Start', NULL, 0, 300), + ('template-games-game-1', 'template-games', 'Spiel 1', NULL, 1, 2700), + ('template-games-break', 'template-games', 'Pause & Chat', NULL, 2, 600), + ('template-games-game-2', 'template-games', 'Spiel 2', NULL, 3, 2700), + ('template-games-game-3', 'template-games', 'Spiel 3', NULL, 4, 2700); diff --git a/package-lock.json b/package-lock.json index debf294..26b9510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index b5b790a..af35ead 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 3a9459c..53c1e0c 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -229,6 +229,38 @@ export function seed(databasePath = getPaths().database) { ] ); + replace( + 'show_templates', + ['id', 'name', 'description'], + [ + ['template-default', 'Standardablauf', 'Start, zwei JC-Blöcke und zwei Spiele.'], + ['template-games', 'Spieleabend', 'Ein ruhiger Einstieg und drei flexible Spieleblöcke.'] + ] + ); + replace( + 'show_template_sections', + ['id', 'template_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'], + [ + ['template-default-start', 'template-default', 'Start', 'cat-jc', 0, 300], + [ + 'template-default-jc-reaction', + 'template-default', + 'JC / Reaction', + 'cat-reaction', + 1, + null + ], + ['template-default-game-1', 'template-default', 'Spiel 1', 'cat-game', 2, 3600], + ['template-default-jc', 'template-default', 'JC', 'cat-jc', 3, null], + ['template-default-game-2', 'template-default', 'Spiel 2', 'cat-game', 4, 3600], + ['template-games-start', 'template-games', 'Start', 'cat-jc', 0, 300], + ['template-games-game-1', 'template-games', 'Spiel 1', 'cat-game', 1, 2700], + ['template-games-break', 'template-games', 'Pause & Chat', 'cat-jc', 2, 600], + ['template-games-game-2', 'template-games', 'Spiel 2', 'cat-game', 3, 2700], + ['template-games-game-3', 'template-games', 'Spiel 3', 'cat-game', 4, 2700] + ] + ); + replace( 'shows', [ @@ -238,6 +270,7 @@ export function seed(databasePath = getPaths().database) { 'twitch_title', 'announcement_text', 'status', + 'revision', 'created_at_utc', 'updated_at_utc' ], @@ -249,6 +282,18 @@ export function seed(databasePath = getPaths().database) { 'Kleine Welten, große Ideen | heinzlive', 'Heute schauen wir auf Hardware mit Kurbel, handgemachte Filmtricks und Pixel mit Haltung.', 'ready', + 1, + timestamp, + timestamp + ], + [ + 'show-games-evening', + 'Spieleabend: Kurbeln & Pixel', + '2026-10-16T18:00:00.000Z', + 'Kurbeln, Pixel und ein langer Spieleabend | heinzlive', + 'Heute spielen wir uns von ungewöhnlicher Hardware bis zu moderner Pixelart.', + 'draft', + 1, timestamp, timestamp ] @@ -257,11 +302,25 @@ export function seed(databasePath = getPaths().database) { replace( 'show_sections', - ['id', 'show_id', 'name', 'category_id', 'position', 'explicit_duration_seconds'], [ - ['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300], - ['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null], - ['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600] + 'id', + 'show_id', + 'name', + 'category_id', + 'position', + 'explicit_duration_seconds', + 'is_reserve_bank' + ], + [ + ['section-start', 'show-october', 'Ankommen & Warmwerden', 'cat-jc', 0, 300, 0], + ['section-stories', 'show-october', 'Kleine Welten', 'cat-reaction', 1, null, 0], + ['section-game', 'show-october', 'Spiel 1', 'cat-game', 2, 3600, 0], + ['reserve-bank-show-october', 'show-october', 'Reservebank', null, 3, null, 1], + ['games-start', 'show-games-evening', 'Start', 'cat-jc', 0, 300, 0], + ['games-first', 'show-games-evening', 'Spiel 1', 'cat-game', 1, 2700, 0], + ['games-chat', 'show-games-evening', 'Pause & Chat', 'cat-jc', 2, 600, 0], + ['games-second', 'show-games-evening', 'Spiel 2', 'cat-game', 3, 2700, 0], + ['reserve-bank-show-games-evening', 'show-games-evening', 'Reservebank', null, 4, null, 1] ] ); @@ -276,9 +335,11 @@ export function seed(databasePath = getPaths().database) { 'duration_seconds', 'title_snapshot', 'intro_snapshot', + 'research_snapshot', 'moderation_snapshot', 'transition_snapshot', 'presentation_snapshot', + 'own_notes', 'is_reserve' ], [ @@ -291,9 +352,11 @@ export function seed(databasePath = getPaths().database) { 480, 'Die kleine Konsole mit dem großen Dreh', 'Eine Kurbel, ein gelber Knopf und sehr viel Charme: Was macht kleine Hardware spannend?', + '## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten', 'Erst Formfaktor zeigen, dann über bewusst gesetzte Grenzen sprechen.', 'Von der kleinen Konsole geht es zur ganz großen Leinwand.', 'Weniger Leistung, mehr Idee.', + '', 0 ], [ @@ -305,24 +368,60 @@ export function seed(databasePath = getPaths().database) { 720, 'Warum praktische Effekte wieder auffallen', 'Man sieht manchmal gerade deshalb weniger Trick, weil wieder mehr vor der Kamera passiert.', + '', 'Beispiele vergleichen, nicht analog gegen digital ausspielen.', 'Nach so viel Handarbeit wird es spielerisch.', 'Wenn das Licht wirklich auf ein Modell fällt.', + '', 0 ], [ 'usage-reserve', - 'section-stories', + 'reserve-bank-show-october', 'piece-pixels', - 2, + 0, 'open', 540, 'Pixelart, die nicht nostalgisch sein will', 'Pixel sind längst keine technische Notlösung mehr.', + '', 'Großaufnahmen vorbereiten, drei Stilmittel benennen.', 'Genug Theorie, jetzt spielen wir selbst.', 'Pixel als bewusste Entscheidung.', + 'Ersatz, falls ein Spiel technisch ausfällt.', 1 + ], + [ + 'usage-games-console', + 'games-first', + 'piece-console', + 0, + 'open', + 480, + 'Die kleine Konsole mit dem großen Dreh', + 'Wir beginnen mit Hardware, die ihr Spielprinzip gleich mitbringt.', + 'Kurbelmechanik und verfügbare Spiele prüfen.', + 'Konsole zeigen und Publikum nach Lieblingsmechaniken fragen.', + 'Danach wechseln wir von Mechanik zu Grafik.', + 'Eine Kurbel verändert, wie wir spielen.', + 'Controller vor Sendungsbeginn laden.', + 0 + ], + [ + 'usage-games-pixels', + 'games-second', + 'piece-pixels', + 0, + 'open', + 540, + 'Pixelart, die nicht nostalgisch sein will', + 'Unser zweites Spiel nutzt Pixel nicht als Rückblick.', + 'Drei aktuelle Beispiele vergleichen.', + 'Auf Licht, Bewegung und Materialwirkung achten.', + 'Zum Schluss sammeln wir eure Empfehlungen.', + 'Pixel als bewusste Entscheidung.', + '', + 0 ] ] ); diff --git a/src/app.css b/src/app.css index 1a212dd..4b6cc53 100644 --- a/src/app.css +++ b/src/app.css @@ -2002,6 +2002,544 @@ a { margin-top: 0.75rem; } +.shows-page { + max-width: 78rem; +} + +.show-create-panel, +.show-library, +.show-settings { + padding: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: var(--panel); +} + +.show-create-panel h2, +.show-library h2 { + margin: 0.15rem 0 0; + font-size: 1rem; +} + +.show-create-form { + display: grid; + grid-template-columns: minmax(12rem, 1.3fr) minmax(11rem, 0.85fr) minmax(10rem, 0.85fr) auto; + align-items: end; + gap: 0.65rem; + margin-top: 0.9rem; +} + +.show-create-form label, +.show-settings-form label, +.section-edit-form label, +.add-section-form label, +.usage-edit-form label, +.usage-command-row label { + display: grid; + min-width: 0; + gap: 0.25rem; + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.show-create-form input, +.show-create-form select, +.show-settings-form input, +.show-settings-form select, +.show-settings-form textarea, +.section-edit-form input, +.section-edit-form select, +.add-section-form input, +.add-section-form select, +.usage-edit-form input, +.usage-edit-form textarea, +.usage-command-row select, +.source-add-form select, +.builder-filter input, +.builder-filter select, +.reserve-usage select { + width: 100%; + min-width: 0; + min-height: 2.45rem; + padding: 0.48rem 0.58rem; + border: 1px solid var(--line-strong); + border-radius: 0.65rem; + background: var(--panel-strong); + color: var(--text); +} + +.template-notes { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; + margin-top: 0.7rem; + color: var(--text-faint); + font-size: 0.65rem; +} + +.template-notes p { + margin: 0; +} + +.show-library { + margin-top: 0.8rem; +} + +.show-library > header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.7rem; +} + +.show-library > header > span { + padding: 0.25rem 0.5rem; + border-radius: 2rem; + background: var(--panel-soft); + color: var(--text-muted); + font-size: 0.68rem; +} + +.show-card-list { + display: grid; + gap: 0.5rem; +} + +.show-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel-soft); +} + +.show-card:hover { + border-color: var(--line-strong); + background: var(--panel-strong); +} + +.show-card h3 { + margin: 0.25rem 0 0.1rem; + font-size: 0.92rem; +} + +.show-card p { + margin: 0; + color: var(--text-muted); + font-size: 0.7rem; +} + +.show-state { + color: var(--text-muted); + font-size: 0.62rem; + font-weight: 750; + text-transform: uppercase; +} + +.show-state.ready, +.show-state.live { + color: var(--accent-text); +} + +.show-card-facts { + display: flex; + align-items: center; + gap: 1rem; + color: var(--text-muted); + font-size: 0.68rem; +} + +.show-card-facts span { + display: grid; +} + +.show-card-facts strong { + color: var(--text); + font-size: 0.78rem; +} + +.show-settings { + margin-bottom: 0.75rem; +} + +.show-settings > summary, +.usage-editor > summary, +.section-editor > summary { + cursor: pointer; + font-size: 0.74rem; + font-weight: 750; +} + +.show-settings-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + margin-top: 0.8rem; +} + +.show-settings-form .wide-field, +.usage-edit-form .wide-field { + grid-column: 1 / -1; +} + +.show-settings-form small { + color: var(--text-faint); + font-weight: 500; +} + +.copy-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.4rem; +} + +.copy-field button, +.row-actions button { + min-width: 2.35rem; + min-height: 2.35rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--panel-soft); + color: var(--text-muted); + cursor: pointer; +} + +.row-actions button:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.settings-submit { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} + +.settings-submit span { + color: var(--text-muted); + font-size: 0.68rem; +} + +.builder-filter { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(7rem, 0.7fr) auto; + gap: 0.35rem; + margin-bottom: 0.45rem; +} + +.builder-source-list { + display: grid; + border-top: 1px solid var(--line); +} + +.builder-source-card { + padding: 0.72rem 0.2rem; + border-bottom: 1px solid var(--line); + transition: + opacity 140ms ease, + transform 140ms ease; +} + +.source-drag-copy { + cursor: grab; +} + +.source-drag-copy:active { + cursor: grabbing; +} + +.source-drag-copy > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.source-drag-copy > div:first-child > span:last-child, +.builder-source-card p { + color: var(--text-muted); + font-size: 0.67rem; +} + +.builder-source-card h3 { + margin: 0.4rem 0 0.12rem; + font-size: 0.86rem; +} + +.builder-source-card p { + margin: 0; +} + +.drag-hint { + display: block; + margin-top: 0.35rem; + color: var(--text-faint); + font-size: 0.61rem; +} + +.builder-source-card.is-dragging { + opacity: 0.45; + transform: scale(0.985); +} + +.editable-run-section.is-dragging, +.editable-usage.is-dragging, +.reserve-usage.is-dragging { + opacity: 0.38; +} + +.drag-insert-preview { + border: 1px dashed color-mix(in srgb, var(--accent) 68%, var(--line-strong)); + background: color-mix(in srgb, var(--accent) 10%, var(--panel-soft)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--text-muted); + animation: reveal-drop-preview 140ms ease-out; +} + +.drag-insert-preview strong { + color: var(--text); +} + +.section-drag-slot, +.usage-drag-slot, +.reserve-drag-slot { + display: grid; +} + +.section-drag-slot { + gap: 0.35rem; +} + +.usage-drag-slot, +.reserve-drag-slot { + gap: 0.42rem; +} + +.section-insert-preview { + display: grid; + min-height: 4.1rem; + align-content: center; + gap: 0.15rem; + padding: 0.72rem 0.85rem; + border-radius: var(--radius-medium); +} + +.section-insert-preview span, +.usage-insert-preview small, +.reserve-insert-preview small { + color: var(--accent-text); + font-size: 0.65rem; + font-weight: 720; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.usage-insert-preview { + display: grid; + min-height: 3.35rem; + align-content: center; + gap: 0.08rem; + padding: 0.55rem 0.65rem; + border-radius: 0.72rem; +} + +.usage-insert-preview strong, +.reserve-insert-preview strong { + font-size: 0.78rem; +} + +.reserve-insert-preview { + display: grid; + min-height: 3.35rem; + align-content: center; + gap: 0.08rem; + margin-top: 0.55rem; + padding: 0.55rem 0.65rem; + border-radius: 0.72rem; +} + +@keyframes reveal-drop-preview { + from { + opacity: 0; + transform: scaleY(0.78); + } +} + +.source-add-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.35rem; + margin-top: 0.55rem; +} + +.editable-run-section { + position: relative; + transition: + border-color 140ms ease, + background-color 140ms ease, + opacity 140ms ease; +} + +.section-drag-handle, +.usage-drag-copy { + cursor: grab; +} + +.section-drag-handle:active, +.usage-drag-copy:active { + cursor: grabbing; +} + +.editable-run-section > .row-actions { + position: absolute; + top: 0.68rem; + right: 4.7rem; +} + +.row-actions { + display: flex; + gap: 0.25rem; +} + +.row-actions form { + display: flex; +} + +.row-actions button { + min-width: 1.8rem; + min-height: 1.8rem; + padding: 0; +} + +.editable-usage { + grid-template-columns: minmax(0, 1fr) auto; + transition: + opacity 140ms ease, + background-color 140ms ease; +} + +.usage-editor { + grid-column: 1 / -1; + padding-top: 0.3rem; + border-top: 1px solid var(--line); +} + +.usage-edit-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-top: 0.55rem; +} + +.usage-command-row, +.usage-command-row > form, +.section-edit-form, +.add-section-form { + display: flex; + align-items: end; + gap: 0.45rem; + flex-wrap: wrap; +} + +.usage-command-row { + justify-content: space-between; + margin-top: 0.55rem; +} + +.usage-command-row label, +.section-edit-form label, +.add-section-form label { + flex: 1 1 8rem; +} + +.section-editor { + margin: 0.55rem 0 0 2.8rem; +} + +.section-edit-form { + margin: 0.55rem 0; +} + +.add-section-form { + margin-top: 0.8rem; + padding: 0.75rem; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-medium); +} + +.editable-reserve { + margin-top: 0.8rem; + transition: + border-color 140ms ease, + background-color 140ms ease, + opacity 140ms ease; +} + +.editable-run-section.drop-ready, +.editable-reserve.drop-ready { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); +} + +.builder-source-card.is-updating, +.editable-run-section.is-updating, +.editable-reserve.is-updating, +.show-settings.is-updating, +.add-section-form.is-updating { + opacity: 0.58; + pointer-events: none; +} + +.editable-reserve > header > div { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.editable-reserve > header > div > span { + color: var(--gold); + font-size: 1.2rem; +} + +.editable-reserve > header div div { + display: grid; +} + +.reserve-usage { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) auto; + align-items: end; + gap: 0.5rem; + margin-top: 0.55rem; + padding-top: 0.55rem; + border-top: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); +} + +.reserve-usage > div { + display: grid; +} + +.reserve-usage small { + color: var(--text-muted); + font-size: 0.65rem; +} + +.reserve-usage form { + display: flex; + align-items: end; + gap: 0.35rem; +} + +.hidden-command-form { + display: none; +} + @media (max-width: 72rem) { .library-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -2019,6 +2557,9 @@ a { .source-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .show-create-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 72rem) { @@ -2141,6 +2682,12 @@ a { .source-row { grid-template-columns: 1fr; } + .show-create-form, + .show-settings-form, + .usage-edit-form, + .builder-filter { + grid-template-columns: 1fr; + } .library-filters .search-field, .wide-field { grid-column: auto; @@ -2271,6 +2818,34 @@ a { .usage-row { grid-template-columns: minmax(0, 1fr) auto; } + .show-card, + .show-card-facts { + align-items: flex-start; + flex-direction: column; + } + .show-card-facts { + width: 100%; + gap: 0.4rem; + } + .show-settings-form .wide-field, + .usage-edit-form .wide-field { + grid-column: auto; + } + .copy-field, + .reserve-usage { + grid-template-columns: 1fr; + } + .copy-field button, + .reserve-usage button { + width: 100%; + } + .editable-run-section > .row-actions { + position: static; + margin: 0.5rem 0 0 2.8rem; + } + .section-editor { + margin-left: 0; + } .live-header-actions { width: 100%; justify-content: space-between; diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index d69edd0..28357b9 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -47,12 +47,28 @@ describe('database setup', () => { (SELECT count(*) FROM schema_migrations) AS migrations, (SELECT count(*) FROM pieces) AS pieces, (SELECT count(*) FROM shows) AS shows, - (SELECT count(*) FROM piece_usages) AS usages` + (SELECT count(*) FROM piece_usages) AS usages, + (SELECT count(*) FROM show_templates) AS templates, + (SELECT count(*) FROM show_sections WHERE is_reserve_bank = 1) AS reserve_banks` ) - .get() as { migrations: number; pieces: number; shows: number; usages: number }; + .get() as { + migrations: number; + pieces: number; + shows: number; + usages: number; + templates: number; + reserve_banks: number; + }; database.close(); - expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 }); + expect(counts).toEqual({ + migrations: 4, + pieces: 6, + shows: 2, + usages: 5, + templates: 2, + reserve_banks: 2 + }); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); }); diff --git a/src/lib/server/previews.ts b/src/lib/server/previews.ts index 6d42a92..df00992 100644 --- a/src/lib/server/previews.ts +++ b/src/lib/server/previews.ts @@ -1,4 +1,5 @@ import { getDatabase } from './database.js'; +import { getShow as getShowById, listShows } from './shows.js'; export type PreviewPiece = { id: string; @@ -104,109 +105,63 @@ export function getPieces(): PreviewPiece[] { })); } -type ShowRow = { - id: string; - title: string; - starts_at_utc: string; - twitch_title: string; - announcement_text: string; - status: string; -}; - -type SectionRow = { - id: string; - name: string; - category: string | null; - category_color: string | null; - explicit_duration_seconds: number | null; -}; - -type UsageRow = { - id: string; - show_section_id: string; - title_snapshot: string; - duration_seconds: number; - status: PreviewUsage['status']; - intro_snapshot: string; - moderation_snapshot: string; - transition_snapshot: string; - is_reserve: number; -}; - -export function getShow(): PreviewShow { - const database = getDatabase(); - const show = database - .prepare( - `SELECT id, title, starts_at_utc, twitch_title, announcement_text, status - FROM shows ORDER BY starts_at_utc DESC LIMIT 1` - ) - .get() as ShowRow | undefined; - +export function getShow(showId?: string): PreviewShow { + const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null); + const show = getShowById(showId ?? latest?.id ?? ''); if (!show) throw new Error('No preview show found. Run npm run db:seed.'); - - const sectionRows = database - .prepare( - `SELECT s.id, s.name, c.name AS category, c.color AS category_color, - s.explicit_duration_seconds - FROM show_sections s - LEFT JOIN section_categories c ON c.id = s.category_id - WHERE s.show_id = ? ORDER BY s.position` - ) - .all(show.id) as SectionRow[]; - const usageRows = database - .prepare( - `SELECT id, show_section_id, title_snapshot, duration_seconds, status, - intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve - FROM piece_usages WHERE show_section_id IN ( - SELECT id FROM show_sections WHERE show_id = ? - ) ORDER BY position` - ) - .all(show.id) as UsageRow[]; - - const sections = sectionRows.map((section) => { - const usages = usageRows - .filter((usage) => usage.show_section_id === section.id) - .map((usage) => ({ + const sections: PreviewSection[] = show.sections.map((section) => ({ + id: section.id, + name: section.name, + category: section.category ?? 'Offen', + categoryColor: section.categoryColor ?? '#8b8f98', + durationSeconds: section.durationSeconds, + usages: section.usages.map((usage) => ({ + id: usage.id, + title: usage.title, + durationSeconds: usage.durationSeconds, + status: usage.status, + intro: usage.intro, + moderation: usage.moderation, + transition: usage.transition, + isReserve: false + })) + })); + const reserveHost = sections[Math.min(1, sections.length - 1)]; + if (reserveHost) { + reserveHost.usages.push( + ...show.reserve.map((usage) => ({ id: usage.id, - title: usage.title_snapshot, - durationSeconds: usage.duration_seconds, + title: usage.title, + durationSeconds: usage.durationSeconds, status: usage.status, - intro: usage.intro_snapshot, - moderation: usage.moderation_snapshot, - transition: usage.transition_snapshot, - isReserve: usage.is_reserve === 1 - })); - const plannedUsages = usages.filter((usage) => !usage.isReserve); - return { - id: section.id, - name: section.name, - category: section.category ?? 'Offen', - categoryColor: section.category_color ?? '#8b8f98', - durationSeconds: - section.explicit_duration_seconds ?? - plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0), - usages - }; - }); + intro: usage.intro, + moderation: usage.moderation, + transition: usage.transition, + isReserve: true + })) + ); + } return { id: show.id, title: show.title, - startsAtUtc: show.starts_at_utc, - startsAtBerlin: new Intl.DateTimeFormat('de-DE', { - timeZone: 'Europe/Berlin', - weekday: 'short', - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }).format(new Date(show.starts_at_utc)), - twitchTitle: show.twitch_title, - announcement: show.announcement_text, + startsAtUtc: show.startsAtUtc ?? '', + startsAtBerlin: show.startsAtUtc + ? new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(new Date(show.startsAtUtc)) + : 'Termin offen', + twitchTitle: show.twitchTitle, + announcement: show.announcementText, status: show.status, sections, - totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) + totalDurationSeconds: show.totalDurationSeconds }; } diff --git a/src/lib/server/shows.spec.ts b/src/lib/server/shows.spec.ts new file mode 100644 index 0000000..67a542c --- /dev/null +++ b/src/lib/server/shows.spec.ts @@ -0,0 +1,272 @@ +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 { getPiece } from './content.js'; +import { closeDatabase, getDatabase } from './database.js'; +import { + addPieceUsage, + addShowSection, + createShow, + getShow, + listShowTemplates, + movePieceUsage, + moveShowSection, + removePieceUsage, + removeShowSection, + updatePieceUsage, + updateShowSection, + type PieceUsage +} from './shows.js'; + +let directory: string; + +beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shows-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 }); +}); + +afterEach(() => { + closeDatabase(); + delete process.env.SENDEWERK_DB_PATH; + delete process.env.SENDEWERK_UPLOAD_DIR; + rmSync(directory, { recursive: true, force: true }); +}); + +function editedUsage(usage: PieceUsage, overrides: Partial = {}) { + const edited = { ...usage, ...overrides }; + return { + title: edited.title, + intro: edited.intro, + research: edited.research, + moderation: edited.moderation, + transition: edited.transition, + presentation: edited.presentation, + ownNotes: edited.ownNotes, + durationSeconds: edited.durationSeconds, + status: edited.status + }; +} + +describe('show builder data layer', () => { + it('creates blank and templated shows with independent sections and reserve banks', () => { + const templatesBefore = listShowTemplates(); + expect( + templatesBefore.find((template) => template.id === 'template-default')?.sections + ).toHaveLength(5); + expect(templatesBefore.find((template) => template.id === 'template-games')?.name).toBe( + 'Spieleabend' + ); + + const blank = createShow({ title: null, startsAtUtc: null, templateId: null }); + expect(getShow(blank.id)).toMatchObject({ + title: 'Neue Sendung', + startsAtUtc: null, + sections: [], + reserve: [] + }); + const reserveCount = getDatabase() + .prepare( + 'SELECT count(*) AS count FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1' + ) + .get(blank.id) as { count: number }; + expect(reserveCount.count).toBe(1); + + const templated = createShow({ + title: 'Vorlagenkopie', + startsAtUtc: '2027-01-08T19:00:00Z', + templateId: 'template-default' + }); + const copy = getShow(templated.id)!; + expect(copy.sections.map((section) => section.name)).toEqual([ + 'Start', + 'JC / Reaction', + 'Spiel 1', + 'JC', + 'Spiel 2' + ]); + updateShowSection(templated.id, copy.sections[0].id, copy.revision, { + name: 'Eigener Start', + categoryId: 'cat-jc', + explicitDurationSeconds: 420 + }); + expect(listShowTemplates()).toEqual(templatesBefore); + }); + + it('snapshots a ready piece repeatedly and keeps usage edits independent from its original', () => { + const created = createShow({ + title: 'Doppelte Verwendung', + startsAtUtc: null, + templateId: null + }); + const section = addShowSection(created.id, created.revision, { + name: 'Fundstücke', + categoryId: 'cat-reaction', + explicitDurationSeconds: null + }); + const first = addPieceUsage(created.id, section.revision, { + pieceId: 'piece-console', + sectionId: section.id + }); + const second = addPieceUsage(created.id, first.revision, { + pieceId: 'piece-console', + sectionId: section.id + }); + const usages = getShow(created.id)!.sections[0].usages; + const firstSaved = updatePieceUsage( + created.id, + first.id, + second.revision, + editedUsage(usages[0], { + title: 'Konsole als Auftakt', + durationSeconds: 300, + ownNotes: 'Kurbel zuerst zeigen', + research: 'Eigene Recherchekopie' + }) + ); + updatePieceUsage( + created.id, + second.id, + firstSaved.revision, + editedUsage(usages[1], { title: 'Konsole als Rückblick', durationSeconds: 900 }) + ); + + const show = getShow(created.id)!; + expect(show.sections[0].usages).toMatchObject([ + { + title: 'Konsole als Auftakt', + durationSeconds: 300, + ownNotes: 'Kurbel zuerst zeigen', + research: 'Eigene Recherchekopie' + }, + { title: 'Konsole als Rückblick', durationSeconds: 900 } + ]); + expect(getPiece('piece-console')).toMatchObject({ + title: 'Die kleine Konsole mit dem großen Dreh', + durationSeconds: 480, + researchText: + '## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten' + }); + }); + + it('persists normalized section and usage reordering, moving and removal', () => { + const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null }); + const firstSection = addShowSection(created.id, created.revision, { + name: 'Erster Block', + categoryId: null, + explicitDurationSeconds: null + }); + const secondSection = addShowSection(created.id, firstSection.revision, { + name: 'Zweiter Block', + categoryId: null, + explicitDurationSeconds: null + }); + const firstUsage = addPieceUsage(created.id, secondSection.revision, { + pieceId: 'piece-console', + sectionId: firstSection.id + }); + const secondUsage = addPieceUsage(created.id, firstUsage.revision, { + pieceId: 'piece-effects', + sectionId: firstSection.id + }); + const thirdUsage = addPieceUsage(created.id, secondUsage.revision, { + pieceId: 'piece-pixels', + sectionId: firstSection.id + }); + const reordered = movePieceUsage(created.id, thirdUsage.id, thirdUsage.revision, { + sectionId: firstSection.id, + position: 0 + }); + const reserved = movePieceUsage(created.id, firstUsage.id, reordered.revision, { + sectionId: null, + position: 0 + }); + const plannedAgain = movePieceUsage(created.id, firstUsage.id, reserved.revision, { + sectionId: secondSection.id, + position: 0 + }); + const removedUsage = removePieceUsage(created.id, secondUsage.id, plannedAgain.revision); + const movedSection = moveShowSection(created.id, secondSection.id, 0, removedUsage.revision); + removeShowSection(created.id, firstSection.id, movedSection.revision); + + const show = getShow(created.id)!; + expect(show.sections.map((section) => [section.name, section.position])).toEqual([ + ['Zweiter Block', 0] + ]); + expect(show.sections[0].usages.map((usage) => [usage.id, usage.position])).toEqual([ + [firstUsage.id, 0] + ]); + expect(show.reserve).toEqual([]); + expect(getDatabase().pragma('foreign_key_check')).toEqual([]); + }); + + it('uses explicit section durations once and excludes the show-wide reserve', () => { + const created = createShow({ title: 'Dauer', startsAtUtc: null, templateId: null }); + const section = addShowSection(created.id, created.revision, { + name: 'Fester Block', + categoryId: null, + explicitDurationSeconds: 600 + }); + const planned = addPieceUsage(created.id, section.revision, { + pieceId: 'piece-effects', + sectionId: section.id + }); + addPieceUsage(created.id, planned.revision, { + pieceId: 'piece-pixels', + sectionId: null + }); + + const explicit = getShow(created.id)!; + expect(explicit.sections[0].durationSeconds).toBe(600); + expect(explicit.totalDurationSeconds).toBe(600); + expect(explicit.reserve[0].durationSeconds).toBe(540); + + updateShowSection(created.id, section.id, explicit.revision, { + name: 'Summierter Block', + categoryId: null, + explicitDurationSeconds: null + }); + expect(getShow(created.id)).toMatchObject({ + totalDurationSeconds: 720, + sections: [{ durationSeconds: 720 }] + }); + }); + + it('rolls stale and invalid aggregate mutations back and rejects completed shows', () => { + const created = createShow({ title: 'Konflikt', startsAtUtc: null, templateId: null }); + const saved = addShowSection(created.id, created.revision, { + name: 'Gültig', + categoryId: null, + explicitDurationSeconds: null + }); + expect(() => + addShowSection(created.id, created.revision, { + name: 'Veraltet', + categoryId: null, + explicitDurationSeconds: null + }) + ).toThrow('CONFLICT'); + expect(getShow(created.id)).toMatchObject({ + revision: saved.revision, + sections: [{ name: 'Gültig' }] + }); + + expect(() => removeShowSection(created.id, 'missing', saved.revision)).toThrow('NOT_FOUND'); + expect(getShow(created.id)?.revision).toBe(saved.revision); + getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(created.id); + expect(() => + addPieceUsage(created.id, saved.revision, { + pieceId: 'piece-console', + sectionId: saved.id + }) + ).toThrow('SHOW_COMPLETED'); + expect(getShow(created.id)).toMatchObject({ + revision: saved.revision, + sections: [{ usages: [] }] + }); + }); +}); diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts new file mode 100644 index 0000000..4776bff --- /dev/null +++ b/src/lib/server/shows.ts @@ -0,0 +1,796 @@ +import { randomUUID } from 'node:crypto'; +import type Database from 'better-sqlite3'; +import { getDatabase } from './database.js'; + +export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const; +export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const; + +export type ShowStatus = (typeof SHOW_STATUSES)[number]; +export type EditableShowStatus = Exclude; +export type UsageStatus = (typeof USAGE_STATUSES)[number]; + +export type ShowListItem = { + id: string; + title: string; + startsAtUtc: string | null; + status: ShowStatus; + revision: number; + totalDurationSeconds: number; + sectionCount: number; +}; + +export type ShowTemplateSection = { + id: string; + name: string; + categoryId: string | null; + position: number; + explicitDurationSeconds: number | null; +}; + +export type ShowTemplate = { + id: string; + name: string; + description: string; + sections: ShowTemplateSection[]; +}; + +export type PieceUsage = { + id: string; + originalPieceId: string | null; + position: number; + status: UsageStatus; + durationSeconds: number; + title: string; + intro: string; + research: string; + moderation: string; + transition: string; + presentation: string; + ownNotes: string; +}; + +export type ShowSection = { + id: string; + name: string; + categoryId: string | null; + category: string | null; + categoryColor: string | null; + position: number; + explicitDurationSeconds: number | null; + durationSeconds: number; + usages: PieceUsage[]; +}; + +export type Show = { + id: string; + title: string; + startsAtUtc: string | null; + twitchTitle: string; + announcementText: string; + status: ShowStatus; + revision: number; + createdAtUtc: string; + updatedAtUtc: string; + sections: ShowSection[]; + reserve: PieceUsage[]; + totalDurationSeconds: number; +}; + +export type CreateShowInput = { + title: string | null; + startsAtUtc: string | null; + templateId: string | null; +}; + +export type ShowMetadataInput = { + title: string; + startsAtUtc: string | null; + twitchTitle: string; + announcementText: string; + status: EditableShowStatus; +}; + +export type ShowSectionInput = { + name: string; + categoryId: string | null; + explicitDurationSeconds: number | null; + position?: number; +}; + +export type PieceUsageInput = { + pieceId: string; + sectionId: string | null; + position?: number; +}; + +export type PieceUsageUpdate = { + title: string; + intro: string; + research: string; + moderation: string; + transition: string; + presentation: string; + ownNotes: string; + durationSeconds: number; + status: UsageStatus; +}; + +export type MovePieceUsageInput = { + sectionId: string | null; + position: number; +}; + +export type MutationResult = { revision: number; updatedAtUtc: string }; +export type CreatedMutationResult = MutationResult & { id: string }; + +type ShowRow = { + id: string; + title: string; + starts_at_utc: string | null; + twitch_title: string; + announcement_text: string; + status: ShowStatus; + revision: number; + created_at_utc: string; + updated_at_utc: string; +}; + +type SectionRow = { + id: string; + name: string; + category_id: string | null; + category: string | null; + category_color: string | null; + position: number; + explicit_duration_seconds: number | null; + is_reserve_bank: number; +}; + +type UsageRow = { + id: string; + show_section_id: string; + original_piece_id: string | null; + position: number; + status: UsageStatus; + duration_seconds: number; + title_snapshot: string; + intro_snapshot: string; + research_snapshot: string; + moderation_snapshot: string; + transition_snapshot: string; + presentation_snapshot: string; + own_notes: string; +}; + +function cleanText(value: unknown, maximum: number, field: string, required = false): string { + const text = String(value ?? '').trim(); + if (text.length > maximum || (required && !text)) { + throw new Error(`INVALID_${field.toUpperCase()}`); + } + return text; +} + +function validDuration(value: number | null): number | null { + if (value === null) return null; + if (!Number.isInteger(value) || value < 0 || value > 86_400) { + throw new Error('INVALID_DURATION'); + } + return value; +} + +function validPosition(value: number, maximum: number): number { + if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION'); + return Math.min(value, maximum); +} + +function validTimestamp(value: string | null): string | null { + if (value === null) return null; + if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP'); + const date = new Date(value); + if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP'); + return date.toISOString(); +} + +function requireRevision(expectedRevision: number): void { + if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { + throw new Error('INVALID_REVISION'); + } +} + +function requireCategory(database: Database.Database, categoryId: string | null): void { + if (!categoryId) return; + if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) { + throw new Error('INVALID_CATEGORY'); + } +} + +function bumpShow( + database: Database.Database, + showId: string, + expectedRevision: number +): MutationResult { + requireRevision(expectedRevision); + const updatedAtUtc = new Date().toISOString(); + const result = database + .prepare( + `UPDATE shows SET revision = revision + 1, updated_at_utc = ? + WHERE id = ? AND revision = ? AND status != 'completed'` + ) + .run(updatedAtUtc, showId, expectedRevision); + if (result.changes !== 1) { + const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as + { status: ShowStatus } | undefined; + if (!show) throw new Error('NOT_FOUND'); + if (show.status === 'completed') throw new Error('SHOW_COMPLETED'); + throw new Error('CONFLICT'); + } + return { revision: expectedRevision + 1, updatedAtUtc }; +} + +function reserveSectionId(database: Database.Database, showId: string): string { + const row = database + .prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1') + .get(showId) as { id: string } | undefined; + if (!row) throw new Error('INVALID_RESERVE_BANK'); + return row.id; +} + +function sectionBelongsToShow( + database: Database.Database, + showId: string, + sectionId: string, + allowReserve = false +): void { + const row = database + .prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?') + .get(sectionId, showId) as { is_reserve_bank: number } | undefined; + if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND'); +} + +function usageSection( + database: Database.Database, + showId: string, + usageId: string +): { sectionId: string } { + const row = database + .prepare( + `SELECT u.show_section_id AS sectionId FROM piece_usages u + JOIN show_sections s ON s.id = u.show_section_id + WHERE u.id = ? AND s.show_id = ?` + ) + .get(usageId, showId) as { sectionId: string } | undefined; + if (!row) throw new Error('NOT_FOUND'); + return row; +} + +function normalizePositions( + database: Database.Database, + table: 'show_sections' | 'piece_usages', + ids: string[] +): void { + if (!ids.length) return; + const placeholders = ids.map(() => '?').join(', '); + const maximum = database + .prepare( + `SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})` + ) + .get(...ids) as { maximum: number }; + const offset = maximum.maximum + ids.length + 1; + database + .prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`) + .run(offset, ...ids); + const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`); + ids.forEach((id, position) => update.run(position, id)); +} + +function usageFromRow(row: UsageRow): PieceUsage { + return { + id: row.id, + originalPieceId: row.original_piece_id, + position: row.position, + status: row.status, + durationSeconds: row.duration_seconds, + title: row.title_snapshot, + intro: row.intro_snapshot, + research: row.research_snapshot, + moderation: row.moderation_snapshot, + transition: row.transition_snapshot, + presentation: row.presentation_snapshot, + ownNotes: row.own_notes + }; +} + +export function listShows(): ShowListItem[] { + return ( + getDatabase() + .prepare( + `SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision, + count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count, + COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN + COALESCE(s.explicit_duration_seconds, + (SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id)) + ELSE 0 END), 0) AS total_duration_seconds + FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id + GROUP BY sh.id + ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC` + ) + .all() as { + id: string; + title: string; + starts_at_utc: string | null; + status: ShowStatus; + revision: number; + section_count: number; + total_duration_seconds: number; + }[] + ).map((row) => ({ + id: row.id, + title: row.title, + startsAtUtc: row.starts_at_utc, + status: row.status, + revision: row.revision, + totalDurationSeconds: row.total_duration_seconds, + sectionCount: row.section_count + })); +} + +export function listShowTemplates(): ShowTemplate[] { + const database = getDatabase(); + const templates = database + .prepare('SELECT id, name, description FROM show_templates ORDER BY name') + .all() as Omit[]; + const sectionStatement = database.prepare( + `SELECT id, name, category_id, position, explicit_duration_seconds + FROM show_template_sections WHERE template_id = ? ORDER BY position` + ); + return templates.map((template) => ({ + ...template, + sections: ( + sectionStatement.all(template.id) as { + id: string; + name: string; + category_id: string | null; + position: number; + explicit_duration_seconds: number | null; + }[] + ).map((section) => ({ + id: section.id, + name: section.name, + categoryId: section.category_id, + position: section.position, + explicitDurationSeconds: section.explicit_duration_seconds + })) + })); +} + +export function getShow(id: string): Show | null { + const database = getDatabase(); + const show = database + .prepare( + `SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision, + created_at_utc, updated_at_utc FROM shows WHERE id = ?` + ) + .get(id) as ShowRow | undefined; + if (!show) return null; + const sectionRows = database + .prepare( + `SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color, + 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 + WHERE s.show_id = ? ORDER BY s.position` + ) + .all(id) as SectionRow[]; + const usageRows = database + .prepare( + `SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status, + u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot, + u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes + FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id + WHERE s.show_id = ? ORDER BY s.position, u.position` + ) + .all(id) as UsageRow[]; + const usagesBySection = new Map(); + for (const row of usageRows) { + const usages = usagesBySection.get(row.show_section_id) ?? []; + usages.push(usageFromRow(row)); + usagesBySection.set(row.show_section_id, usages); + } + const sections = sectionRows + .filter((row) => row.is_reserve_bank === 0) + .map((row) => { + const usages = usagesBySection.get(row.id) ?? []; + return { + id: row.id, + name: row.name, + categoryId: row.category_id, + category: row.category, + categoryColor: row.category_color, + position: row.position, + explicitDurationSeconds: row.explicit_duration_seconds, + durationSeconds: + row.explicit_duration_seconds ?? + usages.reduce((total, usage) => total + usage.durationSeconds, 0), + usages + }; + }); + const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1); + return { + id: show.id, + title: show.title, + startsAtUtc: show.starts_at_utc, + twitchTitle: show.twitch_title, + announcementText: show.announcement_text, + status: show.status, + revision: show.revision, + createdAtUtc: show.created_at_utc, + updatedAtUtc: show.updated_at_utc, + sections, + reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [], + totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0) + }; +} + +export function createShow(input: CreateShowInput): CreatedMutationResult { + const database = getDatabase(); + const id = randomUUID(); + const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true); + const startsAtUtc = validTimestamp(input.startsAtUtc); + const now = new Date().toISOString(); + const create = database.transaction(() => { + if ( + input.templateId && + !database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId) + ) { + throw new Error('INVALID_TEMPLATE'); + } + 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, ?, ?)` + ) + .run(id, title, startsAtUtc, now, now); + const templateSections = input.templateId + ? (database + .prepare( + `SELECT name, category_id, position, explicit_duration_seconds + FROM show_template_sections WHERE template_id = ? ORDER BY position` + ) + .all(input.templateId) as { + name: string; + category_id: string | null; + position: number; + explicit_duration_seconds: number | null; + }[]) + : []; + const insertSection = database.prepare( + `INSERT INTO show_sections (id, show_id, name, category_id, position, + explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + for (const section of templateSections) { + insertSection.run( + randomUUID(), + id, + section.name, + section.category_id, + section.position, + section.explicit_duration_seconds, + 0 + ); + } + insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1); + }); + create(); + return { id, revision: 1, updatedAtUtc: now }; +} + +export function saveShowMetadata( + showId: string, + expectedRevision: number, + input: ShowMetadataInput +): MutationResult { + const title = cleanText(input.title, 200, 'title', true); + const startsAtUtc = validTimestamp(input.startsAtUtc); + const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title'); + const announcementText = cleanText(input.announcementText, 20_000, 'announcement'); + if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS'); + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + database + .prepare( + `UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?, + announcement_text = ?, status = ? WHERE id = ?` + ) + .run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId); + return result; + })(); +} + +export function addShowSection( + showId: string, + expectedRevision: number, + input: ShowSectionInput +): CreatedMutationResult { + const name = cleanText(input.name, 200, 'section_name', true); + const duration = validDuration(input.explicitDurationSeconds); + const database = getDatabase(); + requireCategory(database, input.categoryId); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + const visibleIds = ( + database + .prepare( + `SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position` + ) + .all(showId) as { id: string }[] + ).map((row) => row.id); + const position = validPosition(input.position ?? visibleIds.length, visibleIds.length); + const id = randomUUID(); + const allIds = ( + database + .prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position') + .all(showId) as { + id: string; + }[] + ).map((row) => row.id); + const temporaryPosition = allIds.length; + database + .prepare( + `INSERT INTO show_sections (id, show_id, name, category_id, position, + explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)` + ) + .run(id, showId, name, input.categoryId, temporaryPosition, duration); + visibleIds.splice(position, 0, id); + const reserveId = reserveSectionId(database, showId); + normalizePositions(database, 'show_sections', [...visibleIds, reserveId]); + return { ...result, id }; + })(); +} + +export function updateShowSection( + showId: string, + sectionId: string, + expectedRevision: number, + input: Omit +): MutationResult { + const name = cleanText(input.name, 200, 'section_name', true); + const duration = validDuration(input.explicitDurationSeconds); + const database = getDatabase(); + requireCategory(database, input.categoryId); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + sectionBelongsToShow(database, showId, sectionId); + database + .prepare( + `UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ? + WHERE id = ?` + ) + .run(name, input.categoryId, duration, sectionId); + return result; + })(); +} + +export function removeShowSection( + showId: string, + sectionId: string, + expectedRevision: number +): MutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + sectionBelongsToShow(database, showId, sectionId); + database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId); + const ids = ( + database + .prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position') + .all(showId) as { + id: string; + }[] + ).map((row) => row.id); + normalizePositions(database, 'show_sections', ids); + return result; + })(); +} + +export function moveShowSection( + showId: string, + sectionId: string, + targetPosition: number, + expectedRevision: number +): MutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + sectionBelongsToShow(database, showId, sectionId); + const ids = ( + database + .prepare( + `SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position` + ) + .all(showId) as { id: string }[] + ).map((row) => row.id); + const current = ids.indexOf(sectionId); + ids.splice(current, 1); + ids.splice(validPosition(targetPosition, ids.length), 0, sectionId); + ids.push(reserveSectionId(database, showId)); + normalizePositions(database, 'show_sections', ids); + return result; + })(); +} + +export function addPieceUsage( + showId: string, + expectedRevision: number, + input: PieceUsageInput +): CreatedMutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + const sectionId = input.sectionId ?? reserveSectionId(database, showId); + sectionBelongsToShow(database, showId, sectionId, input.sectionId === null); + const piece = database + .prepare( + `SELECT title, duration_seconds, intro, research_text, moderation_notes, + transition_text, presentation_text, status FROM pieces WHERE id = ?` + ) + .get(input.pieceId) as + | { + title: string; + duration_seconds: number; + intro: string; + research_text: string; + moderation_notes: string; + transition_text: string; + presentation_text: string; + status: string; + } + | undefined; + if (!piece) throw new Error('NOT_FOUND'); + if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY'); + const ids = ( + database + .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') + .all(sectionId) as { id: string }[] + ).map((row) => row.id); + const position = validPosition(input.position ?? ids.length, ids.length); + const id = randomUUID(); + 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, transition_snapshot, presentation_snapshot, own_notes, is_reserve) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)` + ) + .run( + id, + sectionId, + input.pieceId, + ids.length, + piece.duration_seconds, + piece.title, + piece.intro, + piece.research_text, + piece.moderation_notes, + piece.transition_text, + piece.presentation_text, + input.sectionId === null ? 1 : 0 + ); + ids.splice(position, 0, id); + normalizePositions(database, 'piece_usages', ids); + return { ...result, id }; + })(); +} + +export function updatePieceUsage( + showId: string, + usageId: string, + expectedRevision: number, + input: PieceUsageUpdate +): MutationResult { + const title = cleanText(input.title, 200, 'title', true); + const duration = validDuration(input.durationSeconds)!; + if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS'); + const values = { + intro: cleanText(input.intro, 50_000, 'intro'), + research: cleanText(input.research, 50_000, 'research'), + moderation: cleanText(input.moderation, 50_000, 'moderation'), + transition: cleanText(input.transition, 50_000, 'transition'), + presentation: cleanText(input.presentation, 50_000, 'presentation'), + ownNotes: cleanText(input.ownNotes, 50_000, 'notes') + }; + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + usageSection(database, showId, usageId); + database + .prepare( + `UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?, + moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?, + own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?` + ) + .run( + title, + values.intro, + values.research, + values.moderation, + values.transition, + values.presentation, + values.ownNotes, + duration, + input.status, + usageId + ); + return result; + })(); +} + +export function removePieceUsage( + showId: string, + usageId: string, + expectedRevision: number +): MutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + const source = usageSection(database, showId, usageId); + database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId); + const ids = ( + database + .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') + .all(source.sectionId) as { id: string }[] + ).map((row) => row.id); + normalizePositions(database, 'piece_usages', ids); + return result; + })(); +} + +export function movePieceUsage( + showId: string, + usageId: string, + expectedRevision: number, + input: MovePieceUsageInput +): MutationResult { + const database = getDatabase(); + return database.transaction(() => { + const result = bumpShow(database, showId, expectedRevision); + const source = usageSection(database, showId, usageId); + const targetSectionId = input.sectionId ?? reserveSectionId(database, showId); + sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null); + const sourceIds = ( + database + .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') + .all(source.sectionId) as { id: string }[] + ).map((row) => row.id); + if (source.sectionId === targetSectionId) { + sourceIds.splice(sourceIds.indexOf(usageId), 1); + sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId); + normalizePositions(database, 'piece_usages', sourceIds); + } else { + const targetIds = ( + database + .prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position') + .all(targetSectionId) as { id: string }[] + ).map((row) => row.id); + const remainingSourceIds = sourceIds.filter((id) => id !== usageId); + normalizePositions(database, 'piece_usages', sourceIds); + normalizePositions(database, 'piece_usages', targetIds); + const temporaryPosition = ( + database + .prepare( + 'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?' + ) + .get(targetSectionId) as { position: number } + ).position; + database + .prepare( + `UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?` + ) + .run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId); + targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId); + normalizePositions(database, 'piece_usages', remainingSourceIds); + normalizePositions(database, 'piece_usages', targetIds); + } + return result; + })(); +} diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts index 6f601e6..d741a36 100644 --- a/src/routes/fundus/[id]/+page.server.ts +++ b/src/routes/fundus/[id]/+page.server.ts @@ -34,6 +34,10 @@ function text(data: FormData, name: string) { return String(data.get(name) || ''); } +function durationInSeconds(data: FormData) { + return Math.round(Number(data.get('durationMinutes')) * 60); +} + function pieceInput(data: FormData): PieceInput { const labels = data.getAll('sourceLabel').map(String); const urls = data.getAll('sourceUrl').map(String); @@ -55,7 +59,7 @@ function pieceInput(data: FormData): PieceInput { summary: text(data, 'summary'), formatId: text(data, 'formatId') || null, status: text(data, 'status') as EditablePieceStatus, - durationSeconds: Number(data.get('durationSeconds')), + durationSeconds: durationInSeconds(data), intro: text(data, 'intro'), researchText: text(data, 'researchText'), moderationNotes: text(data, 'moderationNotes'), @@ -77,7 +81,7 @@ function saveError(cause: unknown) { }; if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' }; if (code === 'INVALID_DURATION') - return { status: 400, error: 'Die Dauer muss zwischen 0 und 86.400 Sekunden liegen.' }; + return { status: 400, error: 'Die Dauer muss zwischen 0 und 1.440 Minuten liegen.' }; if (code === 'INVALID_SOURCES') return { status: 400, diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index 200047c..ad069d3 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -8,6 +8,7 @@ let { data, form }: PageProps = $props(); let draft = $state(untrack(() => ({ ...data.piece }))); + let durationMinutes = $state(untrack(() => Number((data.piece.durationSeconds / 60).toFixed(2)))); let revision = $state(untrack(() => data.piece.revision)); let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source })))); let rendered = $state(untrack(() => ({ ...data.rendered }))); @@ -156,13 +157,13 @@ >