mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 16:49:41 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c169c419c3 | |||
| 5b30f522aa |
+15
-15
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.6.0",
|
"@base-ui/react": "1.3.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"@codemirror/commands": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -12,27 +12,27 @@
|
|||||||
"@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.2.7",
|
"@fontsource/open-sans": "^5.2.6",
|
||||||
"@mantine/hooks": "^8.3.7",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.59.0",
|
"@sentry/react": "^10.2.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.85.9",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.12.2",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.3",
|
||||||
"react-colorful": "^5.7.0",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.3",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.72.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.5.0",
|
||||||
"react-router": "^8.0.1",
|
"react-router": "^7.11.0",
|
||||||
"react-virtuoso": "^4.18.7",
|
"react-virtuoso": "^4.17.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
padding-top: 10vh;
|
padding-top: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline {
|
.empty {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
opacity: 0.8;
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|||||||
@@ -1,33 +1,18 @@
|
|||||||
import { SupportedEntry } from 'ontime-types';
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
|
||||||
import Button from '../buttons/Button';
|
|
||||||
import Empty from './Empty';
|
|
||||||
|
|
||||||
import style from './EmptyTableBody.module.scss';
|
import style from './EmptyTableBody.module.scss';
|
||||||
|
|
||||||
interface EmptyTableBodyProps {
|
interface EmptyTableBodyProps {
|
||||||
handleAddNew?: (type: SupportedEntry) => void;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const text = getLocalizedString('common.no_data');
|
|
||||||
return (
|
return (
|
||||||
<tbody className={style.emptyContainer}>
|
<tbody className={style.emptyContainer}>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={99} className={style.emptyCell}>
|
<td colSpan={99} className={style.emptyCell}>
|
||||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
<EmptyImage className={style.empty} />
|
||||||
<span className={style.text}>{text}</span>
|
{text && <span className={style.text}>{text}</span>}
|
||||||
{handleAddNew && (
|
|
||||||
<div className={style.inline}>
|
|
||||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
|
||||||
<IoAdd />
|
|
||||||
Create Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -40,15 +40,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const disabledPresets: URLPreset[] = [
|
|
||||||
{
|
|
||||||
enabled: false,
|
|
||||||
alias: 'demopage',
|
|
||||||
target: OntimeView.Timer,
|
|
||||||
search: 'user=guest',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('checks if the current location matches an enabled preset', () => {
|
it('checks if the current location matches an enabled preset', () => {
|
||||||
// we make the current location be the alias
|
// we make the current location be the alias
|
||||||
@@ -56,16 +47,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks if the current location matches an enabled preset target', () => {
|
|
||||||
const location = resolvePath('/timer');
|
|
||||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not redirect disabled presets', () => {
|
|
||||||
const location = resolvePath('/demopage');
|
|
||||||
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when already on a preset path', () => {
|
it('returns null when already on a preset path', () => {
|
||||||
const location = resolvePath('/preset/demopage');
|
const location = resolvePath('/preset/demopage');
|
||||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||||
@@ -105,11 +86,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
const location = resolvePath('/demopage?n=1&token=123');
|
const location = resolvePath('/demopage?n=1&token=123');
|
||||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
|
|
||||||
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
|
|
||||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cuesheet presets', () => {
|
describe('cuesheet presets', () => {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export default function ManageRundowns() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
|
render={<IconButton variant='ghosted-white' />}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
|
|||||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Sentry.withSentryReactRouterV7Routing(Routes);
|
return Sentry.withSentryReactRouterV6Routing(Routes);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ContextProp,
|
ContextProp,
|
||||||
@@ -20,7 +20,6 @@ import { usePersistedRundownOptions } from '../../../features/rundown/rundown.op
|
|||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
|
||||||
import { CuesheetHeader, SortableCuesheetHeader } 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';
|
||||||
@@ -63,8 +62,7 @@ export default function CuesheetTable({
|
|||||||
insertElement,
|
insertElement,
|
||||||
}: CuesheetTableProps) {
|
}: CuesheetTableProps) {
|
||||||
const { flatRundown, status, selectedEventId } = source;
|
const { flatRundown, status, selectedEventId } = source;
|
||||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
|
||||||
|
|
||||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||||
const optionsStore = useOptions();
|
const optionsStore = useOptions();
|
||||||
@@ -204,9 +202,8 @@ export default function CuesheetTable({
|
|||||||
listeners,
|
listeners,
|
||||||
rows,
|
rows,
|
||||||
table,
|
table,
|
||||||
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
|
|
||||||
}),
|
}),
|
||||||
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
|
[columnSizeVars, cursor, listeners, rows, table],
|
||||||
);
|
);
|
||||||
|
|
||||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||||
@@ -276,13 +273,10 @@ interface CuesheetVirtuosoContext {
|
|||||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||||
table: Table<ExtendedEntry>;
|
table: Table<ExtendedEntry>;
|
||||||
handleAddNew?: (type: SupportedEntry) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
|
||||||
context,
|
return <EmptyTableBody text='No data in rundown' />;
|
||||||
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
|
||||||
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const CuesheetTableElement = memo(function CuesheetTableElement({
|
const CuesheetTableElement = memo(function CuesheetTableElement({
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.5",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "5.2.1",
|
"express": "5.1.0",
|
||||||
"express-static-gzip": "3.0.1",
|
"express-static-gzip": "3.0.0",
|
||||||
"express-validator": "7.3.2",
|
"express-validator": "7.2.1",
|
||||||
"fast-equals": "^6.0.0",
|
"fast-equals": "^5.0.1",
|
||||||
"google-auth-library": "^9.4.2",
|
"google-auth-library": "^9.4.2",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"multer": "2.2.0",
|
"multer": "2.1.0",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"osc-min": "2.1.2",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.9",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.3",
|
||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
|
|||||||
@@ -838,7 +838,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
if (_group !== null) {
|
if (_group !== null) {
|
||||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||||
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, {
|
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, {
|
||||||
currentDay: state.rundown.currentDay ?? 0,
|
currentDay: state.rundown.currentDay!,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
mode: offset.mode,
|
mode: offset.mode,
|
||||||
@@ -869,7 +869,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
if (state._end) {
|
if (state._end) {
|
||||||
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
||||||
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
||||||
currentDay: state.rundown.currentDay ?? 0,
|
currentDay: state.rundown.currentDay!,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
mode: offset.mode,
|
mode: offset.mode,
|
||||||
@@ -919,7 +919,8 @@ export function loadGroupFlagAndEnd(
|
|||||||
|
|
||||||
let accumulatedGap = 0;
|
let accumulatedGap = 0;
|
||||||
let isLinkedToLoaded = true;
|
let isLinkedToLoaded = true;
|
||||||
// a countToEnd event absorbs overtime and breaks the chain
|
// a countToEnd event absorbs overtime, so the chain breaks on the event that follows it
|
||||||
|
// mirrors the client logic in common/utils/rundownMetadata.ts
|
||||||
let previousWasCountToEnd = false;
|
let previousWasCountToEnd = false;
|
||||||
|
|
||||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||||
@@ -944,6 +945,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// carry the countToEnd status forward so the next event can break the chain
|
||||||
previousWasCountToEnd = entry.countToEnd;
|
previousWasCountToEnd = entry.countToEnd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,27 +100,3 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
|||||||
await expect(cueEditor).not.toBeFocused();
|
await expect(cueEditor).not.toBeFocused();
|
||||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
|
||||||
// create an empty rundown
|
|
||||||
await page.goto('/editor');
|
|
||||||
await page.getByRole('button', { name: 'Toggle settings' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Manage rundowns' }).click();
|
|
||||||
await page.getByRole('button', { name: 'New' }).nth(1).click();
|
|
||||||
const emptyName = `empty-${Date.now()}`;
|
|
||||||
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
|
||||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
|
||||||
|
|
||||||
// edit it in the cuesheet
|
|
||||||
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
|
||||||
await page.getByText('Edit in cuesheet').click();
|
|
||||||
|
|
||||||
// expect to see and empty screen
|
|
||||||
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
|
|
||||||
|
|
||||||
// create 1 event
|
|
||||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
|
||||||
|
|
||||||
// and expect to find it
|
|
||||||
await expect(page.getByTestId('cuesheet-event')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@
|
|||||||
"devEngines": {
|
"devEngines": {
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"name": "node",
|
"name": "node",
|
||||||
"version": "22.22.3"
|
"version": "22.22.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
|||||||
|
|
||||||
// feature business logic
|
// feature business logic
|
||||||
|
|
||||||
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
export { getExpectedEnd, getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
||||||
|
|
||||||
// feature business logic - rundown
|
// feature business logic - rundown
|
||||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||||
|
|||||||
+21
-34
@@ -1,7 +1,7 @@
|
|||||||
import { Day, OffsetMode } from 'ontime-types';
|
import { Day, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||||
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
import { getExpectedEnd, getExpectedStart } from './getExpectedStart';
|
||||||
|
|
||||||
describe('getExpectedStart()', () => {
|
describe('getExpectedStart()', () => {
|
||||||
describe('Absolute offset mode', () => {
|
describe('Absolute offset mode', () => {
|
||||||
@@ -382,37 +382,7 @@ describe('getExpectedEnd()', () => {
|
|||||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
|
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a countToEnd event drifts when the start is compromised', () => {
|
test('a countToEnd event is NOT shifted by the relative-start offset', () => {
|
||||||
const testEvent = {
|
|
||||||
timeStart: 100,
|
|
||||||
duration: 50,
|
|
||||||
delay: 0,
|
|
||||||
dayOffset: 0 as Day,
|
|
||||||
countToEnd: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// the offset pushes the start (160) past the scheduled end (150) so it can no longer
|
|
||||||
// finish on schedule - the end follows the compromised start
|
|
||||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 60 })).toBe(160);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
|
||||||
const testEvent = {
|
|
||||||
timeStart: 100,
|
|
||||||
duration: 50,
|
|
||||||
delay: 0,
|
|
||||||
dayOffset: 1 as Day,
|
|
||||||
countToEnd: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// the scheduled end must include the day offset (delayedStart + dayInMs + duration),
|
|
||||||
// not collapse to the day-shifted start
|
|
||||||
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
|
|
||||||
// when the running event is already on the same day, no extra day is added
|
|
||||||
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
|
||||||
const testEvent = {
|
const testEvent = {
|
||||||
timeStart: 100,
|
timeStart: 100,
|
||||||
duration: 50,
|
duration: 50,
|
||||||
@@ -421,6 +391,8 @@ describe('getExpectedEnd()', () => {
|
|||||||
countToEnd: true,
|
countToEnd: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// in relative mode a regular event would be shifted by actualStart - plannedStart (+30),
|
||||||
|
// but a countToEnd event is anchored to its wall-clock end and stays at 150
|
||||||
const relativeState = {
|
const relativeState = {
|
||||||
...baseState,
|
...baseState,
|
||||||
mode: OffsetMode.Relative,
|
mode: OffsetMode.Relative,
|
||||||
@@ -429,9 +401,24 @@ describe('getExpectedEnd()', () => {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// a regular event in the same state is shifted by the relative-start offset to 180
|
// sanity: a regular event in the same state is shifted to 180
|
||||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
||||||
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
// the countToEnd event is not shifted
|
||||||
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event on a later day adds the day offset', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 1 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// dayOffset 1 with currentDay 0 -> end normalised one day forward
|
||||||
|
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
|
||||||
|
// when the running event is already on the same day, no extra day is added
|
||||||
|
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+14
-28
@@ -8,6 +8,7 @@ import { dayInMs } from './conversionUtils.js';
|
|||||||
* @param currentDay the day offset of the currently running event
|
* @param currentDay the day offset of the currently running event
|
||||||
* @param totalGap accumulated gap from the current event
|
* @param totalGap accumulated gap from the current event
|
||||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||||
|
* @param clock
|
||||||
* @param offset
|
* @param offset
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
@@ -61,37 +62,22 @@ export function getExpectedStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param event the event that we are counting to
|
* Computes the normalised expected end of an event.
|
||||||
* @param currentDay the day offset of the currently running event
|
* A countToEnd event is anchored to its fixed wall-clock end: it absorbs the accumulated
|
||||||
* @param totalGap accumulated gap from the current event
|
* runtime offset and is not shifted by the relative-start offset, so its expected end is the
|
||||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
* scheduled end normalised for the day (mirrors getExpectedFinish in the running timer, which
|
||||||
* @param offset
|
* returns the raw timeEnd). A regular event's end moves with the runtime offset.
|
||||||
* @returns
|
* The result lives in the same day-normalised space as getExpectedStart (it may exceed dayInMs).
|
||||||
*/
|
*/
|
||||||
export function getExpectedEnd(
|
export function getExpectedEnd(
|
||||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
|
event: Pick<OntimeEvent, 'timeStart' | 'duration' | 'delay' | 'dayOffset' | 'countToEnd'>,
|
||||||
state: {
|
state: Parameters<typeof getExpectedStart>[1],
|
||||||
currentDay: number; // the current day from the rundown
|
|
||||||
totalGap: number;
|
|
||||||
isLinkedToLoaded: boolean;
|
|
||||||
offset: number;
|
|
||||||
mode: OffsetMode;
|
|
||||||
actualStart: MaybeNumber;
|
|
||||||
plannedStart: MaybeNumber;
|
|
||||||
},
|
|
||||||
): number {
|
): number {
|
||||||
// expected start encodes the offset from current delays
|
if (!event.countToEnd) {
|
||||||
const expectedStart = getExpectedStart(event, state);
|
return getExpectedStart(event, state) + event.duration;
|
||||||
|
|
||||||
// count to end events should finish on schedule unlesss the start is compromised
|
|
||||||
if (event.countToEnd) {
|
|
||||||
// the scheduled end, normalised to the same day-space as expectedStart
|
|
||||||
// (a raw timeStart + duration would miss the day offset on multi-day rundowns)
|
|
||||||
const delayedStart = Math.max(0, event.timeStart + event.delay);
|
|
||||||
const relativeDayOffset = event.dayOffset - state.currentDay;
|
|
||||||
const scheduledEnd = delayedStart + relativeDayOffset * dayInMs + event.duration;
|
|
||||||
return Math.max(expectedStart, scheduledEnd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return expectedStart + event.duration;
|
const delayedStart = Math.max(0, event.timeStart + event.delay);
|
||||||
|
const relativeDayOffset = event.dayOffset - state.currentDay;
|
||||||
|
return delayedStart + relativeDayOffset * dayInMs + event.duration;
|
||||||
}
|
}
|
||||||
Generated
+279
-373
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user