diff --git a/apps/signage-manager/USER_STORIES.md b/apps/signage-manager/USER_STORIES.md index 0676ed175ef..f771cd95106 100644 --- a/apps/signage-manager/USER_STORIES.md +++ b/apps/signage-manager/USER_STORIES.md @@ -278,8 +278,9 @@ These stories cover the current app workflows: - Users can search schedules by display, zone, playlist, and source labels where applicable. - Timeline rows link to the related display or zone detail page. - Display rows show the same online, offline, or never seen status as the displays page. +- Timeline blocks show only the times that the player plays. Blocks stop at the playlist and schedule dates, and clock times that daylight saving skips do not show. Blocks of disabled playlists say "Disabled" in text. - Empty and filtered states explain when no rows are available. -- Users must confirm a change that makes two takeover playlists play at the same time on a display in the next 14 days. The check runs when users save playlist schedules and when they assign a playlist to a display or zone. The warning names the display, the other playlist, and the start time of the overlap. +- Users must confirm a change that makes two takeover playlists play at the same time on a display in the next 14 days. The check runs when users save playlist schedules and when they assign a playlist to a display or zone. The warning names the display, the other playlist, and the start time of the overlap. Disabled playlists and overlaps that ended earlier today do not count. A single pass (play period 0) plays alone before timed takeovers, so it conflicts only with another single pass. --- diff --git a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts index 21c249ffed4..47bcaa6c66e 100644 --- a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts @@ -17,6 +17,7 @@ import { } from '@placeos/components'; import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; +import { playlistSchedules } from '../schedules/signage-schedule.util'; import { SignageSharedWithComponent } from '../shared/signage-shared-with.component'; import { SignageContextService } from '../signage-context.service'; import { SignageInventoryService } from '../signage-inventory.service'; @@ -28,27 +29,6 @@ import { import { SignageZoneService } from '../zones/signage-zone.service'; import { SignagePlaylistService } from './signage-playlist.service'; -const DEFAULT_PLAY_PERIOD_MINUTES = 24 * 60; - -function playlistSchedules(playlist: SignagePlaylist) { - const legacy_playlist = playlist as SignagePlaylist & { - play_at?: number; - play_cron?: string; - play_period?: number; - play_takeover?: boolean; - }; - if (playlist.schedules?.length) return playlist.schedules; - return [ - { - play_at: legacy_playlist.play_at, - play_cron: legacy_playlist.play_cron || '0 0 * * *', - play_period: - legacy_playlist.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES, - play_takeover: !!legacy_playlist.play_takeover, - }, - ]; -} - @Component({ selector: 'playlist-item-details', template: ` diff --git a/apps/signage-manager/src/app/schedules/schedule-conflicts.util.ts b/apps/signage-manager/src/app/schedules/schedule-conflicts.util.ts index 5ed72287165..14395608d4f 100644 --- a/apps/signage-manager/src/app/schedules/schedule-conflicts.util.ts +++ b/apps/signage-manager/src/app/schedules/schedule-conflicts.util.ts @@ -25,7 +25,7 @@ export interface TakeoverConflictOptions { playlists: SignagePlaylist[]; /** Only return conflicts that include this playlist */ playlist_id?: string; - /** First day to check. Defaults to today. */ + /** Time to check from. Leaves out overlaps that end before it. Defaults to now. */ start?: Date; days?: number; } @@ -36,14 +36,96 @@ export interface TakeoverConflictOptions { * at midnight. */ function wallClockDate(first_day: Date, minutes: number) { - const date = addDays(first_day, Math.floor(minutes / MINUTES_PER_DAY)); - date.setHours(0, minutes % MINUTES_PER_DAY, 0, 0); + const days = Math.floor(minutes / MINUTES_PER_DAY); + const date = addDays(first_day, days); + date.setHours(0, minutes - days * MINUTES_PER_DAY, 0, 0); return date; } +/** Minutes from the start of the first day in which a takeover plays */ +interface TakeoverRun { + playlist: SignagePlaylist; + start: number; + end: number; +} + +/** Overlap of two takeover playlists, in minutes from the first day */ +interface TakeoverOverlap { + playlists: [SignagePlaylist, SignagePlaylist]; + start: number; + end: number; +} + +/** + * Timed runs and single passes (`play_period` 0) of a playlist, in start + * order. The player plays a single pass alone, ahead of timed runs, so the + * two kinds do not play at the same time. + */ +interface PlaylistRuns { + timed: TakeoverRun[]; + single_pass: TakeoverRun[]; +} + +const byStart = (a: { start: number }, b: { start: number }) => + a.start - b.start; + +const pairKey = ([a, b]: [SignagePlaylist, SignagePlaylist]) => + [a.id, b.id].sort().join('|'); + +/** + * Join touching or overlapping runs. Runs must be in start order. Keeps the + * sweep in `findOverlaps` short when a playlist plays every few minutes. + */ +function mergeRuns(runs: TakeoverRun[]) { + const merged: TakeoverRun[] = []; + for (const run of runs) { + const last = merged[merged.length - 1]; + if (last && run.start <= last.end) { + last.end = Math.max(last.end, run.end); + } else { + merged.push({ ...run }); + } + } + return merged; +} + +/** + * Overlaps of runs of different playlists, from runs in start order. When + * `playlist_id` is set, only overlaps with that playlist. + */ +function findOverlaps(runs: TakeoverRun[], playlist_id?: string) { + const overlaps: TakeoverOverlap[] = []; + for (let i = 0; i < runs.length; i++) { + const first = runs[i]; + for (let j = i + 1; j < runs.length && runs[j].start < first.end; j++) { + const second = runs[j]; + if ( + first.playlist.id === second.playlist.id || + (playlist_id && + first.playlist.id !== playlist_id && + second.playlist.id !== playlist_id) + ) { + continue; + } + overlaps.push({ + playlists: [first.playlist, second.playlist], + start: second.start, + end: Math.min(first.end, second.end), + }); + } + } + return overlaps; +} + /** * Find takeover schedules that overlap on the same display. Returns the - * first overlap of each pair of playlists on each display. + * first overlap of each pair of playlists on each display that has not + * ended before `start`. + * + * Matches the player: only enabled playlists play, and a single pass plays + * alone ahead of timed runs. So a single pass conflicts only with another + * single pass, and a timed run only with another timed run. A single pass + * has no set length, so it counts as one minute. */ export function findTakeoverConflicts({ displays, @@ -54,88 +136,91 @@ export function findTakeoverConflicts({ days = CONFLICT_WINDOW_DAYS, }: TakeoverConflictOptions): TakeoverConflict[] { const first_day = startOfDay(start); - const day_list = Array.from({ length: days }, (_, index) => - addDays(first_day, index), + const now = start.getHours() * 60 + start.getMinutes(); + // Start a day early, so a run from yesterday that still plays counts + const day_list = Array.from({ length: days + 1 }, (_, index) => + addDays(first_day, index - 1), + ); + // Only enabled takeover playlists can conflict + const takeover_playlists = playlists.filter( + (playlist) => playlist.enabled && hasTakeoverSchedule(playlist), ); - // Only takeover playlists can conflict - const takeover_playlists = playlists.filter(hasTakeoverSchedule); - // Blocks do not depend on the display, so build them once per playlist - const playlist_blocks = new Map< - string, - { playlist: SignagePlaylist; start: number; end: number }[] - >(); - const takeoverBlocks = (playlist: SignagePlaylist) => { - let blocks = playlist_blocks.get(playlist.id); - if (blocks) return blocks; - // Minutes from the first day, so blocks that run past midnight - // still overlap blocks on the next day. - blocks = buildScheduleBlocks([{ playlist }], day_list) - .filter((block) => block.takeover) - .map((block) => { - const block_start = - block.day_index * MINUTES_PER_DAY + block.start_minutes; - // A play period of 0 plays the playlist once, so treat it as 1 minute - const length = Math.max(1, block.duration_minutes); - return { - playlist: block.playlist, - start: block_start, - end: block_start + length, - }; + // Runs do not depend on the display, so build them once per playlist + const playlist_runs = new Map(); + const takeoverRuns = (playlist: SignagePlaylist) => { + const cached = playlist_runs.get(playlist.id); + if (cached) return cached; + const runs: PlaylistRuns = { timed: [], single_pass: [] }; + for (const block of buildScheduleBlocks([{ playlist }], day_list)) { + if (!block.takeover) continue; + // Minutes from the first day, so runs that go past midnight + // still overlap runs on the next day + const run_start = + (block.day_index - 1) * MINUTES_PER_DAY + block.start_minutes; + const run_end = run_start + Math.max(1, block.duration_minutes); + if (run_end <= now) continue; + runs[block.duration_minutes ? 'timed' : 'single_pass'].push({ + playlist, + start: run_start, + end: run_end, }); - playlist_blocks.set(playlist.id, blocks); - return blocks; + } + runs.timed = mergeRuns(runs.timed.sort(byStart)); + runs.single_pass = mergeRuns(runs.single_pass.sort(byStart)); + playlist_runs.set(playlist.id, runs); + return runs; + }; + // Displays with the same takeover playlists have the same overlaps + const set_overlaps = new Map(); + const overlapsOf = (assigned: SignagePlaylist[]) => { + const key = assigned + .map(({ id }) => id) + .sort() + .join('|'); + const cached = set_overlaps.get(key); + if (cached) return cached; + const runs = assigned.map(takeoverRuns); + const seen_pairs = new Set(); + const overlaps = [ + ...findOverlaps( + runs.flatMap(({ timed }) => timed).sort(byStart), + playlist_id, + ), + ...findOverlaps( + runs.flatMap(({ single_pass }) => single_pass).sort(byStart), + playlist_id, + ), + ] + .sort(byStart) + .filter(({ playlists }) => { + const pair = pairKey(playlists); + if (seen_pairs.has(pair)) return false; + seen_pairs.add(pair); + return true; + }); + set_overlaps.set(key, overlaps); + return overlaps; }; const conflicts: TakeoverConflict[] = []; for (const display of displays) { - const assignments = buildDisplayScheduleAssignments( + const assigned = buildDisplayScheduleAssignments( display, zones, takeover_playlists, - ); + ).map(({ playlist }) => playlist); if ( - assignments.length < 2 || - (playlist_id && - !assignments.some( - ({ playlist }) => playlist.id === playlist_id, - )) + assigned.length < 2 || + (playlist_id && !assigned.some(({ id }) => id === playlist_id)) ) { continue; } - const blocks = assignments - .flatMap(({ playlist }) => takeoverBlocks(playlist)) - .sort((a, b) => a.start - b.start); - const seen_pairs = new Set(); - for (let i = 0; i < blocks.length; i++) { - const first = blocks[i]; - for ( - let j = i + 1; - j < blocks.length && blocks[j].start < first.end; - j++ - ) { - const second = blocks[j]; - if (first.playlist.id === second.playlist.id) continue; - if ( - playlist_id && - first.playlist.id !== playlist_id && - second.playlist.id !== playlist_id - ) { - continue; - } - const pair = [first.playlist.id, second.playlist.id] - .sort() - .join('|'); - if (seen_pairs.has(pair)) continue; - seen_pairs.add(pair); - conflicts.push({ - display, - playlists: [first.playlist, second.playlist], - starts_at: wallClockDate(first_day, second.start), - ends_at: wallClockDate( - first_day, - Math.min(first.end, second.end), - ), - }); - } + for (const overlap of overlapsOf(assigned)) { + conflicts.push({ + display, + playlists: overlap.playlists, + starts_at: wallClockDate(first_day, overlap.start), + ends_at: wallClockDate(first_day, overlap.end), + }); } } return conflicts; diff --git a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts index de5cf2ca482..c1e653b120d 100644 --- a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts +++ b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts @@ -8,7 +8,7 @@ import { IconComponent, TranslatePipe, } from '@placeos/components'; -import { format, isSameDay, startOfDay } from 'date-fns'; +import { isSameDay } from 'date-fns'; import { isDisplayOnline } from '../displays/display-status.util'; import { MINUTES_PER_DAY, @@ -89,7 +89,11 @@ function dayPercent(minutes: number) { class="border-base-300 bg-base-100 flex h-14 items-end border-b" [style.width.rem]="timeline_width" > - @for (hour of hours; track hour; let i = $index) { + @for ( + hour_label of hour_labels; + track $index; + let i = $index + ) {
- {{ formatHour(hour) }} + {{ hour_label }}
@if (i !== 0) {
([]); public readonly view_tab = input<'displays' | 'zones'>('displays'); @@ -357,8 +362,16 @@ export class ScheduleTimelineComponent { public readonly block_width = 6; public readonly lane_height = LANE_HEIGHT; - public readonly hours = Array.from({ length: 24 }, (_, index) => index); - public readonly timeline_width = this.hours.length * this.block_width; + /** Hour headings in the clock style of the locale, e.g. "9 AM" or "09" */ + public readonly hour_labels = (() => { + const hour_format = new Intl.DateTimeFormat(this._locale, { + hour: 'numeric', + }); + return Array.from({ length: 24 }, (_, hour) => + hour_format.format(new Date(2000, 0, 1, hour)), + ); + })(); + public readonly timeline_width = this.hour_labels.length * this.block_width; /** Position of the current time line from the start of the day, in rem */ public readonly current_offset = computed( @@ -417,12 +430,6 @@ export class ScheduleTimelineComponent { return this._date.transform(last_seen, date_format) || ''; } - public formatHour(hour: number) { - const date = startOfDay(new Date()); - date.setHours(hour); - return format(date, 'haaa').replace('AM', 'am').replace('PM', 'pm'); - } - private _blockView( row: ScheduleTimelineRow, block: TimelineBlock, @@ -456,6 +463,8 @@ export class ScheduleTimelineComponent { const approval = awaiting_approval ? i18n('SIGNAGE_MANAGER.AWAITING_APPROVAL') : ''; + // Said in text too, so it is not shown by the line through only + const disabled = playlist.enabled ? '' : i18n('COMMON.DISABLED'); const tooltip_source = show_source && block.source_label ? i18n('SIGNAGE_MANAGER.TOOLTIP_SOURCE', { @@ -487,6 +496,7 @@ export class ScheduleTimelineComponent { name: playlist.name, }), i18n('SIGNAGE_MANAGER.TOOLTIP_TIME', { time }), + disabled, takeover, tooltip_source, awaiting_approval @@ -499,6 +509,7 @@ export class ScheduleTimelineComponent { row.name, playlist.name, block.all_day ? i18n('SIGNAGE_MANAGER.ALL_DAY_LOWER') : time, + disabled, takeover, approval, source, diff --git a/apps/signage-manager/src/app/schedules/schedules.component.ts b/apps/signage-manager/src/app/schedules/schedules.component.ts index e075ec93daf..93704aa1e7a 100644 --- a/apps/signage-manager/src/app/schedules/schedules.component.ts +++ b/apps/signage-manager/src/app/schedules/schedules.component.ts @@ -3,12 +3,13 @@ import { Component, DestroyRef, computed, - effect, inject, input, + linkedSignal, resource, signal, } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -37,7 +38,7 @@ function parseScheduleTab(value: string | null): 'displays' | 'zones' { return value === 'zones' ? 'zones' : 'displays'; } -/** Rows whose name, description or playlists include the search term */ +/** Rows whose name, description, zones, playlists or sources include the search term */ function filterRows(rows: ScheduleTimelineRow[], search_term: string) { const search = search_term.trim().toLowerCase(); return search @@ -195,9 +196,8 @@ function filterRows(rows: ScheduleTimelineRow[], search_term: string) { (null); - public readonly view_tab = signal<'displays' | 'zones'>('displays'); + /** Follows the route, and changes at once when users pick a tab */ + public readonly view_tab = linkedSignal(() => parseScheduleTab(this.tab())); public readonly search_term = signal(''); public readonly selected_date = signal(startOfDay(new Date())); public readonly current_time = signal(new Date()); @@ -375,6 +377,9 @@ export class SchedulesSectionComponent { const playlists = this._playlists(); const zones = this._zones(); const date = this.selected_date(); + const zone_names = new Map( + zones.map((zone) => [zone.id, zone.display_name || zone.name]), + ); return this._displays().map((display) => { const assignments = buildDisplayScheduleAssignments( @@ -399,6 +404,7 @@ export class SchedulesSectionComponent { const search_index = [ display.display_name || display.name, display.description || '', + ...(display.zones || []).map((id) => zone_names.get(id) || ''), ...assignments.map((item) => item.playlist.name), ...assignments.map((item) => item.source_label || ''), ] @@ -483,13 +489,6 @@ export class SchedulesSectionComponent { ); constructor() { - effect(() => { - const route_tab = parseScheduleTab(this.tab()); - if (route_tab !== this.view_tab()) { - this.view_tab.set(route_tab); - } - }); - const timer = setInterval( () => this.current_time.set(new Date()), 60_000, @@ -502,11 +501,6 @@ export class SchedulesSectionComponent { this._inventory.reload(); } - public setSearch(event: Event) { - const target = event.target as HTMLInputElement | null; - this.search_term.set(target?.value || ''); - } - public clearSearch() { this.search_term.set(''); } diff --git a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts index 8f0a2880048..2c060ece7a4 100644 --- a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts +++ b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts @@ -1,6 +1,12 @@ import { i18n } from '@placeos/common'; import { SignagePlaylist } from '@placeos/ts-client'; -import { addDays, fromUnixTime, isSameDay, startOfDay } from 'date-fns'; +import { + addDays, + differenceInCalendarDays, + fromUnixTime, + isSameDay, + startOfDay, +} from 'date-fns'; import { cronDaySlots, cronParts, @@ -84,25 +90,21 @@ export interface ScheduleItem { zones?: readonly string[]; } -function playlistSchedules( - playlist: SignagePlaylist, +/** Schedule of a playlist with no schedules */ +const ALL_DAY_SCHEDULE: Partial = { + play_cron: '0 0 * * *', + play_period: MINUTES_PER_DAY, +}; + +/** + * Schedules of a playlist. The player plays a playlist with no schedules all + * the time as normal content, so it gets one all day schedule that is not a + * takeover. + */ +export function playlistSchedules( + playlist: Pick, ): Partial[] { - const legacy_playlist = playlist as SignagePlaylist & { - play_at?: number; - play_cron?: string; - play_period?: number; - play_takeover?: boolean; - }; - if (playlist.schedules?.length) return playlist.schedules; - return [ - { - play_at: legacy_playlist.play_at, - play_cron: legacy_playlist.play_cron || '0 0 * * *', - play_period: - legacy_playlist.play_period ?? DEFAULT_PLAYLIST_DURATION, - play_takeover: !!legacy_playlist.play_takeover, - }, - ]; + return playlist.schedules?.length ? playlist.schedules : [ALL_DAY_SCHEDULE]; } /** Whether any schedule of the playlist is a takeover */ @@ -131,44 +133,105 @@ function formatTimeRange( return `${formatTime(start_minutes)} – ${formatTime(start_minutes + duration_minutes)}`; } -function isScheduleValidAt(schedule: Partial, date: Date) { - const time = date.getTime(); +/** Label and all day flag of a block. A length of 0 plays the playlist once. */ +function blockBase( + start_minutes: number, + duration_minutes: number, +): ScheduleBlockBase { + return { + start_minutes, + duration_minutes, + all_day: start_minutes === 0 && duration_minutes >= MINUTES_PER_DAY, + label: duration_minutes + ? formatTimeRange(start_minutes, duration_minutes) + : i18n('SIGNAGE_MANAGER.PLAY_THROUGH_ONCE'), + }; +} + +/** Clock minutes of a date from the start of `day`. Later days add 1440 each. */ +function clockMinutes(day: Date, date: Date) { return ( - (!schedule.valid_from || time >= schedule.valid_from * 1000) && - (!schedule.valid_until || time <= schedule.valid_until * 1000) + differenceInCalendarDays(date, day) * MINUTES_PER_DAY + + date.getHours() * 60 + + date.getMinutes() + ); +} + +/** Earliest of the end dates that are set, in milliseconds */ +function earliestEnd(...values: (number | undefined)[]) { + return Math.min( + Infinity, + ...values + .filter((value): value is number => !!value) + .map((value) => value * 1000), ); } -function isDayInRange( +/** + * Part of a run that plays, in clock minutes from the start of `day`, or + * null when nothing plays. Matches the player: a run starts only inside the + * schedule dates and before the playlist ends. It plays only after the + * playlist starts and until the schedule or the playlist ends. + */ +function playedRun( day: Date, - valid_from?: number, - valid_until?: number, -): boolean { - const day_start = startOfDay(day).getTime(); - if (valid_from) { - const from_start = startOfDay(fromUnixTime(valid_from)).getTime(); - if (day_start < from_start) return false; + starts_at: Date, + duration: number, + schedule: Partial, + playlist: Pick, +) { + const time = starts_at.getTime(); + const ends_at = earliestEnd(schedule.valid_until, playlist.valid_until); + if (time < (schedule.valid_from || 0) * 1000 || time > ends_at) { + return null; } - if (valid_until) { - const until_start = startOfDay(fromUnixTime(valid_until)).getTime(); - if (day_start > until_start) return false; + const start = clockMinutes(day, starts_at); + const from = playlist.valid_from + ? Math.max(start, clockMinutes(day, fromUnixTime(playlist.valid_from))) + : start; + // A single pass has no length, so it plays only when it starts in time + if (!duration) return from === start ? blockBase(start, 0) : null; + // The player ends a run after the elapsed play length, so a run that + // spans a skipped clock hour ends an hour later on the clock. The + // timeline shows a repeated clock hour once, so a run is never shorter + // on the clock than its length. + const run_end = time + duration * 60_000; + let until = Math.max( + start + duration, + clockMinutes(day, new Date(run_end)), + ); + if (ends_at < run_end) { + until = Math.min(until, clockMinutes(day, new Date(ends_at))); } - return true; + return until > from ? blockBase(from, until - from) : null; } -function getCronBlocksForDay( - parts: readonly string[], +/** Start times of a schedule on a day, before the dates and mask apply */ +function scheduleStarts( schedule: Partial, -): ScheduleBlockBase[] { - const duration = playPeriodMinutes(schedule); - return cronDaySlots(parts).map((start_minutes) => ({ - start_minutes, - duration_minutes: duration, - all_day: start_minutes === 0 && duration >= MINUTES_PER_DAY, - label: duration - ? formatTimeRange(start_minutes, duration) - : i18n('SIGNAGE_MANAGER.PLAY_THROUGH_ONCE'), - })); + slots: readonly number[], + parts: readonly string[] | null, + day: Date, +): Date[] { + if (isPlayOnceSchedule(schedule)) { + const at_date = playOnceStart(schedule); + return at_date && isSameDay(day, at_date) ? [at_date] : []; + } + if (!parts || !doesCronMatchDay(parts, day)) return []; + return slots + .map((slot) => { + const starts_at = new Date(day); + starts_at.setHours(0, slot, 0, 0); + return starts_at; + }) + .filter( + // A clock time that a daylight saving change skips never plays. + // A repeated time plays once, at the first occurrence, which is + // the time that Date picks. + (starts_at, index) => + starts_at.getHours() * 60 + starts_at.getMinutes() === + slots[index], + ); } export function buildScheduleBlocks( @@ -188,62 +251,37 @@ function generateScheduleBlocks( const { playlist, source_label, source_type } = assignment; const colour = BLOCK_PALETTE[palette_index % BLOCK_PALETTE.length]; const blocks: ScheduleBlock[] = []; - const { valid_from, valid_until } = playlist; - const schedules = playlistSchedules(playlist).map((schedule) => ({ - schedule, - allows: createScheduleMaskFilter(schedule), - })); + const schedules = playlistSchedules(playlist).map((schedule) => { + const parts = cronParts(schedule.play_cron?.trim() || '0 0 * * *'); + return { + schedule, + parts, + slots: parts ? cronDaySlots(parts) : [], + duration: playPeriodMinutes(schedule), + allows: createScheduleMaskFilter(schedule), + }; + }); for (let index = 0; index < days.length; index++) { - const day = days[index]; - if (!isDayInRange(day, valid_from, valid_until)) continue; - - for (const { schedule, allows } of schedules) { - const play_cron = schedule.play_cron?.trim() || '0 0 * * *'; - const play_period = playPeriodMinutes(schedule); - - if (isPlayOnceSchedule(schedule)) { - const at_date = playOnceStart(schedule); - if ( - !at_date || - !isSameDay(day, at_date) || - !isScheduleValidAt(schedule, at_date) || - !allows(at_date) - ) { - continue; - } - const start_minutes = - at_date.getHours() * 60 + at_date.getMinutes(); - const duration_minutes = play_period; - blocks.push({ + const day = startOfDay(days[index]); + for (const { schedule, parts, slots, duration, allows } of schedules) { + for (const starts_at of scheduleStarts( + schedule, + slots, + parts, + day, + )) { + if (!allows(starts_at)) continue; + const run = playedRun( + day, + starts_at, + duration, + schedule, playlist, - day_index: index, - start_minutes, - duration_minutes, - all_day: false, - takeover: !!schedule.play_takeover, - bg_color: colour.bg, - text_color: colour.text, - label: formatTimeRange(start_minutes, duration_minutes), - source_label, - source_type, - }); - continue; - } - - const parts = cronParts(play_cron); - if (!parts || !doesCronMatchDay(parts, day)) continue; - const cron_blocks = getCronBlocksForDay(parts, schedule); - for (const block of cron_blocks) { - const starts_at = new Date(day); - starts_at.setHours(0, block.start_minutes, 0, 0); - if ( - !isScheduleValidAt(schedule, starts_at) || - !allows(starts_at) - ) - continue; + ); + if (!run) continue; blocks.push({ - ...block, + ...run, playlist, day_index: index, takeover: !!schedule.play_takeover, @@ -276,7 +314,8 @@ export function visibleMinutes(block: ScheduleBlock) { function clipToDay(block: ScheduleBlock): ScheduleBlock[] { const start = (block.day_index - 1) * MINUTES_PER_DAY + block.start_minutes; const end = start + block.duration_minutes; - if (start < 0 && end <= 0) return []; + // A block can start on a later day when the playlist starts late + if (start < 0 ? end <= 0 : start >= MINUTES_PER_DAY) return []; const visible_start = Math.max(0, start); const visible_end = Math.min(MINUTES_PER_DAY, end); return [ diff --git a/apps/signage-manager/src/tests/schedules/schedule-conflicts.util.spec.ts b/apps/signage-manager/src/tests/schedules/schedule-conflicts.util.spec.ts index f31b038f54e..7192b891fab 100644 --- a/apps/signage-manager/src/tests/schedules/schedule-conflicts.util.spec.ts +++ b/apps/signage-manager/src/tests/schedules/schedule-conflicts.util.spec.ts @@ -53,6 +53,83 @@ describe('findTakeoverConflicts', () => { expect(conflicts).toEqual([]); }); + it('ignores disabled playlists, which the player does not play', () => { + const conflicts = findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '0 9 * * *', 60), + new SignagePlaylist({ + id: 'b', + name: 'b', + enabled: false, + schedules: [ + { + play_cron: '0 9 * * *', + play_period: 60, + play_takeover: true, + }, + ], + }), + ], + start, + days: 1, + }); + + expect(conflicts).toEqual([]); + }); + + it('leaves out overlaps that ended earlier today', () => { + const conflicts = findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '0 9 * * *', 60), + playlist('b', '30 9 * * *', 60), + ], + start: new Date(2026, 8, 28, 15), + days: 2, + }); + + expect(conflicts.map(({ starts_at }) => starts_at)).toEqual([ + new Date(2026, 8, 29, 9, 30), + ]); + }); + + it('finds an overlap from yesterday that still plays', () => { + const conflicts = findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '0 23 * * *', 120), + playlist('b', '0 23 * * *', 120), + ], + start: new Date(2026, 8, 28, 0, 30), + days: 1, + }); + + expect(conflicts[0].starts_at).toEqual(new Date(2026, 8, 27, 23)); + expect(conflicts[0].ends_at).toEqual(new Date(2026, 8, 28, 1)); + }); + + // The player plays a single pass alone, then goes on with timed runs + it('compares single passes only with other single passes', () => { + const conflicts = (b_period: number) => + findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '0 9 * * *', 0), + playlist('b', '0 9 * * *', b_period), + ], + start, + days: 1, + }); + + expect(conflicts(60)).toEqual([]); + expect(conflicts(0)).toHaveLength(1); + }); + it('only compares playlists on the same display', () => { const conflicts = findTakeoverConflicts({ displays: [{ ...lobby, zones: [] }, cafe], @@ -80,8 +157,9 @@ describe('findTakeoverConflicts', () => { days: 2, }); + // The run from 23:00 the day before still plays at 00:30 expect(conflicts.map(({ starts_at }) => starts_at)).toEqual([ - new Date(2026, 8, 29, 0, 30), + new Date(2026, 8, 28, 0, 30), ]); }); @@ -130,6 +208,22 @@ describe('findTakeoverConflicts', () => { expect(performance.now() - started).toBeLessThan(200); }); + it('reports the whole overlap when runs follow each other', () => { + const [conflict] = findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '0 * * * *', 60), + playlist('b', '30 9 * * *', 120), + ], + start, + days: 1, + }); + + expect(conflict.starts_at).toEqual(new Date(2026, 8, 28, 9, 30)); + expect(conflict.ends_at).toEqual(new Date(2026, 8, 28, 11, 30)); + }); + describe('across a daylight saving change', () => { const original_timezone = process.env.TZ; // Pin the zone so the result does not depend on the machine @@ -156,5 +250,23 @@ describe('findTakeoverConflicts', () => { expect(conflicts[0].starts_at).toEqual(new Date(2026, 9, 4, 9)); expect(conflicts[0].ends_at).toEqual(new Date(2026, 9, 4, 10)); }); + + it('finds a takeover that starts in the extra clock hour of a run', () => { + // 01:30 plus 120 minutes plays until 04:30 on this day + const conflicts = findTakeoverConflicts({ + displays: [lobby], + zones: [level], + playlists: [ + playlist('a', '30 1 4 10 *', 120), + playlist('b', '30 3 4 10 *', 30), + ], + start: new Date(2026, 9, 4), + days: 1, + }); + + expect(conflicts.map(({ starts_at }) => starts_at)).toEqual([ + new Date(2026, 9, 4, 3, 30), + ]); + }); }); }); diff --git a/apps/signage-manager/src/tests/schedules/schedule-timeline.component.spec.ts b/apps/signage-manager/src/tests/schedules/schedule-timeline.component.spec.ts index 87281b90f5d..bc03afae175 100644 --- a/apps/signage-manager/src/tests/schedules/schedule-timeline.component.spec.ts +++ b/apps/signage-manager/src/tests/schedules/schedule-timeline.component.spec.ts @@ -1,4 +1,5 @@ import { DatePipe } from '@angular/common'; +import { LOCALE_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { SignagePlaylist } from '@placeos/ts-client'; @@ -204,9 +205,28 @@ describe('ScheduleTimelineComponent', () => { expect(component.row_status().size).toBe(0); }); - it('formats an hour into a lowercase am/pm label', () => { - const component = make(); - expect(component.formatHour(0).toLowerCase()).toContain('am'); - expect(component.formatHour(13).toLowerCase()).toContain('pm'); + it('labels hours in the clock style of the locale', () => { + TestBed.overrideProvider(LOCALE_ID, { useValue: 'en-GB' }); + expect(make().hour_labels.slice(12, 14)).toEqual(['12', '13']); + }); + + it('says in text that a playlist is disabled', () => { + const component = make([ + row({ + blocks: [ + block({ + playlist: new SignagePlaylist({ + id: 'off', + name: 'Off', + enabled: false, + }), + }), + ], + }), + ]); + const [view] = component.view_rows()[0].blocks; + + expect(view.aria_label).toBe('Foyer, Off, 09:00 – 11:00, Disabled'); + expect(view.tooltip.split('\n')).toContain('Disabled'); }); }); diff --git a/apps/signage-manager/src/tests/schedules/schedules.component.spec.ts b/apps/signage-manager/src/tests/schedules/schedules.component.spec.ts index bb910567aa1..d77f969e829 100644 --- a/apps/signage-manager/src/tests/schedules/schedules.component.spec.ts +++ b/apps/signage-manager/src/tests/schedules/schedules.component.spec.ts @@ -117,12 +117,30 @@ describe('SchedulesSectionComponent', () => { ); }); - it('reads the search value out of the input event', async () => { + it('finds a display by the name of each of its zones', async () => { + playlists.set([{ id: 'p-1', name: 'News', enabled: true }]); + displays.set([ + { id: 'd-1', name: 'Foyer', zones: ['z-1', 'z-2'] }, + { id: 'd-2', name: 'Cafe', zones: ['z-3'] }, + ]); + // Both zones give the same playlist, so its source is "2 zones" + zones.set([ + { id: 'z-1', name: 'Level 1', playlists: ['p-1'] }, + { id: 'z-2', name: 'Level 2', playlists: ['p-1'] }, + { id: 'z-3', name: 'Basement', playlists: [] }, + ]); const component = await make(); - component.setSearch({ target: { value: 'cafe' } } as any); - expect(component.search_term()).toBe('cafe'); - component.clearSearch(); - expect(component.search_term()).toBe(''); + + component.search_term.set('level 1'); + expect(component.rows().map((r) => r.id)).toEqual(['d-1']); + component.search_term.set('basement'); + expect(component.rows().map((r) => r.id)).toEqual(['d-2']); + }); + + it('follows the tab in the route', async () => { + const component = await make(); + fixture.componentRef.setInput('tab', 'zones'); + expect(component.view_tab()).toBe('zones'); }); it('navigates the selected day forwards, backwards and to today', async () => { diff --git a/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts b/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts index 43959d5f96c..05880f0b41f 100644 --- a/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts +++ b/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts @@ -5,6 +5,7 @@ import { buildDisplayScheduleAssignments, buildScheduleBlocks, buildZoneScheduleAssignments, + hasTakeoverSchedule, } from '../../app/schedules/signage-schedule.util'; import { type PlaylistSchedule } from '../../app/signage-playlist.util'; @@ -145,7 +146,7 @@ describe('signage-schedule.util', () => { play_period: 30, play_takeover: false, valid_from: timestamp + offset, - valid_until: timestamp, + valid_until: timestamp + 30 * 60, }; const blocks = buildScheduleBlocks( [ @@ -281,6 +282,163 @@ describe('signage-schedule.util', () => { expect(assignments[0].source_label).toBe('Lobby'); }); + describe('dates and kinds that match the player', () => { + const day = new Date(2026, 2, 2); + const at = (hours: number) => getUnixTime(new Date(2026, 2, 2, hours)); + const blocksOf = ( + schedule: Partial, + dates: { valid_from?: number; valid_until?: number } = {}, + ) => + buildScheduleBlocks( + [ + { + playlist: new SignagePlaylist({ + id: 'p', + name: 'P', + schedules: [ + { + play_cron: '', + play_period: 60, + play_takeover: false, + ...schedule, + }, + ], + ...dates, + }), + }, + ], + [day], + ).map(({ start_minutes, duration_minutes, all_day, label }) => ({ + start_minutes, + duration_minutes, + all_day, + label, + })); + + it('plays only inside playlist dates shorter than a day', () => { + expect( + blocksOf( + { play_cron: '0 0 * * *', play_period: 1440 }, + { valid_from: at(12), valid_until: at(15) }, + ), + ).toEqual([ + { + start_minutes: 720, + duration_minutes: 180, + all_day: false, + label: '12:00 – 15:00', + }, + ]); + }); + + it('ends a run when its schedule ends', () => { + expect( + blocksOf({ + play_cron: '0 9 * * *', + play_period: 180, + valid_until: at(10), + }), + ).toEqual([ + expect.objectContaining({ + start_minutes: 540, + duration_minutes: 60, + }), + ]); + }); + + it('labels a single pass and marks a whole day play once', () => { + expect(blocksOf({ play_at: at(9), play_period: 0 })[0].label).toBe( + 'Play through once', + ); + expect( + blocksOf({ play_at: at(0), play_period: 1440 })[0].all_day, + ).toBe(true); + }); + + it('plays a playlist with no schedules all day, never as a takeover', () => { + // Old playlists can still carry schedule fields on the playlist + const playlist = { + id: 'old', + name: 'Old', + enabled: true, + schedules: [], + play_cron: '0 9 * * *', + play_period: 60, + play_takeover: true, + } as unknown as SignagePlaylist; + const blocks = buildScheduleBlocks([{ playlist }], [day]); + + expect(hasTakeoverSchedule(playlist)).toBe(false); + expect(blocks).toEqual([ + expect.objectContaining({ all_day: true, takeover: false }), + ]); + }); + + describe('across a daylight saving change', () => { + const original_timezone = process.env.TZ; + // Pin the zone so the result does not depend on the machine + beforeAll(() => (process.env.TZ = 'Australia/Sydney')); + afterAll(() => { + if (original_timezone === undefined) delete process.env.TZ; + else process.env.TZ = original_timezone; + }); + + it('does not draw a clock time that the change skips', () => { + // Sydney clocks go from 02:00 to 03:00 on 4 October 2026 + const blocks = buildScheduleBlocks( + [ + { + playlist: new SignagePlaylist({ + id: 'p', + schedules: [ + { + play_cron: '30 2 * * *', + play_period: 60, + play_takeover: false, + }, + ], + }), + }, + ], + [new Date(2026, 9, 4), new Date(2026, 9, 5)], + ); + + expect(blocks.map(({ day_index }) => day_index)).toEqual([1]); + }); + + it('ends a run after its elapsed length, then at its end date', () => { + // 01:30 plus 120 minutes is 04:30 after the clocks go forward + const end = (valid_until?: number) => + buildScheduleBlocks( + [ + { + playlist: new SignagePlaylist({ + id: 'p', + schedules: [ + { + play_cron: '30 1 * * *', + play_period: 120, + play_takeover: false, + valid_until, + }, + ], + }), + }, + ], + [new Date(2026, 9, 4)], + ).map( + ({ start_minutes, duration_minutes }) => + start_minutes + duration_minutes, + ); + + expect(end()).toEqual([4 * 60 + 30]); + expect(end(getUnixTime(new Date(2026, 9, 4, 3, 45)))).toEqual([ + 3 * 60 + 45, + ]); + }); + }); + }); + describe('day timeline', () => { const day = new Date(2026, 2, 3); const assign = (id: string, play_cron?: string, play_period = 60) => ({