Files
ontime/apps/client/src/views/countdown/countdown.utils.ts
T
2026-07-14 14:31:08 +02:00

283 lines
8.4 KiB
TypeScript

import {
EntryId,
MaybeNumber,
OffsetMode,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeReport,
Playback,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
/**
* Parses string as a title
*/
export function sanitiseTitle(title: string | null) {
return title ?? '{no title}';
}
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/**
* Whether the current event is live
*/
export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playback: Playback): boolean {
return currentId === selectedId && playback !== Playback.Armed;
}
export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
type TimerMessage = Record<ProgressStatus, TranslationKey>;
export const timerProgress: TimerMessage = {
future: 'countdown.to_start',
due: 'timeline.due',
live: 'countdown.running',
pending: 'countdown.waiting',
loaded: 'countdown.loaded',
done: 'countdown.ended',
};
/**
* Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played
*/
export function useSubscriptionDisplayData(
subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation();
const bigDuration = (value: number) => {
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
if (value < MILLIS_PER_MINUTE * 10) {
return removeLeadingZero(millisToString(value));
}
return formatDuration(value, value > MILLIS_PER_MINUTE * 10)
.replace('m', `${getLocalizedString('common.minutes')} `)
.replace('s', getLocalizedString('common.seconds'));
};
if (subscribedEvent.isLoaded) {
if (playback === Playback.Armed) {
return {
status: 'loaded',
statusDisplay: getLocalizedString(timerProgress['loaded']),
timeDisplay: bigDuration(subscribedEvent.duration),
};
}
return {
status: 'live',
statusDisplay: getLocalizedString(
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
? 'countdown.group_running'
: timerProgress['live'],
),
timeDisplay: bigDuration(current ?? 0),
};
}
if (playback === Playback.Stop || playback === Playback.Armed) {
return {
status: 'pending',
statusDisplay: getLocalizedString(timerProgress['pending']),
timeDisplay: ' ',
};
}
if (subscribedEvent.isPast) {
return {
status: 'done',
statusDisplay: getLocalizedString(timerProgress['done']),
timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
};
}
if (subscribedEvent.expectedStart - clock <= 0) {
return {
status: 'due',
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
};
}
return {
status: 'future',
statusDisplay: getLocalizedString(timerProgress['future']),
timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
};
}
/**
* Adds a set of subscriptions to the URL parameters
*/
export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
const url = new URL(urlRef);
const newParams = new URLSearchParams();
// copy existing parameters except for 'sub'
for (const [key, value] of url.searchParams.entries()) {
if (key !== 'sub') {
newParams.append(key, value);
}
}
// add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
url.search = newParams.toString();
return url;
}
/**
* Returns an array of events subscribed events ordered by scheduled
* Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list.
*/
export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
return playableEvents.filter((event) => subscriptions.includes(event.id));
}
/**
* Checks through the rundown whether the current event is linked to the loaded event
*/
export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId | null, currentId: EntryId): boolean {
// if nothing is loaded, we return true to simplify the logic
if (!loadedId) {
return true;
}
const loadedIndex = events.findIndex((event) => event.id === loadedId);
if (loadedIndex === -1) {
return true;
}
for (let i = loadedIndex; i < events.length; i++) {
const event = events[i];
if (event.id === currentId) {
return true;
}
if (event.linkStart === null) {
return false;
}
}
return true;
}
export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE;
}
/**
* A subscription target normalised to the event-shaped object the countdown display consumes.
* For events this is a pass-through; for groups it carries the group identity and display data
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
*/
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
isGroup?: boolean;
targetId?: EntryId; // child entry used for countdown/live/armed state checks
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & {
expectedStart: number;
expectedEnd: number;
endedAt: MaybeNumber;
};
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
* - Events are returned unchanged.
* - Groups count down to their first playable child while displaying the group identity.
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
* Returns null when a group has no playable children (nothing to count down to).
*/
export function resolveSubscriptionTarget(
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
flatRundown: ExtendedEntry<OntimeEntry>[],
): CountdownTarget | null {
if (!isOntimeGroup(entry)) {
return entry;
}
const children = flatRundown.filter(
(item): item is ExtendedEntry<PlayableEvent> =>
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
);
if (children.length === 0) {
return null;
}
const firstChild = children[0];
const lastChild = children[children.length - 1];
const liveEntry = children.find((child) => child.isLoaded) ?? null;
const isLoaded = liveEntry !== null;
const isPast = !isLoaded && lastChild.isPast;
return {
// timing derives from the first playable child
...firstChild,
// group identity and display data
id: entry.id,
parent: null,
title: entry.title,
colour: entry.colour,
note: entry.note,
custom: entry.custom,
duration: entry.duration,
countToEnd: false,
// group-aware status
isLoaded,
isPast,
// group markers
isGroup: true,
targetId: firstChild.id,
reportId: lastChild.id,
liveEntry,
};
}
export function extendEventData(
event: CountdownTarget,
currentDay: number,
actualStart: MaybeNumber,
plannedStart: MaybeNumber,
offset: number,
mode: OffsetMode,
reportData: OntimeReport,
): CountdownEvent {
const { totalGap, isLinkedToLoaded } = event;
const expectedStartState = {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
};
const expectedStart = getExpectedStart(event, expectedStartState);
const expectedEnd = getExpectedEnd(event, expectedStartState);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, expectedEnd, endedAt };
}