Compare commits

..

9 Commits

Author SHA1 Message Date
Carlos Valente a63fa68d39 bump version to 3.3.3 2024-06-23 23:02:14 +02:00
Carlos Valente 20503de6fd refactor: minor code cleanups 2024-06-23 22:07:34 +02:00
Carlos Valente 0335da9786 refactor: improve errors to user 2024-06-23 22:07:34 +02:00
Carlos Valente 9f1a64f53c chore: upgrade dependencies 2024-06-23 22:07:34 +02:00
Alex Christoffer Rasmussen 975356f4f4 events with 0 duration dose not contribute to next day calculation (#1099) 2024-06-23 22:06:44 +02:00
Carlos Valente 1b448835cd fix: duplicate validation 2024-06-23 16:17:03 +02:00
Carlos Valente cbf3d566b9 fix: prevent calling service before init 2024-06-23 11:50:49 +02:00
Carlos Valente 6c20cb5e99 refactor: remove size limits in title 2024-06-22 13:48:19 +02:00
Carlos Valente cfc3aab893 style: add background to highlight 2024-06-21 14:45:45 +02:00
56 changed files with 308 additions and 1009 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.4.0-alpha-timeline.2",
"version": "3.3.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.4.0-alpha-timeline.2",
"version": "3.3.3",
"private": true,
"type": "module",
"dependencies": {
-4
View File
@@ -17,7 +17,6 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Timeline = lazy(() => import('./features/timeline/TimelinePage'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
@@ -27,7 +26,6 @@ const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const STimeline = withPreset(withData(Timeline));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
@@ -62,8 +60,6 @@ export default function AppRouter() {
<Route path='/op' element={<Operator />} />
<Route path='/timeline' element={<STimeline />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
@@ -1,25 +1,37 @@
@use '../../../theme/viewerDefs' as *;
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
}
.inline {
display: flex;
.title-card__title,
.title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-card__title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
@@ -28,13 +40,3 @@
color: var(--accent-color-override, $accent-color);
}
}
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -1,28 +1,32 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss';
interface TitleCardProps {
label: 'now' | 'next';
title: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
}
export default function TitleCard(props: TitleCardProps) {
const { label, title, secondary } = props;
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
return (
<div className='title-card'>
<div className='inline'>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{getLocalizedString(`common.${label}`)}
</span>
</div>
<div className={`title-card ${className}`} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
}
});
TitleCard.displayName = 'TitleCard';
export default TitleCard;
@@ -40,7 +40,6 @@ interface EditFormDrawerProps {
paramFields: ParamField[];
}
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
@@ -509,16 +509,3 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
export const getProgressOptions = (timeFormat: string): ParamField[] => {
return [
getTimeOption(timeFormat),
{
id: 'fullHeight',
title: 'Full height columns',
description: 'Whether the columns in the timeline should be the full height ',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -24,19 +24,6 @@ export const getAccessibleColour = (bgColour?: string): ColourCombination => {
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
};
/**
* Adds opacity to a given colour, returns undefined if invalid
*/
export function alpha(colour: string, amount: number): string | undefined {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return;
}
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
-22
View File
@@ -95,25 +95,3 @@ export const formatTime = (
const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`;
};
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number): string {
if (duration === 0) {
return '0h 0m';
}
const hours = Math.floor(duration / 3600000);
const minutes = Math.floor((duration % 3600000) / 60000);
let result = '';
if (hours > 0) {
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}m`;
}
return result;
}
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss';
export type ProjectFormValues = {
@@ -15,10 +13,9 @@ interface ProjectFormProps {
filename: string;
onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>;
submitError: string | null;
}
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
const {
handleSubmit,
register,
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
}, [setFocus]);
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<Input
className={style.formInput}
id='filename'
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<Input
className={style.formInput}
id='filename'
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename')}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
);
}
@@ -11,6 +11,7 @@ import {
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -40,36 +41,53 @@ export default function ProjectListItem({
onToggleEditMode,
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmitRename = async (values: ProjectFormValues) => {
try {
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
return async (values: ProjectFormValues) => {
setLoading(true);
setSubmitError(null);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
try {
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
const action = actionType === 'rename' ? renameProject : duplicateProject;
await action(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
}
await renameProject(filename, values.filename);
};
};
const handleLoad = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try {
await loadProject(filename);
await onRefetch();
onSubmit();
await invalidateAllCaches();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
}
};
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
const handleDelete = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try {
setSubmitError(null);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await duplicateProject(filename, values.filename);
await deleteProject(filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
}
};
@@ -86,50 +104,55 @@ export default function ProjectListItem({
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
return (
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
<td colSpan={99}>
<ProjectForm
action={editingMode}
filename={filename}
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
onCancel={handleCancel}
submitError={submitError}
/>
</td>
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
<>
{submitError && (
<tr key='filename-error'>
<td colSpan={99}>
<Panel.Error>{submitError}</Panel.Error>
</td>
</tr>
)}
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
<td colSpan={99}>
<ProjectForm
action={editingMode}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onRefetch={onRefetch}
onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
onCancel={handleCancel}
/>
</td>
</>
)}
</tr>
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading}
/>
</td>
</>
)}
</tr>
</>
);
}
function ActionMenu({
current,
filename,
onChangeEditMode,
onRefetch,
}: {
interface ActionMenuProps {
current?: boolean;
filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void;
onRefetch: () => Promise<void>;
}) {
const handleLoad = async () => {
await loadProject(filename);
await invalidateAllCaches();
};
onDelete: (filename: string) => void;
onLoad: (filename: string) => void;
}
function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
const handleRename = () => {
onChangeEditMode('rename', filename);
@@ -139,11 +162,6 @@ function ActionMenu({
onChangeEditMode('duplicate', filename);
};
const handleDelete = async () => {
await deleteProject(filename);
await onRefetch();
};
const handleDownload = async () => {
await downloadProject(filename);
};
@@ -161,16 +179,17 @@ function ActionMenu({
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
isDisabled={isDisabled}
/>
<MenuList>
<MenuItem onClick={handleLoad} isDisabled={current}>
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem>
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={handleDelete}>
<MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
Delete
</MenuItem>
</MenuList>
@@ -30,7 +30,7 @@
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-red);
background-color: var(--operator-running-bg-override, $red-700);
}
&.past {
@@ -65,12 +65,15 @@
font-size: 1.5rem;
letter-spacing: 0.5px;
color: $ui-white;
@include ellipsis-text;
}
.secondaryField {
grid-area: secondary;
font-size: 1.25rem;
letter-spacing: 0.5px;
@include ellipsis-text;
}
.schedule {
@@ -96,10 +99,10 @@
display: flex;
flex-wrap: wrap;
.field {
font-weight: 600;
padding: 0 0.25rem;
margin-right: 0.25rem;
padding-inline: 0.25rem;
}
.noColour {
@@ -110,6 +113,8 @@
}
.value {
padding-inline: 0.25rem;
background-color: $gray-1250;
color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem;
}
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps extends InputProps {
interface EventTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function EventTextInput(props: CountedTextInputProps) {
export default function EventTextInput(props: EventTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
variant='ontime-filled'
data-testid='input-textfield'
value={value}
maxLength={maxLength || 50}
maxLength={maxLength || 100}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
@@ -1,75 +0,0 @@
$timeline-entry-height: 20px;
.timeline {
flex: 1;
}
.timelineEvents {
position: relative;
height: 100%;
}
.entryColumn {
position: absolute;
background-color: var(--lighter, $gray-500);
border-bottom: 0.25rem solid var(--color, $ui-white);
border-right: 1px solid $ui-black;
min-height: 80px;
&.fullHeight {
height: calc(100% - 4rem);
}
}
.entryContent {
padding-top: var(--top, 0);
&.lastElement {
text-align: right;
transform: translateX(-100%);
}
}
.entryText {
position: relative;
z-index: 2;
color: $ui-white;
padding-inline: 0.5em;
width: fit-content;
white-space: nowrap;
&.textBg {
background-color: var(--bg, $black-10);
}
}
.start,
.title {
font-weight: 600;
}
// for elapsed events, we can hide some stuff
[data-status='finished'] {
color: $gray-500;
.status {
display: none;
}
}
[data-status='live'] {
font-weight: 600;
color: $ui-white;
.status {
color: $active-red;
}
}
[data-status='future'] {
font-weight: 600;
color: $gray-300;
.status {
color: $green-500;
}
}
@@ -1,128 +0,0 @@
import { memo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber } from 'ontime-types';
import { dayInMs, getFirstEventNormal, getLastEventNormal, MILLIS_PER_HOUR } from 'ontime-utils';
import useRundown from '../../../common/hooks-query/useRundown';
import { isStringBoolean } from '../../viewers/common/viewUtils';
import { type ProgressStatus, TimelineEntry } from './TimelineEntry';
import { TimelineMarkers } from './TimelineMarkers';
import { ProgressBar } from './TimelineProgressBar';
import { getElementPosition, getEndHour, getEstimatedWidth, getLaneLevel, getStartHour } from './timelineUtils';
import style from './Timeline.module.scss';
export default memo(Timeline);
function useTimeline() {
const { data } = useRundown();
if (data.revision === -1) {
return null;
}
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
// timeline is padded to nearest hours (floor and ceil)
const startHour = getStartHour(firstStart) * MILLIS_PER_HOUR;
const endHour = getEndHour(normalisedLastEnd) * MILLIS_PER_HOUR;
return {
rundown: data.rundown,
order: data.order,
startHour,
endHour,
};
}
interface TimelineProps {
selectedEventId: string | null;
}
function Timeline(props: TimelineProps) {
const { selectedEventId } = props;
const { width: screenWidth } = useViewportSize();
const timelineData = useTimeline();
const [searchParams] = useSearchParams();
const fullHeight = isStringBoolean(searchParams.get('fullHeight'));
if (timelineData === null) {
return null;
}
const { rundown, order, startHour, endHour } = timelineData;
let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
let eventStatus: ProgressStatus = 'finished';
// a list of the right most element for each lane
const rightMostElements: Record<number, number> = {};
return (
<div className={style.timeline}>
<TimelineMarkers />
<ProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{order.map((eventId) => {
// for now we dont render delays and blocks
const event = rundown[eventId];
if (!isOntimeEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (eventId === selectedEventId) {
eventStatus = 'live';
}
// we need to offset the start to account for midnight
if (!hasTimelinePassedMidnight) {
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
}
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour,
endHour,
normalisedStart,
event.duration,
screenWidth,
);
const estimatedWidth = getEstimatedWidth(event.title);
const estimatedRightPosition = elementLeftPosition + estimatedWidth;
const laneLevel = getLaneLevel(rightMostElements, elementLeftPosition);
if (rightMostElements[laneLevel] === undefined || rightMostElements[laneLevel] < estimatedRightPosition) {
rightMostElements[laneLevel] = estimatedRightPosition;
}
return (
<TimelineEntry
key={eventId}
colour={event.colour}
duration={event.duration}
isLast={eventId === order[order.length - 1]}
lane={laneLevel}
left={elementLeftPosition}
status={eventStatus}
start={event.timeStart}
title={event.title}
width={elementWidth}
mayGrow={elementWidth < estimatedWidth}
fullHeight={fullHeight}
/>
);
})}
</div>
</div>
);
}
@@ -1,90 +0,0 @@
import { useClock } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { getStatusLabel } from './timelineUtils';
import style from './Timeline.module.scss';
export type ProgressStatus = 'finished' | 'live' | 'future';
interface TimelineEntry {
colour: string;
duration: number;
isLast: boolean;
lane: number;
left: number;
status: ProgressStatus;
start: number;
title: string;
width: number;
mayGrow: boolean;
fullHeight: boolean;
}
const laneHeight = 120;
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntry) {
const { colour, duration, isLast, lane, left, status, start, title, width, mayGrow, fullHeight } = props;
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const lighterColour = alpha(colour, 0.7);
const alphaColour = alpha(colour, 0.6);
const columnClasses = cx([style.entryColumn, fullHeight && style.fullHeight]);
const contentClasses = cx([style.entryContent, isLast && style.lastElement]);
const textBgClasses = cx([style.entryText, mayGrow && style.textBg]);
return (
<div
className={columnClasses}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
'--top': `${lane * laneHeight}px`,
}}
>
<div
className={textBgClasses}
style={{
'--bg': alphaColour ?? '',
}}
>
<div className={style.start}>{formattedStartTime}</div>
<div className={style.title}>{title}</div>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={start} />
</div>
</div>
</div>
);
}
interface TimelineEntryStatusProps {
status: ProgressStatus;
start: number;
}
// we isolate this component to avoid re-rendering too many elements
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { status, start } = props;
// TODO: account for offset instead of just using the clock
const { clock } = useClock();
const statusText = getStatusLabel(start - clock, status);
return <div className={style.status}>{statusText}</div>;
}
@@ -1,15 +0,0 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid black;
height: 100vh;
}
}
@@ -1,23 +0,0 @@
import useRundown from '../../../common/hooks-query/useRundown';
import { getTimelineSections } from './timelineUtils';
import style from './TimelineMarkers.module.scss';
export function TimelineMarkers() {
const { data } = useRundown();
if (data.revision === -1) {
return null;
}
const elements = getTimelineSections(data.rundown, data.order);
return (
<div className={style.markers}>
{elements.map((tag, index) => {
return <span key={index}>{tag}</span>;
})}
</div>
);
}
@@ -1,13 +0,0 @@
.progressBar {
width: 100%;
height: 0.75rem;
transition-duration: 0.3s;
transition-property: left;
background-color: $gray-900;
.progress {
height: 100%;
background-color: $active-red;;
}
}
@@ -1,23 +0,0 @@
import { useClock } from '../../../common/hooks/useSocket';
import { getRelativePositionX } from './timelineUtils';
import style from './TimelineProgressBar.module.scss';
interface ProgressBarProps {
startHour: number;
endHour: number;
}
export function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
const { clock } = useClock();
const width = getRelativePositionX(startHour, endHour, clock);
return (
<div className={style.progressBar}>
<div className={style.progress} style={{ width: `${width}%` }} />
</div>
);
}
@@ -1,84 +0,0 @@
import { dayInMs } from 'ontime-utils';
import { getElementPosition, getLaneLevel } from '../timelineUtils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
});
});
describe('getLaneLevel()', () => {
it('should place in lane 0 if there is no overlap', () => {
const rightMostElements = { 0: 50 };
const left = 60;
const result = getLaneLevel(rightMostElements, left);
expect(result).toBe(0);
});
it('should place in next available lane if there is overlap', () => {
const rightMostElements = { 0: 150, 1: 100 };
const left = 120;
const result = getLaneLevel(rightMostElements, left);
expect(result).toBe(1);
});
it('should create a new lane if all existing lanes are overlapped', () => {
const rightMostElements = { 0: 200, 1: 250 };
const left = 150;
const result = getLaneLevel(rightMostElements, left);
expect(result).toBe(2);
});
it('should place in lane 0 if it is the first event', () => {
const rightMostElements = {};
const left = 0;
const result = getLaneLevel(rightMostElements, left);
expect(result).toBe(0);
});
});
@@ -1,154 +0,0 @@
import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types';
import {
dayInMs,
getByEventId,
getFirstEvent,
getFirstEventNormal,
getLastEventNormal,
getNextEvent,
MILLIS_PER_HOUR,
millisToString,
removeSeconds,
} from 'ontime-utils';
import { clamp } from '../../../common/utils/math';
import { formatDuration } from '../../../common/utils/time';
import type { ProgressStatus } from './TimelineEntry';
type CSSPosition = {
left: number;
width: number;
};
/**
* Calculates the position (in %) of an element relative to a schedule
*/
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
}
/**
* Estimates the width of an element based on its content
*/
export function getEstimatedWidth(content: string): number {
// use 8 as minimum width to account for duration string
// 12 is a rough estimate of the width of a character at 15px font size
return Math.max(content.length, 8) * 12;
}
/**
* Calculates an absolute position of an element based on a schedule
*/
export function getElementPosition(
scheduleStart: number,
scheduleEnd: number,
eventStart: number,
eventDuration: number,
containerWidth: number,
): CSSPosition {
// TODO: events that finish at midnight have lastEnd 0
const totalDuration = scheduleEnd - scheduleStart;
const width = (eventDuration * containerWidth) / totalDuration;
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
return { left, width };
}
export function getStartHour(startTime: number): number {
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
return hours;
}
export function getEndHour(endTime: number): number {
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
return hours;
}
export function makeTimelineSections(firstHour: number, lastHour: number) {
const timelineSections = [];
for (let i = firstHour; i < lastHour; i++) {
timelineSections.push(removeSeconds(millisToString(i * MILLIS_PER_HOUR)));
}
return timelineSections;
}
// TODO: account for elapsed days
export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] {
if (order.length === 0) {
return [];
}
const { firstEvent } = getFirstEventNormal(rundown, order);
const { lastEvent } = getLastEventNormal(rundown, order);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
const startHour = getStartHour(firstStart);
const endHour = getEndHour(normalisedLastEnd);
const elements = makeTimelineSections(startHour, endHour);
return elements;
}
const MAX_DEPTH = 5;
/**
* Estimates the top offset for an element
*/
export function getLaneLevel(rightMostElements: Record<number, number>, left: number): number {
for (let checkLane = 0; checkLane < MAX_DEPTH; checkLane++) {
if (rightMostElements[checkLane] === undefined) {
return checkLane;
}
if (rightMostElements[checkLane] < left) {
return checkLane;
}
}
return 0;
}
/**
* Returns a formatted label for a progress status
*/
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
if (status === 'finished' || status === 'live') {
return status;
}
if (timeToStart < 0) {
return 'T - 0';
}
return `T - ${formatDuration(timeToStart)}`;
}
type UpcomingEvents = {
now: MaybeString;
next: MaybeString;
followedBy: MaybeString;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
const nowEvent = selectedId ? getByEventId(events, selectedId) : getFirstEvent(events)?.firstEvent;
if (!isOntimeEvent(nowEvent)) {
return { now: null, next: null, followedBy: null };
}
const nextEvent = getNextEvent(events, nowEvent.id)?.nextEvent;
const followedByEvent = nextEvent ? getNextEvent(events, nextEvent.id)?.nextEvent : null;
// Return the titles, handling nulls appropriately
return {
now: nowEvent.title,
next: nextEvent ? nextEvent.title : null,
followedBy: followedByEvent ? followedByEvent.title : null,
};
}
@@ -1,52 +0,0 @@
.progress {
width: 100vw;
height: 100vh;
background-color: $ui-black;
color: $ui-white;
display: flex;
flex-direction: column;
gap: 2rem;
}
.title {
padding-inline: 2rem;
text-align: left;
font-size: 3.5rem;
}
.sections {
padding-inline: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
column-gap: 3rem;
}
.sectionTitle {
line-height: 1.2em;
font-size: 1.5rem;
text-transform: uppercase;
}
.sectionContent {
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
&.now {
color: $red-500;
}
&.next {
color: $green-500;
}
&.subdue {
opacity: $opacity-disabled;
}
}
@@ -1,63 +0,0 @@
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import { getProgressOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import Timeline from './Timeline/Timeline';
import { getUpcomingEvents } from './Timeline/timelineUtils';
import style from './TimelinePage.module.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
general: ProjectData;
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
}
export default function TimelinePage(props: TimelinePageProps) {
const { backstageEvents, general, selectedId, settings, time } = props;
const clock = formatTime(time.clock);
const { now, next, followedBy } = getUpcomingEvents(backstageEvents, selectedId);
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getProgressOptions(defaultFormat);
return (
<div className={style.progress}>
<ViewParamsEditor paramFields={progressOptions} />
<div className={style.title}>{general.title}</div>
<div className={style.sections}>
<Section title='Time now' content={clock} category='now' />
<Section title='Next' content={next} category='next' />
<Section title='Current' content={now} category='now' />
<Section title='Followed by' content={followedBy} category='next' />
</div>
<Timeline selectedEventId={selectedId} />
</div>
);
}
interface SectionProps {
category: 'now' | 'next';
content: MaybeString;
title: string;
}
function Section(props: SectionProps) {
const { category, content, title } = props;
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
return (
<div>
<div className={style.sectionTitle}>{title}</div>
<div className={contentClasses}>{content ?? '-'}</div>
</div>
);
}
@@ -25,6 +25,8 @@ import { getPropertyValue } from '../common/viewUtils';
import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
customFields: CustomFields;
isMirrored: boolean;
@@ -124,7 +126,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible'
exit='exit'
>
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
@@ -151,16 +153,17 @@ export default function Backstage(props: BackstageProps) {
<AnimatePresence>
{eventNext && (
<motion.div
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
</motion.div>
label='next'
title={eventNext.title}
secondary={secondaryTextNext}
/>
)}
</AnimatePresence>
</div>
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
@@ -12,6 +11,7 @@ import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { fetchTimerData, TimerMessage } from './countdown.helpers';
import CountdownSelect from './CountdownSelect';
@@ -84,24 +84,17 @@ export default function Countdown(props: CountdownProps) {
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const isRunningFinished = finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
const formatTimer = (): string => {
if (runningMessage === TimerMessage.ended) {
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' });
}
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay);
if (isSelected || runningMessage === TimerMessage.waiting) {
formattedTime = removeLeadingZero(formattedTime);
}
return formattedTime;
};
const formattedTimer = formatTimer();
const hideSeconds = searchParams.get('hideTimerSeconds');
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: isStringBoolean(hideSeconds),
removeLeadingZero: false,
});
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timeOption = getCountdownOptions(defaultFormat);
@@ -72,6 +72,8 @@
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
text-align: var(--lowerThird-text-align-override, left);
white-space: nowrap;
@include ellipsis-text();
&::after {
content: '\200b';
}
@@ -21,6 +21,8 @@ import { getPropertyValue } from '../common/viewUtils';
import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
customFields: CustomFields;
isMirrored: boolean;
@@ -83,31 +85,33 @@ export default function Public(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{publicEventNow && (
<motion.div
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} />
</motion.div>
label='now'
title={publicEventNow.title}
secondary={secondaryTextNow}
/>
)}
</AnimatePresence>
<AnimatePresence>
{publicEventNext && (
<motion.div
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} />
</motion.div>
label='next'
title={publicEventNext.title}
secondary={secondaryTextNext}
/>
)}
</AnimatePresence>
</div>
@@ -168,7 +168,6 @@ $orange-active: #f60;
display: flex;
align-items: start;
gap: 0.5em;
white-space: nowrap;
&--now {
color: var(--studio-active-label, $red-active);
@@ -36,7 +36,7 @@ export default function StudioClock(props: StudioClockProps) {
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()];
@@ -15,7 +15,7 @@
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid;
grid-template-columns: auto;
grid-template-columns: 100%;
grid-template-rows: auto 1fr auto auto auto;
grid-template-areas:
'clock'
@@ -43,6 +43,8 @@ const titleVariants = {
},
};
export const MotionTitleCard = motion(TitleCard);
interface TimerProps {
customFields: CustomFields;
eventNext: OntimeEvent | null;
@@ -211,31 +213,33 @@ export default function Timer(props: TimerProps) {
<>
<AnimatePresence>
{eventNow?.title && (
<motion.div
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
</motion.div>
label='now'
title={mainFieldNow}
secondary={secondaryTextNow}
/>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext?.title && (
<motion.div
className='event next'
<MotionTitleCard
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
</motion.div>
label='next'
title={mainFieldNext}
secondary={secondaryTextNext}
className='event next'
/>
)}
</AnimatePresence>
</>
+7 -1
View File
@@ -1,4 +1,4 @@
@use "ontimeStyles" as *;
@use 'ontimeStyles' as *;
//////////////////////////////////// general app elements
@@ -15,3 +15,9 @@
color: $ontime-color;
}
}
@mixin ellipsis-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-1
View File
@@ -15,7 +15,6 @@ $ontime-color: #ff7597;
$error-red: $red-500;
$warning-orange: $orange-500;
$opacity-disabled: 0.4;
$active-red: $red-700;
// playback colours
$playback-start: $green-600;
-1
View File
@@ -4,7 +4,6 @@ export const navigatorConstants = [
{ url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' },
{ url: '/public', label: 'Public' },
{ url: '/timeline', label: 'Timeline' },
{ url: '/lower', label: 'Lower Thirds' },
{ url: '/studio', label: 'Studio Clock' },
{ url: '/countdown', label: 'Countdown' },
+1
View File
@@ -61,6 +61,7 @@ export default defineConfig({
additionalData: `
@use './src/theme/ontimeColours' as *;
@use './src/theme/ontimeStyles' as *;
@use './src/theme/mixins' as *;
`,
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.4.0-alpha-timeline.2",
"version": "3.3.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.4.0-alpha-timeline.2",
"version": "3.3.3",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -20,8 +20,7 @@
"node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*",
"sanitize-filename": "^1.6.3",
"steno": "^3.1.0",
"ts-essentials": "^9.4.1",
"steno": "^4.0.2",
"ws": "^8.13.0"
},
"devDependencies": {
@@ -44,6 +43,7 @@
"shx": "^0.3.4",
"ts-node": "^10.9.1",
"typescript": "^5.4.3",
"ts-essentials": "^9.4.1",
"vitest": "^1.6.0"
},
"scripts": {
+2 -1
View File
@@ -18,6 +18,7 @@ import {
validatePatchProject,
validateFilenameBody,
validateFilenameParam,
validateNewFilenameBody,
} from './db.validation.js';
export const router = express.Router();
@@ -31,7 +32,7 @@ router.post('/new', validateFilenameBody, validateNewProject, createProjectFile)
router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject);
router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile);
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
@@ -41,6 +41,30 @@ export const validatePatchProject = [
},
];
/**
* @description Validates request with newFilename in the body.
*/
export const validateNewFilenameBody = [
body('newFilename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates request with filename in the body.
*/
+3 -1
View File
@@ -17,7 +17,6 @@ import {
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
clearUploadfolder,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
@@ -44,6 +43,9 @@ import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
+2 -2
View File
@@ -4,7 +4,7 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { isProduction } from '../setup/index.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleRed } from '../utils/console.js';
import { consoleSubdued, consoleError } from '../utils/console.js';
class Logger {
private queue: Log[];
@@ -47,7 +47,7 @@ class Logger {
private _push(log: Log) {
if (this.canLog || log.level === LogLevel.Severe) {
if (log.level === LogLevel.Severe) {
consoleRed(`[${log.level}] \t ${log.origin} \t ${log.text}`);
consoleError(`[${log.level}] \t ${log.origin} \t ${log.text}`);
} else {
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
}
+2 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-console */
import { consoleHighlight, consoleRed } from './utils/console.js';
import { consoleHighlight, consoleError } from './utils/console.js';
import { initAssets, startIntegrations, startServer } from './app.js';
async function startOntime() {
@@ -16,7 +16,7 @@ async function startOntime() {
consoleHighlight('Request: Start integrations...');
await startIntegrations();
} catch (error) {
consoleRed(`Request failed: ${error}`);
consoleError(`Request failed: ${error}`);
}
}
@@ -14,21 +14,24 @@ interface Config {
class AppStateService {
private config: Low<Config>;
private pathToFile: string;
private didInit = false;
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, null);
this.init();
}
private async init() {
await this.config.read();
await this.config.write();
this.didInit = true;
}
async get(): Promise<Config> {
if (!this.didInit) {
await this.init();
}
await this.config.read();
return this.config.data;
}
@@ -36,6 +39,10 @@ class AppStateService {
async updateDatabaseConfig(filename: string): Promise<void> {
if (isTest) return;
if (!this.didInit) {
await this.init();
}
this.config.data.lastLoadedProject = filename;
await this.config.write();
}
@@ -170,6 +170,19 @@ describe('generate()', () => {
expect(initResult.totalDuration).toBe(500 - 100);
});
it('calculates total duration with 0 duration events without causing a next day', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100);
});
it('calculates total duration across days with gap', () => {
const testRundown: OntimeRundown = [
{
@@ -87,6 +87,7 @@ export function generate(
let daySpan = 0;
let previousStart: MaybeNumber = null;
let previousEnd: MaybeNumber = null;
let previousDuration: MaybeNumber = null;
for (let i = 0; i < initialRundown.length; i++) {
const currentEvent = initialRundown[i];
@@ -111,7 +112,8 @@ export function generate(
lastEnd = updatedEvent.timeEnd;
// check if we go over midnight, account for eventual gaps
const gapOverMidnight = previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart);
const gapOverMidnight =
previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart, previousDuration);
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
if (gapOverMidnight || durationOverMidnight) {
daySpan++;
@@ -134,6 +136,7 @@ export function generate(
updatedEvent.delay = accumulatedDelay;
previousStart = updatedEvent.timeStart;
previousEnd = updatedEvent.timeEnd;
previousDuration = updatedEvent.duration;
}
order.push(updatedEvent.id);
+11 -21
View File
@@ -1,8 +1,6 @@
import { fileURLToPath } from 'url';
import path, { dirname, join } from 'path';
import fs from 'fs';
import { rm } from 'fs/promises';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync } from 'fs';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -17,18 +15,18 @@ import { ensureDirectory } from '../utils/fileManagement.js';
export function getAppDataPath(): string {
// handle docker
if (process.env.ONTIME_DATA) {
return path.join(process.env.ONTIME_DATA);
return join(process.env.ONTIME_DATA);
}
switch (process.platform) {
case 'darwin': {
return path.join(process.env.HOME!, 'Library', 'Application Support', 'Ontime');
return join(process.env.HOME!, 'Library', 'Application Support', 'Ontime');
}
case 'win32': {
return path.join(process.env.APPDATA!, 'Ontime');
return join(process.env.APPDATA!, 'Ontime');
}
case 'linux': {
return path.join(process.env.HOME!, '.Ontime');
return join(process.env.HOME!, '.Ontime');
}
default: {
throw new Error('Could not resolve public folder for platform');
@@ -56,11 +54,11 @@ if (import.meta.url) {
// path to server src folder
const currentDir = dirname(__dirname);
// locally we are in src/setup, in the production build, this is a single file at src
export const srcDirectory = isProduction ? currentDir : path.join(currentDir, '../');
export const srcDirectory = isProduction ? currentDir : join(currentDir, '../');
// resolve path to external
const productionPath = path.join(srcDirectory, 'client/');
const devPath = path.join(srcDirectory, '../../client/build/');
const productionPath = join(srcDirectory, 'client/');
const devPath = join(srcDirectory, '../../client/build/');
export const resolvedPath = (): string => {
if (isTest) {
@@ -83,12 +81,12 @@ export const uploadsFolderPath = join(getAppDataPath(), config.uploads);
const ensureAppState = () => {
ensureDirectory(getAppDataPath());
fs.writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
};
const getLastLoadedProject = () => {
try {
const appState = JSON.parse(fs.readFileSync(appStatePath, 'utf8'));
const appState = JSON.parse(readFileSync(appStatePath, 'utf8'));
if (!appState.lastLoadedProject) {
ensureAppState();
}
@@ -142,11 +140,3 @@ export const resolveCrashReportDirectory = getAppDataPath();
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export async function clearUploadfolder() {
try {
await rm(uploadsFolderPath, { recursive: true });
} catch (_) {
//we dont care that there was no folder
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function consoleSuccess(message: string) {
/**
* Utility function to log messages in red
*/
export function consoleRed(message: string) {
export function consoleError(message: string) {
console.error(inRed(message));
}
+4 -1
View File
@@ -6,6 +6,7 @@ import { get } from '../services/rundown-service/rundownCache.js';
import { getState } from '../stores/runtimeState.js';
import { resolveCrashReportDirectory } from '../setup/index.js';
import { ensureDirectory } from './fileManagement.js';
/**
* Writes a file to the crash report location
* @param fileName
@@ -13,10 +14,12 @@ import { resolveCrashReportDirectory } from '../setup/index.js';
*/
function writeToFile(fileName: string, content: object) {
const path = join(resolveCrashReportDirectory, fileName);
ensureDirectory(resolveCrashReportDirectory);
try {
const textContent = JSON.stringify(content, null, 2);
writeFileSync(path, textContent);
} catch (e_rror) {
} catch (_) {
/** We do not handle the error here */
}
}
+9
View File
@@ -1,6 +1,7 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { rm } from 'fs/promises';
import { ensureDirectory } from './fileManagement.js';
import { getAppDataPath, uploadsFolderPath } from '../setup/index.js';
@@ -56,3 +57,11 @@ export const storage = multer.diskStorage({
cb(null, file.originalname);
},
});
export async function clearUploadfolder() {
try {
await rm(uploadsFolderPath, { recursive: true });
} catch (_) {
//we dont care that there was no folder
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.4.0-alpha-timeline.2",
"version": "3.3.3",
"description": "Time keeping for live events",
"keywords": [
"ontime",
-1
View File
@@ -8,7 +8,6 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js';
export {
getByEventId,
getFirst,
getFirstEvent,
getFirstEventNormal,
+2 -2
View File
@@ -12,8 +12,8 @@
"cleanup": "rm -rf .turbo && rm -rf node_modules"
},
"dependencies": {
"deepmerge-ts": "^5.1.0",
"nanoid": "^5.0.4"
"deepmerge-ts": "^7.0.3",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^v7.12.0",
@@ -8,6 +8,6 @@
* 09:00 - 10:00
* 09:30 - 10:30
*/
export function checkIsNextDay(previousStart: number, timeStart: number): boolean {
return timeStart <= previousStart;
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration?: number): boolean {
return previousDuration === 0 ? false : timeStart <= previousStart;
}
@@ -3,15 +3,6 @@ import { isOntimeEvent } from 'ontime-types';
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
/**
* Gets an event with given if if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeRundownEntry | null}
*/
export function getByEventId(rundown: OntimeRundownEntry[], eventId: string): OntimeRundownEntry | null {
return rundown.find((event) => event.id === eventId) ?? null;
}
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
+15 -19
View File
@@ -304,11 +304,8 @@ importers:
specifier: ^1.6.3
version: 1.6.3
steno:
specifier: ^3.1.0
version: 3.2.0
ts-essentials:
specifier: ^9.4.1
version: 9.4.1(typescript@5.4.3)
specifier: ^4.0.2
version: 4.0.2
ws:
specifier: ^8.13.0
version: 8.13.0
@@ -364,6 +361,9 @@ importers:
shx:
specifier: ^0.3.4
version: 0.3.4
ts-essentials:
specifier: ^9.4.1
version: 9.4.1(typescript@5.4.3)
ts-node:
specifier: ^10.9.1
version: 10.9.1(@types/node@18.11.18)(typescript@5.4.3)
@@ -392,11 +392,11 @@ importers:
packages/utils:
dependencies:
deepmerge-ts:
specifier: ^5.1.0
version: 5.1.0
specifier: ^7.0.3
version: 7.0.3
nanoid:
specifier: ^5.0.4
version: 5.0.4
specifier: ^5.0.7
version: 5.0.7
devDependencies:
'@typescript-eslint/eslint-plugin':
specifier: ^v7.12.0
@@ -5106,8 +5106,8 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
/deepmerge-ts@5.1.0:
resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==}
/deepmerge-ts@7.0.3:
resolution: {integrity: sha512-dxFbFO2RSIhPNBPL/j8Nvdt6/vrkW9+uGf1NLah/QxBGAVbK9fj2fGTO+HwdHpPAyFAsyT9iEn/1SI9SUvespw==}
engines: {node: '>=16.0.0'}
dev: false
@@ -7547,8 +7547,8 @@ packages:
hasBin: true
dev: true
/nanoid@5.0.4:
resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==}
/nanoid@5.0.7:
resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==}
engines: {node: ^18 || >=20}
hasBin: true
dev: false
@@ -8796,11 +8796,6 @@ packages:
resolution: {integrity: sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==}
dev: true
/steno@3.2.0:
resolution: {integrity: sha512-zPKkv+LqoYffxrtD0GIVA08DvF6v1dW02qpP5XnERoobq9g3MKcTSBTi08gbGNFMNRo3TQV/6kBw811T1LUhKg==}
engines: {node: '>=16'}
dev: false
/steno@4.0.2:
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
engines: {node: '>=18'}
@@ -9108,7 +9103,7 @@ packages:
optional: true
dependencies:
typescript: 5.4.3
dev: false
dev: true
/ts-node@10.9.1(@types/node@18.11.18)(typescript@5.4.3):
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
@@ -9305,6 +9300,7 @@ packages:
resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/ufo@1.3.2:
resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==}