From 5760059ee361c88c3625effd2ce2653d30c3fe16 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 29 Sep 2026 18:15:08 -0500 Subject: [PATCH] fix: make timeline activation durable and visible --- src/ui/SignageDashboard.tsx | 25 ++++++--- src/ui/panels/PlaybackPanel.tsx | 15 +++++ src/ui/panels/TimelinesPanel.tsx | 21 ++++++- tests/browser/management-ui.test.tsx | 82 ++++++++++++++++++++++++++++ tests/playback.test.ts | 24 ++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 216e815..0267b86 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -62,6 +62,10 @@ export function SignageDashboard({ const selectedGroupId = groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); + const selectedGroup = + groups.state.status === "ready" + ? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) + : undefined; return ( <> @@ -93,18 +97,23 @@ export function SignageDashboard({ { content: ( { - void api - .send("POST", `playback/${selectedGroupId}/activate`, { - timelineId, - when: "now", - }) - .then(() => setTab("playback")) - .catch(() => setTab("playback")); + ? async (timelineId) => { + await api.send("POST", `playback/${selectedGroupId}/activate`, { + timelineId, + when: "now", + }); + groups.reload(); + setTab("playback"); } : undefined } diff --git a/src/ui/panels/PlaybackPanel.tsx b/src/ui/panels/PlaybackPanel.tsx index 4bf1f5f..2024068 100644 --- a/src/ui/panels/PlaybackPanel.tsx +++ b/src/ui/panels/PlaybackPanel.tsx @@ -70,6 +70,16 @@ export function PlaybackPanel({ command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); } + function timelineLabel(timelineId: string): string { + if (timelines.state.status !== "ready") { + return timelineId; + } + return ( + timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ?? + timelineId + ); + } + return (
+ {value.snapshot.activeTimelineId === null ? undefined : ( + + {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} + + )} void) | undefined; + readonly onActivate: ((timelineId: string) => Promise) | undefined; + readonly activeTimelineId?: string | null; + readonly activeGroupName?: string; }): ReactElement { const timelines = useResource( () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), @@ -62,6 +66,7 @@ export function TimelinesPanel({ setNewName(""); timelines.reload(); }); + const activation = useAction(); return (
@@ -117,6 +122,11 @@ export function TimelinesPanel({ + {timeline.id === activeTimelineId ? ( + + {activeGroupName ? `Active in ${activeGroupName}` : "Active"} + + ) : undefined} {timeline.eligibleFrom === null && timeline.eligibleUntil === null @@ -126,8 +136,12 @@ export function TimelinesPanel({ {timeline.revision} {onActivate === undefined ? undefined : ( - )} @@ -139,6 +153,7 @@ export function TimelinesPanel({ { label: "Loading timelines…", onRetry: timelines.reload }, )} + {selectedId === undefined ? undefined : ( diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx index 40eaae0..06efbb3 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -120,6 +120,16 @@ const PLAYBACK_DETAIL = { snapshot: GROUP.playback, }; +const TIMELINE = { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", +}; + beforeEach(() => { container = document.createElement("div"); document.body.append(container); @@ -194,6 +204,78 @@ describe("Signage dashboard", () => { expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); }); + it("shows which timeline remains active after the dashboard data is reloaded", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + ], + }); + + await render(); + expect(container.textContent).toContain("Active: Sunday morning"); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); + + expect(container.textContent).toContain("Active in Main"); + expect(buttonNamed("Active")?.disabled).toBe(true); + }); + + it("keeps a failed activation visible instead of pretending it succeeded", async () => { + const inactiveGroup = { + ...GROUP, + playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null }, + }; + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [inactiveGroup] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + { + method: "POST", + path: "playback/group-1/activate", + respond: () => + errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); + await click(buttonNamed("Activate")); + + expect(container.textContent).toContain("Sunday morning"); + expect(container.querySelector("[role='alert']")?.textContent).toContain( + "The request conflicts with the current state.", + ); + }); + it("shows Signage's own sanitized reason when a save is refused", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.timelines.manage"], diff --git a/tests/playback.test.ts b/tests/playback.test.ts index ab9fda5..9afcaa0 100644 --- a/tests/playback.test.ts +++ b/tests/playback.test.ts @@ -589,6 +589,30 @@ describe("authoritative playback", () => { expect(announcements.resolveRequests.length).toBe(callsBefore); }); + it("keeps the active timeline through a complete playback-service restart", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + + service.stop(); + clock += 2000; + service = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 }, + store, + }); + await service.start(); + + const after = await service.snapshot(group.group.id); + expect(after.activeTimelineId).toBe(timeline.timeline.id); + expect(after.currentSelectionId).toBe(before.currentSelectionId); + expect(after.deadlineAtMs).toBe(before.deadlineAtMs); + }); + it("publishes a compact snapshot, with no payload or timeline in it", async () => { const group = await seedGroup(); const timeline = await seedPattern();