diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 98629d28b..52f281211 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState']; export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; export const CLIENT_LIST = ['clientList']; +export const REPORT = ['report']; // API URLs export const apiEntryUrl = `${serverURL}/data`; diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index b382498ac..9edf73eb5 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -1,7 +1,8 @@ import axios, { AxiosResponse } from 'axios'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; -import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils'; +import { makeTable } from '../../views/cuesheet/cuesheet.utils'; +import { makeCSVFromArrayOfArrays } from '../utils/csv'; import { apiEntryUrl } from './constants'; import { createBlob, downloadBlob } from './utils'; @@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') { const { project, rundown, customFields } = data; const sheetData = makeTable(project, rundown, customFields); - const fileContent = makeCSV(sheetData); + const fileContent = makeCSVFromArrayOfArrays(sheetData); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); downloadBlob(blob, `${name}.csv`); diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts new file mode 100644 index 000000000..2ea67e94a --- /dev/null +++ b/apps/client/src/common/api/report.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { OntimeReport } from 'ontime-types'; + +import { ontimeQueryClient } from '../../common/queryClient'; + +import { apiEntryUrl, REPORT } from './constants'; + +export const reportUrl = `${apiEntryUrl}/report`; + +/** + * HTTP request to fetch all reports + */ +export async function fetchReport(): Promise { + const res = await axios.get(`${reportUrl}/`); + return res.data; +} + +export async function deleteReport(id: string) { + await axios.delete(`${reportUrl}/${id}`); + await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); +} + +export async function deleteAllReport() { + await axios.delete(`${reportUrl}/all`); + await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); +} diff --git a/apps/client/src/common/hooks-query/useReport.ts b/apps/client/src/common/hooks-query/useReport.ts new file mode 100644 index 000000000..90bb4bc15 --- /dev/null +++ b/apps/client/src/common/hooks-query/useReport.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { OntimeReport } from 'ontime-types'; +import { MILLIS_PER_HOUR } from 'ontime-utils'; + +import { REPORT } from '../api/constants'; +import { fetchReport } from '../api/report'; + +export default function useReport() { + const { data, refetch } = useQuery({ + queryKey: REPORT, + queryFn: fetchReport, + placeholderData: (previousData, _previousQuery) => previousData, + retry: 5, + retryDelay: (attempt) => attempt * 2500, + networkMode: 'always', + staleTime: MILLIS_PER_HOUR, + }); + + return { data: data ?? {}, refetch }; +} diff --git a/apps/client/src/common/utils/__tests__/csv.test.ts b/apps/client/src/common/utils/__tests__/csv.test.ts new file mode 100644 index 000000000..13bd6b1b5 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/csv.test.ts @@ -0,0 +1,13 @@ +import { makeCSVFromArrayOfArrays } from '../csv'; + +describe('makeCSVFromArrayOfArrays()', () => { + it('joins an array of arrays with commas and newlines', () => { + const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']]; + expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(` +"field +after newline,after comma +,after empty +" +`); + }); +}); diff --git a/apps/client/src/common/utils/csv.ts b/apps/client/src/common/utils/csv.ts new file mode 100644 index 000000000..527e7a5f2 --- /dev/null +++ b/apps/client/src/common/utils/csv.ts @@ -0,0 +1,10 @@ +import { stringify } from 'csv-stringify/browser/esm/sync'; + +/** + * @description Converts an array of arrays to a CSV file + * @param {string[][]} arrayOfArrays + * @return {string} + */ +export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { + return stringify(arrayOfArrays); +} diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 3d92b12dd..5358ee78e 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,7 +1,7 @@ import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { isProduction, websocketUrl } from '../../externals'; -import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants'; +import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { invalidateAllCaches } from '../api/utils'; import { ontimeQueryClient } from '../queryClient'; import { @@ -198,19 +198,23 @@ export const connectSocket = () => { } case 'ontime-refetch': { // the refetch message signals that the rundown has changed in the server side - const { revision, reload } = payload; - const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; - + const { reload, target } = payload; if (reload) { invalidateAllCaches(); - } else if (revision > currentRevision) { - ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); - ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); + } else if (target === 'RUNDOWN') { + const { revision } = payload; + const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; + if (revision > currentRevision) { + ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); + ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); + } + } else if (target === 'REPORT') { + ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); } break; } case 'ontime-flush': { - flushBatchUpdates() + flushBatchUpdates(); break; } } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 3287f6640..837433e14 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -183,7 +183,7 @@ $inner-padding: 1rem; color: $muted-gray; td { - padding-block: 1rem; + padding-block: 5rem; } button { diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index f8b081169..f04fadfd0 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
{label ?? 'No data yet'}
- + {handleClick && ( + + )} ); diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx index e12601167..b636af1d7 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/FeatureSettingsPanel.tsx @@ -3,11 +3,13 @@ import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import CustomFields from './custom-fields/CustomFields'; +import ReportSettings from './ReportSettings'; import UrlPresetsForm from './UrlPresetsForm'; export default function FeatureSettingsPanel({ location }: PanelBaseProps) { const customFieldsRef = useScrollIntoView('custom', location); const urlPresetsRef = useScrollIntoView('urlpresets', location); + const reportRef = useScrollIntoView('report', location); return ( <> @@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
+ +
+ +
); } diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.module.scss b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.module.scss new file mode 100644 index 000000000..de8289546 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.module.scss @@ -0,0 +1,7 @@ +th.over { + color: $ontime-delay-text; +} + +th.under { + color: $playback-ahead; +} diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx new file mode 100644 index 000000000..de06222e2 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { Button } from '@chakra-ui/react'; +import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin'; + +import { deleteAllReport } from '../../../../common/api/report'; +import { createBlob, downloadBlob } from '../../../../common/api/utils'; +import useReport from '../../../../common/hooks-query/useReport'; +import useRundown from '../../../../common/hooks-query/useRundown'; +import { cx } from '../../../../common/utils/styleUtils'; +import { formatTime } from '../../../../common/utils/time'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils'; + +import style from './ReportSettings.module.scss'; + +export default function ReportSettings() { + const { data: reportData } = useReport(); + const { data } = useRundown(); + + const clearReport = async () => await deleteAllReport(); + const downloadCSV = (combinedReport: CombinedReport[]) => { + if (!combinedReport) { + return; + } + const csv = makeReportCSV(combinedReport); + const blob = createBlob(csv, 'text/csv;charset=utf-8;'); + downloadBlob(blob, 'ontime-report.csv'); + }; + + const combinedReport = useMemo(() => { + return getCombinedReport(reportData, data.rundown, data.order); + }, [reportData, data.rundown, data.order]); + + return ( + + + Report + + + + Manage report + + + + + + + + + + + # + Cue + Title + Scheduled Start + Actual Start + Scheduled End + Actual End + + + + {combinedReport.length === 0 && ( + + )} + + {combinedReport.map((entry) => { + const start = (() => { + if (entry.actualStart === null) return null; + if (entry.actualStart <= entry.scheduledStart) return 'under'; + return 'over'; + })(); + const end = (() => { + if (entry.actualEnd === null) return null; + if (entry.actualEnd <= entry.scheduledEnd) return 'under'; + return 'over'; + })(); + return ( + + {entry.index} + {entry.cue} + {entry.title} + {formatTime(entry.scheduledStart)} + {formatTime(entry.actualStart)} + {formatTime(entry.scheduledEnd)} + {formatTime(entry.actualEnd)} + + ); + })} + + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts new file mode 100644 index 000000000..0370baa00 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts @@ -0,0 +1,64 @@ +import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; + +import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; +import { formatTime } from '../../../../common/utils/time'; + +export type CombinedReport = { + index: number; + title: string; + cue: string; + scheduledStart: number; + actualStart: MaybeNumber; + scheduledEnd: number; + actualEnd: MaybeNumber; +}; + +/** + * Creates a combined report with the rundown data + */ +export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { + if (Object.keys(report).length === 0) return []; + if (order.length === 0) return []; + + const combinedReport: CombinedReport[] = []; + + for (const [key, value] of Object.entries(report)) { + if (!rundown[key] || !isOntimeEvent(rundown[key])) continue; + + combinedReport.push({ + index: order.findIndex((id) => id === key), + title: rundown[key].title, + cue: rundown[key].cue, + scheduledStart: rundown[key].timeStart, + actualEnd: value.endedAt, + scheduledEnd: rundown[key].timeEnd, + actualStart: value.startedAt, + }); + } + + return combinedReport; +} + +const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; + +/** + * Transforms a CombinedReport into a CSV string + */ +export function makeReportCSV(combinedReport: CombinedReport[]) { + const csv: string[][] = []; + csv.push(csvHeader); + + for (const entry of combinedReport) { + csv.push([ + String(entry.index), + entry.title, + entry.cue, + formatTime(entry.scheduledStart), + formatTime(entry.actualStart), + formatTime(entry.scheduledEnd), + formatTime(entry.actualEnd), + ]); + } + + return makeCSVFromArrayOfArrays(csv); +} diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index a3b57ef10..56f3d9d99 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -35,6 +35,7 @@ const staticOptions = [ secondary: [ { id: 'feature_settings__custom', label: 'Custom fields' }, { id: 'feature_settings__urlpresets', label: 'URL Presets' }, + { id: 'feature_settings__report', label: 'Report' }, ], }, { diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx index 609026396..a86b0e84a 100644 --- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { Tooltip } from '@chakra-ui/react'; import { IoPause } from '@react-icons/all-files/io5/IoPause'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; @@ -34,7 +35,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const isLast = selectedEventIndex === numEvents - 1; const noEvents = numEvents === 0; - const disableGo = isRolling || noEvents || (isLast && !isArmed); + const disableGo = isRolling || noEvents; const disableNext = isRolling || noEvents || isLast; const disablePrev = isRolling || noEvents || isFirst; @@ -45,14 +46,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const disableStop = !playbackCan.stop; const disableReload = !playbackCan.reload; - const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next'; - const goModeAction = () => { + const [goModeAction, goModeText] = useMemo(() => { if (isArmed) { - setPlayback.start(); - } else { - setPlayback.startNext(); + return [setPlayback.start, 'Start']; + } else if (isLast) { + return [setPlayback.stop, 'Finish']; + } else if (selectedEventIndex === null) { + return [setPlayback.startNext, 'Start']; } - }; + return [setPlayback.startNext, 'Next']; + }, [isArmed, isLast, selectedEventIndex]); return (
diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss index 1fd3fdbdb..18f826bf2 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss @@ -76,3 +76,10 @@ color: $ontime-roll; font-size: $text-body-size; } + +.reportLink { + color: $label-gray; + font-size: $text-body-size; + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx index e168fd588..b2996c8f3 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx @@ -1,9 +1,10 @@ import { PropsWithChildren } from 'react'; import { Tooltip } from '@chakra-ui/react'; -import { Playback, TimerPhase } from 'ontime-types'; +import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; import { dayInMs, millisToString } from 'ontime-utils'; import { useTimer } from '../../../../common/hooks/useSocket'; +import useReport from '../../../../common/hooks-query/useReport'; import { formatDuration } from '../../../../common/utils/time'; import TimerDisplay from '../timer-display/TimerDisplay'; @@ -29,10 +30,6 @@ export default function PlaybackTimer(props: PropsWithChildrenRoll: Countdown to start ) : ( - <> - - Started at - {started} - - - Expect end - {finish} - - + )}
{children} ); } + +interface RunningStatusProps { + startedAt: MaybeNumber; + expectedFinish: MaybeNumber; + playback: Playback; +} +function RunningStatus(props: RunningStatusProps) { + const { startedAt, expectedFinish, playback } = props; + + if (playback === Playback.Stop) { + return ; + } + + const started = millisToString(startedAt); + const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null; + const finish = millisToString(finishedMs); + + return ( + <> + + Started at + {started} + + + Expect end + {finish} + + + ); +} + +function StoppedStatus() { + const { data } = useReport(); + const hasReport = Object.keys(data).length > 0; + + if (hasReport) { + return ( + + Go to report management + + ); + } + + return null; +} diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 92847e298..e178d95f5 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -46,7 +46,7 @@ } .ahead { - color: $green-500; + color: $playback-ahead; } .behind { diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 744674dcc..8e4ebf96c 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -22,7 +22,8 @@ export type EventItemActions = | 'delete' | 'clone' | 'update' - | 'swap'; + | 'swap' + | 'clear-report'; interface RundownEntryProps { type: SupportedEvent; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 8fd2fc4ea..1fe5d959f 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -175,7 +175,7 @@ export default function EventBlock(props: EventBlockProps) { }, isDisabled: selectedEventId == null || selectedEventId === eventId, }, - { withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') }, + { withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, ], ); diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index a59450486..b2e549edc 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -125,6 +125,7 @@ function EventBlockInner(props: EventBlockInnerProps) { isLoaded={loaded} totalGap={totalGap} isLinkedAndNext={isNext && linkStart !== null} + duration={duration} /> )}
diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss index b69696431..d59e42736 100644 --- a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss @@ -8,7 +8,7 @@ border-radius: 2px; &.over { - color: $playback-negative; + color: $ontime-delay-text; } &.under { diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx index 7cf9f2360..0a8003a71 100644 --- a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx @@ -1,9 +1,12 @@ +import { useMemo } from 'react'; import { Tooltip } from '@chakra-ui/react'; +import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle'; import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket'; +import useReport from '../../../../common/hooks-query/useReport'; import { cx } from '../../../../common/utils/styleUtils'; -import { formatDuration } from '../../../../common/utils/time'; +import { formatDuration, formatTime } from '../../../../common/utils/time'; import { tooltipDelayFast } from '../../../../ontimeConfig'; import style from './EventBlockChip.module.scss'; @@ -16,10 +19,11 @@ interface EventBlockChipProps { className: string; totalGap: number; isLinkedAndNext: boolean; + duration: number; } export default function EventBlockChip(props: EventBlockChipProps) { - const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext } = props; + const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props; const { playback } = usePlayback(); if (isLoaded) { @@ -29,7 +33,7 @@ export default function EventBlockChip(props: EventBlockChipProps) { const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return null; //TODO: Event report will go here + return ; } if (playbackActive) { @@ -65,3 +69,55 @@ function EventUntil(props: EventUntilProps) { return
{timeUntilString}
; } + +interface EventReportProps { + className: string; + id: string; + duration: number; +} + +function EventReport(props: EventReportProps) { + const { className, id, duration } = props; + const { data } = useReport(); + const currentReport = data[id]; + + const [value, overUnderStyle, tooltip] = useMemo(() => { + if (!currentReport) { + return [null, 'none', '']; + } + + const { startedAt, endedAt } = currentReport; + if (!startedAt || !endedAt) { + return [null, 'none', '']; + } + + const actualDuration = endedAt - startedAt; + const difference = actualDuration - duration; + const absDifference = Math.abs(difference); + + if (absDifference < MILLIS_PER_SECOND) { + return ['ontime', 'ontime', 'Event finished ontime']; + } + + const isOver = difference > 0; + + const fullTimeValue = formatTime(absDifference); + + const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; + + const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; + return [value, isOver ? 'over' : 'under', tooltip]; + }, [currentReport, duration]); + + if (!value) { + return null; + } + + return ( + +
+ {value === 'ontime' ? : value} +
+
+ ); +} diff --git a/apps/client/src/views/cuesheet/__tests__/cuesheet.utils.test.ts b/apps/client/src/views/cuesheet/__tests__/cuesheet.utils.test.ts index a7b162246..e5014b1a4 100644 --- a/apps/client/src/views/cuesheet/__tests__/cuesheet.utils.test.ts +++ b/apps/client/src/views/cuesheet/__tests__/cuesheet.utils.test.ts @@ -1,6 +1,6 @@ import { ProjectData } from 'ontime-types'; -import { makeCSV, makeTable, parseField } from '../cuesheet.utils'; +import { makeTable, parseField } from '../cuesheet.utils'; describe('parseField()', () => { it('returns a string from given millis on timeStart, TimeEnd and duration', () => { @@ -114,15 +114,3 @@ describe('makeTable()', () => { `); }); }); - -describe('make CSV()', () => { - it('joins an array of arrays with commas and newlines', () => { - const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']]; - expect(makeCSV(testdata)).toMatchInlineSnapshot(` -"field -after newline,after comma -,after empty -" -`); - }); -}); diff --git a/apps/client/src/views/cuesheet/cuesheet.utils.ts b/apps/client/src/views/cuesheet/cuesheet.utils.ts index a70305d83..0227a7e24 100644 --- a/apps/client/src/views/cuesheet/cuesheet.utils.ts +++ b/apps/client/src/views/cuesheet/cuesheet.utils.ts @@ -1,4 +1,3 @@ -import { stringify } from 'csv-stringify/browser/esm/sync'; import { CustomFields, isOntimeDelay, @@ -103,13 +102,3 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo return data; }; - -/** - * @description Converts an array of arrays to a csv file - * @param {string[][]} arrayOfArrays - * @return {string} - */ -export const makeCSV = (arrayOfArrays: string[][]): string => { - const stringifiedData = stringify(arrayOfArrays); - return stringifiedData; -}; diff --git a/apps/electron/src/menu/applicationMenu.js b/apps/electron/src/menu/applicationMenu.js index 5e4df7924..4b63269ad 100644 --- a/apps/electron/src/menu/applicationMenu.js +++ b/apps/electron/src/menu/applicationMenu.js @@ -234,6 +234,10 @@ function makeSettingsMenu(redirectWindow) { label: 'URL presets', click: () => redirectWindow('/editor?settings=feature_settings__urlpresets'), }, + { + label: 'Report', + click: () => redirectWindow('/editor?settings=feature_settings__report'), + }, ], }, { diff --git a/apps/server/src/api-data/index.ts b/apps/server/src/api-data/index.ts index d7211b909..da5c369fa 100644 --- a/apps/server/src/api-data/index.ts +++ b/apps/server/src/api-data/index.ts @@ -11,6 +11,7 @@ import { router as sheetsRouter } from './sheets/sheets.router.js'; import { router as excelRouter } from './excel/excel.router.js'; import { router as sessionRouter } from './session/session.router.js'; import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js'; +import { router as reportRouter } from './report/report.router.js'; export const appRouter = express.Router(); @@ -25,6 +26,7 @@ appRouter.use('/excel', excelRouter); appRouter.use('/url-presets', urlPresetsRouter); appRouter.use('/session', sessionRouter); appRouter.use('/view-settings', viewSettingsRouter); +appRouter.use('/report', reportRouter); //we don't want to redirect to react index when using api routes appRouter.all('/*', (_req, res) => { diff --git a/apps/server/src/api-data/report/report.controller.ts b/apps/server/src/api-data/report/report.controller.ts new file mode 100644 index 000000000..a81a2f5b2 --- /dev/null +++ b/apps/server/src/api-data/report/report.controller.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from 'express'; +import type { OntimeReport } from 'ontime-types'; +import * as report from './report.service.js'; + +export function getAll(_req: Request, res: Response) { + res.json(report.generate()); +} + +export function deleteAll(_req: Request, res: Response) { + report.clear(); + res.status(200).send(); +} + +export function deleteWithId(req: Request, res: Response) { + const { eventId } = req.params; + report.clear(eventId); + res.status(200).send(); +} diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts new file mode 100644 index 000000000..cb7fb622e --- /dev/null +++ b/apps/server/src/api-data/report/report.router.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { getAll, deleteWithId, deleteAll } from './report.controller.js'; +import { paramsMustHaveEventId } from '../rundown/rundown.validation.js'; + +export const router = express.Router(); + +router.get('/', getAll); + +router.delete('/all', deleteAll); +router.delete('/:eventId', paramsMustHaveEventId, deleteWithId); diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts new file mode 100644 index 000000000..8cdd75f55 --- /dev/null +++ b/apps/server/src/api-data/report/report.service.ts @@ -0,0 +1,65 @@ +import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types'; +import { RuntimeState } from '../../stores/runtimeState.js'; +import { sendRefetch } from '../../adapters/websocketAux.js'; +import { DeepReadonly } from 'ts-essentials'; + +const report = new Map(); + +let formattedReport: OntimeReport | null = null; + +/** + * generates a full report + * @returns full report + */ +export function generate(): OntimeReport { + if (formattedReport === null) { + formattedReport = Object.fromEntries(report); + } + return formattedReport; +} + +/** + * clear report + * @param id optional id of a event report to clear + */ +export function clear(id?: string) { + formattedReport = null; + if (id) { + report.delete(id); + } else { + report.clear(); + } +} + +/** + * trigger report entry + * @param cycle + * @param state + * @returns + */ +export function triggerReportEntry( + cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop, + state: DeepReadonly, +) { + if (!state.eventNow?.id) { + return; + } + + const eventId = state.eventNow.id; + + if (cycle === TimerLifeCycle.onStart) { + report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null }); + formattedReport = null; + return; + } + + if (cycle === TimerLifeCycle.onStop) { + const startedAt = report.get(eventId)?.startedAt ?? null; + report.set(eventId, { startedAt, endedAt: state.clock }); + formattedReport = null; + sendRefetch({ + target: 'REPORT', + }); + return; + } +} diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 473481794..e5bd7f13d 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -266,6 +266,7 @@ function notifyChanges(options: NotifyChangesOptions) { if (options.external) { // advice socket subscribers of change const payload = { + target: 'RUNDOWN', changes: Array.isArray(options.timer) ? options.timer : undefined, reload: options.reload, revision: cache.getMetadata().revision, diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 2d6fe844f..1dc486992 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -20,6 +20,8 @@ import type { RuntimeState } from '../../stores/runtimeState.js'; import { timerConfig } from '../../config/config.js'; import { eventStore } from '../../stores/EventStore.js'; +import { triggerReportEntry } from '../../api-data/report/report.service.js'; + import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; import { @@ -288,14 +290,17 @@ class RuntimeService { logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); return false; } + const previousState = runtimeState.getState(); const rundown = getRundown(); const success = runtimeState.load(event, rundown, initialData); if (success) { logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); + const newState = runtimeState.getState(); process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onLoad, runtimeState.getState()); + triggerReportEntry(TimerLifeCycle.onStop, previousState); + triggerAutomations(TimerLifeCycle.onLoad, newState); }); } return success; @@ -474,6 +479,7 @@ class RuntimeService { if (didStart) { process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStart, newState); triggerAutomations(TimerLifeCycle.onStart, newState); }); } @@ -536,8 +542,8 @@ class RuntimeService { */ @broadcastResult public stop(): boolean { - const state = runtimeState.getState(); - const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop; + const previousState = runtimeState.getState(); + const canStop = validatePlayback(previousState.timer.playback, previousState.timer.phase).stop; if (!canStop) { return false; } @@ -546,6 +552,7 @@ class RuntimeService { const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerAutomations(TimerLifeCycle.onStop, newState); }); @@ -598,12 +605,14 @@ class RuntimeService { if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerAutomations(TimerLifeCycle.onLoad, newState); }); } if (result.didStart) { process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStart, newState); triggerAutomations(TimerLifeCycle.onStart, newState); }); } diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f2a311807..3c01871e1 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -391,7 +391,6 @@ export function start(state: RuntimeState = runtimeState): boolean { // update offset state.runtime.offset = getRuntimeOffset(state); state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset; - return true; } @@ -410,7 +409,6 @@ export function stop(state: RuntimeState = runtimeState): boolean { if (state.timer.playback === Playback.Stop) { return false; } - clear(); runtimeState.runtime.actualStart = null; runtimeState.runtime.expectedEnd = null; diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts new file mode 100644 index 000000000..c34a0978d --- /dev/null +++ b/packages/types/src/definitions/core/Report.type.ts @@ -0,0 +1,8 @@ +import type { MaybeNumber } from '../../utils/utils.type.js'; + +export type OntimeEventReport = { + startedAt: MaybeNumber; + endedAt: MaybeNumber; +}; + +export type OntimeReport = Record; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5a9a75c26..f7a216a61 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,6 +16,9 @@ export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from '. export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; +// ---> Report +export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js'; + // ---> Automations export type { Automation,