mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 23:19:09 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fec1651e39 | |||
| 1fad99f505 | |||
| 636b2e7c3e | |||
| 5cf36f049a | |||
| c6248b0c72 |
@@ -20,7 +20,7 @@
|
|||||||
.wide {
|
.wide {
|
||||||
top: 4vh;
|
top: 4vh;
|
||||||
min-width: min(1280px, 96vw);
|
min-width: min(1280px, 96vw);
|
||||||
max-width: min(1600px, 96vw);
|
max-width: min(1800px, 98vw);
|
||||||
height: 88vh;
|
height: 88vh;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
|
|||||||
...state.timer,
|
...state.timer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Shared timer for the running group, null unless the group opted in */
|
||||||
|
export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer);
|
||||||
|
|
||||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||||
id: state.eventFlag?.id ?? null,
|
id: state.eventFlag?.id ?? null,
|
||||||
expectedStart: state.offset.expectedFlagStart,
|
expectedStart: state.offset.expectedFlagStart,
|
||||||
|
|||||||
+7
@@ -25,6 +25,13 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sourceGrid {
|
.sourceGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
+22
-8
@@ -8,6 +8,7 @@ import type {
|
|||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||||
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||||
|
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||||
|
|
||||||
const { data: currentRundown } = useRundown();
|
const { data: currentRundown } = useRundown();
|
||||||
const { applyImport } = useSpreadsheetImport();
|
const { applyImport } = useSpreadsheetImport();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
|
|||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGSheetFlow = () => {
|
const openGSheetFlow = () => {
|
||||||
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinished = () => {
|
const handleFinished = (rundownTitle: string) => {
|
||||||
setImportFlow('finished');
|
setImportFlow('finished');
|
||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle(rundownTitle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyImport = async (
|
const handleApplyImport = async (
|
||||||
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
|
|||||||
if (mode === 'new') {
|
if (mode === 'new') {
|
||||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||||
handleFinished();
|
handleFinished(title);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
|
|||||||
customFields: preview.customFields,
|
customFields: preview.customFields,
|
||||||
providedFields,
|
providedFields,
|
||||||
});
|
});
|
||||||
handleFinished();
|
handleFinished(currentRundown.title);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadWorksheetMetadata = useCallback(
|
const loadWorksheetMetadata = useCallback(
|
||||||
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
|
|||||||
{showCompleted && (
|
{showCompleted && (
|
||||||
<div className={style.finishSection}>
|
<div className={style.finishSection}>
|
||||||
<span className={style.finishBadge}>Import complete</span>
|
<span className={style.finishBadge}>Import complete</span>
|
||||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
<div className={style.finishTitle}>
|
||||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||||
<Button variant='subtle-white' onClick={resetFlow}>
|
</div>
|
||||||
Reset flow
|
<div className={style.finishDescription}>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isGSheetFlow && (
|
{isGSheetFlow && (
|
||||||
|
|||||||
+4
@@ -92,6 +92,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarWarning {
|
||||||
|
color: $orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
.mappingPaneTitle {
|
.mappingPaneTitle {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</Panel.InlineElements>
|
||||||
|
|
||||||
<div className={style.editorBody}>
|
<div className={style.editorBody}>
|
||||||
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
|
|||||||
|
|
||||||
<section className={style.previewPane}>
|
<section className={style.previewPane}>
|
||||||
<div className={style.previewPaneHeader}>
|
<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>
|
||||||
<div className={style.tableShell}>
|
<div className={style.tableShell}>
|
||||||
<PreviewTable
|
<PreviewTable
|
||||||
preview={state.preview}
|
preview={state.preview}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
|
canRefresh={canPreview}
|
||||||
isLoadingMetadata={isLoadingMetadata}
|
isLoadingMetadata={isLoadingMetadata}
|
||||||
|
isRefreshing={state.loading === 'preview'}
|
||||||
|
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||||
|
onRefresh={handlePreviewSubmit}
|
||||||
worksheetHeaders={worksheetHeaders}
|
worksheetHeaders={worksheetHeaders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+102
-24
@@ -1,27 +1,35 @@
|
|||||||
.emptyState {
|
.emptyState {
|
||||||
height: 100%;
|
padding: 3rem 1.5rem;
|
||||||
min-height: 16rem;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyTitle {
|
.emptyTitle {
|
||||||
color: $ui-white;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 1rem;
|
color: rgba($gray-200, 0.72);
|
||||||
font-weight: 600;
|
font-size: calc(1rem + 2px);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyBody {
|
.emptyBody {
|
||||||
color: $gray-400;
|
color: rgba($gray-200, 0.55);
|
||||||
font-size: 0.95rem;
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyAction {
|
||||||
|
margin: 1rem auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
color: $ui-white;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
@@ -34,31 +42,101 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
color: $gray-300;
|
||||||
color: $gray-400;
|
font-size: 0.8rem;
|
||||||
text-transform: capitalize;
|
font-weight: 600;
|
||||||
vertical-align: top;
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
vertical-align: bottom;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.5rem;
|
box-sizing: border-box;
|
||||||
min-width: 8rem;
|
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) {
|
tbody tr {
|
||||||
background-color: $white-1;
|
--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 {
|
.rowNumber {
|
||||||
width: 4.5rem;
|
left: 0;
|
||||||
min-width: 4.5rem;
|
width: 3.25rem;
|
||||||
|
min-width: 3.25rem !important;
|
||||||
|
color: $gray-400;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowType {
|
.rowType {
|
||||||
width: 7rem;
|
left: 3.25rem;
|
||||||
min-width: 7rem;
|
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 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 { 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 { getCellValue } from './previewTableUtils';
|
||||||
|
|
||||||
import style from './PreviewTable.module.scss';
|
import style from './PreviewTable.module.scss';
|
||||||
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
|
|||||||
interface PreviewTableProps {
|
interface PreviewTableProps {
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
columnLabels: string[];
|
columnLabels: string[];
|
||||||
|
canRefresh: boolean;
|
||||||
isLoadingMetadata: boolean;
|
isLoadingMetadata: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
worksheetHeaders: string[];
|
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({
|
export default function PreviewTable({
|
||||||
preview,
|
preview,
|
||||||
columnLabels,
|
columnLabels,
|
||||||
|
canRefresh,
|
||||||
isLoadingMetadata,
|
isLoadingMetadata,
|
||||||
|
isRefreshing,
|
||||||
|
needsPreviewRefresh,
|
||||||
|
onRefresh,
|
||||||
worksheetHeaders,
|
worksheetHeaders,
|
||||||
}: PreviewTableProps) {
|
}: PreviewTableProps) {
|
||||||
const customFieldKeyByLabel = useMemo(() => {
|
const customFieldKeyByLabel = useMemo(() => {
|
||||||
@@ -24,33 +99,57 @@ export default function PreviewTable({
|
|||||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||||
}, [preview]);
|
}, [preview]);
|
||||||
|
|
||||||
|
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||||
|
|
||||||
|
const previewMetadata = useMemo(() => {
|
||||||
|
if (!preview) return null;
|
||||||
|
return getRundownMetadata(preview.rundown, null);
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
|
let emptyTitle = 'Preview not generated';
|
||||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||||
|
|
||||||
if (isLoadingMetadata) {
|
if (isLoadingMetadata) {
|
||||||
|
emptyTitle = 'Loading worksheet';
|
||||||
emptyContent = 'Loading worksheet metadata...';
|
emptyContent = 'Loading worksheet metadata...';
|
||||||
} else if (worksheetHeaders.length === 0) {
|
} else if (worksheetHeaders.length === 0) {
|
||||||
|
emptyTitle = 'No headers found';
|
||||||
emptyContent =
|
emptyContent =
|
||||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
'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 (
|
return (
|
||||||
<div className={style.emptyState}>
|
<div className={style.emptyState}>
|
||||||
<div className={style.emptyTitle}>Preview not generated</div>
|
<div className={style.emptyMessage}>
|
||||||
<div className={style.emptyBody}>{emptyContent}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.rowNumber}>#</th>
|
<th className={style.rowNumber}>#</th>
|
||||||
<th className={style.rowType}>Type</th>
|
<th className={style.rowType}>Type</th>
|
||||||
{columnLabels.map((label, index) => (
|
{displayColumns.map((label, index) => (
|
||||||
<th key={`${label}-${index}`}>{label}</th>
|
<th key={`${label}-${index}`}>{label}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,16 +158,29 @@ export default function PreviewTable({
|
|||||||
{preview.rundown.flatOrder.map((entryId) => {
|
{preview.rundown.flatOrder.map((entryId) => {
|
||||||
const entry = preview.rundown.entries[entryId];
|
const entry = preview.rundown.entries[entryId];
|
||||||
const isEvent = isOntimeEvent(entry);
|
const isEvent = isOntimeEvent(entry);
|
||||||
if (isEvent) eventIndex++;
|
const entryMetadata = previewMetadata?.[entryId];
|
||||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={entryId}>
|
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
<td className={style.rowType}>
|
||||||
{columnLabels.map((label, colIndex) => (
|
<Tag>{entryType}</Tag>
|
||||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+36
-11
@@ -5,7 +5,8 @@ import type {
|
|||||||
SpreadsheetPreviewResponse,
|
SpreadsheetPreviewResponse,
|
||||||
SpreadsheetWorksheetMetadata,
|
SpreadsheetWorksheetMetadata,
|
||||||
} from 'ontime-types';
|
} 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 { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ type ImportAction =
|
|||||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||||
| { type: 'applySuccess' }
|
| { type: 'applySuccess' }
|
||||||
| { type: 'exportSuccess' }
|
| { type: 'exportSuccess' }
|
||||||
| { type: 'clearPreview'; error?: string }
|
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||||
| { type: 'failure'; error: string }
|
| { type: 'failure'; error: string }
|
||||||
| { type: 'reset' };
|
| { type: 'reset' };
|
||||||
|
|
||||||
@@ -40,12 +41,14 @@ type ImportState = {
|
|||||||
loading: '' | 'preview' | 'apply' | 'export';
|
loading: '' | 'preview' | 'apply' | 'export';
|
||||||
error: string;
|
error: string;
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialImportState: ImportState = {
|
const initialImportState: ImportState = {
|
||||||
loading: '',
|
loading: '',
|
||||||
error: '',
|
error: '',
|
||||||
preview: null,
|
preview: null,
|
||||||
|
needsPreviewRefresh: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||||
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
|||||||
case 'startExport':
|
case 'startExport':
|
||||||
return { ...state, loading: 'export', error: '' };
|
return { ...state, loading: 'export', error: '' };
|
||||||
case 'previewSuccess':
|
case 'previewSuccess':
|
||||||
return { loading: '', error: '', preview: action.preview };
|
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||||
case 'applySuccess':
|
case 'applySuccess':
|
||||||
case 'exportSuccess':
|
case 'exportSuccess':
|
||||||
return { ...state, loading: '' };
|
return { ...state, loading: '' };
|
||||||
case 'clearPreview':
|
case 'clearPreview':
|
||||||
return { ...state, error: action.error ?? '', preview: null };
|
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||||
case 'failure': {
|
case 'failure': {
|
||||||
if (state.loading === 'preview') {
|
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 };
|
return { ...state, loading: '', error: action.error };
|
||||||
}
|
}
|
||||||
@@ -221,7 +224,7 @@ export function useSheetImportForm({
|
|||||||
const sub = watch(() => {
|
const sub = watch(() => {
|
||||||
if (!previewRef.current) return;
|
if (!previewRef.current) return;
|
||||||
previewRef.current = null;
|
previewRef.current = null;
|
||||||
dispatch({ type: 'clearPreview' });
|
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||||
});
|
});
|
||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [watch]);
|
}, [watch]);
|
||||||
@@ -297,15 +300,37 @@ export function useSheetImportForm({
|
|||||||
}, [append]);
|
}, [append]);
|
||||||
|
|
||||||
const toolbarStatus = (() => {
|
const toolbarStatus = (() => {
|
||||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
|
||||||
|
|
||||||
if (!state.preview) {
|
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;
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||||
@@ -22,6 +22,7 @@ const secondarySourceLabels: Record<string, string> = {
|
|||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
const isGroupTimerActive = useGroupTimer() !== null;
|
||||||
|
|
||||||
const main = (() => {
|
const main = (() => {
|
||||||
if (showTimerMessage) return 'Message';
|
if (showTimerMessage) return 'Message';
|
||||||
@@ -105,6 +106,14 @@ export default function TimerPreview() {
|
|||||||
>
|
>
|
||||||
<LuArrowDownToLine />
|
<LuArrowDownToLine />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text={`Timer views are showing the ${isGroupTimerActive ? 'shared group timer' : 'event timer'}`}
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={isGroupTimerActive}
|
||||||
|
>
|
||||||
|
<IoFolderOutline />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useCallback } from 'react';
|
|||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
|
import Switch from '../../../common/components/switch/Switch';
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
|||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Tooltip
|
||||||
|
text='Timer views count the scheduled duration of the whole group instead of the running event'
|
||||||
|
render={<Editor.Label htmlFor='useGroupTimer' />}
|
||||||
|
>
|
||||||
|
Group timer
|
||||||
|
</Tooltip>
|
||||||
|
<Editor.Label className={style.switchLabel}>
|
||||||
|
<Switch
|
||||||
|
id='useGroupTimer'
|
||||||
|
checked={group.useGroupTimer}
|
||||||
|
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
|
||||||
|
/>
|
||||||
|
{group.useGroupTimer ? 'On' : 'Off'}
|
||||||
|
</Editor.Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import Tag from '../../../common/components/tag/Tag';
|
import Tag from '../../../common/components/tag/Tag';
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{data.useGroupTimer && (
|
||||||
|
<div className={style.metaEntry}>
|
||||||
|
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
|
||||||
|
<Tag>Group timer</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,18 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signals that the displayed timer belongs to the group, not to the running event
|
||||||
|
.group-indicator {
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2.5vw;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--timer-colour, $ui-white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
|
|||||||
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import { useTimerSocket } from '../../../common/hooks/useSocket';
|
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||||
import {
|
import {
|
||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
getIsPlaying,
|
getIsPlaying,
|
||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowMessage,
|
getShowMessage,
|
||||||
getShowModifiers,
|
getShowsTimerValue,
|
||||||
getShowProgressBar,
|
resolveTimerDisplay,
|
||||||
getTotalTime,
|
|
||||||
} from '../../timer/timer.utils';
|
} from '../../timer/timer.utils';
|
||||||
import { getTimerColour } from '../../utils/presentation.utils';
|
import { getTimerColour } from '../../utils/presentation.utils';
|
||||||
|
|
||||||
@@ -24,23 +23,28 @@ interface PipTimerProps {
|
|||||||
|
|
||||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||||
|
const groupTimer = useGroupTimer();
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
const { showFinished, showWarning, showDanger } = getShowModifiers(
|
const timerDisplay = resolveTimerDisplay({
|
||||||
timerTypeNow,
|
time,
|
||||||
countToEndNow,
|
groupTimer,
|
||||||
time.phase,
|
event: eventNow,
|
||||||
false,
|
timerType: timerTypeNow,
|
||||||
'',
|
countToEnd: countToEndNow,
|
||||||
false,
|
freezeOvertime: false,
|
||||||
);
|
freezeMessage: '',
|
||||||
|
hidePhase: false,
|
||||||
|
});
|
||||||
|
const { showFinished, showWarning, showDanger } = timerDisplay;
|
||||||
|
|
||||||
const isPlaying = getIsPlaying(time.playback);
|
const isPlaying = getIsPlaying(time.playback);
|
||||||
const showProgressBar = getShowProgressBar(timerTypeNow);
|
const showsTimerValue = getShowsTimerValue(timerTypeNow);
|
||||||
|
const showProgressBar = showsTimerValue;
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
|
||||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
|
||||||
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
||||||
removeSeconds: false,
|
removeSeconds: false,
|
||||||
removeLeadingZero: false,
|
removeLeadingZero: false,
|
||||||
@@ -80,10 +84,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
<div
|
<div
|
||||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: `${timerFontSize}vw` }}
|
||||||
data-phase={time.phase}
|
data-phase={timerDisplay.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</div>
|
</div>
|
||||||
|
{timerDisplay.isGroup && showsTimerValue && <div className='group-indicator'>group</div>}
|
||||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||||
<FitText mode='multi' min={12} max={256}>
|
<FitText mode='multi' min={12} max={256}>
|
||||||
{secondaryContent}
|
{secondaryContent}
|
||||||
@@ -94,12 +99,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
{showProgressBar && (
|
{showProgressBar && (
|
||||||
<MultiPartProgressBar
|
<MultiPartProgressBar
|
||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={timerDisplay.source.current}
|
||||||
complete={totalTime}
|
complete={timerDisplay.total}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={timerDisplay.warning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={timerDisplay.danger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -131,6 +131,18 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signals that the displayed timer belongs to the group, not to the running event
|
||||||
|
.group-indicator {
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.5vw;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--timer-colour, var(--timer-color-override, $ui-white));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
|||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
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 { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
@@ -25,9 +25,8 @@ import {
|
|||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowClock,
|
getShowClock,
|
||||||
getShowMessage,
|
getShowMessage,
|
||||||
getShowModifiers,
|
getShowsTimerValue,
|
||||||
getShowProgressBar,
|
resolveTimerDisplay,
|
||||||
getTotalTime,
|
|
||||||
} from './timer.utils';
|
} from './timer.utils';
|
||||||
import { TimerData, useTimerData } from './useTimerData';
|
import { TimerData, useTimerData } from './useTimerData';
|
||||||
|
|
||||||
@@ -72,23 +71,29 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
timeformat,
|
timeformat,
|
||||||
} = useTimerOptions();
|
} = useTimerOptions();
|
||||||
|
|
||||||
|
const groupTimer = useGroupTimer();
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const localisedMinutes = getLocalizedString('common.minutes');
|
const localisedMinutes = getLocalizedString('common.minutes');
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const viewTimerType = timerType ?? timerTypeNow;
|
const viewTimerType = timerType ?? timerTypeNow;
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
|
const timerDisplay = resolveTimerDisplay({
|
||||||
timerTypeNow,
|
time,
|
||||||
countToEndNow,
|
groupTimer,
|
||||||
time.phase,
|
event: eventNow,
|
||||||
|
timerType: timerTypeNow,
|
||||||
|
countToEnd: countToEndNow,
|
||||||
freezeOvertime,
|
freezeOvertime,
|
||||||
freezeMessage,
|
freezeMessage,
|
||||||
hidePhase,
|
hidePhase,
|
||||||
);
|
});
|
||||||
|
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
|
||||||
const isPlaying = getIsPlaying(time.playback);
|
const isPlaying = getIsPlaying(time.playback);
|
||||||
const showClock = !hideClock && getShowClock(viewTimerType);
|
const showClock = !hideClock && getShowClock(viewTimerType);
|
||||||
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
|
const showsTimerValue = getShowsTimerValue(viewTimerType);
|
||||||
|
const showProgressBar = !hideProgress && showsTimerValue;
|
||||||
|
|
||||||
// gather card data
|
// gather card data
|
||||||
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
||||||
@@ -102,8 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
);
|
);
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
|
||||||
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
|
|
||||||
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||||
removeSeconds: hideTimerSeconds,
|
removeSeconds: hideTimerSeconds,
|
||||||
removeLeadingZero: removeLeadingZeros,
|
removeLeadingZero: removeLeadingZeros,
|
||||||
@@ -178,11 +182,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: `${timerFontSize}vw` }}
|
||||||
data-type={viewTimerType}
|
data-type={viewTimerType}
|
||||||
data-phase={time.phase}
|
data-phase={timerDisplay.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{timerDisplay.isGroup && showsTimerValue && !showEndMessage && (
|
||||||
|
<div className='group-indicator'>group</div>
|
||||||
|
)}
|
||||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||||
<FitText mode='multi' min={64} max={256}>
|
<FitText mode='multi' min={64} max={256}>
|
||||||
{secondaryContent}
|
{secondaryContent}
|
||||||
@@ -193,12 +200,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
{showProgressBar && (
|
{showProgressBar && (
|
||||||
<MultiPartProgressBar
|
<MultiPartProgressBar
|
||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={timerDisplay.source.current}
|
||||||
complete={totalTime}
|
complete={timerDisplay.total}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={timerDisplay.warning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={timerDisplay.danger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { GroupTimerState, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getShowsTimerValue, resolveTimerDisplay } from '../timer.utils';
|
||||||
|
|
||||||
|
const makeTime = (patch: Partial<TimerState> = {}): TimerState => ({
|
||||||
|
addedTime: 0,
|
||||||
|
current: 10000,
|
||||||
|
duration: 60000,
|
||||||
|
elapsed: 50000,
|
||||||
|
expectedFinish: null,
|
||||||
|
phase: TimerPhase.Default,
|
||||||
|
playback: Playback.Play,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: null,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeGroupTimer = (patch: Partial<GroupTimerState> = {}): GroupTimerState => ({
|
||||||
|
current: 100000,
|
||||||
|
elapsed: 80000,
|
||||||
|
duration: 180000,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolve = (time: TimerState, groupTimer: GroupTimerState | null) =>
|
||||||
|
resolveTimerDisplay({
|
||||||
|
time,
|
||||||
|
groupTimer,
|
||||||
|
event: { timeWarning: 120000, timeDanger: 60000 },
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
freezeOvertime: false,
|
||||||
|
freezeMessage: '',
|
||||||
|
hidePhase: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTimerDisplay()', () => {
|
||||||
|
it('uses the event timer when there is no group timer', () => {
|
||||||
|
const time = makeTime();
|
||||||
|
const result = resolve(time, null);
|
||||||
|
|
||||||
|
expect(result.isGroup).toBe(false);
|
||||||
|
expect(result.source).toBe(time);
|
||||||
|
expect(result.total).toBe(60000);
|
||||||
|
expect(result.warning).toBe(120000);
|
||||||
|
expect(result.danger).toBe(60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the time added to an event into the event total', () => {
|
||||||
|
expect(resolve(makeTime({ addedTime: 30000 }), null).total).toBe(90000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the group timer when one is present', () => {
|
||||||
|
const groupTimer = makeGroupTimer();
|
||||||
|
const result = resolve(makeTime(), groupTimer);
|
||||||
|
|
||||||
|
expect(result.isGroup).toBe(true);
|
||||||
|
expect(result.source).toBe(groupTimer);
|
||||||
|
expect(result.total).toBe(180000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the event thresholds while showing the group', () => {
|
||||||
|
const result = resolve(makeTime({ phase: TimerPhase.Danger }), makeGroupTimer());
|
||||||
|
|
||||||
|
expect(result.warning).toBeUndefined();
|
||||||
|
expect(result.danger).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([TimerPhase.Warning, TimerPhase.Danger])(
|
||||||
|
'reports the group as running while the event is in %s',
|
||||||
|
(phase) => {
|
||||||
|
const result = resolve(makeTime({ phase }), makeGroupTimer());
|
||||||
|
|
||||||
|
expect(result.phase).toBe(TimerPhase.Default);
|
||||||
|
expect(result.showWarning).toBe(false);
|
||||||
|
expect(result.showDanger).toBe(false);
|
||||||
|
expect(result.showFinished).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reports overtime only once the group itself has run out of time', () => {
|
||||||
|
const overrunningEvent = makeTime({ current: -5000, phase: TimerPhase.Overtime });
|
||||||
|
|
||||||
|
// the event is over, but the group still has time left
|
||||||
|
const stillRunning = resolve(overrunningEvent, makeGroupTimer({ current: 60000 }));
|
||||||
|
expect(stillRunning.phase).toBe(TimerPhase.Default);
|
||||||
|
expect(stillRunning.showFinished).toBe(false);
|
||||||
|
|
||||||
|
const overtime = resolve(overrunningEvent, makeGroupTimer({ current: -1000 }));
|
||||||
|
expect(overtime.phase).toBe(TimerPhase.Overtime);
|
||||||
|
expect(overtime.showFinished).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([TimerPhase.Pending, TimerPhase.None])('keeps the %s playback phase on the group', (phase) => {
|
||||||
|
// these describe the playback state rather than a threshold, so they are true of the group too
|
||||||
|
expect(resolve(makeTime({ phase }), makeGroupTimer()).phase).toBe(phase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShowsTimerValue()', () => {
|
||||||
|
it('is true for timer types which render the running timer', () => {
|
||||||
|
expect(getShowsTimerValue(TimerType.CountDown)).toBe(true);
|
||||||
|
expect(getShowsTimerValue(TimerType.CountUp)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for timer types which do not reflect what is loaded', () => {
|
||||||
|
expect(getShowsTimerValue(TimerType.Clock)).toBe(false);
|
||||||
|
expect(getShowsTimerValue(TimerType.None)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
GroupTimerState,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
MessageState,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
RundownEntries,
|
RundownEntries,
|
||||||
TimerMessage,
|
TimerMessage,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
|
TimerState,
|
||||||
TimerType,
|
TimerType,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
@@ -34,9 +36,11 @@ export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): num
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the progress bar should be shown for this timer type
|
* Whether this timer type renders a value derived from the running timer.
|
||||||
|
* Clock shows the time of day and none shows nothing, so neither reflects what is loaded,
|
||||||
|
* which also means neither can carry a progress bar or a group timer indicator
|
||||||
*/
|
*/
|
||||||
export function getShowProgressBar(timerType: TimerType) {
|
export function getShowsTimerValue(timerType: TimerType) {
|
||||||
return timerType !== TimerType.None && timerType !== TimerType.Clock;
|
return timerType !== TimerType.None && timerType !== TimerType.Clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +118,59 @@ export function getShowModifiers(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolveTimerDisplayOptions {
|
||||||
|
time: TimerState;
|
||||||
|
/** when present, views show the group instead of the running event */
|
||||||
|
groupTimer: GroupTimerState | null;
|
||||||
|
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | null;
|
||||||
|
timerType: TimerType;
|
||||||
|
countToEnd: boolean;
|
||||||
|
freezeOvertime: boolean;
|
||||||
|
freezeMessage: string;
|
||||||
|
hidePhase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which timer a view should display, and the modifiers that go with it.
|
||||||
|
*
|
||||||
|
* A group has no warning or danger thresholds of its own, so it only ever reports as
|
||||||
|
* running or overtime. Feeding that phase through `getShowModifiers` means the warning
|
||||||
|
* and danger states are suppressed as a consequence of what a group is, rather than
|
||||||
|
* every view having to remember to special case them.
|
||||||
|
*/
|
||||||
|
export function resolveTimerDisplay({
|
||||||
|
time,
|
||||||
|
groupTimer,
|
||||||
|
event,
|
||||||
|
timerType,
|
||||||
|
countToEnd,
|
||||||
|
freezeOvertime,
|
||||||
|
freezeMessage,
|
||||||
|
hidePhase,
|
||||||
|
}: ResolveTimerDisplayOptions) {
|
||||||
|
const isGroup = groupTimer !== null;
|
||||||
|
const phase = (() => {
|
||||||
|
if (!isGroup) return time.phase;
|
||||||
|
// pending and none describe the playback state rather than a threshold,
|
||||||
|
// they are just as true of the group as they are of the event
|
||||||
|
if (time.phase === TimerPhase.Pending || time.phase === TimerPhase.None) return time.phase;
|
||||||
|
return groupTimer.current <= 0 ? TimerPhase.Overtime : TimerPhase.Default;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGroup,
|
||||||
|
phase,
|
||||||
|
/** the values to render, shaped for getTimerByType */
|
||||||
|
source: isGroup ? groupTimer : time,
|
||||||
|
/** the target of a progress bar */
|
||||||
|
total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime),
|
||||||
|
// thresholds belong to the event, they carry no meaning against a group duration
|
||||||
|
warning: isGroup ? undefined : event?.timeWarning,
|
||||||
|
danger: isGroup ? undefined : event?.timeDanger,
|
||||||
|
...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What, if anything, should be displayed in the secondary field
|
* What, if anything, should be displayed in the secondary field
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ export function migrateRundown(
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
colour: '', //leave default colour
|
colour: '', //leave default colour
|
||||||
custom: {}, // leave empty
|
custom: {}, // leave empty
|
||||||
|
useGroupTimer: false, // opt-in feature, off for migrated projects
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: -1,
|
revision: -1,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
useGroupTimer: false,
|
||||||
},
|
},
|
||||||
event2: {
|
event2: {
|
||||||
id: 'event2',
|
id: 'event2',
|
||||||
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
|
useGroupTimer: false,
|
||||||
},
|
},
|
||||||
delay: {
|
delay: {
|
||||||
type: SupportedEntry.Delay,
|
type: SupportedEntry.Delay,
|
||||||
|
|||||||
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
|
|||||||
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
|
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
|
||||||
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalises groups from projects made before the group timer existed', () => {
|
||||||
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: '',
|
||||||
|
order: ['group'],
|
||||||
|
flatOrder: ['group'],
|
||||||
|
entries: {
|
||||||
|
// a group as it would have been persisted by a previous version
|
||||||
|
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
} as unknown as Rundown;
|
||||||
|
|
||||||
|
const parsedRundown = parseRundown(rundown, {});
|
||||||
|
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sanitiseCustomFields()', () => {
|
describe('sanitiseCustomFields()', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
|
|||||||
import { parseRundown } from '../rundown.parser.js';
|
import { parseRundown } from '../rundown.parser.js';
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
|
createGroupPatch,
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
|
|||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createGroupPatch()', () => {
|
||||||
|
it('preserves useGroupTimer when it is not part of the patch', () => {
|
||||||
|
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
|
||||||
|
const patched = createGroupPatch(original, { title: 'Morning sessions' });
|
||||||
|
|
||||||
|
expect(patched.title).toBe('Morning sessions');
|
||||||
|
expect(patched.useGroupTimer).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates useGroupTimer when it is part of the patch', () => {
|
||||||
|
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
|
||||||
|
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
|
||||||
|
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non boolean values for useGroupTimer', () => {
|
||||||
|
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
|
||||||
|
// @ts-expect-error -- testing a value coming from an unvalidated request
|
||||||
|
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
|||||||
entries: patchGroup.entries ?? originalGroup.entries,
|
entries: patchGroup.entries ?? originalGroup.entries,
|
||||||
targetDuration: maybeTargetDuration(),
|
targetDuration: maybeTargetDuration(),
|
||||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||||
|
useGroupTimer:
|
||||||
|
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||||
revision: originalGroup.revision,
|
revision: originalGroup.revision,
|
||||||
timeStart: originalGroup.timeStart,
|
timeStart: originalGroup.timeStart,
|
||||||
timeEnd: originalGroup.timeEnd,
|
timeEnd: originalGroup.timeEnd,
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
|
|||||||
note: string
|
note: string
|
||||||
entries: EntryId[]
|
entries: EntryId[]
|
||||||
targetDuration: number | null
|
targetDuration: number | null
|
||||||
|
useGroupTimer: boolean // views show a shared timer for the whole group
|
||||||
custom: { [key: string]: string }
|
custom: { [key: string]: string }
|
||||||
timeStart: number | null // calculated from nested entries (runtime)
|
timeStart: number | null // calculated from nested entries (runtime)
|
||||||
timeEnd: number | null // calculated from nested entries (runtime)
|
timeEnd: number | null // calculated from nested entries (runtime)
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export type EventFieldArgs = Partial<
|
|||||||
>;
|
>;
|
||||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
||||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
||||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
export type GroupFieldArgs = Partial<
|
||||||
|
Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom' | 'useGroupTimer'>
|
||||||
|
>;
|
||||||
|
|
||||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
||||||
export type TargetRundownArgs = { rundownId?: string };
|
export type TargetRundownArgs = { rundownId?: string };
|
||||||
@@ -170,6 +172,7 @@ function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGro
|
|||||||
if (args.colour !== undefined) patch.colour = args.colour;
|
if (args.colour !== undefined) patch.colour = args.colour;
|
||||||
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
||||||
if (args.custom !== undefined) patch.custom = args.custom;
|
if (args.custom !== undefined) patch.custom = args.custom;
|
||||||
|
if (args.useGroupTimer !== undefined) patch.useGroupTimer = args.useGroupTimer;
|
||||||
|
|
||||||
return Object.keys(patch).length > 1 ? patch : null;
|
return Object.keys(patch).length > 1 ? patch : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
|||||||
eventStore.init({
|
eventStore.init({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
timer: state.timer,
|
timer: state.timer,
|
||||||
|
groupTimer: state.groupTimer,
|
||||||
message: { ...runtimeStorePlaceholder.message },
|
message: { ...runtimeStorePlaceholder.message },
|
||||||
offset: state.offset,
|
offset: state.offset,
|
||||||
rundown: state.rundown,
|
rundown: state.rundown,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['0aaa7d'],
|
entries: ['0aaa7d'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#A790F5',
|
colour: '#A790F5',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0301'],
|
entries: ['bs0301'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0101', 'br0102', 'br0103'],
|
entries: ['br0101', 'br0102', 'br0103'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#ED3333',
|
colour: '#ED3333',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0301', 'br0302'],
|
entries: ['br0301', 'br0302'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0401', 'br0402', 'br0403'],
|
entries: ['br0401', 'br0402', 'br0403'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import {
|
||||||
|
EndAction,
|
||||||
|
MaybeNumber,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeGroup,
|
||||||
|
Playback,
|
||||||
|
Rundown,
|
||||||
|
SupportedEntry,
|
||||||
|
TimeOfDay,
|
||||||
|
TimeStrategy,
|
||||||
|
TimerPhase,
|
||||||
|
TimerType,
|
||||||
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||||
@@ -7,6 +19,8 @@ import {
|
|||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
|
getGroupTimer,
|
||||||
|
getGroupTiming,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
@@ -1452,3 +1466,138 @@ describe('findDay()', () => {
|
|||||||
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
|
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getGroupTiming()', () => {
|
||||||
|
const makeEvent = (id: string, duration: number, patch: object = {}) =>
|
||||||
|
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
|
||||||
|
|
||||||
|
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
|
||||||
|
|
||||||
|
const makeEntries = (...entries: OntimeEvent[]) =>
|
||||||
|
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
|
||||||
|
|
||||||
|
it('splits the scheduled content around the loaded event', () => {
|
||||||
|
const group = makeGroup(['1', '2', '3']);
|
||||||
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
|
||||||
|
|
||||||
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
|
||||||
|
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
|
||||||
|
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for the gaps between events', () => {
|
||||||
|
const group = makeGroup(['1', '2', '3']);
|
||||||
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
|
||||||
|
|
||||||
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
|
||||||
|
// the gap before the loaded event has already passed, so it counts as time spent
|
||||||
|
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits into values which add up to the group duration', () => {
|
||||||
|
const group = makeGroup(['1', '2', '3']);
|
||||||
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
|
||||||
|
const groupDuration = 10 + 20 + 5 + 30;
|
||||||
|
|
||||||
|
for (const id of ['1', '2', '3']) {
|
||||||
|
const timing = getGroupTiming(group, entries, id)!;
|
||||||
|
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips entries which are not playable events', () => {
|
||||||
|
const group = makeGroup(['1', '2', '3', '4']);
|
||||||
|
const entries = {
|
||||||
|
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
|
||||||
|
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
|
||||||
|
} as Rundown['entries'];
|
||||||
|
|
||||||
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the loaded event is not part of the group', () => {
|
||||||
|
const group = makeGroup(['1', '2']);
|
||||||
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
|
||||||
|
|
||||||
|
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGroupTimer()', () => {
|
||||||
|
const makeState = (patch: {
|
||||||
|
useGroupTimer?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
timing?: { before: number; after: number } | null;
|
||||||
|
current?: MaybeNumber;
|
||||||
|
elapsed?: MaybeNumber;
|
||||||
|
addedTime?: number;
|
||||||
|
}) =>
|
||||||
|
({
|
||||||
|
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
|
||||||
|
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
|
||||||
|
timer: {
|
||||||
|
current: patch.current ?? null,
|
||||||
|
elapsed: patch.elapsed ?? null,
|
||||||
|
addedTime: patch.addedTime ?? 0,
|
||||||
|
},
|
||||||
|
}) as RuntimeState;
|
||||||
|
|
||||||
|
it('is null when there is no group running', () => {
|
||||||
|
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when the group has not opted in', () => {
|
||||||
|
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when the loaded event is not part of the group', () => {
|
||||||
|
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the event timer by the content scheduled around it', () => {
|
||||||
|
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
|
||||||
|
|
||||||
|
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps elapsed and current adding up to the total', () => {
|
||||||
|
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
|
||||||
|
const groupTimer = getGroupTimer(state)!;
|
||||||
|
|
||||||
|
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows the total with the time added to the running event', () => {
|
||||||
|
const state = makeState({
|
||||||
|
duration: 100,
|
||||||
|
timing: { before: 0, after: 0 },
|
||||||
|
current: 160,
|
||||||
|
elapsed: 0,
|
||||||
|
addedTime: 60,
|
||||||
|
});
|
||||||
|
const groupTimer = getGroupTimer(state)!;
|
||||||
|
|
||||||
|
expect(groupTimer.duration).toBe(160);
|
||||||
|
// elapsed keeps counting up rather than clamping at zero once time is added
|
||||||
|
expect(groupTimer.elapsed).toBe(0);
|
||||||
|
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports elapsed time while the group is in credit from added time', () => {
|
||||||
|
const state = makeState({
|
||||||
|
duration: 100,
|
||||||
|
timing: { before: 0, after: 0 },
|
||||||
|
current: 150,
|
||||||
|
elapsed: 10,
|
||||||
|
addedTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes negative when the group runs into overtime', () => {
|
||||||
|
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
|
||||||
|
|
||||||
|
expect(getGroupTimer(state)?.current).toBe(-15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
RuntimeService.previousState.timer = { ...state.timer };
|
RuntimeService.previousState.timer = { ...state.timer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the group timer is the event timer offset by a constant, so it changes exactly when
|
||||||
|
* the event timer does and can share its throttling.
|
||||||
|
* entry changes are also considered since loading an event can move us between groups
|
||||||
|
*/
|
||||||
|
if (updateTimer || entryChanged) {
|
||||||
|
if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) {
|
||||||
|
batch.add('groupTimer', state.groupTimer);
|
||||||
|
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* clock has changed by a second or more.
|
* clock has changed by a second or more.
|
||||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
import {
|
||||||
|
Day,
|
||||||
|
EntryId,
|
||||||
|
GroupTimerState,
|
||||||
|
MaybeNumber,
|
||||||
|
OntimeGroup,
|
||||||
|
Rundown,
|
||||||
|
TimeOfDay,
|
||||||
|
TimerPhase,
|
||||||
|
isOntimeEvent,
|
||||||
|
isPlayableEvent,
|
||||||
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* handle events that span over midnight
|
* handle events that span over midnight
|
||||||
@@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
|
|||||||
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
||||||
return 0 as Day;
|
return 0 as Day;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a group's scheduled content around the loaded event.
|
||||||
|
*
|
||||||
|
* The result is stable for as long as the same event is loaded, so it is calculated
|
||||||
|
* when the group is loaded rather than on every update.
|
||||||
|
* The aggregation mirrors the group duration calculated in the rundown
|
||||||
|
* (see rundown.dao.ts): non playable entries are skipped and the gap is
|
||||||
|
* accounted for in every entry other than the first.
|
||||||
|
*/
|
||||||
|
export function getGroupTiming(
|
||||||
|
group: OntimeGroup,
|
||||||
|
entries: Rundown['entries'],
|
||||||
|
currentEventId: EntryId,
|
||||||
|
): GroupTiming | null {
|
||||||
|
const currentIndex = group.entries.indexOf(currentEventId);
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = 0;
|
||||||
|
let after = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < group.entries.length; i++) {
|
||||||
|
const entry = entries[group.entries[i]];
|
||||||
|
if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first entry of the group has no gap to account for,
|
||||||
|
// any other entry could be preceded by idle time
|
||||||
|
const gap = i > 0 ? entry.gap : 0;
|
||||||
|
|
||||||
|
if (i < currentIndex) {
|
||||||
|
before += gap + entry.duration;
|
||||||
|
} else if (i === currentIndex) {
|
||||||
|
// the loaded event contributes its own duration through the event timer,
|
||||||
|
// but the idle time before it has already passed
|
||||||
|
before += gap;
|
||||||
|
} else {
|
||||||
|
after += gap + entry.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { before, after };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the shared group timer from the event timer.
|
||||||
|
*
|
||||||
|
* Keeping this relative to the event timer is what makes the group behave as if it were
|
||||||
|
* a single event containing all its children: pause, added time, overtime, roll and
|
||||||
|
* midnight rollovers are all inherited instead of being recalculated.
|
||||||
|
*/
|
||||||
|
export function getGroupTimer(state: RuntimeState): GroupTimerState | null {
|
||||||
|
const { groupNow, _groupTiming } = state;
|
||||||
|
|
||||||
|
if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current, elapsed, addedTime } = state.timer;
|
||||||
|
if (current === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: current + _groupTiming.after,
|
||||||
|
elapsed: (elapsed ?? 0) + _groupTiming.before,
|
||||||
|
// mirrors the total time of an event, which grows with the time added to it
|
||||||
|
duration: groupNow.duration + addedTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const baseState: RuntimeState = {
|
|||||||
eventNext: null,
|
eventNext: null,
|
||||||
eventFlag: null,
|
eventFlag: null,
|
||||||
groupNow: null,
|
groupNow: null,
|
||||||
|
groupTimer: null,
|
||||||
|
_groupTiming: null,
|
||||||
rundown: {
|
rundown: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null,
|
||||||
numEvents: 0,
|
numEvents: 0,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TimeOfDay,
|
TimeOfDay,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
|
GroupTimerState,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
runtimeStorePlaceholder,
|
runtimeStorePlaceholder,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
|
getGroupTimer,
|
||||||
|
getGroupTiming,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
@@ -51,6 +54,12 @@ type ExpectedMetadata = {
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
/** scheduled content of the running group, split around the loaded event */
|
||||||
|
export type GroupTiming = {
|
||||||
|
before: number;
|
||||||
|
after: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeState = {
|
export type RuntimeState = {
|
||||||
clock: TimeOfDay;
|
clock: TimeOfDay;
|
||||||
groupNow: OntimeGroup | null;
|
groupNow: OntimeGroup | null;
|
||||||
@@ -59,6 +68,8 @@ export type RuntimeState = {
|
|||||||
eventFlag: PlayableEvent | null;
|
eventFlag: PlayableEvent | null;
|
||||||
offset: Offset;
|
offset: Offset;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
|
/** derived from the timer, only refreshed on the getState() projection */
|
||||||
|
groupTimer: GroupTimerState | null;
|
||||||
rundown: RundownState;
|
rundown: RundownState;
|
||||||
// private properties of the timer calculations
|
// private properties of the timer calculations
|
||||||
_timer: {
|
_timer: {
|
||||||
@@ -72,6 +83,7 @@ export type RuntimeState = {
|
|||||||
totalDelay: number; // this value comes from rundown service
|
totalDelay: number; // this value comes from rundown service
|
||||||
};
|
};
|
||||||
_group: ExpectedMetadata;
|
_group: ExpectedMetadata;
|
||||||
|
_groupTiming: GroupTiming | null;
|
||||||
_flag: ExpectedMetadata;
|
_flag: ExpectedMetadata;
|
||||||
_end: ExpectedMetadata;
|
_end: ExpectedMetadata;
|
||||||
_startEpoch: Maybe<Instant>;
|
_startEpoch: Maybe<Instant>;
|
||||||
@@ -86,6 +98,7 @@ const runtimeState: RuntimeState = {
|
|||||||
eventFlag: null,
|
eventFlag: null,
|
||||||
offset: { ...runtimeStorePlaceholder.offset },
|
offset: { ...runtimeStorePlaceholder.offset },
|
||||||
timer: { ...runtimeStorePlaceholder.timer },
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
|
groupTimer: null,
|
||||||
rundown: { ...runtimeStorePlaceholder.rundown },
|
rundown: { ...runtimeStorePlaceholder.rundown },
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
@@ -98,6 +111,7 @@ const runtimeState: RuntimeState = {
|
|||||||
totalDelay: 0,
|
totalDelay: 0,
|
||||||
},
|
},
|
||||||
_group: null,
|
_group: null,
|
||||||
|
_groupTiming: null,
|
||||||
_flag: null,
|
_flag: null,
|
||||||
_end: null,
|
_end: null,
|
||||||
_startEpoch: null,
|
_startEpoch: null,
|
||||||
@@ -115,6 +129,8 @@ export function getState(): Readonly<RuntimeState> {
|
|||||||
offset: { ...runtimeState.offset },
|
offset: { ...runtimeState.offset },
|
||||||
rundown: { ...runtimeState.rundown },
|
rundown: { ...runtimeState.rundown },
|
||||||
timer: { ...runtimeState.timer },
|
timer: { ...runtimeState.timer },
|
||||||
|
// derived here so it can never drift from the timer values it is built on
|
||||||
|
groupTimer: getGroupTimer(runtimeState),
|
||||||
_timer: { ...runtimeState._timer },
|
_timer: { ...runtimeState._timer },
|
||||||
_rundown: { ...runtimeState._rundown },
|
_rundown: { ...runtimeState._rundown },
|
||||||
};
|
};
|
||||||
@@ -156,6 +172,7 @@ export function clearState() {
|
|||||||
|
|
||||||
runtimeState.groupNow = null;
|
runtimeState.groupNow = null;
|
||||||
runtimeState._group = null;
|
runtimeState._group = null;
|
||||||
|
runtimeState._groupTiming = null;
|
||||||
|
|
||||||
runtimeState.rundown.actualStart = null;
|
runtimeState.rundown.actualStart = null;
|
||||||
runtimeState.rundown.selectedEventIndex = null;
|
runtimeState.rundown.selectedEventIndex = null;
|
||||||
@@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
const previousGroup = state.groupNow?.id;
|
const previousGroup = state.groupNow?.id;
|
||||||
state.groupNow = null;
|
state.groupNow = null;
|
||||||
state._group = null;
|
state._group = null;
|
||||||
|
state._groupTiming = null;
|
||||||
state.eventFlag = null;
|
state.eventFlag = null;
|
||||||
state._flag = null;
|
state._flag = null;
|
||||||
state._end = null;
|
state._end = null;
|
||||||
@@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd(
|
|||||||
state.rundown.actualGroupStart = null;
|
state.rundown.actualGroupStart = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the split is stable while the same event is loaded, so we only calculate it here
|
||||||
|
if (state.groupNow !== null) {
|
||||||
|
state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id);
|
||||||
|
}
|
||||||
|
|
||||||
// if we don't have a any flags in the rundown then no need to look for it
|
// if we don't have a any flags in the rundown then no need to look for it
|
||||||
let foundFlag = !flagsPresent;
|
let foundFlag = !flagsPresent;
|
||||||
// if we don't have a last event for the group there is no need to find its end time
|
// if we don't have a last event for the group there is no need to find its end time
|
||||||
|
|||||||
@@ -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: 'Preview import' }).click();
|
||||||
await page.getByRole('button', { name: 'Apply import' }).click();
|
await page.getByRole('button', { name: 'Apply import' }).click();
|
||||||
await expect(page.getByText('Import complete')).toBeVisible();
|
await expect(page.getByText('Import complete')).toBeVisible();
|
||||||
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Reset flow' }).click();
|
await page.getByRole('button', { name: 'Import another' }).click();
|
||||||
|
|
||||||
// verify the data in the rundown
|
// verify the data in the rundown
|
||||||
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
|
|||||||
targetDuration: MaybeNumber;
|
targetDuration: MaybeNumber;
|
||||||
colour: string;
|
colour: string;
|
||||||
custom: EntryCustomFields;
|
custom: EntryCustomFields;
|
||||||
|
/** whether views should display a shared timer for the group instead of the event timer */
|
||||||
|
useGroupTimer: boolean;
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: number;
|
revision: number;
|
||||||
timeStart: MaybeNumber; // calculated at runtime
|
timeStart: MaybeNumber; // calculated at runtime
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
|||||||
secondaryTimer: null, // change on every update
|
secondaryTimer: null, // change on every update
|
||||||
startedAt: null, // change can only be initiated by user
|
startedAt: null, // change can only be initiated by user
|
||||||
},
|
},
|
||||||
|
groupTimer: null, // derived from the timer, null unless the running group opted in
|
||||||
message: {
|
message: {
|
||||||
timer: {
|
timer: {
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
|
|||||||
import type { MessageState } from './MessageControl.type.js';
|
import type { MessageState } from './MessageControl.type.js';
|
||||||
import type { Offset } from './Offset.type.js';
|
import type { Offset } from './Offset.type.js';
|
||||||
import type { RundownState } from './RundownState.type.js';
|
import type { RundownState } from './RundownState.type.js';
|
||||||
import type { TimerState } from './TimerState.type.js';
|
import type { GroupTimerState, TimerState } from './TimerState.type.js';
|
||||||
|
|
||||||
export type RuntimeStore = {
|
export type RuntimeStore = {
|
||||||
// timer data
|
// timer data
|
||||||
clock: number;
|
clock: number;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
|
/** shared timer for the running group, null unless the group opted in */
|
||||||
|
groupTimer: GroupTimerState | null;
|
||||||
|
|
||||||
// messages service
|
// messages service
|
||||||
message: MessageState;
|
message: MessageState;
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export enum TimerPhase {
|
|||||||
Pending = 'pending',
|
Pending = 'pending',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer for the running group, present when the group opted into a shared timer.
|
||||||
|
*
|
||||||
|
* The group is treated as a single event containing all its children: the values are
|
||||||
|
* derived from the running event timer plus the content scheduled around it, so pause,
|
||||||
|
* added time, overtime and roll are inherited from the event timer rather than recalculated.
|
||||||
|
*/
|
||||||
|
export type GroupTimerState = {
|
||||||
|
/** Time remaining in the group */
|
||||||
|
current: number;
|
||||||
|
/** Time elapsed since the group started */
|
||||||
|
elapsed: number;
|
||||||
|
/** Total time in the group, includes time added to the running event */
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gathers the current running timer state
|
* Gathers the current running timer state
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
|
|||||||
export { OffsetMode } from './definitions/runtime/Offset.type.js';
|
export { OffsetMode } from './definitions/runtime/Offset.type.js';
|
||||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||||
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
||||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||||
|
|
||||||
// ---> Extra Timer
|
// ---> Extra Timer
|
||||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
|
|||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
colour: '',
|
colour: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
|
useGroupTimer: false,
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: 0, // calculated at runtime
|
revision: 0, // calculated at runtime
|
||||||
timeStart: null, // calculated at runtime
|
timeStart: null, // calculated at runtime
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
|||||||
targetDuration: patch.targetDuration ?? null,
|
targetDuration: patch.targetDuration ?? null,
|
||||||
colour: makeString(patch.colour, ''),
|
colour: makeString(patch.colour, ''),
|
||||||
custom: patch.custom ?? {},
|
custom: patch.custom ?? {},
|
||||||
|
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
timeStart: null,
|
timeStart: null,
|
||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user