Compare commits

...

37 Commits

Author SHA1 Message Date
arc-alex 6397d33eaa feat: option to select all events for countdown 2025-09-16 16:59:29 +02:00
arc-alex 9288566a7f chore: remove unused code 2025-09-16 16:38:11 +02:00
Alex Christoffer Rasmussen 7fd9f83fb0 fix docker ignore (#1780) 2025-09-16 16:27:00 +02:00
Carlos Valente e621f1386e fix(cuesheet): infinite render loop on resizing columns 2025-09-16 06:43:19 +02:00
Carlos Valente b51e7cbd2d fix: maintain multiline in cuesheet cells 2025-09-14 14:19:18 +02:00
Carlos Valente 7cd92ce5f7 docs: add contribution guidelines 2025-09-14 14:19:18 +02:00
Carlos Valente f6a02abf36 fix: skip nested events 2025-09-14 14:19:18 +02:00
Carlos Valente e4b0df42cf feat: allow finding milestones 2025-09-14 14:19:18 +02:00
Carlos Valente ded8bddb2d refactor: improve automated following 2025-09-14 14:19:18 +02:00
Carlos Valente 8d3ce46c56 fix: allow moving a group after another 2025-09-14 14:19:18 +02:00
Carlos Valente 578cb2b244 refactor: improve pin styling 2025-09-14 14:19:18 +02:00
Carlos Valente c79ee193c9 fix: prevent layout reflow on different entry types 2025-09-14 14:19:18 +02:00
Carlos Valente 5810ae0d54 refactor: add default value to secondary source 2025-09-14 14:19:18 +02:00
Carlos Valente bfbf8574e3 fix: phase style overrides in timer 2025-09-14 14:19:18 +02:00
Alex Christoffer Rasmussen 490e429f44 cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-13 16:31:56 +02:00
Alex Christoffer Rasmussen 2e950965e0 fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-08 09:49:01 +02:00
arc-alex cc34db775a chore: bump version 2025-09-08 06:51:07 +02:00
Carlos Valente 0057b4a3f1 refactor: tweaks to countdown styles 2025-09-08 06:23:39 +02:00
Carlos Valente ac8c1e333e bump version to 4.0.0-beta.2 2025-09-08 06:09:31 +02:00
Carlos Valente 321297b685 fix: redirect to preset 2025-09-08 06:09:00 +02:00
Alex Christoffer Rasmussen 67e67d6a5d feat: propper timeuntil calc in countdown view (#1765)
* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
2025-09-07 21:49:45 +02:00
Carlos Valente 4385e0b9c9 refactor: ensure clocks timer and clock are in sync (#1772)
* refactor: ensure clocks timer and clock are in sync

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-07 21:49:45 +02:00
Carlos Valente 02243242f1 fix: redirect to preset 2025-09-07 21:49:45 +02:00
arc-alex 1af42b766d fix: insert group-end where needed 2025-09-07 21:49:45 +02:00
arc-alex 9d6ec3758b chore: clarifying comments 2025-09-07 21:49:45 +02:00
Carlos Valente 9e78ff4d1c refactor: export data for all entry types 2025-09-07 21:49:45 +02:00
Carlos Valente 2037aef080 fix: prevent clipping on bold font sizes 2025-09-07 21:49:45 +02:00
Carlos Valente 48b9cf042f refactor: lax validation for settings
with this changes we allow the client and server to share the same data
shape. even if some of it is useless.
we also get an extra version property in the payload, if we want to save
the transfer cost, we would rather remove it from the sent payload
2025-09-07 21:49:45 +02:00
Carlos Valente 1866e5e78d refactor: simplify restore point 2025-09-07 21:49:45 +02:00
Carlos Valente bc1b3800c8 refactor: op clock uses time format string 2025-09-07 21:49:45 +02:00
Carlos Valente a2505c838b refactor: add none option to optional select 2025-09-07 21:49:45 +02:00
Carlos Valente bffa863fb4 fix: multi select infinite loop 2025-09-07 21:49:45 +02:00
Carlos Valente d00d7677bc fix: merge virtuoso and rundown styles 2025-09-07 21:49:45 +02:00
Carlos Valente 3ae73be562 refactor: prevent emitting errors from user pages 2025-09-07 21:49:45 +02:00
Carlos Valente 763080cd4d fix: generate link for companion 2025-09-07 21:49:45 +02:00
Carlos Valente 6ff06b21a8 fix: hide fade out elements by default 2025-09-07 21:49:45 +02:00
Carlos Valente adebde1b43 chore: upgrade client packages 2025-09-07 21:49:45 +02:00
83 changed files with 1614 additions and 1209 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
# Ignore build folders
node_modules
dist
**/node_modules
**/dist
# Ignore default volumes created by running docker compose up
ontime-db
+13
View File
@@ -99,3 +99,16 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.6",
"version": "4.0.0-beta.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+11 -11
View File
@@ -1,23 +1,23 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.6",
"version": "4.0.0-beta.3",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.2",
"@base-ui-components/react": "1.0.0-beta.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.2.4",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.84.1",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.11.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
@@ -27,11 +27,11 @@
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.0",
"react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.7"
"zustand": "^5.0.8"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -64,8 +64,8 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
if (newPath === '/' || newPath === currentPath) {
return;
}
setRedirect({ target: id, redirect: newPath });
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
onClose();
};
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.search,
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect <IoArrowForward />
Redirect
<IoArrowForward />
</Button>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
{origin}
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
</>
}
/>
@@ -11,9 +11,11 @@
}
.pin {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
input {
font-size: 4rem;
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
const optionFromParams = searchParams.getAll(id);
return (
<MultiOption
paramField={paramField}
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
/>
);
}
if (type === 'boolean') {
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
options: string[];
}
function MultiOption({ paramField }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { id, values, defaultValue = [''] } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
const { id, values } = paramField;
const [paramState, setParamState] = useState<string[]>(options);
// synchronise options
useEffect(() => {
const params = searchParams.getAll(id);
setParamState(params.length ? params : defaultValue);
}, [searchParams, id, defaultValue]);
setParamState(options);
}, [options]);
const toggleValue = (value: string, checked: boolean) => {
if (checked) {
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
/**
* Creates data for a select element that displays project custom data
*/
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
return projectData.custom.map((entry, index) => {
export function makeProjectDataOptions(
projectData: ProjectData,
additionalOptions: SelectOption[] = [],
): SelectOption[] {
const generatedOptions = projectData.custom.map((entry, index) => {
return {
value: `${index}-${entry.title}`,
label: entry.title,
};
});
return [...additionalOptions, ...generatedOptions];
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
// we call the function on mount, to make sure the menu is hidden
throttledShowMenu();
document.addEventListener('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu);
@@ -1,6 +1,5 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
if (!doFollow) {
if (!doFollow || !followTrigger) {
return;
}
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(false);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent;
}
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
+3 -1
View File
@@ -10,7 +10,9 @@ const createSelector =
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => {
sendSocket('client', payload);
},
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
};
@@ -1,19 +0,0 @@
export type OverridableOptions = {
keyColour?: string;
textColour?: string;
textBackground?: string;
font?: string;
size?: number;
justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'end';
left?: string;
top?: string;
hideNav?: boolean;
hideOvertime?: boolean;
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
};
@@ -173,7 +173,7 @@ describe('initRundownMetadata()', () => {
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
@@ -125,6 +125,7 @@ function processEntry(
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
processedData.isLinkedToLoaded = true;
}
if (isOntimeGroup(entry)) {
@@ -158,7 +159,8 @@ function processEntry(
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
}
if (isNewLatest(entry, processedData.latestEvent)) {
@@ -14,4 +14,8 @@
.copiable {
cursor: text ;
user-select: text;
}
}
.self {
background-color: $blue-1100;
}
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
import Tag from '../../../../../common/components/tag/Tag';
import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss';
@@ -71,13 +72,13 @@ export default function ClientList() {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key}>
<tr key={key} className={cx([isCurrent && style.self])}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner'>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
@@ -49,6 +49,7 @@ export default function Operator() {
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
followTrigger: selectedEventId,
});
useWindowTitle('Operator');
@@ -181,7 +182,7 @@ export default function Operator() {
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
// hide past events (if setting) and skipped events
if (hidePast && isPast) {
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
@@ -38,7 +39,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
defaultValue: 'none',
},
{
id: 'subscribe',
@@ -179,8 +179,9 @@ export function OffsetOverview() {
export function ClockOverview({ className }: { className?: string }) {
const { clock } = useClock();
const formattedClock = formatTime(clock);
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
}
export function TimerOverview({ className }: { className?: string }) {
+7 -2
View File
@@ -82,7 +82,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: editorMode === AppMode.Run });
useFollowComponent({
followRef: cursorRef,
scrollRef,
doFollow: true,
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
});
// DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
@@ -312,7 +317,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
// in run mode, we follow selection
// in run mode, we follow the playback selection and open groups as needed
useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
@@ -28,20 +28,22 @@
padding-inline: 0;
box-shadow: $box-shadow-right;
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
min-width: 38rem;
max-width: 60rem;
max-width: none;
width: 0;
}
.side {
max-height: 100%;
max-width: 45rem;
margin: 0.5rem 0;
padding: 1rem;
padding-right: 0;
background-color: $gray-1325;
border-radius: 0 8px 8px 0;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
max-width: 45rem;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
// width is locked to swatch picker elements
min-width: calc(15 * 2rem + 13 * 0.5rem);
width: 0;
}
@@ -41,9 +41,9 @@ export function canDrop(
order?: 'after' | 'before',
isTargetCollapsed?: boolean,
): boolean {
// this would mean inserting a group inside another
// inserting before would mean adding a group inside another
if (targetType === 'end-group') {
return false;
return order === 'after';
}
// this means swapping places with another group
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: '', label: 'Companion' },
{ value: '<<companion>>', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
+1 -1
View File
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
/NetworkError/i,
/The operation couldn't be completed/i,
],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /external\//i],
beforeSend(event) {
// Drop errors that happen during known data-unavailable states
const error = event.exception?.values?.[0]?.value;
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langDe: TranslationObject = {
'common.expected_finish': 'Erwartetes Ende',
'common.minutes': 'min',
'common.seconds': 'sek',
'common.now': 'Jetzt',
'common.next': 'Nächste',
'common.scheduled_start': 'Geplanter beginn',
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft',
'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langEs: TranslationObject = {
'common.expected_finish': 'Finalización esperada',
'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Ahora',
'common.next': 'Siguiente',
'common.scheduled_start': 'Inicio programado',
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langFr: TranslationObject = {
'common.expected_finish': 'Fin estimée à',
'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Maintenant',
'common.next': 'A suivre',
'common.scheduled_start': 'Début prévu',
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langIt: TranslationObject = {
'common.expected_finish': 'Fine Prevista',
'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Adesso',
'common.next': 'Prossimo',
'common.scheduled_start': 'Inizio programmato',
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento",
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langPt: TranslationObject = {
'common.expected_finish': 'Término esperado',
'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Agora',
'common.next': 'Próximo',
'common.scheduled_start': 'Início programado',
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento',
@@ -17,8 +17,11 @@ export const getBackstageOptions = (
customFields: CustomFields,
projectData: ProjectData,
): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
const projectDataOptions = makeProjectDataOptions(projectData);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
]);
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -32,7 +35,7 @@ export const getBackstageOptions = (
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
defaultValue: 'none',
},
],
},
@@ -47,7 +50,7 @@ export const getBackstageOptions = (
description: 'Select a project data source to show in the view',
type: 'option',
values: projectDataOptions,
defaultValue: '',
defaultValue: 'none',
},
],
},
+11 -3
View File
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
}
.sub--live {
background-color: $green-700;
background-color: $active-green;
}
.sub--armed {
background-color: $gray-1325;
}
.sub__binder {
@@ -140,12 +144,16 @@ $item-height: 3.5rem;
color: $delay-color;
}
.sub__schedule--strike {
text-decoration: line-through;
}
.sub__schedule--over {
color: $playback-over
color: $playback-over;
}
.sub__schedule--under {
color: $playback-under
color: $playback-under;
}
.sub__title {
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
@@ -9,13 +9,14 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useClock } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils';
import { CountdownSubscription, getOrderedSubscriptions } from './countdown.utils';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown';
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
return <Countdown {...data} />;
}
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) {
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
const { getLocalizedString } = useTranslation();
const { subscriptions } = useCountdownOptions();
const [editMode, setEditMode] = useState(false);
// gather rundown data
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
// gather presentation data
const hasEvents = playableEvents.length > 0;
@@ -58,6 +59,8 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
[defaultFormat, customFields, subscriptions],
);
console.log(subscriptions)
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor target={OntimeView.Countdown} viewOptions={countdownOptions} />
@@ -85,8 +88,8 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
}
interface CountdownContentsProps {
playableEvents: OntimeEvent[];
subscriptions: EntryId[];
playableEvents: ExtendedEntry<OntimeEvent>[];
subscriptions: CountdownSubscription;
goToEditMode: () => void;
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { IoArrowBack, IoClose, IoSaveOutline, IoAlbumsOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { EntryId, OntimeEvent } from 'ontime-types';
@@ -7,18 +7,19 @@ import Button from '../../common/components/buttons/Button';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { makeSubscriptionsUrl } from './countdown.utils';
import { CountdownSubscription, makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeEvent[];
subscriptions: EntryId[];
subscriptions: CountdownSubscription;
disableEdit: () => void;
}
export default function CountdownSelect({ events, subscriptions, disableEdit }: CountdownSelectProps) {
const [selected, setSelected] = useState<EntryId[]>(subscriptions);
const maybeAllSubscriptions: EntryId[] = subscriptions === 'all' ? events.map((event) => event.id) : subscriptions;
const [selected, setSelected] = useState<EntryId[]>(maybeAllSubscriptions);
const navigate = useNavigate();
/**
@@ -47,6 +48,18 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
navigate(url.search.toString());
};
/**
* Creates a URL with all
* and navigates to it
*/
const applyAll = () => {
// we remove events that no longer exist to avoid stale subscriptions
const url = makeSubscriptionsUrl(window.location.href, 'all');
disableEdit();
setSelected([]);
navigate(url.search.toString());
};
// make a copy of the selected array for quick lookup
const selectedIds = new Set(selected);
@@ -86,6 +99,10 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
<Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back
</Button>
<Button variant='subtle' size='xlarge' onClick={applyAll}>
{/* TODO: icon ??? */}
<IoAlbumsOutline /> Use All
</Button>
<Button variant='subtle' size='xlarge' onClick={() => setSelected([])} disabled={selected.length === 0}>
<IoClose /> Clear
</Button>
@@ -1,32 +1,37 @@
import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import { EntryId, OntimeEvent } from 'ontime-types';
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import {
useCountdownSocket,
useCurrentDay,
usePlayback,
useRuntimeOffset,
useSelectedEventId,
} from '../../common/hooks/useSocket';
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
import useReport from '../../common/hooks-query/useReport';
import { getOffsetState } from '../../common/utils/offset';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import FollowButton from '../../features/operator/follow-button/FollowButton';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options';
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
import {
CountdownEvent,
extendEventData,
getIsLive,
isOutsideRange,
preferredFormat12,
preferredFormat24,
useSubscriptionDisplayData,
} from './countdown.utils';
import './Countdown.scss';
interface CountdownSubscriptionsProps {
subscribedEvents: OntimeEvent[];
subscribedEvents: ExtendedEntry<OntimeEvent>[];
goToEditMode: () => void;
}
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const { selectedEventId } = useSelectedEventId();
const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -45,6 +53,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
followTrigger: selectedEventId,
});
// reset scroll if nothing is selected
@@ -92,23 +101,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
{subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
return (
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
{showExpected ? (
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
) : (
<>
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
)}
</div>
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
@@ -123,63 +128,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
);
}
interface ExpectedScheduleProps {
timeStart: number;
timeEnd: number;
delay: number;
}
function ExpectedSchedule(props: ExpectedScheduleProps) {
const { timeStart, timeEnd, delay } = props;
type ScheduleTimeProps = {
event: CountdownEvent;
showExpected: boolean;
};
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const { offset } = useRuntimeOffset();
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
// offset is negative if we are ahead
const expectedOffset = offset - delay;
const expectedState = getOffsetState(expectedOffset);
// only show new exacted value if outside range of the planned value
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
<>
<div className='sub__schedule'>
<ClockTime
value={timeStart - expectedOffset}
className={`sub__schedule--${expectedState}`}
preferredFormat12='h:mm'
preferredFormat24='HH:mm'
value={plannedStart}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
className={plannedStateClass}
/>
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
{!isExpectedValueShow && (
<>
<ClockTime
value={plannedEnd}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
className={plannedStateClass}
/>
</>
)}
{isExpectedValueShow && (
<>
<ClockTime
value={expectedStart}
className={expectedStateClass}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
/>
<ClockTime
value={expectedEnd}
className={expectedEndClass}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
/>
</>
)}
</div>
);
}
interface SubscriptionStatusProps {
event: OntimeEvent;
selectedEventId: EntryId | null;
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
}
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showExpected } = useCountdownOptions();
const { playback, current, clock } = useCountdownSocket();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
current,
playback,
clock,
event,
selectedEventId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showExpected,
);
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
return (
<>
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
<div className='sub__timer'>{timer}</div>
<div className='sub__status'>{statusDisplay}</div>
{status === 'done' ? (
<SuperscriptTime className='sub__timer' time={timeDisplay} />
) : (
<div className='sub__timer'>{timeDisplay}</div>
)}
</>
);
}
@@ -1,11 +1,10 @@
@use '@/theme/viewerDefs' as *;
.single-container {
height: 100%;
margin-top: 5vh;
margin-top: 7.5vh;
display: flex;
flex-direction: column;
gap: $view-element-gap;
gap: 5rem;
}
.event__title {
@@ -13,13 +12,25 @@
padding: $view-card-padding;
border-radius: $element-border-radius;
font-size: clamp(40px, 4.5vw, 80px);
line-height: 1.1em;
line-height: 1.1;
text-align: center;
border-left: 0.25em solid;
border-color: var(--card-background-color-override, $viewer-card-bg-color);
.secondary {
text-align: left;
font-size: $title-font-size;
}
.sub__schedule {
color: inherit;
font-size: $base-font-size;
}
}
.event__status {
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: clamp(2rem, 3.5vw, 3.5rem);
font-size: $header-font-size;
font-weight: 600;
text-transform: uppercase;
}
@@ -1,29 +1,57 @@
import { IoPencil } from 'react-icons/io5';
import { OntimeEvent } from 'ontime-types';
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import useReport from '../../common/hooks-query/useReport';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
import { useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
interface SingleEventCountdownProps {
subscribedEvent: OntimeEvent;
subscribedEvent: ExtendedEntry<OntimeEvent>;
goToEditMode: () => void;
}
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const { totalGap, isLinkedToLoaded } = subscribedEvent;
const expectedStart = getExpectedStart(subscribedEvent, {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
return (
<div className='single-container' data-testid='countdown-event'>
<SubscriptionStatus event={subscribedEvent} />
<div className='event__title'>{subscribedEvent.title}</div>
<SubscriptionStatus event={countdownEvent} />
<div className='event__title' style={{ borderColor: countdownEvent.colour }}>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
{title}
{secondaryData && <div className='secondary'>{secondaryData}</div>}
</div>
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: OntimeEvent;
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation();
const { selectedEventId } = useSelectedEventId();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showExpected } = useCountdownOptions();
const { playback, current, clock } = useCountdownSocket();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
current,
playback,
clock,
event,
selectedEventId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showExpected,
);
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
return (
<>
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
<div className='event__timer'>{timer}</div>
<div className='event__status'>{statusDisplay}</div>
{status === 'done' ? (
<SuperscriptTime className='event__timer' time={timeDisplay} />
) : (
<div className='event__timer'>{timeDisplay}</div>
)}
</>
);
}
@@ -8,13 +8,17 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
import { CountdownSubscription } from './countdown.utils';
export const getCountdownOptions = (
timeFormat: string,
customFields: CustomFields,
persistedSubscriptions: EntryId[],
persistedSubscriptions: CountdownSubscription,
): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -28,7 +32,7 @@ export const getCountdownOptions = (
description: 'Select the data source for auxiliary text shown in the card',
type: 'option',
values: secondaryOptions,
defaultValue: '',
defaultValue: 'none',
},
],
},
@@ -52,7 +56,7 @@ export const getCountdownOptions = (
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
values: persistedSubscriptions,
values: persistedSubscriptions === 'all' ? ['all'] : persistedSubscriptions,
type: 'persist',
},
],
@@ -61,7 +65,7 @@ export const getCountdownOptions = (
};
type CountdownOptions = {
subscriptions: EntryId[];
subscriptions: CountdownSubscription;
secondarySource: keyof OntimeEvent | null;
showExpected: boolean;
};
@@ -82,8 +86,10 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return searchParams.getAll(key) as EntryId[];
};
const subscriptions = getArrayValues('sub');
return {
subscriptions: getArrayValues('sub'),
subscriptions: subscriptions.at(0) === 'all' ? 'all' : subscriptions,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
showExpected: isStringBoolean(getValue('showExpected')),
};
+107 -131
View File
@@ -1,8 +1,11 @@
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
import type { TranslationKey } from '../../translation/TranslationProvider';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
/**
* Parses string as a title
@@ -11,6 +14,11 @@ export function sanitiseTitle(title: string | null) {
return title ?? '{no title}';
}
export type CountdownSubscription = EntryId[] | 'all';
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/**
* Whether the current event is live
*/
@@ -18,144 +26,103 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
return currentId === selectedId && playback !== Playback.Armed;
}
const subscriptionTimerDisplayOptions = {
removeSeconds: true,
removeLeadingZero: true,
} as const;
const subscriptionScheduledTimeDisplayOptions = {
removeSeconds: true,
removeLeadingZero: false,
} as const;
type TimerMessage = Record<string, TranslationKey>;
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
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: 'timeline.live',
live: 'countdown.running',
pending: 'countdown.waiting',
loaded: 'countdown.loaded',
done: 'countdown.ended',
};
export function getFormattedTime(
value: MaybeNumber,
status: ProgressStatus,
minText: string,
secText: string,
dueText: string,
) {
if (value === null) return timerPlaceholderMin;
if (status === 'future' || status === 'live') {
if (value <= 0) return dueText.toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
.replace('m', `${minText} `)
.replace('s', secText);
}
return removeSeconds(formatTime(value));
}
/**
* Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played
* TODO: get data from reporter and check if the event has played
* TODO: get timer data granularly
*/
export function getSubscriptionDisplayData(
current: MaybeNumber,
playback: Playback,
clock: number,
subscribedEvent: OntimeEvent,
selectedId: EntryId | null,
offset: number,
currentDay: number,
minutesString: string,
showExpected = false,
): { status: ProgressStatus; timer: string } {
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
export function useSubscriptionDisplayData(
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation();
if (selectedId === subscribedEvent.id) {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
const bigDuration = (value: number) => {
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
.replace('m', `${getLocalizedString('common.minutes')} `)
.replace('s', getLocalizedString('common.seconds'));
};
if (subscribedEvent.isLoaded) {
if (playback === Playback.Armed) {
// if we are following the event, but it is not running, we show the scheduled start
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + offsetAndDelay,
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
status: 'loaded',
statusDisplay: getLocalizedString(timerProgress['loaded']),
timeDisplay: bigDuration(subscribedEvent.duration),
};
}
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
return {
status: 'live',
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
statusDisplay: getLocalizedString(timerProgress['live']),
timeDisplay: bigDuration(current ?? 0),
};
}
/**
* If the running timer is not the one we are following
* we can be in future, due or have ended
*/
// 3. event is the day after, we show a countdown to start
if (subscribedEvent.dayOffset > currentDay) {
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
if (playback === Playback.Stop || playback === Playback.Armed) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
status: 'pending',
statusDisplay: getLocalizedString(timerProgress['pending']),
timeDisplay: ' ',
};
}
// 4. event is the before after, show the scheduled end
// TODO: get the time from the reporter
if (subscribedEvent.dayOffset < currentDay) {
if (subscribedEvent.isPast) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
statusDisplay: getLocalizedString(timerProgress['done']),
timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
};
}
// 5. if event is in future, we count to the scheduled start
// TODO: get time until
if (clock < subscribedEvent.timeStart) {
if (subscribedEvent.expectedStart - clock <= 0) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart - clock - offsetAndDelay,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
status: 'due',
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
};
}
// 6. if event has ended, we show the scheduled end
// TODO: get the time from the reporter
if (clock > subscribedEvent.timeEnd) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
};
}
// the event here has to be due, we show the countdown the expected start time
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + offsetAndDelay,
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
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[]) {
export function makeSubscriptionsUrl(urlRef: string, subscriptions: CountdownSubscription) {
const url = new URL(urlRef);
const newParams = new URLSearchParams();
@@ -166,10 +133,14 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
}
}
// add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
if (subscriptions === 'all') {
newParams.append('sub', 'all');
} else {
// add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
}
url.search = newParams.toString();
@@ -181,34 +152,39 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
* Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list.
*/
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
export function getOrderedSubscriptions<T extends OntimeEntry>(
subscriptions: CountdownSubscription,
playableEvents: T[],
): T[] {
if (subscriptions === 'all') return playableEvents;
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;
}
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
export function extendEventData(
event: ExtendedEntry<OntimeEvent>,
currentDay: number,
actualStart: MaybeNumber,
plannedStart: MaybeNumber,
offset: number,
mode: OffsetMode,
reportData: OntimeReport,
): CountdownEvent {
const { totalGap, isLinkedToLoaded } = event;
const expectedStart = getExpectedStart(event, {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
}
@@ -2,14 +2,15 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface CountdownData {
customFields: CustomFields;
events: OntimeEntry[];
rundownData: ExtendedEntry<OntimeEntry>[];
projectData: ProjectData;
isMirrored: boolean;
settings: Settings;
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
return {
data: {
customFields,
events: rundownData,
rundownData,
projectData,
isMirrored,
settings,
@@ -11,14 +11,14 @@ import {
import { ColumnDef } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import useColumnManager from '../cuesheet-table/useColumnManager';
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[];
}
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
const { columnOrder, saveColumnOrder } = useColumnManager(columns);
const { columnOrder, saveColumnOrder } = useColumnOrder(columns);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -13,14 +13,14 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss';
@@ -79,8 +79,9 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns);
const { columnSizing, setColumnSizing } = useColumnSizes();
const { columnVisibility, setColumnVisibility } = useColumnVisibility();
const table = useReactTable({
data,
@@ -167,7 +168,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
/>
);
},
TableRow: ({ item: _item, ...virtuosoProps }) => {
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
@@ -183,13 +184,16 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={injectedStyles}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />;
return (
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
);
}
if (isOntimeMilestone(entry)) {
@@ -204,6 +208,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
{...virtuosoProps}
/>
);
@@ -225,6 +230,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
{...virtuosoProps}
/>
);
@@ -232,11 +238,14 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
}}
fixedHeaderContent={() => {
return table
.getHeaderGroups()
.map((headerGroup) => (
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
));
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}}
/>
@@ -7,7 +7,7 @@ import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell';
import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
@@ -16,8 +16,9 @@ interface CuesheetHeaderProps {
cuesheetMode: AppMode;
}
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
export function SortableCuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return (
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
@@ -43,8 +44,10 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
return (
<SortableCell
key={header.column.columnDef.id}
header={header}
columnId={header.column.id}
colSpan={header.colSpan}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
@@ -54,3 +57,43 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
</tr>
);
}
export function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return (
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
{!hideIndexColumn && (
<th className={style.indexColumn} tabIndex={-1}>
#
</th>
)}
{headerGroup.headers.map((header) => {
const customBackground = header.column.columnDef.meta?.colour;
const canWrite = header.column.columnDef.meta?.canWrite;
const customStyles: CSSProperties = {
opacity: canWrite ? 1 : 0.6,
};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles.backgroundColor = customColour.backgroundColor;
customStyles.color = customColour.color;
}
return (
<TableCell
key={header.column.columnDef.id}
columnId={header.column.id}
colSpan={header.colSpan}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
);
})}
</tr>
);
}
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { CSSProperties, memo } from 'react';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -7,9 +7,10 @@ import style from './DelayRow.module.scss';
interface DelayRowProps {
duration: number;
injectedStyles?: CSSProperties;
}
function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) {
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
if (hideDelays || duration === 0) {
@@ -19,7 +20,7 @@ function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
<tr className={style.delayRow} data-testid='cuesheet-delay' style={injectedStyles} {...virtuosoProps}>
<td tabIndex={0}>{delayTime}</td>
</tr>
);
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { CSSProperties, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
@@ -26,6 +26,7 @@ interface EventRowProps {
parent: EntryId | null;
rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>;
injectedStyles?: CSSProperties;
}
export default function EventRow({
@@ -42,6 +43,7 @@ export default function EventRow({
parent,
rowIndex,
table,
injectedStyles,
...virtuosoProps
}: EventRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -81,6 +83,7 @@ export default function EventRow({
parent && style.hasParent,
])}
style={{
...injectedStyles,
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': groupColour ?? 'transparent',
}}
@@ -4,3 +4,7 @@
padding-top: 0.25em;
width: 100%;
}
.multiline {
white-space: break-spaces;
}
@@ -2,6 +2,10 @@ import { PropsWithChildren } from 'react';
import style from './GhostedText.module.scss';
export default function GhostedText({ children }: PropsWithChildren) {
return <div className={style.ghostedText}>{children}</div>;
interface GhostedTextProps {
multiline?: boolean;
}
export default function GhostedText({ children, multiline }: PropsWithChildren<GhostedTextProps>) {
return <div className={`${style.ghostedText} ${multiline ? style.multiline : ''}`}>{children}</div>;
}
@@ -1,3 +1,4 @@
import { CSSProperties } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
@@ -15,9 +16,18 @@ interface GroupRowProps {
rowId: string;
rowIndex: number;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
}
export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
export default function GroupRow({
groupId,
colour,
rowId,
rowIndex,
table,
injectedStyles,
...virtuosoProps
}: GroupRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit,
hideIndexColumn: false,
@@ -26,7 +36,12 @@ export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...v
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
return (
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group' {...virtuosoProps}>
<tr
className={style.groupRow}
style={{ ...injectedStyles, '--user-bg': colour }}
data-testid='cuesheet-group'
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
@@ -1,3 +1,4 @@
import { CSSProperties } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
@@ -20,6 +21,7 @@ interface MilestoneRowProps {
rowId: string;
rowIndex: number;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
}
export default function MilestoneRow({
@@ -31,6 +33,7 @@ export default function MilestoneRow({
rowId,
rowIndex,
table,
injectedStyles,
...virtuosoProps
}: MilestoneRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -56,6 +59,7 @@ export default function MilestoneRow({
<tr
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
style={{
...injectedStyles,
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent',
}}
@@ -8,16 +8,16 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss';
interface SortableCellProps {
header: Header<ExtendedEntry, unknown>;
columnId: string;
colSpan: number;
injectedStyles: CSSProperties;
children: ReactNode;
draggable: ReactNode;
}
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
const { column, colSpan } = header;
export function SortableCell({ columnId, colSpan, injectedStyles, children, draggable }: SortableCellProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
id: columnId,
});
// build drag styles
@@ -34,12 +34,31 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
<div {...attributes} {...listeners}>
{children}
</div>
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
{draggable}
</th>
);
}
export function TableCell({ colSpan, injectedStyles, children, draggable }: SortableCellProps) {
return (
<th style={injectedStyles} colSpan={colSpan} tabIndex={-1}>
<div>{children}</div>
{draggable}
</th>
);
}
interface DraggableProps {
header: Header<ExtendedEntry, unknown>;
}
export function Draggable({ header }: DraggableProps) {
return (
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
);
}
@@ -143,7 +143,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage';
@@ -14,17 +14,8 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
}, 500);
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: tableOrderKey,
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizingState] = useState(() => {
export function useColumnSizes() {
const [columnSizing, setColumnSizingState] = useState<Record<string, number>>(() => {
try {
const stored = localStorage.getItem(tableSizesKey);
return stored ? JSON.parse(stored) : {};
@@ -38,10 +29,22 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
saveSizesToStorage(columnSizing);
}, [columnSizing]);
const setColumnSizing = useCallback((sizes: typeof columnSizing) => {
setColumnSizingState(sizes);
const setColumnSizing = useCallback((sizesOrUpdater: Updater<ColumnSizingState>) => {
setColumnSizingState(sizesOrUpdater);
}, []);
return {
columnSizing,
setColumnSizing,
};
}
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[]) {
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: tableOrderKey,
defaultValue: columns.map((col) => col.id as string),
});
// update column order if columns change
useEffect(() => {
const newColumns = columns.map((col) => col.id as string);
@@ -55,12 +58,20 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
}, [columns, saveColumnOrder]);
return {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
resetColumnOrder,
};
}
export function useColumnVisibility() {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
});
return {
columnVisibility,
setColumnVisibility,
};
}
@@ -66,4 +66,5 @@
.scrollContainer {
max-height: 70vh;
overflow: auto;
padding-top: 1rem;
}
@@ -71,8 +71,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
const displayCue = 'cue' in entry ? entry.cue : '';
return (
<li
@@ -83,7 +82,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': colour }}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
</div>
<div className={style.cue}>{displayCue}</div>
@@ -99,7 +98,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
footerElements={
<div className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search
<span className={style.em}>title</span> to filter search.
</div>
}
/>
@@ -1,6 +1,6 @@
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId, isOntimeEvent, isOntimeGroup, MaybeString, SupportedEntry } from 'ontime-types';
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -9,14 +9,15 @@ const maxResults = 12;
type FilterableGroup = {
type: SupportedEntry.Group;
id: string;
id: EntryId;
index: number;
title: string;
colour: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: string;
id: EntryId;
index: number;
eventIndex: number;
title: string;
@@ -25,7 +26,17 @@ type FilterableEvent = {
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent;
type FilterableMilestone = {
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
@@ -59,7 +70,7 @@ export default function useFinder() {
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
@@ -67,14 +78,14 @@ export default function useFinder() {
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
@@ -162,33 +173,46 @@ export default function useFinder() {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
id: entry.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeGroup(event)) {
if (event.title.toLowerCase().includes(searchString)) {
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: event.id,
id: entry.id,
index: i,
title: event.title,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
@@ -200,7 +224,7 @@ export default function useFinder() {
const select = useCallback(
(selectedEvent: FilterableEntry) => {
// First expand the parent group if this is an event inside a group
if (selectedEvent.type === SupportedEntry.Event && selectedEvent.parent !== null) {
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
// Try direct state update instead of using callback
const currentGroups = [...new Set(collapsedGroups)];
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
+3 -3
View File
@@ -68,7 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
hidePhase,
font,
keyColour,
textColour,
timerColour,
} = useTimerOptions();
const { getLocalizedString } = useTranslation();
@@ -131,11 +131,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
);
// gather presentation styles
const timerColour = getTimerColour(viewSettings, textColour, showWarning, showDanger);
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
const userStyles = {
...(keyColour && { '--timer-bg': keyColour }),
...(textColour && { '--timer-colour': timerColour }),
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
...(font && { '--timer-font': font }),
};
+9 -7
View File
@@ -25,10 +25,12 @@ const timerDisplayOptions: SelectOption[] = [
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
@@ -84,7 +86,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
defaultValue: 'title',
},
{
id: 'secondary-src',
@@ -92,7 +94,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
defaultValue: 'none',
},
],
},
@@ -163,9 +165,9 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
defaultValue: '101010',
},
{
id: 'textColour',
title: 'Text Colour',
description: 'Text colour. Default: #f6f6f6',
id: 'timerColour',
title: 'Timer Colour',
description: 'Timer colour. Default: #f6f6f6',
type: 'colour',
defaultValue: 'f6f6f6',
},
@@ -191,7 +193,7 @@ type TimerOptions = {
hidePhase: boolean;
font?: string;
keyColour?: string;
textColour?: string;
timerColour?: string;
};
/**
@@ -226,7 +228,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
textColour: makeColourString(getValue('textColour')),
timerColour: makeColourString(getValue('timerColour')),
};
}
+2 -2
View File
@@ -42,8 +42,8 @@ const fontSizeMap: { [key: number]: number } = {
4: 28, // 9:01
5: 28, // -9:01, 10:01, 9 min
6: 25, // -10:01, 10 min
8: 20, // 23:01:01
9: 20, // -23:01:01
8: 18, // 23:01:01
9: 18, // -23:01:01
};
/**
+2 -1
View File
@@ -32,7 +32,8 @@
"preserveConstEnums": true,
"allowJs": true,
"baseUrl": "src",
"jsx": "react-jsx"
"jsx": "react-jsx",
"outDir": "build"
},
"include": [
"src"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.0.0-alpha.6",
"version": "4.0.0-beta.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.0.0-alpha.6",
"version": "4.0.0-beta.3",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -10,7 +10,7 @@ export const dataFromExcelTemplate = [
'Timer type',
'Count to end',
'Skip',
'Notes',
'Note',
't0',
'Test1',
'test2',
@@ -27,7 +27,7 @@ describe('generateAuthenticatedUrl()', () => {
authenticate: true,
hash: '1234',
});
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1');
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?n=1&token=1234');
});
it('generates a link to an unlocked preset', () => {
@@ -69,6 +69,17 @@ describe('generateAuthenticatedUrl()', () => {
});
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
});
it('generates a link for companion', () => {
const withAuth = generateShareUrl('http://192.168.10.173:4001', '<<companion>>', {
lockConfig: false,
lockNav: false,
authenticate: true,
preset: undefined,
hash: '1234',
});
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/?token=1234');
});
});
describe('for ontime-cloud URLs', () => {
@@ -100,7 +111,7 @@ describe('generateAuthenticatedUrl()', () => {
prefix: 'prefix',
hash: '1234',
});
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1');
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1&token=1234');
});
it('generates a link to an unlocked preset', () => {
@@ -64,18 +64,21 @@ export function generateShareUrl(
): URL {
const url = new URL(baseUrl);
// if the config is locked and we are in a preset, we hide the canonical path
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
// companion links point to the root
if (canonicalPath !== '<<companion>>') {
// if the config is locked and we are in a preset, we hide the canonical path
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
if (lockNav) {
url.searchParams.append('n', '1');
}
}
if (authenticate && hash) {
url.searchParams.append('token', hash);
}
if (lockNav) {
url.searchParams.append('n', '1');
}
return url;
}
@@ -9,8 +9,6 @@ export const validateGenerateUrl = [
body('lockConfig').isBoolean(),
body('lockNav').isBoolean(),
body('preset').optional().isString().trim().notEmpty(),
body('prefix').optional().isString().trim().notEmpty(),
body('hash').optional().isString().trim().notEmpty(),
requestValidationFunction,
];
@@ -1,4 +1,4 @@
import { body, checkExact } from 'express-validator';
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
/**
@@ -8,11 +8,10 @@ export const validateWelcomeDialog = [body('show').isBoolean(), requestValidatio
const pinValidator = (key: string) => {
return body(key)
.isString()
.trim()
.optional()
.isLength({ min: 0, max: 4 })
.customSanitizer((input) => {
if (input.length === 0) {
if (input === null || input.length === 0) {
return null;
}
return input;
@@ -28,8 +27,6 @@ export const validateSettings = [
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(),
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
checkExact(),
requestValidationFunction,
];
// TODO: dont allow other keys
+2 -1
View File
@@ -34,7 +34,8 @@ import { populateTranslation } from './setup/loadTranslations.js';
import { populateStyles } from './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/runtime.service.js';
import { RestorePoint, restoreService } from './services/RestoreService.js';
import { restoreService } from './services/restore-service/restore.service.js';
import type { RestorePoint } from './services/restore-service/restore.type.js';
import * as messageService from './services/message-service/message.service.js';
import { getState } from './stores/runtimeState.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
+1 -1
View File
@@ -337,7 +337,7 @@ export const demoDb: DatabaseModel = {
alias: 'minimal',
target: OntimeView.Timer,
search:
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&textcolour=ffffff',
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
},
],
customFields: {
-146
View File
@@ -1,146 +0,0 @@
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
import { JSONFile } from 'lowdb/node';
import { deepEqual } from 'fast-equals';
import { publicFiles } from '../setup/index.js';
export type RestorePoint = {
playback: Playback;
selectedEventId: MaybeString;
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
};
/**
* Utility validates a RestorePoint
* @param obj
* @return boolean
*/
export function isRestorePoint(obj: unknown): obj is RestorePoint {
if (!obj) {
return false;
}
const restorePoint = obj as RestorePoint;
if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) {
return false;
}
if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) {
return false;
}
if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) {
return false;
}
if (typeof restorePoint.addedTime !== 'number') {
return false;
}
if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) {
return false;
}
if (typeof restorePoint.firstStart !== 'number' && restorePoint.firstStart !== null) {
return false;
}
return true;
}
/**
* Utility interface to allow dependency injection during test
*/
/**
* Service manages saving of application state
* that can then be restored when reopening
*/
export class RestoreService {
private readonly filePath: MaybeString;
private readonly file: JSONFile<RestorePoint | null>;
private failedCreateAttempts: number;
private savedState: RestorePoint | null;
constructor(filePath: string) {
this.filePath = filePath;
this.savedState = null;
this.file = new JSONFile(this.filePath);
this.failedCreateAttempts = 0;
}
/**
* Utility, reads from file
* @private
*/
private async read() {
return this.file.read();
}
/**
* Utility writes payload to file
* @throws
* @param stringifiedState
*/
private async write(data: RestorePoint) {
await this.file.write(data);
}
/**
* Saves runtime data to restore file
* @param newState RestorePoint
*/
async save(newState: RestorePoint) {
// after three failed attempts, mark the service as unavailable
if (this.failedCreateAttempts > 3) {
return;
}
if (deepEqual(newState, this.savedState)) {
return;
}
try {
await this.write(newState);
this.savedState = { ...newState };
this.failedCreateAttempts = 0;
} catch (_error) {
this.failedCreateAttempts += 1;
}
}
/**
* Attempts reading a restore point from a given file path
* Returns null if none found, restore point otherwise
*/
async load(): Promise<RestorePoint | null> {
try {
const maybeRestorePoint = await this.read();
if (isRestorePoint(maybeRestorePoint)) {
return maybeRestorePoint;
}
} catch (_error) {
// no need to notify the user
}
return null;
}
/**
* Clears the restore file
*/
async clear() {
try {
await this.file.write(null);
} catch (_error) {
// nothing to do
}
}
}
export const restoreService = new RestoreService(publicFiles.restoreFile);
@@ -1,139 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it, vi } from 'vitest';
import { Playback } from 'ontime-types';
import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js';
describe('isRestorePoint()', () => {
it('validates a well defined object', () => {
let restorePoint: RestorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: 3,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
restorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => {
const restorePoint = {
playback: 'unknown',
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with missing playback value', () => {
const restorePoint = {
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with incorrect value', () => {
const restorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 'testing',
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
});
});
describe('RestoreService()', () => {
describe('load()', () => {
it('loads working file with times', async () => {
const expected: RestorePoint = {
playback: Playback.Play,
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 5678,
pausedAt: 9087,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = await restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('loads working file without times', async () => {
const expected: RestorePoint = {
playback: Playback.Stop,
selectedEventId: null,
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = await restoreService.load();
expect(testLoad).toStrictEqual(expected);
});
it('does not load wrong play state', async () => {
const expected = {
playback: 'does-not-exist',
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
groupStartAt: 10,
};
const restoreService = new RestoreService('/path/to/restore/file');
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
const testLoad = await restoreService.load();
expect(testLoad).toBe(null);
});
});
describe('save()', () => {
it('saves data to file', async () => {
const testData: RestorePoint = {
playback: Playback.Play,
selectedEventId: '1234',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const restoreService = new RestoreService('/path/to/restore/file');
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
await restoreService.save(testData);
expect(writeSpy).toHaveBeenCalledWith(testData);
});
});
});
@@ -0,0 +1,63 @@
import { Playback } from 'ontime-types';
import { isRestorePoint } from '../restore.parser.js';
import { RestorePoint } from '../restore.type.js';
describe('isRestorePoint()', () => {
it('validates a well defined object', () => {
let restorePoint: RestorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: 3,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
restorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
});
describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => {
const restorePoint = {
playback: 'unknown',
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with missing playback value', () => {
const restorePoint = {
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
it('with incorrect value', () => {
const restorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 'testing',
addedTime: 0,
pausedAt: null,
groupStartAt: 10,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
});
});
@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Playback } from 'ontime-types';
import { vi } from 'vitest';
import { RestorePoint } from '../restore.type.js';
import { restoreService } from '../restore.service.js';
describe('restoreService', () => {
describe('load()', () => {
it('loads working file with times', async () => {
const expected: RestorePoint = {
playback: Playback.Play,
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 5678,
pausedAt: 9087,
firstStart: 1234,
};
const mockRead = vi.fn().mockResolvedValue(expected);
const testLoad = await restoreService.load(mockRead);
expect(testLoad).toStrictEqual(expected);
expect(mockRead).toHaveBeenCalledOnce();
});
it('loads working file without times', async () => {
const expected: RestorePoint = {
playback: Playback.Stop,
selectedEventId: null,
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1234,
};
const mockRead = vi.fn().mockResolvedValue(expected);
const testLoad = await restoreService.load(mockRead);
expect(testLoad).toStrictEqual(expected);
expect(mockRead).toHaveBeenCalledOnce();
});
it('does not load wrong play state', async () => {
const expected = {
// Missing required field 'firstStart' to make validation fail
playback: 'does-not-exist',
selectedEventId: 'da5b4',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
groupStartAt: 10,
};
const mockRead = vi.fn().mockResolvedValue(expected);
const testLoad = await restoreService.load(mockRead);
// Should return null because isRestorePoint validation fails
expect(testLoad).toBe(null);
expect(mockRead).toHaveBeenCalledOnce();
});
it('returns null when file read fails', async () => {
const mockRead = vi.fn().mockRejectedValue(new Error('File not found'));
const testLoad = await restoreService.load(mockRead);
expect(testLoad).toBe(null);
expect(mockRead).toHaveBeenCalledOnce();
});
});
describe('save()', () => {
it('saves data to file', async () => {
const testData: RestorePoint = {
playback: Playback.Play,
selectedEventId: '1234',
startedAt: 1234,
addedTime: 1234,
pausedAt: 1234,
firstStart: 1234,
};
const mockWrite = vi.fn().mockResolvedValue(undefined);
await restoreService.save(testData, mockWrite);
expect(mockWrite).toHaveBeenCalledWith(testData);
});
it('handles write failures gracefully', async () => {
const testData: RestorePoint = {
playback: Playback.Pause,
selectedEventId: '5678',
startedAt: 5678,
addedTime: 5678,
pausedAt: 5678,
firstStart: 5678,
};
const mockWrite = vi.fn().mockRejectedValue(new Error('Write failed'));
// Should not throw, and should still call write
await expect(restoreService.save(testData, mockWrite)).resolves.toBeUndefined();
expect(mockWrite).toHaveBeenCalledWith(testData);
});
});
describe('clear()', () => {
it('clears the restore file', async () => {
const mockWrite = vi.fn().mockResolvedValue(undefined);
await restoreService.clear(mockWrite);
expect(mockWrite).toHaveBeenCalledWith(null);
});
it('handles clear failures gracefully', async () => {
const mockWrite = vi.fn().mockRejectedValue(new Error('Clear failed'));
// Should not throw
await expect(restoreService.clear(mockWrite)).resolves.toBeUndefined();
expect(mockWrite).toHaveBeenCalledWith(null);
});
});
});
@@ -0,0 +1,53 @@
import { Playback } from 'ontime-types';
import { is } from '../../utils/is.js';
import type { RestorePoint } from './restore.type.js';
/**
* Utility validates a RestorePoint
*/
export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePoint {
if (!is.object(restorePoint)) {
return false;
}
if (
!is.objectWithKeys(restorePoint, [
'playback',
'selectedEventId',
'startedAt',
'addedTime',
'pausedAt',
'firstStart',
])
) {
return false;
}
if (!is.string(restorePoint.playback) && !Object.values(Playback).includes(restorePoint.playback as Playback)) {
return false;
}
if (!is.string(restorePoint.selectedEventId) && restorePoint.selectedEventId !== null) {
return false;
}
if (!is.number(restorePoint.startedAt) && restorePoint.startedAt !== null) {
return false;
}
if (!is.number(restorePoint.addedTime)) {
return false;
}
if (!is.number(restorePoint.pausedAt) && restorePoint.pausedAt !== null) {
return false;
}
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
return false;
}
return true;
}
@@ -0,0 +1,106 @@
import { JSONFile } from 'lowdb/node';
import { deepEqual } from 'fast-equals';
import { publicFiles } from '../../setup/index.js';
import { isRestorePoint } from './restore.parser.js';
import type { RestorePoint } from './restore.type.js';
let failedCreateAttempts = 0;
let savedState: RestorePoint | null = null;
let fileRef: JSONFile<RestorePoint | null> | null = null;
/**
* Service manages saving snapshot of application state
* that can then be restored when reopening
*/
export const restoreService = {
save,
load,
clear,
};
/**
* Saves a restore point
* @param [writeFn=write] - allows overriding the write function for testing
* @public
*/
async function save(data: RestorePoint, writeFn = write) {
// after three failed attempts, mark the service as unavailable
if (failedCreateAttempts > 3) {
return;
}
if (deepEqual(data, savedState)) {
return;
}
try {
await writeFn(data);
savedState = { ...data };
failedCreateAttempts = 0;
} catch (_error) {
failedCreateAttempts += 1;
}
}
/**
* Attempts reading a restore point from a given file path
* Returns null if none found, restore point otherwise
* @param [readFn=read] - allows overriding the read function for testing
* @public
*/
async function load(readFn = read): Promise<RestorePoint | null> {
try {
const maybeRestorePoint = await readFn();
if (isRestorePoint(maybeRestorePoint)) {
return maybeRestorePoint;
}
} catch (_error) {
// no need to notify the user
}
return null;
}
/**
* Clears the restore file
* @param [writeFn=write] - allows overriding the write function for testing
* @public
*/
async function clear(writeFn = write) {
try {
await writeFn(null);
} catch (_error) {
// nothing to do
}
}
/**
* Initialised file reference
* @private
*/
async function init(): Promise<JSONFile<RestorePoint | null>> {
if (fileRef) return fileRef;
fileRef = new JSONFile<RestorePoint | null>(publicFiles.restoreFile);
return fileRef;
}
/**
* Reads from an intialized file reference
* @private
*/
async function read(): Promise<RestorePoint | null> {
const file = await init();
return file.read();
}
/**
* Writes to an intialized file reference
* @throws - if writing fails
* @private
*/
async function write(data: RestorePoint | null) {
const file = await init();
return file.write(data);
}
@@ -0,0 +1,10 @@
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
export type RestorePoint = {
playback: Playback;
selectedEventId: MaybeString;
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
};
@@ -25,7 +25,8 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import type { RestorePoint } from '../restore-service/restore.type.js';
import { restoreService } from '../restore-service/restore.service.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import {
@@ -635,6 +636,8 @@ const eventTimer = new EventTimer({
});
export const runtimeService = new RuntimeService(eventTimer);
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
/**
* Decorator manages side effects from updating the runtime
* This should only be applied to functions that are exposed for consumption
@@ -671,21 +674,30 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// combine all big changes
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
// clock has changed by a second or more
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
if (updateClock) {
batch.add('clock', state.clock);
RuntimeService.previousState.clock = state.clock;
}
// if any values have changed, values that have the possibility to tick are updated when the seconds roll over
/**
* if any values have changed.
* values that have the possibility to tick are updated when the seconds roll over
*/
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
if (updateTimer) {
batch.add('timer', state.timer);
RuntimeService.previousState.timer = { ...state.timer };
}
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate`
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
*/
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
if (updateClock) {
batch.add('clock', state.clock);
RuntimeService.previousState.clock = state.clock;
}
/**
* if any values have changed.
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
*/
const updateRuntime = getShouldOffsetUpdate(
RuntimeService.previousState?.offset,
state.offset,
@@ -696,16 +708,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.offset = structuredClone(state.offset);
}
// if any values have changed
/**
* if any values have changed.
*/
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
if (updateRundownData) {
batch.add('rundown', state.rundown);
RuntimeService.previousState.rundown = structuredClone(state.rundown);
}
function updateMaybeEntryIfChanged<
K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
>(key: K) {
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
const previousEntry = RuntimeService.previousState[key];
const currentEntry = state[key];
@@ -24,7 +24,6 @@ export function isNewSecond(
/**
* Checks whether we should update the clock value
* - we have rolled into a new seconds unit
* this is different from the timer update as it looks at the clock as counting up
*/
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp);
@@ -32,8 +31,10 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
}
/**
* Checks whether we should update the timer value
* - we have rolled into a new seconds unit
* Checks whether we should update the timer values
* - `current` and `secondaryTimer` trigger on seconds roll over
* - the rest trigger on any change
* - `elapsed` and `expectedFinish` is not checked
*/
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
if (previousValue === undefined) return true;
@@ -53,6 +54,11 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
);
}
/**
* Checks whether we should update the offset values
* - `mode` triggers update
* - `absolute`, `relative`, `expected**End` are ticked with `didDependencyUpdate`
*/
export function getShouldOffsetUpdate(
previousValue: Offset | undefined,
currentValue: Offset,
@@ -60,7 +66,6 @@ export function getShouldOffsetUpdate(
): boolean {
if (previousValue === undefined) return true;
if (previousValue.mode !== currentValue.mode) return true;
// absolute, relative, expected*End are ticked with `didDependencyUpdate`
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
}
@@ -4,7 +4,18 @@
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
*/
import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types';
import {
AuthenticationStatus,
CustomFields,
DatabaseModel,
isOntimeEvent,
isOntimeMilestone,
LogOrigin,
MaybeString,
OntimeGroup,
Rundown,
SupportedEntry,
} from 'ontime-types';
import { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets';
@@ -348,6 +359,21 @@ export async function upload(sheetId: string, options: ImportMap) {
const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
const rundown = getCurrentRundown();
const sheetOrder: string[] = [];
let prevGroup: string | null = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (isOntimeEvent(entry) || isOntimeMilestone(entry)) {
if (prevGroup && entry.parent === null) {
// if we were in a group and are now not insert a group end
sheetOrder.push(`group-end-${prevGroup}`);
}
prevGroup = entry.parent;
}
sheetOrder.push(entry.id);
}
const titleMetadata = Object.values(sheetMetadata)[0];
if (titleMetadata === undefined) {
throw new Error(`Sheet read failed: failed to find title row`);
@@ -380,16 +406,20 @@ export async function upload(sheetId: string, options: ImportMap) {
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.order.length,
endIndex: titleRow + sheetOrder.length,
sheetId: worksheetId,
},
},
});
// update the corresponding row with event data
rundown.order.forEach((entryId, index) => {
const entry = rundown.entries[entryId];
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
});
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
@@ -1,4 +1,11 @@
import { isOntimeGroup, isOntimeEvent, OntimeEvent, OntimeEntry, RGBColour } from 'ontime-types';
import {
OntimeEntry,
RGBColour,
isOntimeDelay,
OntimeEntryCommonKeys,
isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types';
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets';
@@ -70,64 +77,67 @@ export function getA1Notation(row: number, column: number): string {
/**
* @description - creates updateCells request from ontime event
* @param {OntimeEntry} event
* @param {OntimeEntry} entry
* @param {number} index - index of the event
* @param {number} worksheetId
* @param {object} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequestFromEvent(
event: OntimeEntry,
entry: OntimeEntry,
index: number,
worksheetId: number,
metadata: object,
): sheets_v4.Schema$Request {
const rowData = Object.entries(metadata)
.filter(([_, value]) => value !== undefined)
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [keyof OntimeEvent | 'blank', { col: number; row: number }][];
const titleCol = rowData[0][1].col;
const rowData = Object.entries(metadata) // check what headings are available in the sheet
.filter(([_, value]) => value !== undefined) // drop anything that is undefined
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [
OntimeEntryCommonKeys | 'blank',
{ col: number; row: number },
][]; // sort the array by the column index
// inset blank data is there is spacing between relevant ontime columns
for (const [index, e] of rowData.entries()) {
if (index !== 0) {
const prevCol = rowData[index - 1][1].col;
const thisCol = e[1].col;
const diff = thisCol - prevCol;
if (diff > 1) {
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
rowData.splice(index, 0, ...fillArr);
}
if (index === 0) continue;
const prevCol = rowData[index - 1][1].col;
const thisCol = e[1].col;
const diff = thisCol - prevCol;
if (diff > 1) {
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
rowData.splice(index, 0, ...fillArr);
}
}
const colors = isOntimeEvent(event) || isOntimeGroup(event) ? getAccessibleColour(event.colour) : undefined;
const cellColor: sheets_v4.Schema$CellData = !colors
const colours = 'colour' in entry ? getAccessibleColour(entry.colour) : undefined;
const cellColor: sheets_v4.Schema$CellData = !colours
? {}
: {
userEnteredFormat: {
backgroundColor: toSheetColourLevel(colors.background),
backgroundColor: toSheetColourLevel(colours.background),
textFormat: {
foregroundColor: toSheetColourLevel(colors.text),
foregroundColor: toSheetColourLevel(colours.text),
},
borders: {
bottom: {
style: 'SOLID',
color: toSheetColourLevel(colors.border),
color: toSheetColourLevel(colours.border),
},
},
},
};
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
return { ...getCellData(key, event), ...cellColor };
return { ...getCellData(key, entry), ...cellColor };
});
const headerLocation = rowData[0][1];
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: index + rowData[0][1]['row'] + 1,
columnIndex: titleCol,
rowIndex: index + headerLocation.row + 1,
columnIndex: headerLocation.col,
},
fields: 'userEnteredValue,userEnteredFormat',
rows: [
@@ -139,40 +149,39 @@ export function cellRequestFromEvent(
};
}
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) {
if (isOntimeEvent(event)) {
if (key === 'blank') {
return {};
}
if (key === 'colour') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (key.startsWith('custom')) {
const customKey = key.split(':')[1];
return { userEnteredValue: { stringValue: event.custom[customKey] } };
}
if (typeof event[key] === 'number') {
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
}
if (typeof event[key] === 'string') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (typeof event[key] === 'boolean') {
return { userEnteredValue: { boolValue: event[key] } };
}
function getCellData(key: OntimeEntryCommonKeys | 'blank', entry: OntimeEntry) {
if (isOntimeDelay(entry) || key === 'blank') {
return {};
}
if (isOntimeGroup(event)) {
if (key === 'title') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (key === 'timerType') {
return { userEnteredValue: { stringValue: 'group' } };
}
// we need to flatten the milestones
if (key.startsWith('custom')) {
const customKey = key.split(':')[1];
return { userEnteredValue: { stringValue: entry.custom[customKey] } };
}
return {};
// we need to remap the event type to timer type in the case of groups and milestones
if (key === 'timerType') {
if (isOntimeGroup(entry))
return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
return { userEnteredValue: { stringValue: entry.timerType } };
}
// typescript cannot guarantee that the key exists for every entry
// so we check for the key existence and assert the type
if (!(key in entry)) return {};
const value = entry[key as keyof OntimeEntry];
if (typeof value === 'number') {
return { userEnteredValue: { stringValue: millisToString(value) } };
}
if (typeof value === 'string') {
return { userEnteredValue: { stringValue: value } };
}
if (typeof value === 'boolean') {
return { userEnteredValue: { boolValue: value } };
}
}
type googleSheetCellColour = {
+1 -1
View File
@@ -24,7 +24,7 @@ import {
} from 'ontime-utils';
import { timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/RestoreService.js';
import type { RestorePoint } from '../services/restore-service/restore.type.js';
import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.0.0-alpha.6",
"version": "4.0.0-beta.3",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+2
View File
@@ -1,6 +1,7 @@
export const langEn = {
'common.expected_finish': 'Expected Finish',
'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Now',
'common.next': 'Next',
'common.scheduled_start': 'Scheduled start',
@@ -13,6 +14,7 @@ export const langEn = {
'common.no_data': 'No data',
'countdown.ended': 'Event ended at',
'countdown.running': 'Event running',
'countdown.loaded': 'Event loaded',
'countdown.select_event': 'Select an event to follow',
'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start',
@@ -14,7 +14,7 @@ export const defaultImportMap = {
title: 'title',
countToEnd: 'count to end',
skip: 'skip',
note: 'notes',
note: 'note',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
+377 -320
View File
File diff suppressed because it is too large Load Diff