diff --git a/src/server/store/drizzle-timelines.ts b/src/server/store/drizzle-timelines.ts index 8a2347b..ed08cbc 100644 --- a/src/server/store/drizzle-timelines.ts +++ b/src/server/store/drizzle-timelines.ts @@ -112,20 +112,21 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt /** * Rewrites `position` for one timeline from an explicit order. * - * Positions are written in two passes, through a temporary negative range, because the unique + * Positions are written in two passes, through a temporary high range, because the unique * `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while - * another still holds 1 would collide. Negative positions are outside every real value, so the - * intermediate state cannot be mistaken for an order. + * another still holds 1 would collide. Temporary positions sit beyond the final order while + * remaining inside the database's non-negative constraint. */ async function renumber( transaction: ModuleDatabase, timelineId: string, orderedSlotIds: readonly string[], ): Promise { + const temporaryOffset = orderedSlotIds.length + 1; for (const [index, slotId] of orderedSlotIds.entries()) { await transaction .update(timelineSlots) - .set({ position: -1 - index }) + .set({ position: temporaryOffset + index }) .where(eq(timelineSlots.id, slotId)); } for (const [index, slotId] of orderedSlotIds.entries()) { @@ -175,7 +176,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length)); - await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000)); + await transaction.insert(timelineSlots).values(slotValues(slot, existing.length + 1)); const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)]; await renumber(transaction, slot.timelineId, next); }); diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 8319e80..f1ff740 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -24,8 +24,12 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js"; */ export function SignageDashboard({ context, + copyText, + displaySetupUrl, }: { readonly context: ModuleUiHostContext; + readonly copyText: (value: string) => Promise; + readonly displaySetupUrl: string; }): ReactElement { const api = useMemo(() => createSignageApi(context.api), [context.api]); // Read before the resources below, so a viewer who may not see Signage at all asks for nothing. @@ -135,6 +139,8 @@ export function SignageDashboard({ { setGroupId(undefined); groups.reload(); diff --git a/src/ui/index.tsx b/src/ui/index.tsx index eb3fb7d..ec342ce 100644 --- a/src/ui/index.tsx +++ b/src/ui/index.tsx @@ -19,7 +19,19 @@ export default defineModuleUi({ styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, mount(container, context) { const root = createRoot(container); - root.render(); + const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href; + root.render( + { + if (globalThis.navigator.clipboard === undefined) { + throw new Error("Clipboard access is not available in this browser."); + } + await globalThis.navigator.clipboard.writeText(value); + }} + displaySetupUrl={displaySetupUrl} + />, + ); return () => root.unmount(); }, }); diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx index 8facd36..4ff4a9c 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -1,17 +1,4 @@ -import { - Badge, - Button, - EmptyState, - Field, - Panel, - Select, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextInput, -} from "@ekklesia/ui"; +import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource, useTick } from "../hooks.js"; @@ -20,30 +7,93 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared. const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +function DisplayRow({ + canManage, + groups, + nowMs, + onChannelChange, + onNameChange, + viewer, +}: { + readonly canManage: boolean; + readonly groups: readonly ChannelGroupView[]; + readonly nowMs: number; + readonly onChannelChange: (channelId: string) => void; + readonly onNameChange: (name: string) => void; + readonly viewer: ViewerHealthView; +}): ReactElement { + return ( +
+
+ {canManage ? ( + event.target.value !== viewer.name && onNameChange(event.target.value)} + /> + ) : ( + {viewer.name} + )} + +
+
+ {canManage ? ( + + {(control) => ( + + )} + + ) : undefined} +
+
+ {`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} + + {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} + + {viewer.lastError === null ? undefined : ( + + {viewer.lastError.code} + + )} +
+
+ ); +} + /** - * Channel groups, audience channels, and the display roster. - * - * A church starts with one group and one default channel. `in-person` and `online` are created here, - * when someone actually needs them — not shipped as rows nobody asked for and everybody has to - * understand and ignore (ADR 0004). + * Channel groups, audience channels, and displays, presented in the same hierarchy they use at run + * time: one group clock, one audience variant per channel, then the screens assigned to it. */ export function DisplaysPanel({ api, canManage, + copyText, + displaySetupUrl, onGroupsChanged, }: { readonly api: SignageApi; readonly canManage: boolean; + readonly copyText: (value: string) => Promise; + readonly displaySetupUrl: string; readonly onGroupsChanged?: () => void; }): ReactElement { - const groups = useResource( - () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), - [], - ); - const viewers = useResource( - () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), - [], - ); + const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []); + const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []); const edit = useAction(() => { groups.reload(); viewers.reload(); @@ -51,30 +101,87 @@ export function DisplaysPanel({ }); const viewerEdit = useAction(viewers.reload); const [groupName, setGroupName] = useState(""); + const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle"); const [confirmGroupId, setConfirmGroupId] = useState(undefined); - const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ + const [channelDraft, setChannelDraft] = useState<{ + groupId: string; + key: string; + name: string; + }>({ groupId: "", key: "", name: "", }); - // "Last seen" is a moving number: without a tick it would freeze at whatever it said when the - // roster loaded, and an operator would read a screen that went dark ten minutes ago as current. useTick(5000); const nowMs = Date.now(); + const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; - const channels = - groups.state.status === "ready" - ? groups.state.value.groups.flatMap((group) => - group.channels.map((channel) => ({ ...channel, groupName: group.name })), - ) - : []; + const updateViewer = (viewerId: string, changes: Record): void => { + viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); + }; return (
+ + + Open display setup + + +
+ } + description="Connect a screen in three short steps." + title="Pair a display" + > +
    +
  1. + 1 + Open display setup on the screen that will show signage. +
  2. +
  3. + 2 + Enter its six-digit code under Display devices in the Dashboard sidebar. +
  4. +
  5. + 3 + Name the display and choose its audience channel below. +
  6. +
+ + {copyStatus === "copied" + ? "Display setup URL copied." + : copyStatus === "failed" + ? "Could not copy the URL. Copy it from the newly opened setup page." + : ""} + + {viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? ( +
+ No displays paired yet + A newly approved screen appears below in its assigned channel. +
+ ) : undefined} + + +
{(control) => ( edit.run(async () => { - await api.send("POST", "channel-groups", { name: groupName.trim() }); + await api.send("POST", "channel-groups", { + name: groupName.trim(), + }); setGroupName(""); }) } variant="primary" > - Create + Create group
) : undefined } - description="A group is one clock. An audience channel is one rendering of it." - title="Channel groups" + description="A channel group shares one playback clock. Each audience channel can receive a different content variant." + title="Channel groups and displays" > {renderLoadable( groups.state, - (value) => ( -
- {value.groups.map((group) => ( -
-
-

{group.name}

- {group.playback.transport} - {canManage && value.groups.length > 1 ? ( - - ) : canManage ? ( - At least one group required + (value) => { + const knownChannelIds = new Set( + value.groups.flatMap((group) => group.channels.map((channel) => channel.id)), + ); + const unassigned = readyViewers.filter( + (viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), + ); + return ( +
+ {value.groups.map((group) => ( +
+
+
+ Channel group +

{group.name}

+
+ {group.playback.transport} + {canManage && value.groups.length > 1 ? ( + + ) : canManage ? ( + Required group + ) : undefined} +
+ {confirmGroupId === group.id ? ( +
+

+ Delete {group.name}? Its playback history will be removed. Displays assigned here will move to + the default channel of another group. +

+ + +
) : undefined} -
- {confirmGroupId === group.id ? ( -
-

- Delete {group.name}? Its playback history will be removed. Displays assigned - here will move to the default channel of another group. -

- - -
- ) : undefined} -
    - {group.channels.map((channel) => ( -
  • - {channel.name} - - {channel.key} - - {canManage && !channel.isDefault ? ( - + ) : undefined} + +
    + Displays + {assigned.length === 0 ? ( +

    No displays assigned.

    + ) : ( + assigned.map((viewer) => ( + updateViewer(viewer.id, { channelId })} + onNameChange={(name) => updateViewer(viewer.id, { name })} + viewer={viewer} + /> + )) + )} +
    + + ); + })} +
+ + {canManage ? ( +
+
+
Add an audience channel
+ The default channel always remains available. +
+ + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: current.groupId === group.id ? current.key : "", + name: event.target.value, + })) + } + placeholder="Online audience" + value={channelDraft.groupId === group.id ? channelDraft.name : ""} + /> + )} + + - Remove + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: event.target.value, + name: current.groupId === group.id ? current.name : "", + })) + } + pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" + placeholder="online" + value={channelDraft.groupId === group.id ? channelDraft.key : ""} + /> + )} + + - ) : undefined} - +
+ ) : undefined} + + + ))} + + {unassigned.length === 0 ? undefined : ( +
+ Needs assignment + {unassigned.map((viewer) => ( + updateViewer(viewer.id, { channelId })} + onNameChange={(name) => updateViewer(viewer.id, { name })} + viewer={viewer} + /> ))} - - {canManage ? ( -
- - {(control) => ( - - setChannelDraft((current) => ({ - groupId: group.id, - key: event.target.value, - name: current.groupId === group.id ? current.name : "", - })) - } - pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" - placeholder="online" - value={channelDraft.groupId === group.id ? channelDraft.key : ""} - /> - )} - - - {(control) => ( - - setChannelDraft((current) => ({ - groupId: group.id, - key: current.groupId === group.id ? current.key : "", - name: event.target.value, - })) - } - placeholder="Online audience" - value={channelDraft.groupId === group.id ? channelDraft.name : ""} - /> - )} - - -
- ) : undefined} -
- ))} -
- ), + + )} + + ); + }, { label: "Loading channel groups…", onRetry: groups.reload }, )} -
- - - {canManage ? ( -
-

Register a display

-
    -
  1. Open the Ekklesia path /display/signage on the screen.
  2. -
  3. Enter its six-digit code under Display devices in the Dashboard sidebar.
  4. -
  5. When it appears below, give it a clear name and assign its channel.
  6. -
-
- ) : undefined} - {renderLoadable( - viewers.state, - (value) => - value.viewers.length === 0 ? ( - - ) : ( - - - - Name - State - Channel - Last seen - Offline ready - Most recent error - - - - {value.viewers.map((viewer) => ( - - - {canManage ? ( - - event.target.value !== viewer.name && - viewerEdit.run(() => - api.send("PATCH", `viewers/${viewer.id}`, { - name: event.target.value, - }), - ) - } - /> - ) : ( - viewer.name - )} - - - - - - {canManage ? ( - - ) : ( - (channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—") - )} - - {formatSince(viewer.lastSeenAtMs, nowMs)} - - - {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"} - - - - {viewer.lastError === null ? ( - "—" - ) : ( - - {viewer.lastError.code} - - )} - - - ))} - -
- ), - { label: "Loading displays…", onRetry: viewers.reload }, - )} + {renderLoadable(viewers.state, () => null, { + label: "Loading displays…", + onRetry: viewers.reload, + })}
diff --git a/src/ui/styles.css b/src/ui/styles.css index 07279e0..c13f4ff 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -335,24 +335,112 @@ gap: var(--signage-gap); } -.signage-group { - border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - border-radius: var(--ekklesia-radius-md); +.signage-pairing-steps { display: grid; gap: var(--signage-gap-tight); + grid-template-columns: repeat(3, minmax(0, 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.signage-pairing-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); +} + +.signage-pairing-actions a { + text-decoration: none; +} + +.signage-copy-status { + color: var(--ekklesia-text-secondary); + display: block; + font-size: var(--ekklesia-typography-size-sm); + min-height: 1.25em; + margin-block-start: var(--ekklesia-spacing-xs); +} + +.signage-pairing-steps li { + align-items: center; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-md); + display: flex; + gap: var(--signage-gap-tight); + min-width: 0; padding: var(--ekklesia-spacing-md); } +.signage-pairing-steps__number { + align-items: center; + background: var(--ekklesia-accent-base); + border-radius: var(--ekklesia-radius-pill); + color: var(--ekklesia-accent-foreground); + display: inline-flex; + flex: 0 0 var(--ekklesia-control-height-sm); + font-weight: var(--ekklesia-typography-weight-bold); + height: var(--ekklesia-control-height-sm); + justify-content: center; +} + +.signage-display-empty { + align-items: baseline; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight); + margin-block-start: var(--signage-gap-tight); +} + +.signage-display-empty strong { + color: var(--ekklesia-text-primary); +} + +.signage-new-group > .ek-field { + min-width: 16rem; +} + +.signage-group { + background: var(--ekklesia-background-canvas); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--signage-panel-radius); + display: grid; + gap: var(--signage-gap); + padding: var(--ekklesia-spacing-lg); +} + .signage-group__header { align-items: center; display: flex; gap: var(--signage-gap-tight); } -.signage-group__header .ek-button:first-of-type { +.signage-group__header > .ek-button { margin-inline-start: auto; } +.signage-group__identity { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-group__identity h3, +.signage-audiences__heading h4, +.signage-channel__header h5, +.signage-channel-form__heading h5 { + margin: 0; +} + +.signage-hierarchy-label { + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-xs); + font-weight: var(--ekklesia-typography-weight-medium); + letter-spacing: 0.05em; + text-transform: uppercase; +} + .signage-confirm { align-items: center; background: var(--ekklesia-status-danger-background); @@ -369,18 +457,160 @@ margin: 0; } -.signage-registration-guide { - background: var(--ekklesia-background-inset); +.signage-audiences { + border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap); + padding-block-start: var(--signage-gap); +} + +.signage-audiences__heading { + align-items: end; + display: flex; + gap: var(--signage-gap); + justify-content: space-between; +} + +.signage-audiences__heading > div { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-audiences__heading > span { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-channel-list { + display: grid; + gap: var(--signage-gap-tight); +} + +.signage-channel { + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base); border-radius: var(--ekklesia-radius-md); display: grid; - gap: var(--ekklesia-spacing-xs); - margin-block-end: var(--signage-gap); + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-sm); padding: var(--ekklesia-spacing-md); } -.signage-registration-guide ol { +.signage-channel__header { + align-items: start; + display: flex; + gap: var(--signage-gap-tight); + justify-content: space-between; +} + +.signage-channel__header > div { + display: grid; + gap: var(--ekklesia-spacing-xs); +} + +.signage-channel__meta { + align-items: center; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + font-size: var(--ekklesia-typography-size-sm); + gap: var(--ekklesia-spacing-xs); +} + +.signage-channel__meta code, +.signage-pairing-steps code { + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + color: var(--ekklesia-text-primary); + padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs); +} + +.signage-channel__displays { + border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap-tight); + padding-block-start: var(--signage-gap-tight); +} + +.signage-channel__empty { + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-sm); margin: 0; - padding-inline-start: var(--ekklesia-spacing-xl); +} + +.signage-display-row { + align-items: end; + background: var(--ekklesia-background-canvas); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr); + padding: var(--ekklesia-spacing-sm); +} + +.signage-display-row__identity, +.signage-display-row__health { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs); +} + +.signage-display-row__identity .ek-control, +.signage-display-row__assignment .ek-field { + width: 100%; +} + +.signage-display-row__health { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-display-row__error { + color: var(--ekklesia-text-danger); +} + +.signage-channel-form, +.signage-unassigned { + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-sm); + padding: var(--ekklesia-spacing-md); +} + +.signage-channel-form { + align-items: start; + grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr); +} + +.signage-channel-form__heading, +.signage-channel-form > .ek-button { + grid-column: 1 / -1; +} + +.signage-channel-form__heading { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-channel-form__heading span { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-channel-form > .ek-button { + justify-self: start; +} + +@media (max-width: 1200px) { + .signage-pairing-steps, + .signage-channel-form, + .signage-display-row { + grid-template-columns: 1fr; + } } .signage-provider__id { diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx index 8a4604d..049e598 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -18,6 +18,21 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su let container: HTMLElement; let root: Root; +const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage"; +const copiedText: string[] = []; + +function dashboard(context: Parameters[0]["context"]): React.ReactElement { + return ( + { + copiedText.push(value); + return Promise.resolve(); + }} + displaySetupUrl={DISPLAY_SETUP_URL} + /> + ); +} async function render(element: React.ReactElement): Promise { await act(async () => { @@ -131,6 +146,7 @@ const TIMELINE = { }; beforeEach(() => { + copiedText.length = 0; container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -168,7 +184,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( "welcome", @@ -197,7 +213,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); // The current slide is still legible; the buttons that would be refused are simply not offered. expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); @@ -220,7 +236,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.textContent).toContain("Active: Sunday morning"); await click( [...container.querySelectorAll("[role='tab']")].find( @@ -262,7 +278,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Timelines", @@ -293,7 +309,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", @@ -357,7 +373,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", @@ -407,7 +423,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", @@ -430,7 +446,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", @@ -476,13 +492,15 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), ); expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("12 cached"); + const display = container.querySelector("[data-testid='signage-display-viewer-1']"); + expect(display?.closest(".signage-channel")?.textContent).toContain("Default"); // A sanitized code, never a stack or a message a browser happened to have. expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); }); @@ -521,15 +539,27 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Displays", ), ); - expect(container.textContent).toContain("/display/signage"); - expect(container.textContent).toContain("Provider audience key"); + const setupLink = [...container.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Open display setup", + ); + expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); + expect(setupLink?.target).toBe("_blank"); + expect(setupLink?.rel).toContain("noopener"); + await click(buttonNamed("Copy URL")); + expect(copiedText).toEqual([DISPLAY_SETUP_URL]); + expect(container.textContent).toContain("Display setup URL copied."); + expect(container.textContent).toContain("Channel groups and displays"); + expect(container.textContent).toContain("Audience channels"); + expect(container.textContent).toContain("Channel name"); + expect(container.textContent).toContain("Audience key"); + expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers"); await click(buttonNamed("Delete group")); expect(deleted).toEqual([]); expect(container.textContent).toContain("Displays assigned here will move"); @@ -564,7 +594,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), ); @@ -615,7 +645,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain("The operation could not be completed."); diff --git a/tests/browser/support/fake-host.ts b/tests/browser/support/fake-host.ts index 2c9e898..de1e3b6 100644 --- a/tests/browser/support/fake-host.ts +++ b/tests/browser/support/fake-host.ts @@ -108,7 +108,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost { /** The settings payload every screen loads first. */ export const FAKE_SETTINGS = Object.freeze({ crossfadeMs: 1000, - moduleVersion: "0.1.0", + moduleVersion: "0.1.1", nativeImageDurationMs: 7000, nativePresentation: { backgroundColor: "#000000", diff --git a/tests/drizzle-timelines.test.ts b/tests/drizzle-timelines.test.ts new file mode 100644 index 0000000..07add12 --- /dev/null +++ b/tests/drizzle-timelines.test.ts @@ -0,0 +1,95 @@ +import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { describe, expect, it } from "vitest"; +import { createTimelineStore } from "../src/server/store/drizzle-timelines.js"; +import type { NewStoredSlot } from "../src/server/store/types.js"; + +const TIMELINE_ID = "00000000-0000-4000-8000-000000000001"; + +function slot(id: string): NewStoredSlot { + return { + durationOverrideMs: null, + eligibleFrom: null, + eligibleUntil: null, + enabled: true, + id, + itemId: "image-1", + kind: "pinned-item", + presentation: { + backgroundColor: null, + backgroundDarkened: null, + backgroundMode: null, + blurPixels: null, + foregroundShadow: null, + }, + providerId: "signage.native-images", + sourceId: null, + timelineId: TIMELINE_ID, + }; +} + +/** + * A narrow database contract double for the production timeline store. + * + * Unlike the service-level memory store, it enforces the shipped PostgreSQL + * `signage_timeline_slots_position_check` on every insert and update. That is the boundary the + * deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the + * actual database rejected a native-image slot before playback ever had content to resolve. + */ +function constraintDatabase(existing: readonly { id: string; position: number }[]): { + readonly database: ModuleDatabase; + readonly positions: number[]; +} { + const positions: number[] = []; + const enforce = (values: { position?: unknown }): void => { + if (typeof values.position === "number") { + expect(values.position).toBeGreaterThanOrEqual(0); + positions.push(values.position); + } + }; + const transaction = { + insert: () => ({ + values: async (values: { position?: unknown }) => enforce(values), + }), + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: async () => existing, + }), + }), + }), + update: () => ({ + set: (values: { position?: unknown }) => ({ + where: async () => enforce(values), + }), + }), + }; + return { + database: { + transaction: async (operation: (value: typeof transaction) => Promise) => operation(transaction), + } as unknown as ModuleDatabase, + positions, + }; +} + +describe("production timeline slot ordering", () => { + it("creates the first pinned native image without violating the database position constraint", async () => { + const { database, positions } = constraintDatabase([]); + + await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002")); + + expect(positions).toEqual([1, 2, 0]); + }); + + it("reorders through a non-negative temporary range before writing the final order", async () => { + const first = "00000000-0000-4000-8000-000000000002"; + const second = "00000000-0000-4000-8000-000000000003"; + const { database, positions } = constraintDatabase([ + { id: first, position: 0 }, + { id: second, position: 1 }, + ]); + + await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]); + + expect(positions).toEqual([3, 4, 0, 1]); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 165c6c1..629bae1 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -66,7 +66,7 @@ describe("Signage module manifest", () => { expect(manifest.manifestVersion).toBe(7); expect(manifest.id).toBe("signage"); expect(manifest.name).toBe("Signage"); - expect(manifest.version).toBe("0.1.0"); + expect(manifest.version).toBe("0.1.1"); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" }); }); @@ -142,7 +142,7 @@ describe("Signage module manifest", () => { describe("Signage server runtime", () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { - expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" }); + expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" }); }); it("registers its routes declaratively, with no database, storage, or realtime work", async () => { @@ -241,7 +241,7 @@ describe("Signage server runtime", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ crossfadeMs: 400, - moduleVersion: "0.1.0", + moduleVersion: "0.1.1", nativeImageDurationMs: 12_000, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, rendererInputVersion: "1.0.0",