diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 98629d28b2..52f2812117 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/report.ts b/apps/client/src/common/api/report.ts new file mode 100644 index 0000000000..e4d42484a5 --- /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 events + */ +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 0000000000..5f9da7643c --- /dev/null +++ b/apps/client/src/common/hooks-query/useReport.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { OntimeReport } from 'ontime-types'; + +import { REPORT } from '../api/constants'; +import { fetchReport } from '../api/report'; + +export default function useReport() { + const { data } = useQuery({ + queryKey: REPORT, + queryFn: fetchReport, + placeholderData: (previousData, _previousQuery) => previousData, + retry: 5, + retryDelay: (attempt) => attempt * 2500, + networkMode: 'always', + refetchOnMount: false, + enabled: true, + }); + + return { data: data ?? {} }; +} + +export function useGetEventReport(id: string) { + const { data } = useReport(); + return useMemo(() => { + return data[id]; + }, [data, id]); +} diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index f61360122e..c84b0fa28c 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 { @@ -196,19 +196,24 @@ 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.refetchQueries({ queryKey: REPORT }); + //we need to use refech and not invalidate } break; } case 'ontime-flush': { - flushBatchUpdates() + flushBatchUpdates(); break; } } diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 6acaa1b9b0..7dc41eecc7 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: 'Reporter' }, ], }, { diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 92847e298a..e178d95f52 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 386950aa52..d128b683e4 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; +import { deleteReport } from '../../common/api/report'; import { useEventAction } from '../../common/hooks/useEventAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import { useEmitLog } from '../../common/stores/logger'; @@ -22,7 +23,8 @@ export type EventItemActions = | 'delete' | 'clone' | 'update' - | 'swap'; + | 'swap' + | 'clear-report'; interface RundownEntryProps { type: SupportedEvent; @@ -142,6 +144,14 @@ export default function RundownEntry(props: RundownEntryProps) { return emitError(`Unknown field: ${field}`); } + case 'clear-report': { + const { field, value } = payload as FieldValue; + if (field === undefined || field !== 'id' || value === undefined || typeof value !== 'string') { + return; + } + deleteReport(value); + break; + } default: throw new Error(`Unhandled event ${action}`); } diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 8fd2fc4eaa..bb25d6695d 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle'; import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline'; import { IoLink } from '@react-icons/all-files/io5/IoLink'; import { IoPeople } from '@react-icons/all-files/io5/IoPeople'; @@ -175,7 +176,13 @@ 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: 'Clear report', + icon: IoCloseCircle, + onClick: () => actionHandler('clear-report', { field: 'id', value: eventId }), + }, { 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 a59450486a..b2e549edcf 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 b696964318..d59e427369 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 7a6857cbb4..900c39832a 100644 --- a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx +++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx @@ -1,10 +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 { useGetEventReport } 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'; @@ -17,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) { @@ -30,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) { @@ -75,3 +78,54 @@ function EventUntil(props: EventUntilProps) { ); } + +interface EventReportProps { + className: string; + id: string; + duration: number; +} + +function EventReport(props: EventReportProps) { + const { className, id, duration } = props; + const currentReport = useGetEventReport(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/server/src/api-data/index.ts b/apps/server/src/api-data/index.ts index d7211b9096..da5c369fac 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 0000000000..e0251baf97 --- /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 async function getAll(_req: Request, res: Response) { + res.json(report.generate()); +} + +export async function deleteAll(_req: Request, res: Response) { + report.clear(); + res.status(200).send(); +} + +export async 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 0000000000..cb7fb622e5 --- /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 0000000000..79ef3cc104 --- /dev/null +++ b/apps/server/src/api-data/report/report.service.ts @@ -0,0 +1,79 @@ +import { OntimeReport, OntimeEventReport, TimerLifeCycle, MaybeString } from 'ontime-types'; +import { RuntimeState } from '../../stores/runtimeState.js'; +import { sendRefetch } from '../../adapters/websocketAux.js'; + +//TODO: there seams to be some actions that should invalidate reports +// events timer edits? +// event delete +// Also what about roll mode? + +const report = new Map(); + +let formattedReport: OntimeReport | null = null; + +/** + * blank placeholder data + */ +const blankReportData: OntimeEventReport = { + startedAt: null, + endedAt: null, +} as const; + +/** + * generates a full report + * @returns full report + */ +export function generate(): OntimeReport { + if (formattedReport === null) { + formattedReport = Object.fromEntries(report); + } + return formattedReport; +} + +export function getWithId(id: string): OntimeEventReport | null { + return report.get(id) ?? null; +} + +/** + * 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(); + } +} + +let currentReportId: MaybeString = null; + +/** + * trigger report entry + * @param cycle + * @param state + * @returns + */ +export function triggerReportEntry(cycle: TimerLifeCycle, state: Readonly) { + switch (cycle) { + case TimerLifeCycle.onStart: { + currentReportId = state.eventNow.id; + report.set(currentReportId, { ...blankReportData, startedAt: state.timer.startedAt }); + break; + } + case TimerLifeCycle.onLoad: + case TimerLifeCycle.onStop: { + if (currentReportId) { + const startedAt = report.get(currentReportId).startedAt; + report.set(currentReportId, { startedAt, endedAt: state.clock }); + currentReportId = null; + formattedReport = null; + sendRefetch({ + target: 'REPORT', + }); + } + break; + } + } +} diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 4734817945..e5bd7f13de 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 2d6fe844ff..ba179de11d 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 { @@ -294,8 +296,10 @@ class RuntimeService { 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.onLoad, newState); + triggerAutomations(TimerLifeCycle.onLoad, newState); }); } return success; @@ -474,6 +478,7 @@ class RuntimeService { if (didStart) { process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStart, newState); triggerAutomations(TimerLifeCycle.onStart, newState); }); } @@ -546,6 +551,7 @@ class RuntimeService { const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onStop, newState); triggerAutomations(TimerLifeCycle.onStop, newState); }); @@ -598,12 +604,14 @@ class RuntimeService { if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); process.nextTick(() => { + triggerReportEntry(TimerLifeCycle.onLoad, newState); 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 4db8dd88ac..6724ec9e20 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -416,7 +416,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; } @@ -435,7 +434,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 0000000000..c34a0978d8 --- /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 396c135942..f33c3a0437 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,