Compare commits

...

45 Commits

Author SHA1 Message Date
Carlos Valente 00aa8a09fa refactor(roll breaking): make starting with roll backtrace planned times 2025-09-21 07:42:47 +02:00
Carlos Valente d04f958c11 fix: initialise group time 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 5716fc31cf feat: export package to help api consumers (#1624) 2025-09-21 07:42:47 +02:00
Carlos Valente a5d628af0c fix: prevent params drawer from being larger than screen 2025-09-21 07:42:47 +02:00
Carlos Valente 111a1f95ba fix: correct offset direction in expected time calculations 2025-09-21 07:42:47 +02:00
Carlos Valente 4bf74d86b3 refactor: set start metadata when starting from roll 2025-09-21 07:42:47 +02:00
arc-alex d2638d64e4 fix: group end elements should only send timertype data nothing else 2025-09-21 07:42:47 +02:00
arc-alex 02cddeec4d refactor: add a try grourd to the cell data generator to better pinpoint errors 2025-09-21 07:42:47 +02:00
Carlos Valente a8fe8b5f1e refactor: allow filtering schedule 2025-09-21 07:42:47 +02:00
Carlos Valente 8d2db7ffcd Relative mode (#1764)
* feat: add group start time to state

* refactor: calculate expected times when offset mode changes

* feat: show relative data in ui

* fixup! feat: add group start time to state

* fixup! refactor: calculate expected times when offset mode changes

* fixup! feat: show relative data in ui

* try to handle multiday

* show planed/expected rundown end day offset values

* refactor: style tweaks to timers

* refactor: cleanup data use

* day offset

* update tests

* test getExpectedStart multiday

* write start epoch to restore file

* current day in relative mode

* remove todo

* move find day offset to utils

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 313590905f fix docker ignore (#1780) 2025-09-21 07:42:47 +02:00
Carlos Valente 7bbdab7dd9 fix(cuesheet): infinite render loop on resizing columns 2025-09-21 07:42:47 +02:00
Carlos Valente 304832ecde fix: maintain multiline in cuesheet cells 2025-09-21 07:42:47 +02:00
Carlos Valente 0ab4abfcfe docs: add contribution guidelines 2025-09-21 07:42:47 +02:00
Carlos Valente 1847822f31 fix: skip nested events 2025-09-21 07:42:47 +02:00
Carlos Valente 4499495212 feat: allow finding milestones 2025-09-21 07:42:47 +02:00
Carlos Valente 0035070db8 refactor: improve automated following 2025-09-21 07:42:47 +02:00
Carlos Valente 890a97d071 fix: allow moving a group after another 2025-09-21 07:42:47 +02:00
Carlos Valente 5441e5b07f refactor: improve pin styling 2025-09-21 07:42:47 +02:00
Carlos Valente ab4b94f2d8 fix: prevent layout reflow on different entry types 2025-09-21 07:42:47 +02:00
Carlos Valente fd7ccb4a5b refactor: add default value to secondary source 2025-09-21 07:42:47 +02:00
Carlos Valente 2e6bf234d5 fix: phase style overrides in timer 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen c934b0ecdd cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen cd6d6f24eb fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-21 07:42:47 +02:00
arc-alex e632a08557 chore: bump version 2025-09-21 07:42:47 +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
121 changed files with 2794 additions and 1345 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
# Ignore build folders # Ignore build folders
node_modules node_modules
dist **/node_modules
**/dist
# Ignore default volumes created by running docker compose up # Ignore default volumes created by running docker compose up
ontime-db ontime-db
+42
View File
@@ -0,0 +1,42 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_cli:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm turbo @getontime/resolver#build
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+13
View File
@@ -99,3 +99,16 @@ Other useful commands
- __List running processes__ by running `docker ps` - __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>` - __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", "name": "@getontime/cli",
"version": "4.0.0-alpha.6", "version": "4.0.0-beta.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+11 -11
View File
@@ -1,23 +1,23 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-alpha.6", "version": "4.0.0-beta.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.4", "@mantine/hooks": "^8.2.8",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.84.1", "@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.84.1", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.11.0", "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", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
@@ -27,11 +27,11 @@
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.8.0", "react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0", "react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
"zustand": "^5.0.7" "zustand": "^5.0.8"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "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", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^19.1.8", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
if (newPath === '/' || newPath === currentPath) { if (newPath === '/' || newPath === currentPath) {
return; return;
} }
setRedirect({ target: id, redirect: newPath });
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
onClose(); onClose();
}; };
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label, label: view.label,
})), })),
...enabledPresets.map((preset) => ({ ...enabledPresets.map((preset) => ({
value: preset.search, value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`, label: `URL Preset: ${preset.alias}`,
})), })),
]; ];
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<> <>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> 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 />
<br /> <br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info> </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}> <div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span> <span className={style.label}>Enter custom path</span>
<label className={style.textEntry}> <label className={style.textEntry}>
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
className={style.redirect} className={style.redirect}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
> >
Redirect <IoArrowForward /> Redirect
<IoArrowForward />
</Button> </Button>
</div> </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 { .pin {
margin-top: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px;
input { input {
font-size: 4rem; font-size: 4rem;
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'multi-option') { 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') { if (type === 'boolean') {
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
interface EditFormMultiOptionProps { interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' }; paramField: ParamField & { type: 'multi-option' };
options: string[];
} }
function MultiOption({ paramField }: EditFormMultiOptionProps) { function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams(); const { id, values } = paramField;
const { id, values, defaultValue = [''] } = paramField; const [paramState, setParamState] = useState<string[]>(options);
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
// synchronise options
useEffect(() => { useEffect(() => {
const params = searchParams.getAll(id); setParamState(options);
setParamState(params.length ? params : defaultValue); }, [options]);
}, [searchParams, id, defaultValue]);
const toggleValue = (value: string, checked: boolean) => { const toggleValue = (value: string, checked: boolean) => {
if (checked) { if (checked) {
@@ -28,6 +28,7 @@
bottom: 0; bottom: 0;
width: 40rem; width: 40rem;
max-width: 100vw;
height: 100vh; height: 100vh;
display: flex; display: flex;
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
/** /**
* Creates data for a select element that displays project custom data * Creates data for a select element that displays project custom data
*/ */
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] { export function makeProjectDataOptions(
return projectData.custom.map((entry, index) => { projectData: ProjectData,
additionalOptions: SelectOption[] = [],
): SelectOption[] {
const generatedOptions = projectData.custom.map((entry, index) => {
return { return {
value: `${index}-${entry.title}`, value: `${index}-${entry.title}`,
label: entry.title, label: entry.title,
}; };
}); });
return [...additionalOptions, ...generatedOptions];
} }
type ViewParamsObj = { [key: string]: string | FormDataEntryValue }; type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
const throttledShowMenu = throttle(setShowMenuTrue, 1000); 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('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu); document.addEventListener('keydown', throttledShowMenu);
@@ -1,6 +1,5 @@
import { RefObject, useCallback, useEffect, useRef } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef>,
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' }); 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 { interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; topOffset?: number;
setScrollFlag?: (newValue: boolean) => void; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent(props: UseFollowComponentProps) { export default function useFollowComponent({
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props; followRef,
scrollRef,
// when cursor moves, view should follow doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => { useEffect(() => {
if (!doFollow) { if (!doFollow || !followTrigger) {
return; return;
} }
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) { if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null // @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset); scrollToComponent(componentRef!, containerRef!, offset);
} }
}, },
[followRef, scrollRef, topOffset], [followRef, scrollRef, topOffset],
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent; 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,
};
}
+43 -20
View File
@@ -10,7 +10,9 @@ const createSelector =
export const setClientRemote = { export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload), 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), setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
}; };
@@ -162,24 +164,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
groupExpectedEnd: state.offset.expectedGroupEnd,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.offset.absolute, offset: state.offset.absolute,
@@ -188,7 +172,7 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
mode: state.offset.mode, mode: state.offset.mode,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.rundown.currentDay ?? 0,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
clock: state.clock, clock: state.clock,
@@ -225,6 +209,45 @@ export const usePlayback = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
/* ======================= Overview data subscriptions ======================= */
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
}));
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
playback: state.timer.playback,
}));
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
/* ======================= View specific subscriptions ======================= */ /* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({ export const useTimerSocket = createSelector((state: RuntimeStore) => ({
@@ -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, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 10,
isLinkedToLoaded: false, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
groupColour: 'red', groupColour: 'red',
@@ -125,6 +125,7 @@ function processEntry(
if (entry.id === selectedEventId) { if (entry.id === selectedEventId) {
processedData.isLoaded = true; processedData.isLoaded = true;
processedData.isPast = false; processedData.isPast = false;
processedData.isLinkedToLoaded = true;
} }
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
@@ -158,7 +159,8 @@ function processEntry(
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd 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)) { if (isNewLatest(entry, processedData.latestEvent)) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { import {
ApiAction, ApiActionTag,
Log, Log,
MessageTag, MessageTag,
RefetchKey, RefetchKey,
@@ -199,7 +199,7 @@ export const connectSocket = () => {
}; };
}; };
export function sendSocket<T extends MessageTag | ApiAction>( export function sendSocket<T extends MessageTag | ApiActionTag>(
tag: T, tag: T,
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown, payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
): void { ): void {
@@ -15,3 +15,7 @@
cursor: text ; cursor: text ;
user-select: 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 Tag from '../../../../../common/components/tag/Tag';
import { setClientRemote } from '../../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore'; import { useClientStore } from '../../../../../common/stores/clientStore';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
@@ -71,13 +72,13 @@ export default function ClientList() {
const { identify, name, path } = client; const { identify, name, path } = client;
const isCurrent = id === key; const isCurrent = id === key;
return ( return (
<tr key={key}> <tr key={key} className={cx([isCurrent && style.self])}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>} {isCurrent && <Tag>SELF</Tag>}
{name} {name}
</Panel.InlineElements> </Panel.InlineElements>
<td className={style.copiable}>{path}</td> <td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner' as='td'>
<Button <Button
size='small' size='small'
className={`${identify ? style.blink : ''}`} className={`${identify ? style.blink : ''}`}
@@ -49,6 +49,7 @@ export default function Operator() {
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: selectedOffset, topOffset: selectedOffset,
followTrigger: selectedEventId,
}); });
useWindowTitle('Operator'); useWindowTitle('Operator');
@@ -181,7 +182,7 @@ export default function Operator() {
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry); const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
if (hidePast && isPast) { if ((hidePast && isPast) || nestedEntry.skip) {
return null; return null;
} }
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, [ const fieldOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' }, { value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' }, { 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', description: 'Field to be shown in the second line of text',
type: 'option', type: 'option',
values: fieldOptions, values: fieldOptions,
defaultValue: '', defaultValue: 'none',
}, },
{ {
id: 'subscribe', id: 'subscribe',
@@ -47,9 +47,10 @@
.daySpan { .daySpan {
&::after { &::after {
content: '*'; content: "+"attr(data-day-offset);
vertical-align: super; vertical-align: super;
font-size: 0.75em; font-size: 0.6em;
letter-spacing: 0;
color: $info-blue; color: $info-blue;
} }
} }
@@ -67,3 +68,10 @@
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
text-align: right; text-align: right;
} }
.dueTime {
text-transform: capitalize;
font-size: 1rem;
letter-spacing: 0;
color: $playback-over;
}
@@ -8,23 +8,26 @@ import {
TbFolderPin, TbFolderPin,
TbFolderStar, TbFolderStar,
} from 'react-icons/tb'; } from 'react-icons/tb';
import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types'; import { OffsetMode, OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils'; import { dayInMs, isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { import {
useClock, useClock,
useCurrentGroupId, useCurrentGroupId,
useFlagTimerOverView,
useGroupTimerOverView,
useNextFlag, useNextFlag,
useOffsetOverview,
useProgressOverview,
useRundownOverview, useRundownOverview,
useRuntimePlaybackOverview,
useTimer, useTimer,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
import { useEntry } from '../../../common/hooks-query/useRundown'; import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils'; import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout'; import { OverUnder, TimeColumn } from './TimeLayout';
@@ -58,8 +61,8 @@ export function StartTimes() {
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} /> <Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? ( {maybePlannedDaySpan > 0 ? (
<Tooltip <Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`} text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />} render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
> >
{plannedEndText} {plannedEndText}
</Tooltip> </Tooltip>
@@ -71,8 +74,8 @@ export function StartTimes() {
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} /> <Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? ( {maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
<Tooltip <Tooltip
text={`Event spans over ${maybeExpectedDaySpan + 1} days`} text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />} render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
> >
{formattedTime(maybeExpectedEnd)} {formattedTime(maybeExpectedEnd)}
</Tooltip> </Tooltip>
@@ -96,61 +99,110 @@ export function MetadataTimes() {
); );
} }
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
function GroupTimes() { function GroupTimes() {
const { clock, groupExpectedEnd } = useRuntimePlaybackOverview(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { currentGroupId } = useCurrentGroupId(); const { currentGroupId } = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
// the group end time dose not encode any day offsets const active = isPlaybackActive(playback);
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown); // the group end time dose not encode any day offsets so it is calculated with group start time and duration
const plannedGroupEnd = (() => {
if (!active) return null;
if (!group || group.timeStart === null) return null;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? group.timeStart + group.duration - normalizedClock
: actualGroupStart + group.duration - normalizedClock;
})();
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null; const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown); const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
const groupTitle = group?.title ?? null;
return ( return (
<div className={style.metadataRow}> <div className={style.metadataRow}>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span> <span className={group?.title ? style.labelTitle : style.label}>{`${group?.title || 'Group'} `}</span>
<div className={style.labelledElement}> <div className={style.labelledElement}>
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} /> <Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span> <span
className={cx([
style.time,
(!group || !active) && style.muted,
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{plannedTimeUntilGroupEnd}
</span>
</div> </div>
<div className={style.labelledElement}> <div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} /> <Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span> <span
className={cx([
style.time,
!groupExpectedEnd && style.muted,
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{expectedTimeUntilGroupEnd}
</span>
</div> </div>
</div> </div>
); );
} }
function FlagTimes() { function FlagTimes() {
const { clock } = useClock(); const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null; const entry = useEntry(id) as OntimeEvent | null;
const plannedFlagStart = entry ? entry.timeStart - clock : null; const active = isPlaybackActive(playback);
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
const plannedFlagStart = (() => {
if (!active) return null;
if (!entry) return null;
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? normalizedTimeStart - normalizedClock
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
})();
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null; const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown); const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
const title = entry?.title ?? null; const title = entry?.title ?? null;
return ( return (
<div className={style.metadataRow}> <div className={style.metadataRow}>
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span> <span className={title ? style.labelTitle : style.label}>{`${title || 'Flag'} `}</span>
<div className={style.labelledElement}> <div className={style.labelledElement}>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} /> <Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}> <span
data-testid='flag-plannedStart'
className={cx([
style.time,
(!entry || !active) && style.muted,
plannedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{plannedTimeUntilDisplay} {plannedTimeUntilDisplay}
</span> </span>
</div> </div>
<div className={style.labelledElement}> <div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} /> <Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}> <span
data-testid='flag-expectedStart'
className={cx([
style.time,
expectedTimeUntil === null && style.muted,
expectedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{expectedTimeUntilDisplay} {expectedTimeUntilDisplay}
</span> </span>
</div> </div>
@@ -159,7 +211,7 @@ function FlagTimes() {
} }
export function ProgressOverview() { export function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview(); const { numEvents, selectedEventIndex } = useProgressOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash; const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
@@ -168,7 +220,7 @@ export function ProgressOverview() {
} }
export function OffsetOverview() { export function OffsetOverview() {
const { offset, playback } = useRuntimePlaybackOverview(); const { offset, playback } = useOffsetOverview();
const isPlaying = isPlaybackActive(playback); const isPlaying = isPlaybackActive(playback);
const offsetState = getOffsetState(isPlaying ? offset : null); const offsetState = getOffsetState(isPlaying ? offset : null);
@@ -179,8 +231,9 @@ export function OffsetOverview() {
export function ClockOverview({ className }: { className?: string }) { export function ClockOverview({ className }: { className?: string }) {
const { clock } = useClock(); 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 }) { export function TimerOverview({ className }: { className?: string }) {
@@ -3,6 +3,23 @@ import { dayInMs, millisToString } from 'ontime-utils';
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils'; import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
/**
* Composition to stop negative timers from being formatted
* They should show a due string instead
*
* This is used for cases when a negative timer is unwanted
* eg: count down to a milestone
*/
export function formatDueTime(
time: MaybeNumber,
segments: number = 3,
direction?: TimerType.CountDown | TimerType.CountUp,
dueString = 'due',
): string {
if (time !== null && time <= 0) return dueString;
return formattedTime(time, segments, direction);
}
/** /**
* Encapsulates the logic for formatting time in overview * Encapsulates the logic for formatting time in overview
*/ */
+7 -2
View File
@@ -82,7 +82,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = 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 // DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
@@ -312,7 +317,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
setMetadata(rundownMetadata); setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]); }, [order, entries, rundownMetadata]);
// in run mode, we follow selection // in run mode, we follow the playback selection and open groups as needed
useEffect(() => { useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) { if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return; return;
@@ -28,20 +28,22 @@
padding-inline: 0; padding-inline: 0;
box-shadow: $box-shadow-right; 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; min-width: 38rem;
max-width: 60rem; max-width: none;
width: 0;
} }
.side { .side {
max-height: 100%; max-height: 100%;
max-width: 45rem;
margin: 0.5rem 0; margin: 0.5rem 0;
padding: 1rem; padding: 1rem;
padding-right: 0; padding-right: 0;
background-color: $gray-1325; background-color: $gray-1325;
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */ flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
max-width: 45rem; // width is locked to swatch picker elements
min-width: calc(15 * 2rem + 13 * 0.5rem);
width: 0;
} }
@@ -73,9 +73,7 @@ interface EventUntilProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
function EventUntil(props: EventUntilProps) { function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: EventUntilProps) {
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded }); const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND; const isDue = timeUntil < MILLIS_PER_SECOND;
@@ -41,9 +41,9 @@ export function canDrop(
order?: 'after' | 'before', order?: 'after' | 'before',
isTargetCollapsed?: boolean, isTargetCollapsed?: boolean,
): boolean { ): boolean {
// this would mean inserting a group inside another // inserting before would mean adding a group inside another
if (targetType === 'end-group') { if (targetType === 'end-group') {
return false; return order === 'after';
} }
// this means swapping places with another group // this means swapping places with another group
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: 'Operator' },
{ value: '', label: 'Companion' }, { value: '<<companion>>', label: 'Companion' },
...urlPresetData.map((preset) => ({ ...urlPresetData.map((preset) => ({
value: `preset-${preset.alias}`, value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`, label: `URL Preset: ${preset.alias}`,
+1 -1
View File
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
/NetworkError/i, /NetworkError/i,
/The operation couldn't be completed/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) { beforeSend(event) {
// Drop errors that happen during known data-unavailable states // Drop errors that happen during known data-unavailable states
const error = event.exception?.values?.[0]?.value; const error = event.exception?.values?.[0]?.value;
-1
View File
@@ -17,7 +17,6 @@ $header-font-size: clamp(24px, 2.5vw, 48px);
// General styling // General styling
$accent-color: $red-500; // --accent-color-override $accent-color: $red-500; // --accent-color-override
$delay-color: $ontime-delay-text;
$viewer-label-color: rgba(white, 25%); $viewer-label-color: rgba(white, 25%);
// Main Properties of a viewer // Main Properties of a viewer
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langDe: TranslationObject = { export const langDe: TranslationObject = {
'common.expected_finish': 'Erwartetes Ende', 'common.expected_finish': 'Erwartetes Ende',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sek',
'common.now': 'Jetzt', 'common.now': 'Jetzt',
'common.next': 'Nächste', 'common.next': 'Nächste',
'common.scheduled_start': 'Geplanter beginn', 'common.scheduled_start': 'Geplanter beginn',
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten', 'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um', 'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft', 'countdown.running': 'Veranstaltung läuft',
'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen', 'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn', 'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langEs: TranslationObject = { export const langEs: TranslationObject = {
'common.expected_finish': 'Finalización esperada', 'common.expected_finish': 'Finalización esperada',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Ahora', 'common.now': 'Ahora',
'common.next': 'Siguiente', 'common.next': 'Siguiente',
'common.scheduled_start': 'Inicio programado', 'common.scheduled_start': 'Inicio programado',
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos', 'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las', 'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso', 'countdown.running': 'Evento en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir', 'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento', 'countdown.waiting': 'Esperando el inicio del evento',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langFr: TranslationObject = { export const langFr: TranslationObject = {
'common.expected_finish': 'Fin estimée à', 'common.expected_finish': 'Fin estimée à',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Maintenant', 'common.now': 'Maintenant',
'common.next': 'A suivre', 'common.next': 'A suivre',
'common.scheduled_start': 'Début prévu', 'common.scheduled_start': 'Début prévu',
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée', 'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à', 'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours', 'countdown.running': 'Évènement en cours',
'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre', 'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement', 'countdown.waiting': 'En attente du début de l’évènement',
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langIt: TranslationObject = { export const langIt: TranslationObject = {
'common.expected_finish': 'Fine Prevista', 'common.expected_finish': 'Fine Prevista',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Adesso', 'common.now': 'Adesso',
'common.next': 'Prossimo', 'common.next': 'Prossimo',
'common.scheduled_start': 'Inizio programmato', 'common.scheduled_start': 'Inizio programmato',
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile', 'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle', 'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso', 'countdown.running': 'Evento in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire', 'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento", 'countdown.waiting': "In attesa dell'inizio dell'evento",
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
export const langPt: TranslationObject = { export const langPt: TranslationObject = {
'common.expected_finish': 'Término esperado', 'common.expected_finish': 'Término esperado',
'common.minutes': 'min', 'common.minutes': 'min',
'common.seconds': 'sec',
'common.now': 'Agora', 'common.now': 'Agora',
'common.next': 'Próximo', 'common.next': 'Próximo',
'common.scheduled_start': 'Início programado', 'common.scheduled_start': 'Início programado',
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados', 'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às', 'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento', 'countdown.running': 'Evento em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar', 'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar', 'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento', 'countdown.waiting': 'Aguardando o início do evento',
@@ -10,15 +10,19 @@ import {
makeProjectDataOptions, makeProjectDataOptions,
} from '../../common/components/view-params-editor/viewParams.utils'; } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { scheduleOptions } from '../common/schedule/schedule.options'; import { getScheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = ( export const getBackstageOptions = (
timeFormat: string, timeFormat: string,
customFields: CustomFields, customFields: CustomFields,
projectData: ProjectData, projectData: ProjectData,
): ViewOption[] => { ): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]); const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
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 [ return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] }, { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -32,11 +36,11 @@ export const getBackstageOptions = (
description: 'Select the data source for auxiliary text shown in now and next cards', description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option', type: 'option',
values: secondaryOptions, values: secondaryOptions,
defaultValue: '', defaultValue: 'none',
}, },
], ],
}, },
scheduleOptions, getScheduleOptions(customFieldOptions),
{ {
title: OptionTitle.ElementVisibility, title: OptionTitle.ElementVisibility,
collapsible: true, collapsible: true,
@@ -47,7 +51,7 @@ export const getBackstageOptions = (
description: 'Select a project data source to show in the view', description: 'Select a project data source to show in the view',
type: 'option', type: 'option',
values: projectDataOptions, values: projectDataOptions,
defaultValue: '', defaultValue: 'none',
}, },
], ],
}, },
@@ -1,14 +1,5 @@
import { import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutEffect, useRef, useState } from 'react';
createContext, import { EntryId, isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
PropsWithChildren,
RefObject,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown'; import { usePartialRundown } from '../../../common/hooks-query/useRundown';
@@ -25,13 +16,18 @@ interface ScheduleContextState {
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined); const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps { interface ScheduleProviderProps {
selectedEventId: string | null; selectedEventId: EntryId | null;
} }
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => { export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions(); const { cycleInterval, stopCycle, filter } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeEntry) => { const { data: events } = usePartialRundown((entry: OntimeEntry) => {
return isOntimeEvent(event); if (filter) {
// custom keys are prepended with custom-
const customKey = filter.startsWith('custom-') ? filter.slice('custom-'.length) : filter;
return isOntimeEvent(entry) && Boolean(entry.custom[customKey]);
}
return isOntimeEvent(entry);
}); });
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
@@ -135,7 +131,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
selectedEventIndex = 0; selectedEventIndex = 0;
return ( return (
<ScheduleContext.Provider <ScheduleContext
value={{ value={{
events: viewEvents as OntimeEvent[], events: viewEvents as OntimeEvent[],
selectedEventId, selectedEventId,
@@ -145,12 +141,12 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
}} }}
> >
{children} {children}
</ScheduleContext.Provider> </ScheduleContext>
); );
}; };
export const useSchedule = () => { export const useSchedule = () => {
const context = useContext(ScheduleContext); const context = use(ScheduleContext);
if (!context) { if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext'); throw new Error('useSchedule() can only be used inside a ScheduleContext');
} }
@@ -22,8 +22,7 @@ interface ScheduleItemProps {
delay: number; delay: number;
} }
export default function ScheduleItem(props: ScheduleItemProps) { export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
const { showExpected } = useScheduleOptions(); const { showExpected } = useScheduleOptions();
if (showExpected) { if (showExpected) {
@@ -67,9 +66,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
); );
} }
function DelayedScheduleItem(props: ScheduleItemProps) { function DelayedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
const start = formatTime(timeStart, formatOptions); const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions); const end = formatTime(timeEnd, formatOptions);
const delayedStart = formatTime(timeStart + delay, formatOptions); const delayedStart = formatTime(timeStart + delay, formatOptions);
@@ -95,9 +92,7 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
); );
} }
function ExpectedScheduleItem(props: ScheduleItemProps) { function ExpectedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
return ( return (
<li className={cx(['entry', skip && 'entry--skip'])}> <li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'> <div className='entry-times'>
@@ -116,12 +111,11 @@ interface ExpectedTimeProps {
delay: number; delay: number;
} }
function ExpectedTime(props: ExpectedTimeProps) { function ExpectedTime({ time, delay }: ExpectedTimeProps) {
const { time, delay } = props;
const { offset } = useRuntimeOffset(); const { offset } = useRuntimeOffset();
const expectedOffset = offset - delay; const expectedOffset = offset - delay;
const expectedTime = formatTime(time - offset, formatOptions); const expectedTime = formatTime(time + offset, formatOptions);
const expectedState = getOffsetState(expectedOffset); const expectedState = getOffsetState(expectedOffset);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />; return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
@@ -1,14 +1,23 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { SelectOption } from '../../../common/components/select/Select';
import { OptionTitle } from '../../../common/components/view-params-editor/constants'; import { OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types'; import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
export const scheduleOptions: ViewOption = { export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOption => ({
title: OptionTitle.Schedule, title: OptionTitle.Schedule,
collapsible: true, collapsible: true,
options: [ options: [
{
id: 'filter',
title: 'Filter',
description: 'Hide events without data in the selected custom field',
type: 'option',
values: customFieldOptions,
defaultValue: 'None',
},
{ {
id: 'stopCycle', id: 'stopCycle',
title: 'Stop cycling through event pages', title: 'Stop cycling through event pages',
@@ -31,9 +40,10 @@ export const scheduleOptions: ViewOption = {
defaultValue: false, defaultValue: false,
}, },
], ],
}; });
type ScheduleOptions = { type ScheduleOptions = {
filter: string | null;
cycleInterval: number; cycleInterval: number;
stopCycle: boolean; stopCycle: boolean;
showExpected: boolean; showExpected: boolean;
@@ -41,6 +51,7 @@ type ScheduleOptions = {
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions { function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
return { return {
filter: searchParams.get('filter'),
cycleInterval: Number(searchParams.get('cycleInterval')) || 10, cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
stopCycle: isStringBoolean(searchParams.get('stopCycle')), stopCycle: isStringBoolean(searchParams.get('stopCycle')),
showExpected: isStringBoolean(searchParams.get('showExpected')), showExpected: isStringBoolean(searchParams.get('showExpected')),
+12 -4
View File
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
} }
.sub--live { .sub--live {
background-color: $green-700; background-color: $active-green;
}
.sub--armed {
background-color: $gray-1325;
} }
.sub__binder { .sub__binder {
@@ -137,15 +141,19 @@ $item-height: 3.5rem;
} }
.sub__schedule--delayed { .sub__schedule--delayed {
color: $delay-color; color: $ontime-delay-text;
}
.sub__schedule--strike {
text-decoration: line-through;
} }
.sub__schedule--over { .sub__schedule--over {
color: $playback-over color: $playback-over;
} }
.sub__schedule--under { .sub__schedule--under {
color: $playback-under color: $playback-under;
} }
.sub__title { .sub__title {
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useClock } from '../../common/hooks/useSocket'; import { useClock } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatTime, getDefaultFormat } from '../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
return <Countdown {...data} />; return <Countdown {...data} />;
} }
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) { function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { subscriptions } = useCountdownOptions(); const { subscriptions } = useCountdownOptions();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
// gather rundown data // gather rundown data
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event)); const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
// gather presentation data // gather presentation data
const hasEvents = playableEvents.length > 0; const hasEvents = playableEvents.length > 0;
@@ -85,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
} }
interface CountdownContentsProps { interface CountdownContentsProps {
playableEvents: OntimeEvent[]; playableEvents: ExtendedEntry<OntimeEvent>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -1,32 +1,37 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; 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 Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
useCountdownSocket, import useReport from '../../common/hooks-query/useReport';
useCurrentDay,
usePlayback,
useRuntimeOffset,
useSelectedEventId,
} from '../../common/hooks/useSocket';
import { getOffsetState } from '../../common/utils/offset'; import { getOffsetState } from '../../common/utils/offset';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import FollowButton from '../../features/operator/follow-button/FollowButton'; import FollowButton from '../../features/operator/follow-button/FollowButton';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime'; 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 { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options'; 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'; import './Countdown.scss';
interface CountdownSubscriptionsProps { interface CountdownSubscriptionsProps {
subscribedEvents: OntimeEvent[]; subscribedEvents: ExtendedEntry<OntimeEvent>[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const showFab = useFadeOutOnInactivity(true); const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
const timeoutId = useRef<NodeJS.Timeout | null>(null); const timeoutId = useRef<NodeJS.Timeout | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -45,6 +53,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, topOffset: 0,
followTrigger: selectedEventId,
}); });
// reset scroll if nothing is selected // reset scroll if nothing is selected
@@ -92,23 +101,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
{subscribedEvents.map((event) => { {subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource); const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback); 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 ( 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='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}> <ScheduleTime event={countdownEvent} showExpected={showExpected} />
{showExpected ? ( <SubscriptionStatus event={countdownEvent} />
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} /> <div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
) : (
<>
<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>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div> </div>
); );
@@ -123,63 +128,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
); );
} }
interface ExpectedScheduleProps { type ScheduleTimeProps = {
timeStart: number; event: CountdownEvent;
timeEnd: number; showExpected: boolean;
delay: number; };
} //TODO: consider relative mode
function ExpectedSchedule(props: ExpectedScheduleProps) { export function ScheduleTime(props: ScheduleTimeProps) {
const { timeStart, timeEnd, delay } = props; 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 // only show new exacted value if outside range of the planned value
const expectedOffset = offset - delay; const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
const expectedState = getOffsetState(expectedOffset);
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 ( return (
<> <div className='sub__schedule'>
<ClockTime <ClockTime
value={timeStart - expectedOffset} value={plannedStart}
className={`sub__schedule--${expectedState}`} preferredFormat12={preferredFormat12}
preferredFormat12='h:mm' preferredFormat24={preferredFormat24}
preferredFormat24='HH:mm' className={plannedStateClass}
/> />
{!isExpectedValueShow && (
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <>
</>
<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 { interface SubscriptionStatusProps {
event: OntimeEvent; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
selectedEventId: EntryId | null;
} }
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation(); const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
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,
);
return ( return (
<> <>
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div> <div className='sub__status'>{statusDisplay}</div>
<div className='sub__timer'>{timer}</div> {status === 'done' ? (
<SuperscriptTime className='sub__timer' time={timeDisplay} />
) : (
<div className='sub__timer'>{timeDisplay}</div>
)}
</> </>
); );
} }
@@ -1,11 +1,10 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
.single-container { .single-container {
height: 100%; margin-top: 7.5vh;
margin-top: 5vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $view-element-gap; gap: 5rem;
} }
.event__title { .event__title {
@@ -13,13 +12,25 @@
padding: $view-card-padding; padding: $view-card-padding;
border-radius: $element-border-radius; border-radius: $element-border-radius;
font-size: clamp(40px, 4.5vw, 80px); font-size: clamp(40px, 4.5vw, 80px);
line-height: 1.1em; line-height: 1.1;
text-align: center; 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 { .event__status {
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
font-size: clamp(2rem, 3.5vw, 3.5rem); font-size: $header-font-size;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -1,29 +1,57 @@
import { IoPencil } from 'react-icons/io5'; 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 Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; 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 { 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 { useCountdownOptions } from './countdown.options';
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils'; import { useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
interface SingleEventCountdownProps { interface SingleEventCountdownProps {
subscribedEvent: OntimeEvent; subscribedEvent: ExtendedEntry<OntimeEvent>;
goToEditMode: () => void; goToEditMode: () => void;
} }
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) { export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true); 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 ( return (
<div className='single-container' data-testid='countdown-event'> <div className='single-container' data-testid='countdown-event'>
<SubscriptionStatus event={subscribedEvent} /> <SubscriptionStatus event={countdownEvent} />
<div className='event__title'>{subscribedEvent.title}</div> <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'])}> <div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit <IoPencil /> Edit
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
} }
interface SubscriptionStatusProps { interface SubscriptionStatusProps {
event: OntimeEvent; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
} }
function SubscriptionStatus({ event }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { getLocalizedString } = useTranslation(); const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
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,
);
return ( return (
<> <>
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div> <div className='event__status'>{statusDisplay}</div>
<div className='event__timer'>{timer}</div> {status === 'done' ? (
<SuperscriptTime className='event__timer' time={timeDisplay} />
) : (
<div className='event__timer'>{timeDisplay}</div>
)}
</> </>
); );
} }
@@ -14,7 +14,10 @@ export const getCountdownOptions = (
customFields: CustomFields, customFields: CustomFields,
persistedSubscriptions: EntryId[], persistedSubscriptions: EntryId[],
): ViewOption[] => { ): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]); const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
]);
return [ return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] }, { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -28,7 +31,7 @@ export const getCountdownOptions = (
description: 'Select the data source for auxiliary text shown in the card', description: 'Select the data source for auxiliary text shown in the card',
type: 'option', type: 'option',
values: secondaryOptions, values: secondaryOptions,
defaultValue: '', defaultValue: 'none',
}, },
], ],
}, },
@@ -1,8 +1,11 @@
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
import { getFormattedTimer } from '../../features/viewers/common/viewUtils'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import type { TranslationKey } from '../../translation/TranslationProvider'; 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 * Parses string as a title
@@ -11,6 +14,9 @@ export function sanitiseTitle(title: string | null) {
return title ?? '{no title}'; return title ?? '{no title}';
} }
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/** /**
* Whether the current event is live * Whether the current event is live
*/ */
@@ -18,137 +24,96 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
return currentId === selectedId && playback !== Playback.Armed; return currentId === selectedId && playback !== Playback.Armed;
} }
const subscriptionTimerDisplayOptions = { export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
removeSeconds: true, type TimerMessage = Record<ProgressStatus, TranslationKey>;
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 const timerProgress: TimerMessage = { export const timerProgress: TimerMessage = {
future: 'countdown.to_start', future: 'countdown.to_start',
due: 'timeline.due', due: 'timeline.due',
live: 'timeline.live', live: 'countdown.running',
pending: 'countdown.waiting',
loaded: 'countdown.loaded',
done: 'countdown.ended', 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 * Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played * 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( export function useSubscriptionDisplayData(
current: MaybeNumber, subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
playback: Playback, ): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
clock: number, const { playback, current, clock } = useCountdownSocket();
subscribedEvent: OntimeEvent, const { getLocalizedString } = useTranslation();
selectedId: EntryId | null,
offset: number,
currentDay: number,
minutesString: string,
showExpected = false,
): { status: ProgressStatus; timer: string } {
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
if (selectedId === subscribedEvent.id) { const bigDuration = (value: number) => {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>} 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 (playback === Playback.Armed) {
// if we are following the event, but it is not running, we show the scheduled start
return { return {
status: 'due', status: 'loaded',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['loaded']),
subscribedEvent.timeStart + offsetAndDelay, timeDisplay: bigDuration(subscribedEvent.duration),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
return { return {
status: 'live', status: 'live',
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions), statusDisplay: getLocalizedString(timerProgress['live']),
timeDisplay: bigDuration(current ?? 0),
}; };
} }
/** if (playback === Playback.Stop || playback === Playback.Armed) {
* 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;
return { return {
status: 'future', status: 'pending',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['pending']),
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay, timeDisplay: ' ',
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
}; };
} }
// 4. event is the before after, show the scheduled end if (subscribedEvent.isPast) {
// TODO: get the time from the reporter
if (subscribedEvent.dayOffset < currentDay) {
return { return {
status: 'done', status: 'done',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['done']),
subscribedEvent.timeEnd, timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
// 5. if event is in future, we count to the scheduled start if (subscribedEvent.expectedStart - clock <= 0) {
// TODO: get time until
if (clock < subscribedEvent.timeStart) {
return { return {
status: 'future', status: 'due',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
subscribedEvent.timeStart - clock - offsetAndDelay, timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
}; };
} }
// 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 { return {
status: 'due', status: 'future',
timer: getFormattedTimer( statusDisplay: getLocalizedString(timerProgress['future']),
subscribedEvent.timeStart + offsetAndDelay, timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
TimerType.CountDown,
minutesString,
subscriptionScheduledTimeDisplayOptions,
),
}; };
} }
@@ -181,7 +146,7 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
* Since the original array is already ordered, we simply filter out the events * Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list. * which are not in the subscriptions list.
*/ */
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] { export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
return playableEvents.filter((event) => subscriptions.includes(event.id)); return playableEvents.filter((event) => subscriptions.includes(event.id));
} }
@@ -212,3 +177,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
return true; 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 useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; 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 useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils'; import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface CountdownData { export interface CountdownData {
customFields: CustomFields; customFields: CustomFields;
events: OntimeEntry[]; rundownData: ExtendedEntry<OntimeEntry>[];
projectData: ProjectData; projectData: ProjectData;
isMirrored: boolean; isMirrored: boolean;
settings: Settings; settings: Settings;
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
return { return {
data: { data: {
customFields, customFields,
events: rundownData, rundownData,
projectData, projectData,
isMirrored, isMirrored,
settings, settings,
@@ -11,14 +11,14 @@ import {
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import useColumnManager from '../cuesheet-table/useColumnManager'; import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[]; columns: ColumnDef<ExtendedEntry>[];
} }
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) { export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
const { columnOrder, saveColumnOrder } = useColumnManager(columns); const { columnOrder, saveColumnOrder } = useColumnOrder(columns);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -13,14 +13,14 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; 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 DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow'; import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow'; import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu'; import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
@@ -79,8 +79,9 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer], [cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
); );
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = const { columnOrder, resetColumnOrder } = useColumnOrder(columns);
useColumnManager(columns); const { columnSizing, setColumnSizing } = useColumnSizes();
const { columnVisibility, setColumnVisibility } = useColumnVisibility();
const table = useReactTable({ const table = useReactTable({
data, 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 // eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index']; const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex]; const row = rows[rowIndex];
@@ -183,13 +184,16 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id} rowId={row.id}
rowIndex={row.index} rowIndex={row.index}
table={table} table={table}
injectedStyles={injectedStyles}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
} }
if (isOntimeDelay(entry)) { if (isOntimeDelay(entry)) {
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />; return (
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
);
} }
if (isOntimeMilestone(entry)) { if (isOntimeMilestone(entry)) {
@@ -204,6 +208,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id} rowId={row.id}
rowIndex={rowIndex} rowIndex={rowIndex}
table={table} table={table}
injectedStyles={injectedStyles}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
@@ -225,6 +230,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
rowId={row.id} rowId={row.id}
rowIndex={rowIndex} rowIndex={rowIndex}
table={table} table={table}
injectedStyles={injectedStyles}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
@@ -232,11 +238,14 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />, TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
}} }}
fixedHeaderContent={() => { fixedHeaderContent={() => {
return table return table.getHeaderGroups().map((headerGroup) => {
.getHeaderGroups() const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
.map((headerGroup) => ( ? CuesheetHeader
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} /> : 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 { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell'; import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
@@ -16,8 +16,9 @@ interface CuesheetHeaderProps {
cuesheetMode: AppMode; cuesheetMode: AppMode;
} }
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) { export function SortableCuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return ( return (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />} {cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
@@ -43,8 +44,10 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
return ( return (
<SortableCell <SortableCell
key={header.column.columnDef.id} key={header.column.columnDef.id}
header={header} columnId={header.column.id}
colSpan={header.colSpan}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell> </SortableCell>
@@ -54,3 +57,43 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
</tr> </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 { millisToDelayString } from '../../../../common/utils/dateConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { usePersistedCuesheetOptions } from '../../cuesheet.options';
@@ -7,9 +7,10 @@ import style from './DelayRow.module.scss';
interface DelayRowProps { interface DelayRowProps {
duration: number; duration: number;
injectedStyles?: CSSProperties;
} }
function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) { function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) {
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays); const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
if (hideDelays || duration === 0) { if (hideDelays || duration === 0) {
@@ -19,7 +20,7 @@ function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
const delayTime = millisToDelayString(duration, 'expanded'); const delayTime = millisToDelayString(duration, 'expanded');
return ( 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> <td tabIndex={0}>{delayTime}</td>
</tr> </tr>
); );
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { CSSProperties, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types'; import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
@@ -26,6 +26,7 @@ interface EventRowProps {
parent: EntryId | null; parent: EntryId | null;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>; table: Table<ExtendedEntry<OntimeEntry>>;
injectedStyles?: CSSProperties;
} }
export default function EventRow({ export default function EventRow({
@@ -42,6 +43,7 @@ export default function EventRow({
parent, parent,
rowIndex, rowIndex,
table, table,
injectedStyles,
...virtuosoProps ...virtuosoProps
}: EventRowProps) { }: EventRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -81,6 +83,7 @@ export default function EventRow({
parent && style.hasParent, parent && style.hasParent,
])} ])}
style={{ style={{
...injectedStyles,
opacity: `${isPast ? '0.2' : '1'}`, opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': groupColour ?? 'transparent', '--user-bg': groupColour ?? 'transparent',
}} }}
@@ -4,3 +4,7 @@
padding-top: 0.25em; padding-top: 0.25em;
width: 100%; width: 100%;
} }
.multiline {
white-space: break-spaces;
}
@@ -2,6 +2,10 @@ import { PropsWithChildren } from 'react';
import style from './GhostedText.module.scss'; import style from './GhostedText.module.scss';
export default function GhostedText({ children }: PropsWithChildren) { interface GhostedTextProps {
return <div className={style.ghostedText}>{children}</div>; 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 { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
@@ -15,9 +16,18 @@ interface GroupRowProps {
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; 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 ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
cuesheetMode: AppMode.Edit, cuesheetMode: AppMode.Edit,
hideIndexColumn: false, hideIndexColumn: false,
@@ -26,7 +36,12 @@ export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...v
const openMenu = useCuesheetTableMenu((store) => store.openMenu); const openMenu = useCuesheetTableMenu((store) => store.openMenu);
return ( 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 && ( {cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'> <td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton <IconButton
@@ -1,3 +1,4 @@
import { CSSProperties } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
@@ -20,6 +21,7 @@ interface MilestoneRowProps {
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
} }
export default function MilestoneRow({ export default function MilestoneRow({
@@ -31,6 +33,7 @@ export default function MilestoneRow({
rowId, rowId,
rowIndex, rowIndex,
table, table,
injectedStyles,
...virtuosoProps ...virtuosoProps
}: MilestoneRowProps) { }: MilestoneRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -56,6 +59,7 @@ export default function MilestoneRow({
<tr <tr
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])} className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
style={{ style={{
...injectedStyles,
opacity: `${isPast ? '0.2' : '1'}`, opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent', '--user-bg': parentBgColour ?? 'transparent',
}} }}
@@ -8,16 +8,16 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface SortableCellProps { interface SortableCellProps {
header: Header<ExtendedEntry, unknown>; columnId: string;
colSpan: number;
injectedStyles: CSSProperties; injectedStyles: CSSProperties;
children: ReactNode; children: ReactNode;
draggable: ReactNode;
} }
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) { export function SortableCell({ columnId, colSpan, injectedStyles, children, draggable }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id, id: columnId,
}); });
// build drag styles // build drag styles
@@ -34,12 +34,31 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
<div {...attributes} {...listeners}> <div {...attributes} {...listeners}>
{children} {children}
</div> </div>
<div {draggable}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
</th> </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; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>; return <GhostedText multiline>{initialValue}</GhostedText>;
} }
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks'; 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 { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage'; import { makeStageKey } from '../../../common/utils/localStorage';
@@ -14,17 +14,8 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
localStorage.setItem(tableSizesKey, JSON.stringify(sizes)); localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
}, 500); }, 500);
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) { export function useColumnSizes() {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ const [columnSizing, setColumnSizingState] = useState<Record<string, number>>(() => {
key: tableHiddenKey,
defaultValue: {},
});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: tableOrderKey,
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizingState] = useState(() => {
try { try {
const stored = localStorage.getItem(tableSizesKey); const stored = localStorage.getItem(tableSizesKey);
return stored ? JSON.parse(stored) : {}; return stored ? JSON.parse(stored) : {};
@@ -38,10 +29,22 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
saveSizesToStorage(columnSizing); saveSizesToStorage(columnSizing);
}, [columnSizing]); }, [columnSizing]);
const setColumnSizing = useCallback((sizes: typeof columnSizing) => { const setColumnSizing = useCallback((sizesOrUpdater: Updater<ColumnSizingState>) => {
setColumnSizingState(sizes); 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 // update column order if columns change
useEffect(() => { useEffect(() => {
const newColumns = columns.map((col) => col.id as string); const newColumns = columns.map((col) => col.id as string);
@@ -55,12 +58,20 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
}, [columns, saveColumnOrder]); }, [columns, saveColumnOrder]);
return { return {
columnVisibility,
columnOrder, columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder, saveColumnOrder,
setColumnSizing, resetColumnOrder,
};
}
export function useColumnVisibility() {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
});
return {
columnVisibility,
setColumnVisibility,
}; };
} }
@@ -66,4 +66,5 @@
.scrollContainer { .scrollContainer {
max-height: 70vh; max-height: 70vh;
overflow: auto; overflow: auto;
padding-top: 1rem;
} }
@@ -71,8 +71,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
results.map((entry, index) => { results.map((entry, index) => {
const isSelected = selected === index; const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : ''; const displayCue = 'cue' in entry ? entry.cue : '';
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
return ( return (
<li <li
@@ -83,7 +82,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
onClick={submit} onClick={submit}
> >
<div className={style.data}> <div className={style.data}>
<div className={style.index} style={{ '--color': colour }}> <div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex} {displayIndex}
</div> </div>
<div className={style.cue}>{displayCue}</div> <div className={style.cue}>{displayCue}</div>
@@ -99,7 +98,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
footerElements={ footerElements={
<div className={style.footer}> <div className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or 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> </div>
} }
/> />
@@ -1,6 +1,6 @@
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSessionStorage } from '@mantine/hooks'; 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 { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -9,14 +9,15 @@ const maxResults = 12;
type FilterableGroup = { type FilterableGroup = {
type: SupportedEntry.Group; type: SupportedEntry.Group;
id: string; id: EntryId;
index: number; index: number;
title: string; title: string;
colour: string;
}; };
type FilterableEvent = { type FilterableEvent = {
type: SupportedEntry.Event; type: SupportedEntry.Event;
id: string; id: EntryId;
index: number; index: number;
eventIndex: number; eventIndex: number;
title: string; title: string;
@@ -25,7 +26,17 @@ type FilterableEvent = {
parent: MaybeString; 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() { export default function useFinder() {
const { data, rundownId } = useFlatRundown(); const { data, rundownId } = useFlatRundown();
@@ -59,7 +70,7 @@ export default function useFinder() {
lastSearchString.current = searchValue; lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) { if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim(); const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString); const { results, error } = searchByIndex(searchString);
setResults(results); setResults(results);
setError(error); setError(error);
@@ -67,14 +78,14 @@ export default function useFinder() {
} }
if (searchValue.startsWith('cue ')) { if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim(); const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString); const { results, error } = searchByCue(searchString);
setResults(results); setResults(results);
setError(error); setError(error);
return; return;
} }
const searchString = searchValue.replace('title ', '').trim(); const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString); const { results, error } = searchByTitle(searchString);
setResults(results); setResults(results);
setError(error); setError(error);
@@ -162,33 +173,46 @@ export default function useFinder() {
break; break;
} }
const event = data[i]; const entry = data[i];
if (isOntimeEvent(event)) { if (isOntimeEvent(entry)) {
if (event.title.toLowerCase().includes(searchString)) { if (entry.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEntry.Event, type: SupportedEntry.Event,
id: event.id, id: entry.id,
index: i, index: i,
eventIndex, eventIndex,
title: event.title, title: entry.title,
cue: event.cue, cue: entry.cue,
colour: event.colour, colour: entry.colour,
parent: event.parent, parent: entry.parent,
} satisfies FilterableEvent); } satisfies FilterableEvent);
} }
eventIndex++; eventIndex++;
} } else if (isOntimeGroup(entry)) {
if (isOntimeGroup(event)) { if (entry.title.toLowerCase().includes(searchString)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEntry.Group, type: SupportedEntry.Group,
id: event.id, id: entry.id,
index: i, index: i,
title: event.title, title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup); } 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 }; return { results, error: null };
@@ -200,7 +224,7 @@ export default function useFinder() {
const select = useCallback( const select = useCallback(
(selectedEvent: FilterableEntry) => { (selectedEvent: FilterableEntry) => {
// First expand the parent group if this is an event inside a group // 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 // Try direct state update instead of using callback
const currentGroups = [...new Set(collapsedGroups)]; const currentGroups = [...new Set(collapsedGroups)];
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent); const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
@@ -105,7 +105,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
} }
.delay { .delay {
color: $delay-color; color: $ontime-delay-text;
} }
.timeOverview { .timeOverview {
@@ -117,7 +117,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.cross { .cross {
text-decoration: line-through; text-decoration: line-through;
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
text-decoration-color: $delay-color; text-decoration-color: $ontime-delay-text;
} }
.separeLeft { .separeLeft {
@@ -183,7 +183,7 @@ export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeStri
* Utility function calculates time to start * Utility function calculates time to start
*/ */
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number { export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
return start + delay - now - offset; return start + delay - now + offset;
} }
interface TimelineLayout { interface TimelineLayout {
+3 -3
View File
@@ -68,7 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
hidePhase, hidePhase,
font, font,
keyColour, keyColour,
textColour, timerColour,
} = useTimerOptions(); } = useTimerOptions();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
@@ -131,11 +131,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
); );
// gather presentation styles // gather presentation styles
const timerColour = getTimerColour(viewSettings, textColour, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent); const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
const userStyles = { const userStyles = {
...(keyColour && { '--timer-bg': keyColour }), ...(keyColour && { '--timer-bg': keyColour }),
...(textColour && { '--timer-colour': timerColour }), ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
...(font && { '--timer-font': font }), ...(font && { '--timer-font': font }),
}; };
+9 -7
View File
@@ -25,10 +25,12 @@ const timerDisplayOptions: SelectOption[] = [
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => { export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [ const mainOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' }, { value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' }, { value: 'note', label: 'Note' },
]); ]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [ const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' }, { value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' }, { value: 'note', label: 'Note' },
]); ]);
@@ -84,7 +86,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
description: 'Select the data source for the main text', description: 'Select the data source for the main text',
type: 'option', type: 'option',
values: mainOptions, values: mainOptions,
defaultValue: 'Title', defaultValue: 'title',
}, },
{ {
id: 'secondary-src', id: 'secondary-src',
@@ -92,7 +94,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
description: 'Select the data source for the secondary text', description: 'Select the data source for the secondary text',
type: 'option', type: 'option',
values: secondaryOptions, values: secondaryOptions,
defaultValue: '', defaultValue: 'none',
}, },
], ],
}, },
@@ -163,9 +165,9 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
defaultValue: '101010', defaultValue: '101010',
}, },
{ {
id: 'textColour', id: 'timerColour',
title: 'Text Colour', title: 'Timer Colour',
description: 'Text colour. Default: #f6f6f6', description: 'Timer colour. Default: #f6f6f6',
type: 'colour', type: 'colour',
defaultValue: 'f6f6f6', defaultValue: 'f6f6f6',
}, },
@@ -191,7 +193,7 @@ type TimerOptions = {
hidePhase: boolean; hidePhase: boolean;
font?: string; font?: string;
keyColour?: string; keyColour?: string;
textColour?: string; timerColour?: string;
}; };
/** /**
@@ -226,7 +228,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
font: getValue('font') ?? undefined, font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')), 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 4: 28, // 9:01
5: 28, // -9:01, 10:01, 9 min 5: 28, // -9:01, 10:01, 9 min
6: 25, // -10:01, 10 min 6: 25, // -10:01, 10 min
8: 20, // 23:01:01 8: 18, // 23:01:01
9: 20, // -23:01:01 9: 18, // -23:01:01
}; };
/** /**
+2 -1
View File
@@ -32,7 +32,8 @@
"preserveConstEnums": true, "preserveConstEnums": true,
"allowJs": true, "allowJs": true,
"baseUrl": "src", "baseUrl": "src",
"jsx": "react-jsx" "jsx": "react-jsx",
"outDir": "build"
}, },
"include": [ "include": [
"src" "src"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.0.0-alpha.6", "version": "4.0.0-beta.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1
View File
@@ -0,0 +1 @@
*.tgz
+17
View File
@@ -0,0 +1,17 @@
# Ontime Resolver
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
The Resolver is an attempt to expose our ontime's api so it is easier to integrate with
## Links
- [Ontime's repository](https://github.com/cpvalente/ontime)
- [Ontime's documentation](https://docs.getontime.no/)
- [Ontime's website](https://getontime.no/)
## Sponsoring
You can help the development of this project or say thank you with a one time donation. \
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@getontime/resolver",
"version": "4.0.0-beta.3",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
"main": "./dist/main.js",
"private": true,
"description": "shared typings for ontime",
"scripts": {
"lint": "eslint . --quiet",
"prebuild": "pnpm rimraf ./dist",
"build": "tsup && pnpm rimraf ./dist/index.js",
"postbuild": "pnpm rimraf ./dist/index.d.ts"
},
"keywords": ["ontime", "resolver", "parser"],
"author": "",
"license": "AGPL-3.0-only",
"devDependencies": {
"@sprout2000/esbuild-copy-plugin": "^1.1.19",
"@typescript-eslint/parser": "catalog:",
"eslint": "catalog:",
"tsup": "^8.5.0",
"rimraf": "catalog:",
"typescript": "catalog:",
"ontime-types": "workspace:^4.0.0"
},
"files": ["dist"]
}
+20
View File
@@ -0,0 +1,20 @@
// api
export { MessageTag, RefetchKey } from 'ontime-types';
export type { ApiAction, ApiActionTag, ApiResponse } from 'ontime-types';
export type { WsPacketToClient, WsPacketToServer } from 'ontime-types';
// stores
export type { RuntimeStore, TimerState, MessageState, RundownState, Offset } from 'ontime-types';
export { TimerPhase, Playback, runtimeStorePlaceholder, OffsetMode } from 'ontime-types';
// aux timer
export type { SimpleTimerState } from 'ontime-types';
export { SimplePlayback, SimpleDirection } from 'ontime-types';
// entries
export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types';
export { SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeDelay, isOntimeMilestone } from 'ontime-types';
// functions
export { isWsPacketToClient } from './websocket.js';
export type { SocketSender } from './websocket.js';
+18
View File
@@ -0,0 +1,18 @@
import { ApiAction, ApiActionTag, MessageTag, WsPacketToClient, WsPacketToServer } from 'ontime-types';
/**
* A helper type for sending correct websocket messages to ontime
*/
export type SocketSender = <T extends MessageTag | ApiActionTag>(
tag: T,
payload: T extends MessageTag
? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload']
: Pick<ApiAction & { tag: T }, 'payload'>['payload'],
) => void;
/**
* a soft type guard for WS packets
*/
export function isWsPacketToClient(data: unknown): data is WsPacketToClient {
return typeof data === 'object' && data !== null && 'tag' in data && 'payload' in data;
}
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noImplicitThis": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"removeComments": true,
"preserveConstEnums": true,
"allowJs": true,
"declaration": true,
"outDir": "dist",
"sourceMap": true,
"baseUrl": "src",
},
"include": [
"src",
],
"exclude": [
"node_modules",
]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
import copyPlugin from '@sprout2000/esbuild-copy-plugin';
export default defineConfig({
clean: false, //we can't use clean as i dose so after the copy plugin runs
entry: ['src/main.ts'],
outDir: 'dist',
bundle: true,
dts: { resolve: true },
format: 'esm',
target: 'esnext',
platform: 'node',
esbuildPlugins: [
copyPlugin.copyPlugin({
src: './node_modules/ontime-types/dist',
dest: './dist',
}),
],
});
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.0.0-alpha.6", "version": "4.0.0-beta.3",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -41,7 +41,7 @@
"prettier": "catalog:", "prettier": "catalog:",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"shx": "^0.3.4", "shx": "^0.3.4",
"ts-essentials": "^10.0.3", "ts-essentials": "catalog:",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
@@ -10,7 +10,7 @@ export const dataFromExcelTemplate = [
'Timer type', 'Timer type',
'Count to end', 'Count to end',
'Skip', 'Skip',
'Notes', 'Note',
't0', 't0',
'Test1', 'Test1',
'test2', 'test2',
@@ -27,7 +27,7 @@ describe('generateAuthenticatedUrl()', () => {
authenticate: true, authenticate: true,
hash: '1234', 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', () => { 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'); 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', () => { describe('for ontime-cloud URLs', () => {
@@ -100,7 +111,7 @@ describe('generateAuthenticatedUrl()', () => {
prefix: 'prefix', prefix: 'prefix',
hash: '1234', 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', () => { it('generates a link to an unlocked preset', () => {
@@ -64,18 +64,21 @@ export function generateShareUrl(
): URL { ): URL {
const url = new URL(baseUrl); const url = new URL(baseUrl);
// if the config is locked and we are in a preset, we hide the canonical path // companion links point to the root
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig); if (canonicalPath !== '<<companion>>') {
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath; // if the config is locked and we are in a preset, we hide the canonical path
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath; 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) { if (authenticate && hash) {
url.searchParams.append('token', hash); url.searchParams.append('token', hash);
} }
if (lockNav) {
url.searchParams.append('n', '1');
}
return url; return url;
} }
@@ -9,8 +9,6 @@ export const validateGenerateUrl = [
body('lockConfig').isBoolean(), body('lockConfig').isBoolean(),
body('lockNav').isBoolean(), body('lockNav').isBoolean(),
body('preset').optional().isString().trim().notEmpty(), body('preset').optional().isString().trim().notEmpty(),
body('prefix').optional().isString().trim().notEmpty(),
body('hash').optional().isString().trim().notEmpty(),
requestValidationFunction, requestValidationFunction,
]; ];
@@ -1,4 +1,4 @@
import { body, checkExact } from 'express-validator'; import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
/** /**
@@ -8,11 +8,10 @@ export const validateWelcomeDialog = [body('show').isBoolean(), requestValidatio
const pinValidator = (key: string) => { const pinValidator = (key: string) => {
return body(key) return body(key)
.isString() .optional()
.trim()
.isLength({ min: 0, max: 4 }) .isLength({ min: 0, max: 4 })
.customSanitizer((input) => { .customSanitizer((input) => {
if (input.length === 0) { if (input === null || input.length === 0) {
return null; return null;
} }
return input; 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('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(), body('language').isString().trim().notEmpty(),
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(), body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
checkExact(),
requestValidationFunction, requestValidationFunction,
]; ];
// TODO: dont allow other keys
@@ -1,5 +1,5 @@
import { import {
ApiAction, ApiActionTag,
MessageState, MessageState,
OffsetMode, OffsetMode,
OntimeEvent, OntimeEvent,
@@ -30,7 +30,7 @@ let lastRequest: Date | null = null;
export function dispatchFromAdapter(tag: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { export function dispatchFromAdapter(tag: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
const action = tag.toLowerCase(); const action = tag.toLowerCase();
const handler = actionHandlers[action as ApiAction]; const handler = actionHandlers[action as ApiActionTag];
lastRequest = new Date(); lastRequest = new Date();
if (handler) { if (handler) {
@@ -46,7 +46,7 @@ export function getLastRequest() {
type ActionHandler = (payload: unknown) => { payload: unknown }; type ActionHandler = (payload: unknown) => { payload: unknown };
const actionHandlers: Record<ApiAction, ActionHandler> = { const actionHandlers: Record<ApiActionTag, ActionHandler> = {
/* General */ /* General */
version: () => ({ payload: ONTIME_VERSION }), version: () => ({ payload: ONTIME_VERSION }),
poll: () => ({ poll: () => ({
+2 -1
View File
@@ -34,7 +34,8 @@ import { populateTranslation } from './setup/loadTranslations.js';
import { populateStyles } from './setup/loadStyles.js'; import { populateStyles } from './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js'; import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/runtime.service.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 * as messageService from './services/message-service/message.service.js';
import { getState } from './stores/runtimeState.js'; import { getState } from './stores/runtimeState.js';
import { initialiseProject } from './services/project-service/ProjectService.js'; import { initialiseProject } from './services/project-service/ProjectService.js';
+1 -1
View File
@@ -337,7 +337,7 @@ export const demoDb: DatabaseModel = {
alias: 'minimal', alias: 'minimal',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 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: { 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);
});
});
});
@@ -1,7 +1,8 @@
import { dayInMs, millisToString } from 'ontime-utils'; import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { import {
findDayOffset,
getCurrent, getCurrent,
getExpectedFinish, getExpectedFinish,
getRuntimeOffset, getRuntimeOffset,
@@ -726,6 +727,7 @@ describe('getRuntimeOffset()', () => {
eventNow: { eventNow: {
id: '1', id: '1',
timeStart: 100, timeStart: 100,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 150, startedAt: 150,
@@ -738,7 +740,10 @@ describe('getRuntimeOffset()', () => {
rundown: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
currentDay: 0,
}, },
clock: 150,
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
@@ -750,6 +755,7 @@ describe('getRuntimeOffset()', () => {
eventNow: { eventNow: {
id: '1', id: '1',
timeStart: 100, timeStart: 100,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 150, // we started 50ms delayed startedAt: 150, // we started 50ms delayed
@@ -762,7 +768,9 @@ describe('getRuntimeOffset()', () => {
rundown: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
@@ -775,6 +783,7 @@ describe('getRuntimeOffset()', () => {
id: '1', id: '1',
timeStart: 100, timeStart: 100,
timeEnd: 140, timeEnd: 140,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 100, // we started ontime startedAt: 100, // we started ontime
@@ -787,7 +796,9 @@ describe('getRuntimeOffset()', () => {
rundown: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 100, plannedStart: 100,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
@@ -800,6 +811,7 @@ describe('getRuntimeOffset()', () => {
id: '1', id: '1',
timeStart: 100, timeStart: 100,
timeEnd: 150, timeEnd: 150,
dayOffset: 0,
}, },
clock: 150, clock: 150,
timer: { timer: {
@@ -813,7 +825,9 @@ describe('getRuntimeOffset()', () => {
rundown: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 100, plannedStart: 100,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
@@ -830,6 +844,7 @@ describe('getRuntimeOffset()', () => {
duration: 3600000, duration: 3600000,
timeStrategy: 'lock-duration', timeStrategy: 'lock-duration',
linkStart: false, linkStart: false,
dayOffset: 0,
}, },
rundown: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
@@ -837,6 +852,7 @@ describe('getRuntimeOffset()', () => {
plannedStart: 77400000, plannedStart: 77400000,
plannedEnd: 84600000, plannedEnd: 84600000,
actualStart: null, actualStart: null,
currentDay: 0,
}, },
offset: { offset: {
absolute: -77400000, absolute: -77400000,
@@ -852,6 +868,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: null, startedAt: null,
}, },
_startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
@@ -874,6 +891,7 @@ describe('getRuntimeOffset()', () => {
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
countToEnd: true, countToEnd: true,
dayOffset: 0,
skip: false, skip: false,
note: '', note: '',
colour: '', colour: '',
@@ -890,6 +908,7 @@ describe('getRuntimeOffset()', () => {
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00 actualStart: 78000000, // 21:40:00
currentDay: 0,
}, },
offset: { offset: {
absolute: 0, absolute: 0,
@@ -905,6 +924,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: 78000000, startedAt: 78000000,
}, },
_startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
@@ -922,6 +942,7 @@ describe('getRuntimeOffset()', () => {
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00 duration: 3600000, // 01:00:00
dayOffset: 0,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: false, linkStart: false,
endAction: EndAction.None, endAction: EndAction.None,
@@ -943,6 +964,7 @@ describe('getRuntimeOffset()', () => {
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00 actualStart: 78000000, // 21:40:00
currentDay: 0,
}, },
offset: { offset: {
absolute: 0, absolute: 0,
@@ -958,6 +980,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: 78000000, startedAt: 78000000,
}, },
_startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
@@ -978,6 +1001,7 @@ describe('getRuntimeOffset()', () => {
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
countToEnd: true, countToEnd: true,
dayOffset: 0,
}, },
rundown: { rundown: {
selectedEventIndex: 0, selectedEventIndex: 0,
@@ -985,6 +1009,7 @@ describe('getRuntimeOffset()', () => {
plannedStart: 77400000, // 21:30:00 plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00 plannedEnd: 81000000, // 22:30:00
actualStart: 82000000, // 22:46:40 <--- started now actualStart: 82000000, // 22:46:40 <--- started now
currentDay: 0,
}, },
offset: { offset: {
absolute: 0, absolute: 0,
@@ -1000,6 +1025,7 @@ describe('getRuntimeOffset()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: 82000000, // <--- started now startedAt: 82000000, // <--- started now
}, },
_startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as RuntimeState; } as RuntimeState;
@@ -1017,6 +1043,7 @@ describe('getRuntimeOffset() relative', () => {
eventNow: { eventNow: {
id: '1', id: '1',
timeStart: 150, timeStart: 150,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 150, startedAt: 150,
@@ -1029,7 +1056,9 @@ describe('getRuntimeOffset() relative', () => {
rundown: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 150, plannedStart: 150,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
@@ -1041,6 +1070,7 @@ describe('getRuntimeOffset() relative', () => {
eventNow: { eventNow: {
id: '1', id: '1',
timeStart: 100, timeStart: 100,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 150, startedAt: 150,
@@ -1053,7 +1083,9 @@ describe('getRuntimeOffset() relative', () => {
rundown: { rundown: {
actualStart: 150, actualStart: 150,
plannedStart: 100, plannedStart: 100,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
@@ -1065,6 +1097,7 @@ describe('getRuntimeOffset() relative', () => {
eventNow: { eventNow: {
id: '1', id: '1',
timeStart: 150, timeStart: 150,
dayOffset: 0,
}, },
timer: { timer: {
startedAt: 100, startedAt: 100,
@@ -1077,7 +1110,9 @@ describe('getRuntimeOffset() relative', () => {
rundown: { rundown: {
actualStart: 100, actualStart: 100,
plannedStart: 150, plannedStart: 150,
currentDay: 0,
}, },
_startDayOffset: 0,
} as RuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
@@ -1258,3 +1293,21 @@ describe('getTimerPhase()', () => {
expect(phase).toBe(TimerPhase.Pending); expect(phase).toBe(TimerPhase.Pending);
}); });
}); });
describe('findDay()', () => {
test('finds dayOffset', () => {
//both have 1 hour offset but the clock are on different days
expect(findDayOffset(0, 23 * MILLIS_PER_HOUR)).toBe(-1); // -> 23
expect(findDayOffset(0, 13 * MILLIS_PER_HOUR)).toBe(-1); // -> 13
expect(findDayOffset(0, 12 * MILLIS_PER_HOUR)).toBe(-1); // -> 12
expect(findDayOffset(0, 11 * MILLIS_PER_HOUR)).toBe(0); // -> 11
expect(findDayOffset(1 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -1
//both have 1 hour offset but the clock are on different days
expect(findDayOffset(23 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -23
expect(findDayOffset(13 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -13
expect(findDayOffset(12 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -12
expect(findDayOffset(11 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -11
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
});
});
@@ -0,0 +1,67 @@
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,
startEpoch: 1,
};
expect(isRestorePoint(restorePoint)).toBe(true);
restorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: null,
addedTime: 0,
pausedAt: null,
firstStart: 1,
startEpoch: 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,
startEpoch: 1,
};
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,
startEpoch: 1,
};
expect(isRestorePoint(restorePoint)).toBe(false);
});
});
});
@@ -0,0 +1,128 @@
/* 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,
startEpoch: 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,
startEpoch: 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,
startEpoch: 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,
startEpoch: 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,58 @@
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',
'startEpoch',
])
) {
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;
}
if (!is.number(restorePoint.startEpoch) && restorePoint.startEpoch !== 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,11 @@
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
export type RestorePoint = {
playback: Playback;
selectedEventId: MaybeString;
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
startEpoch: 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 { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { EventTimer } from '../EventTimer.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 { skippedOutOfEvent } from '../timerUtils.js';
import { import {
@@ -635,6 +636,8 @@ const eventTimer = new EventTimer({
}); });
export const runtimeService = new RuntimeService(eventTimer); export const runtimeService = new RuntimeService(eventTimer);
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
/** /**
* Decorator manages side effects from updating the runtime * Decorator manages side effects from updating the runtime
* This should only be applied to functions that are exposed for consumption * 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 // combine all big changes
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged; const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
// clock has changed by a second or more /**
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock); * if any values have changed.
if (updateClock) { * values that have the possibility to tick are updated when the seconds roll over
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
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer); const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
if (updateTimer) { if (updateTimer) {
batch.add('timer', state.timer); batch.add('timer', state.timer);
RuntimeService.previousState.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( const updateRuntime = getShouldOffsetUpdate(
RuntimeService.previousState?.offset, RuntimeService.previousState?.offset,
state.offset, state.offset,
@@ -696,16 +708,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.offset = structuredClone(state.offset); RuntimeService.previousState.offset = structuredClone(state.offset);
} }
// if any values have changed /**
* if any values have changed.
*/
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown); const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
if (updateRundownData) { if (updateRundownData) {
batch.add('rundown', state.rundown); batch.add('rundown', state.rundown);
RuntimeService.previousState.rundown = structuredClone(state.rundown); RuntimeService.previousState.rundown = structuredClone(state.rundown);
} }
function updateMaybeEntryIfChanged< function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
>(key: K) {
const previousEntry = RuntimeService.previousState[key]; const previousEntry = RuntimeService.previousState[key];
const currentEntry = state[key]; const currentEntry = state[key];
@@ -731,6 +743,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt, pausedAt: state._timer.pausedAt,
firstStart: state.rundown.actualStart, firstStart: state.rundown.actualStart,
startEpoch: state._startEpoch,
}) })
.catch((_e) => { .catch((_e) => {
//we don't do anything with the error here //we don't do anything with the error here
@@ -24,7 +24,6 @@ export function isNewSecond(
/** /**
* Checks whether we should update the clock value * Checks whether we should update the clock value
* - we have rolled into a new seconds unit * - 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 { export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp); 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 * Checks whether we should update the timer values
* - we have rolled into a new seconds unit * - `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 { export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
if (previousValue === undefined) return true; 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( export function getShouldOffsetUpdate(
previousValue: Offset | undefined, previousValue: Offset | undefined,
currentValue: Offset, currentValue: Offset,
@@ -60,7 +66,6 @@ export function getShouldOffsetUpdate(
): boolean { ): boolean {
if (previousValue === undefined) return true; if (previousValue === undefined) return true;
if (previousValue.mode !== currentValue.mode) return true; if (previousValue.mode !== currentValue.mode) return true;
// absolute, relative, expected*End are ticked with `didDependencyUpdate`
return didDependencyUpdate && !deepEqual(previousValue, currentValue); return didDependencyUpdate && !deepEqual(previousValue, currentValue);
} }
@@ -4,7 +4,18 @@
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device * @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 { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets'; 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 { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
const rundown = getCurrentRundown(); 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]; const titleMetadata = Object.values(sheetMetadata)[0];
if (titleMetadata === undefined) { if (titleMetadata === undefined) {
throw new Error(`Sheet read failed: failed to find title row`); throw new Error(`Sheet read failed: failed to find title row`);
@@ -380,17 +406,25 @@ export async function upload(sheetId: string, options: ImportMap) {
range: { range: {
dimension: 'ROWS', dimension: 'ROWS',
startIndex: titleRow + 1, startIndex: titleRow + 1,
endIndex: titleRow + rundown.order.length, endIndex: titleRow + sheetOrder.length,
sheetId: worksheetId, sheetId: worksheetId,
}, },
}, },
}); });
// update the corresponding row with event data try {
rundown.order.forEach((entryId, index) => { // update the corresponding row with event data
const entry = rundown.entries[entryId]; sheetOrder.forEach((entryId, index) => {
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); 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));
});
} catch (e) {
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`)
}
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId, spreadsheetId: sheetId,
@@ -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 { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets'; 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 * @description - creates updateCells request from ontime event
* @param {OntimeEntry} event * @param {OntimeEntry} entry
* @param {number} index - index of the event * @param {number} index - index of the event
* @param {number} worksheetId * @param {number} worksheetId
* @param {object} metadata - object with all the cell positions of the title of each attribute * @param {object} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests * @returns {sheets_v4.Schema} - list of update requests
*/ */
export function cellRequestFromEvent( export function cellRequestFromEvent(
event: OntimeEntry, entry: OntimeEntry,
index: number, index: number,
worksheetId: number, worksheetId: number,
metadata: object, metadata: object,
): sheets_v4.Schema$Request { ): sheets_v4.Schema$Request {
const rowData = Object.entries(metadata) const rowData = Object.entries(metadata) // check what headings are available in the sheet
.filter(([_, value]) => value !== undefined) .filter(([_, value]) => value !== undefined) // drop anything that is undefined
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [keyof OntimeEvent | 'blank', { col: number; row: number }][]; .sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [
OntimeEntryCommonKeys | 'blank',
const titleCol = rowData[0][1].col; { 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()) { for (const [index, e] of rowData.entries()) {
if (index !== 0) { if (index === 0) continue;
const prevCol = rowData[index - 1][1].col; const prevCol = rowData[index - 1][1].col;
const thisCol = e[1].col; const thisCol = e[1].col;
const diff = thisCol - prevCol; const diff = thisCol - prevCol;
if (diff > 1) { if (diff > 1) {
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]); const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
rowData.splice(index, 0, ...fillArr); rowData.splice(index, 0, ...fillArr);
}
} }
} }
const colors = isOntimeEvent(event) || isOntimeGroup(event) ? getAccessibleColour(event.colour) : undefined; const colours = 'colour' in entry ? getAccessibleColour(entry.colour) : undefined;
const cellColor: sheets_v4.Schema$CellData = !colors const cellColor: sheets_v4.Schema$CellData = !colours
? {} ? {}
: { : {
userEnteredFormat: { userEnteredFormat: {
backgroundColor: toSheetColourLevel(colors.background), backgroundColor: toSheetColourLevel(colours.background),
textFormat: { textFormat: {
foregroundColor: toSheetColourLevel(colors.text), foregroundColor: toSheetColourLevel(colours.text),
}, },
borders: { borders: {
bottom: { bottom: {
style: 'SOLID', style: 'SOLID',
color: toSheetColourLevel(colors.border), color: toSheetColourLevel(colours.border),
}, },
}, },
}, },
}; };
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => { 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 { return {
updateCells: { updateCells: {
start: { start: {
sheetId: worksheetId, sheetId: worksheetId,
rowIndex: index + rowData[0][1]['row'] + 1, rowIndex: index + headerLocation.row + 1,
columnIndex: titleCol, columnIndex: headerLocation.col,
}, },
fields: 'userEnteredValue,userEnteredFormat', fields: 'userEnteredValue,userEnteredFormat',
rows: [ rows: [
@@ -139,40 +149,44 @@ export function cellRequestFromEvent(
}; };
} }
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) { function getCellData(key: OntimeEntryCommonKeys | 'blank', entry: OntimeEntry) {
if (isOntimeEvent(event)) { if (isOntimeDelay(entry) || key === 'blank') {
if (key === 'blank') { return {};
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] } };
}
} }
if (isOntimeGroup(event)) { // we need to remap the event type to timer type in the case of groups and milestones
if (key === 'title') { if (key === 'timerType') {
return { userEnteredValue: { stringValue: event[key] } }; if (isOntimeGroup(entry))
} return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
if (key === 'timerType') { if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
return { userEnteredValue: { stringValue: 'group' } }; return { userEnteredValue: { stringValue: entry.timerType } };
}
} }
return {}; // all other data is not relevant for the group end entry
if (entry.id.startsWith('group-end')) {
return {};
}
// we need to flatten the milestones
if (key.startsWith('custom')) {
const customKey = key.split(':')[1];
return { userEnteredValue: { stringValue: entry.custom[customKey] } };
}
// 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 = { type googleSheetCellColour = {
+21 -8
View File
@@ -1,5 +1,6 @@
import { MaybeNumber, TimerPhase } from 'ontime-types'; import { MaybeNumber, TimerPhase } from 'ontime-types';
import { dayInMs, isPlaybackActive } from 'ontime-utils'; import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
/** /**
@@ -113,14 +114,14 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Negative offset is under time / ahead of schedule * Negative offset is under time / ahead of schedule
*/ */
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } { export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, clock } = state; const { eventNow, clock, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started // nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null) { if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: 0, relative: 0 }; return { absolute: 0, relative: 0 };
} }
const { countToEnd, timeStart } = eventNow; const { countToEnd, timeStart, dayOffset } = eventNow;
const { plannedStart, actualStart } = state.rundown; const { plannedStart, actualStart } = state.rundown;
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
@@ -131,8 +132,8 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set'); if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set');
} }
// difference between planned event start and actual event start (will be positive if we stared behind ) // difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset = startedAt - timeStart; const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset) // how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
const overtime = Math.abs(Math.min(current, 0)); const overtime = Math.abs(Math.min(current, 0));
@@ -140,10 +141,11 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight // time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
const absolute = eventStartOffset + overtime + pausedTime + addedTime; const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset i the same as the absolute offset but adjusted relative to the actual start time // the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart; const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
// in case of count to end, the absolute offset is just the overtime // in case of count to end, the absolute offset is just the overtime
return countToEnd ? { absolute: overtime, relative } : { absolute, relative }; return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
@@ -180,3 +182,14 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
return TimerPhase.Default; return TimerPhase.Default;
} }
/**
* Finds the day offset relative to an event start
* used byt the runtimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): number {
const distance = clock - plannedStart;
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
if (distance < -12 * MILLIS_PER_HOUR) return 1;
return 0;
}
@@ -14,6 +14,8 @@ const baseState: RuntimeState = {
plannedStart: 0, plannedStart: 0,
plannedEnd: 0, plannedEnd: 0,
actualStart: null, actualStart: null,
actualGroupStart: null,
currentDay: 0,
}, },
offset: { offset: {
absolute: 0, absolute: 0,
@@ -46,6 +48,8 @@ const baseState: RuntimeState = {
_group: null, _group: null,
_end: null, _end: null,
_flag: null, _flag: null,
_startDayOffset: null,
_startEpoch: null,
}; };
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState { export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {

Some files were not shown because too many files have changed in this diff Show More