mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cf36f049a | |||
| c6248b0c72 | |||
| 2a890cf2b3 |
@@ -20,7 +20,7 @@
|
||||
.wide {
|
||||
top: 4vh;
|
||||
min-width: min(1280px, 96vw);
|
||||
max-width: min(1600px, 96vw);
|
||||
max-width: min(1800px, 98vw);
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MILLIS_PER_SECOND,
|
||||
dayInMs,
|
||||
formatFromMillis,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
} from 'ontime-utils';
|
||||
|
||||
@@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
|
||||
...state,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||
|
||||
return {
|
||||
expectedStart,
|
||||
timeToStart: expectedStart - state.clock,
|
||||
expectedEnd: event.countToEnd
|
||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||
: expectedStart + event.duration,
|
||||
expectedEnd,
|
||||
plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
+7
@@ -25,6 +25,13 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.finishActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sourceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
+22
-8
@@ -8,6 +8,7 @@ import type {
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import {
|
||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
|
||||
const [error, setError] = useState('');
|
||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||
|
||||
const { data: currentRundown } = useRundown();
|
||||
const { applyImport } = useSpreadsheetImport();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle('');
|
||||
};
|
||||
|
||||
const openGSheetFlow = () => {
|
||||
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = () => {
|
||||
const handleFinished = (rundownTitle: string) => {
|
||||
setImportFlow('finished');
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle(rundownTitle);
|
||||
};
|
||||
|
||||
const handleApplyImport = async (
|
||||
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
|
||||
if (mode === 'new') {
|
||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||
handleFinished();
|
||||
handleFinished(title);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
|
||||
customFields: preview.customFields,
|
||||
providedFields,
|
||||
});
|
||||
handleFinished();
|
||||
handleFinished(currentRundown.title);
|
||||
};
|
||||
|
||||
const loadWorksheetMetadata = useCallback(
|
||||
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
<span className={style.finishBadge}>Import complete</span>
|
||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Reset flow
|
||||
</Button>
|
||||
<div className={style.finishTitle}>
|
||||
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||
</div>
|
||||
<div className={style.finishDescription}>
|
||||
Review the imported rundown in the editor or start another import.
|
||||
</div>
|
||||
<div className={style.finishActions}>
|
||||
<Button variant='primary' onClick={() => navigate('/editor')}>
|
||||
Open editor
|
||||
</Button>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Import another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isGSheetFlow && (
|
||||
|
||||
+4
@@ -92,6 +92,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbarWarning {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.mappingPaneTitle {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
+17
-2
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
|
||||
{toolbarStatus && (
|
||||
<Panel.Description>
|
||||
{toolbarStatus.entries === '–'
|
||||
? 'No import preview yet'
|
||||
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`}
|
||||
{toolbarStatus.warnings > 0 && (
|
||||
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
|
||||
)}
|
||||
</Panel.Description>
|
||||
)}
|
||||
</Panel.InlineElements>
|
||||
|
||||
<div className={style.editorBody}>
|
||||
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
|
||||
|
||||
<section className={style.previewPane}>
|
||||
<div className={style.previewPaneHeader}>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
<div className={style.previewPaneHeading}>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.tableShell}>
|
||||
<PreviewTable
|
||||
preview={state.preview}
|
||||
columnLabels={columnLabels}
|
||||
canRefresh={canPreview}
|
||||
isLoadingMetadata={isLoadingMetadata}
|
||||
isRefreshing={state.loading === 'preview'}
|
||||
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||
onRefresh={handlePreviewSubmit}
|
||||
worksheetHeaders={worksheetHeaders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+102
-24
@@ -1,27 +1,35 @@
|
||||
.emptyState {
|
||||
height: 100%;
|
||||
min-height: 16rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1.5rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
width: min(30rem, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: rgba($gray-200, 0.72);
|
||||
font-size: calc(1rem + 2px);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.emptyBody {
|
||||
color: $gray-400;
|
||||
font-size: 0.95rem;
|
||||
color: rgba($gray-200, 0.55);
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.emptyAction {
|
||||
margin: 1rem auto 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 2px);
|
||||
text-align: left;
|
||||
table-layout: auto;
|
||||
@@ -34,31 +42,101 @@
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
text-transform: capitalize;
|
||||
vertical-align: top;
|
||||
color: $gray-300;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: bottom;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
min-width: 8rem;
|
||||
vertical-align: top;
|
||||
max-width: 20rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
tbody tr {
|
||||
--entry-colour: transparent;
|
||||
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
|
||||
box-shadow: inset 3px 0 var(--entry-colour);
|
||||
}
|
||||
|
||||
td[data-empty='true'] {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.rowNumber,
|
||||
.rowType {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
width: 4.5rem;
|
||||
min-width: 4.5rem;
|
||||
left: 0;
|
||||
width: 3.25rem;
|
||||
min-width: 3.25rem !important;
|
||||
color: $gray-400;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowType {
|
||||
width: 7rem;
|
||||
min-width: 7rem;
|
||||
left: 3.25rem;
|
||||
width: 6.25rem;
|
||||
min-width: 6.25rem !important;
|
||||
color: $gray-400;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 1px 0 $white-10;
|
||||
}
|
||||
|
||||
thead .rowNumber,
|
||||
thead .rowType {
|
||||
z-index: 2;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
.numericCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.multilineCell {
|
||||
max-width: 30rem !important;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
.rowNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.groupRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
|
||||
box-shadow: inset 4px 0 var(--entry-colour) !important;
|
||||
font-weight: 600;
|
||||
|
||||
td {
|
||||
min-height: 3.25rem;
|
||||
border-top: 0.75rem solid $gray-1350;
|
||||
}
|
||||
}
|
||||
|
||||
.milestoneRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
|
||||
box-shadow: inset 3px 0 var(--entry-colour) !important;
|
||||
color: $gray-300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
+126
-14
@@ -1,7 +1,11 @@
|
||||
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Button from '../../../../../../../common/components/buttons/Button';
|
||||
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||
import { getCellValue } from './previewTableUtils';
|
||||
|
||||
import style from './PreviewTable.module.scss';
|
||||
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
|
||||
interface PreviewTableProps {
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
columnLabels: string[];
|
||||
canRefresh: boolean;
|
||||
isLoadingMetadata: boolean;
|
||||
isRefreshing: boolean;
|
||||
needsPreviewRefresh: boolean;
|
||||
onRefresh: () => void;
|
||||
worksheetHeaders: string[];
|
||||
}
|
||||
|
||||
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
|
||||
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
|
||||
const transparentColour = 'transparent';
|
||||
|
||||
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
|
||||
|
||||
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return {
|
||||
rowClassName: style.groupRow,
|
||||
entryColour: entry.colour,
|
||||
entryType: 'Group',
|
||||
};
|
||||
}
|
||||
|
||||
const entryColour = groupColour ?? transparentColour;
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return {
|
||||
rowClassName: style.milestoneRow,
|
||||
entryColour,
|
||||
entryType: 'Milestone',
|
||||
};
|
||||
}
|
||||
|
||||
if (isOntimeDelay(entry)) {
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Delay',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Event',
|
||||
};
|
||||
}
|
||||
|
||||
function getCellClassName(label: string, value: string) {
|
||||
if (value.includes('\n')) {
|
||||
return style.multilineCell;
|
||||
}
|
||||
|
||||
if (numericColumns.has(label)) {
|
||||
return style.numericCell;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDisplayColumns(columnLabels: string[]) {
|
||||
return [...columnLabels].sort((left, right) => {
|
||||
const leftPriority = priorityColumns.indexOf(left);
|
||||
const rightPriority = priorityColumns.indexOf(right);
|
||||
return (
|
||||
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
|
||||
(rightPriority === -1 ? priorityColumns.length : rightPriority)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewTable({
|
||||
preview,
|
||||
columnLabels,
|
||||
canRefresh,
|
||||
isLoadingMetadata,
|
||||
isRefreshing,
|
||||
needsPreviewRefresh,
|
||||
onRefresh,
|
||||
worksheetHeaders,
|
||||
}: PreviewTableProps) {
|
||||
const customFieldKeyByLabel = useMemo(() => {
|
||||
@@ -24,33 +99,57 @@ export default function PreviewTable({
|
||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||
}, [preview]);
|
||||
|
||||
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||
|
||||
const previewMetadata = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return getRundownMetadata(preview.rundown, null);
|
||||
}, [preview]);
|
||||
|
||||
if (!preview) {
|
||||
let emptyTitle = 'Preview not generated';
|
||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||
|
||||
if (isLoadingMetadata) {
|
||||
emptyTitle = 'Loading worksheet';
|
||||
emptyContent = 'Loading worksheet metadata...';
|
||||
} else if (worksheetHeaders.length === 0) {
|
||||
emptyTitle = 'No headers found';
|
||||
emptyContent =
|
||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
||||
} else if (needsPreviewRefresh) {
|
||||
emptyTitle = 'Preview needs updating';
|
||||
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyTitle}>Preview not generated</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
<div className={style.emptyMessage}>
|
||||
<div className={style.emptyTitle}>{emptyTitle}</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
{needsPreviewRefresh && (
|
||||
<Button
|
||||
className={style.emptyAction}
|
||||
variant='primary'
|
||||
onClick={onRefresh}
|
||||
disabled={!canRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
Refresh preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let eventIndex = 0;
|
||||
|
||||
return (
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.rowNumber}>#</th>
|
||||
<th className={style.rowType}>Type</th>
|
||||
{columnLabels.map((label, index) => (
|
||||
{displayColumns.map((label, index) => (
|
||||
<th key={`${label}-${index}`}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -59,16 +158,29 @@ export default function PreviewTable({
|
||||
{preview.rundown.flatOrder.map((entryId) => {
|
||||
const entry = preview.rundown.entries[entryId];
|
||||
const isEvent = isOntimeEvent(entry);
|
||||
if (isEvent) eventIndex++;
|
||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
||||
const entryMetadata = previewMetadata?.[entryId];
|
||||
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||
|
||||
return (
|
||||
<tr key={entryId}>
|
||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
||||
{columnLabels.map((label, colIndex) => (
|
||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
||||
))}
|
||||
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||
<td className={style.rowType}>
|
||||
<Tag>{entryType}</Tag>
|
||||
</td>
|
||||
{displayColumns.map((label, colIndex) => {
|
||||
const value = getCellValue(label, entry, customFieldKeyByLabel);
|
||||
const cellClassName = getCellClassName(label, value);
|
||||
return (
|
||||
<td
|
||||
key={`${entryId}-${colIndex}`}
|
||||
className={cellClassName}
|
||||
data-empty={value === ''}
|
||||
title={value || undefined}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
+36
-11
@@ -5,7 +5,8 @@ import type {
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
} from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -32,7 +33,7 @@ type ImportAction =
|
||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||
| { type: 'applySuccess' }
|
||||
| { type: 'exportSuccess' }
|
||||
| { type: 'clearPreview'; error?: string }
|
||||
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||
| { type: 'failure'; error: string }
|
||||
| { type: 'reset' };
|
||||
|
||||
@@ -40,12 +41,14 @@ type ImportState = {
|
||||
loading: '' | 'preview' | 'apply' | 'export';
|
||||
error: string;
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
needsPreviewRefresh: boolean;
|
||||
};
|
||||
|
||||
const initialImportState: ImportState = {
|
||||
loading: '',
|
||||
error: '',
|
||||
preview: null,
|
||||
needsPreviewRefresh: false,
|
||||
};
|
||||
|
||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
case 'startExport':
|
||||
return { ...state, loading: 'export', error: '' };
|
||||
case 'previewSuccess':
|
||||
return { loading: '', error: '', preview: action.preview };
|
||||
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||
case 'applySuccess':
|
||||
case 'exportSuccess':
|
||||
return { ...state, loading: '' };
|
||||
case 'clearPreview':
|
||||
return { ...state, error: action.error ?? '', preview: null };
|
||||
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||
case 'failure': {
|
||||
if (state.loading === 'preview') {
|
||||
return { loading: '', error: action.error, preview: null };
|
||||
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
|
||||
}
|
||||
return { ...state, loading: '', error: action.error };
|
||||
}
|
||||
@@ -221,7 +224,7 @@ export function useSheetImportForm({
|
||||
const sub = watch(() => {
|
||||
if (!previewRef.current) return;
|
||||
previewRef.current = null;
|
||||
dispatch({ type: 'clearPreview' });
|
||||
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [watch]);
|
||||
@@ -297,15 +300,37 @@ export function useSheetImportForm({
|
||||
}, [append]);
|
||||
|
||||
const toolbarStatus = (() => {
|
||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
||||
|
||||
if (!state.preview) {
|
||||
return `entries: – | start: – | end: – | duration: –${warningText}`;
|
||||
return {
|
||||
entries: '–',
|
||||
groups: '–',
|
||||
milestones: '–',
|
||||
start: '–',
|
||||
end: '–',
|
||||
duration: '–',
|
||||
warnings: warningCount,
|
||||
};
|
||||
}
|
||||
|
||||
const { flatOrder } = state.preview.rundown;
|
||||
const { entries, flatOrder } = state.preview.rundown;
|
||||
const { start, end, duration } = state.preview.summary;
|
||||
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
|
||||
let groups = 0;
|
||||
let milestones = 0;
|
||||
for (const entryId of flatOrder) {
|
||||
const entry = entries[entryId];
|
||||
if (isOntimeGroup(entry)) groups++;
|
||||
else if (isOntimeMilestone(entry)) milestones++;
|
||||
}
|
||||
|
||||
return {
|
||||
entries: flatOrder.length,
|
||||
groups,
|
||||
milestones,
|
||||
start: removeTrailingZero(millisToString(start)),
|
||||
end: removeTrailingZero(millisToString(end)),
|
||||
duration: formatDuration(duration),
|
||||
warnings: warningCount,
|
||||
};
|
||||
})();
|
||||
|
||||
return {
|
||||
|
||||
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
||||
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
|
||||
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 (
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
mode,
|
||||
});
|
||||
|
||||
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
event: CountdownEvent;
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||
};
|
||||
|
||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
|
||||
|
||||
/**
|
||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||
@@ -271,6 +271,7 @@ export function extendEventData(
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
return { ...event, expectedStart, endedAt, expectedEnd };
|
||||
}
|
||||
|
||||
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
||||
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
|
||||
@@ -180,13 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
||||
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;
|
||||
// in case of count to end, the absolute offset is overtime and added time
|
||||
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||
|
||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||
|
||||
// in case of count to end, the absolute offset is just the overtime
|
||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
eventNow: rundown.entries[0],
|
||||
});
|
||||
});
|
||||
|
||||
test('countToEnd entries decouple the link chain for subsequent events', () => {
|
||||
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||
// Event 1: countToEnd=true → breaks the link chain
|
||||
// Event 2: linkStart=true → unlinked (chain broken by event 1)
|
||||
// Event 3: flag event → also unlinked
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||
0: makeOntimeEvent({
|
||||
id: '0',
|
||||
parent: 'group',
|
||||
timeStart: 0,
|
||||
duration: 3600000,
|
||||
countToEnd: false,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
1: makeOntimeEvent({
|
||||
id: '1',
|
||||
parent: 'group',
|
||||
timeStart: 3600000,
|
||||
duration: 3600000,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
2: makeOntimeEvent({
|
||||
id: '2',
|
||||
parent: 'group',
|
||||
timeStart: 7200000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
3: makeOntimeEvent({
|
||||
id: '3',
|
||||
parent: null,
|
||||
timeStart: 10800000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
},
|
||||
order: ['group', '0', '1', '2', '3'],
|
||||
});
|
||||
|
||||
const state = {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[0],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
|
||||
// _group is the last event in the group (event 2)
|
||||
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
|
||||
// accumulatedGap includes event 1's duration carried forward to event 2
|
||||
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
|
||||
expect(state._group).toMatchObject({
|
||||
event: rundown.entries[2],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
|
||||
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
|
||||
// so previousWasCountToEnd is null and no further duration is carried forward
|
||||
expect(state._flag).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _end (event 3): also unlinked because the chain was broken at event 1
|
||||
expect(state._end).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
|
||||
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||
// Event 1: linkStart=true → linked to loaded
|
||||
// Event 2: countToEnd=true → breaks the chain (also last in group)
|
||||
// Event 3: linkStart=true → unlinked (chain broken by event 2)
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||
0: makeOntimeEvent({
|
||||
id: '0',
|
||||
parent: 'group',
|
||||
timeStart: 0,
|
||||
duration: 3600000,
|
||||
countToEnd: false,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
1: makeOntimeEvent({
|
||||
id: '1',
|
||||
parent: 'group',
|
||||
timeStart: 3600000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
2: makeOntimeEvent({
|
||||
id: '2',
|
||||
parent: 'group',
|
||||
timeStart: 7200000,
|
||||
duration: 3600000,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
3: makeOntimeEvent({
|
||||
id: '3',
|
||||
parent: null,
|
||||
timeStart: 10800000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
},
|
||||
order: ['group', '0', '1', '2', '3'],
|
||||
});
|
||||
|
||||
const state = {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[0],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
|
||||
// _group is the last event in the group (event 2)
|
||||
// isLinkedToLoaded is true because no preceding event had countToEnd
|
||||
// accumulatedGap is 0 because no preceding event was countToEnd
|
||||
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
|
||||
expect(state._group).toMatchObject({
|
||||
event: rundown.entries[2],
|
||||
isLinkedToLoaded: true,
|
||||
accumulatedGap: 0,
|
||||
});
|
||||
|
||||
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
|
||||
// accumulatedGap includes event 2's duration carried forward
|
||||
expect(state._flag).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _end (event 3): unlinked because event 2 has countToEnd=true
|
||||
expect(state._end).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
calculateDuration,
|
||||
checkIsNow,
|
||||
dayInMs,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
getLastEventNormal,
|
||||
isPlaybackActive,
|
||||
@@ -831,7 +832,6 @@ function getExpectedTimes(state = runtimeState) {
|
||||
state.offset.expectedRundownEnd = null;
|
||||
state.offset.expectedGroupEnd = null;
|
||||
state.offset.expectedFlagStart = null;
|
||||
state.offset.expectedRundownEnd = null;
|
||||
|
||||
const { offset } = state;
|
||||
const { plannedStart, actualStart } = state.rundown;
|
||||
@@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
||||
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd(
|
||||
|
||||
let accumulatedGap = 0;
|
||||
let isLinkedToLoaded = true;
|
||||
let previousWasCountToEnd: Maybe<number> = null;
|
||||
|
||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||
const entry = entries[playableEventOrder[idx]];
|
||||
@@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd(
|
||||
if (isOntimeEvent(entry)) {
|
||||
if (idx !== currentIndex) {
|
||||
// we only accumulate data after the loaded event
|
||||
accumulatedGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||
|
||||
if (previousWasCountToEnd !== null) {
|
||||
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
|
||||
* and break the link to the loaded event since countToEnd events reset the schedule
|
||||
*/
|
||||
accumulatedGap += entry.gap + previousWasCountToEnd;
|
||||
isLinkedToLoaded = false;
|
||||
} else {
|
||||
accumulatedGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||
}
|
||||
|
||||
if (entry.countToEnd) {
|
||||
previousWasCountToEnd = entry.duration;
|
||||
} else {
|
||||
previousWasCountToEnd = null;
|
||||
}
|
||||
|
||||
// and the loaded event is not allowed to be the next flag
|
||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||
|
||||
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
|
||||
await page.getByRole('button', { name: 'Preview import' }).click();
|
||||
await page.getByRole('button', { name: 'Apply import' }).click();
|
||||
await expect(page.getByText('Import complete')).toBeVisible();
|
||||
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Reset flow' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Import another' }).click();
|
||||
|
||||
// verify the data in the rundown
|
||||
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
||||
|
||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
||||
|
||||
// feature business logic
|
||||
|
||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
||||
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||
|
||||
// feature business logic - rundown
|
||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||
|
||||
+142
-1
@@ -1,7 +1,7 @@
|
||||
import { Day, OffsetMode } from 'ontime-types';
|
||||
|
||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||
import { getExpectedStart } from './getExpectedStart';
|
||||
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
||||
|
||||
describe('getExpectedStart()', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
@@ -315,3 +315,144 @@ describe('getExpectedStart()', () => {
|
||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedEnd()', () => {
|
||||
const baseState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
test('a regular event ends at its expected start plus duration', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: false,
|
||||
};
|
||||
|
||||
// on schedule
|
||||
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
|
||||
|
||||
// running 20 behind pushes the end out
|
||||
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).toBe(170);
|
||||
});
|
||||
|
||||
test('a countToEnd event pins to the planned end while in overtime', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150);
|
||||
});
|
||||
|
||||
test('an overnight countToEnd event returns a normalised end', () => {
|
||||
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
|
||||
const timeStart = 23 * MILLIS_PER_HOUR;
|
||||
const duration = 2 * MILLIS_PER_HOUR;
|
||||
const testEvent = {
|
||||
timeStart,
|
||||
duration,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(timeStart + duration);
|
||||
});
|
||||
|
||||
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 20,
|
||||
dayOffset: 0 as Day,
|
||||
};
|
||||
|
||||
// events shift their schedule based on delay...
|
||||
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
|
||||
|
||||
// ... but count to end events stay pinned to the scheduled end
|
||||
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 60,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// the delayed start (160) is past the fixed end (150), so the event can no longer
|
||||
// finish on time and the end follows the compromised start
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).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 (timeStart + dayInMs + duration),
|
||||
// not collapse to the day-shifted start
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
|
||||
|
||||
// when the running event is already on the same day, no extra day is added
|
||||
const expectedStartSameDay = getExpectedStart(
|
||||
{ ...testEvent, dayOffset: 0 as Day },
|
||||
{ ...baseState, currentDay: 0, offset: 0 },
|
||||
);
|
||||
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
const relativeState = {
|
||||
...baseState,
|
||||
mode: OffsetMode.Relative,
|
||||
actualStart: 30,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
|
||||
180,
|
||||
);
|
||||
|
||||
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
|
||||
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -3,15 +3,6 @@ import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* @param event the event that we are counting to
|
||||
* @param currentDay the day offset of the currently running 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 clock
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export function getExpectedStart(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||
state: {
|
||||
@@ -60,3 +51,25 @@ export function getExpectedStart(
|
||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||
return offsetStartTimeBufferedByGaps;
|
||||
}
|
||||
|
||||
export function getExpectedEnd(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
|
||||
expectedStart: number,
|
||||
currentRuntimeDay: number,
|
||||
): number {
|
||||
/**
|
||||
* Count to end events are a special case
|
||||
* - the end time is always the wall clock
|
||||
*/
|
||||
if (event.countToEnd) {
|
||||
// account for day offset
|
||||
const relativeDayOffset = event.dayOffset - currentRuntimeDay;
|
||||
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
|
||||
|
||||
// count to end should finish on the planned time or on start
|
||||
return Math.max(expectedStart, plannedEnd);
|
||||
}
|
||||
|
||||
// for normal events, the expected end is when we would start + its duration
|
||||
return expectedStart + event.duration;
|
||||
}
|
||||
Reference in New Issue
Block a user