mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Report (#1469)
* create report service * write report from runtimeState * use in UI * clear report * update types * clear all from settings menu * rearence rightclik menu * refactor styling * also report roll events * ontime/under time is same colour * refactor reporter * add target to ontime-refetch * remove menu * fectch only on message from server * memo useGetEventReport * refactor * use staleTime * dont add to menu yet * implement review * clear all from menu * fix merge * start end show * combine test for the go button text and action * add report to menu * extract csv utility * report management * unneeded async * small refacort of triggerReportEntry --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
committed by
GitHub
parent
21877e4bff
commit
b6507c27a6
@@ -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`;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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<OntimeReport> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<OntimeReport>({
|
||||
queryKey: REPORT,
|
||||
queryFn: fetchReport,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
networkMode: 'always',
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
return { data: data ?? {}, refetch };
|
||||
}
|
||||
@@ -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
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<RundownCached>(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<RundownCached>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding-block: 1rem;
|
||||
padding-block: 5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
|
||||
<tr className={style.empty}>
|
||||
<td colSpan={99}>
|
||||
<div>{label ?? 'No data yet'}</div>
|
||||
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
|
||||
New
|
||||
</Button>
|
||||
{handleClick && (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
isDisabled={!handleClick}
|
||||
variant='ontime-filled'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
+6
@@ -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<HTMLDivElement>('custom', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
|
||||
<div ref={reportRef}>
|
||||
<ReportSettings />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
th.over {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
+113
@@ -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 (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Report</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Manage report
|
||||
<Panel.InlineElements>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
leftIcon={<IoTrashBin />}
|
||||
size='sm'
|
||||
onClick={() => downloadCSV(combinedReport)}
|
||||
isDisabled={combinedReport.length === 0}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
leftIcon={<IoTrashBin />}
|
||||
size='sm'
|
||||
color='#FA5656'
|
||||
onClick={clearReport}
|
||||
isDisabled={combinedReport.length === 0}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Cue</th>
|
||||
<th>Title</th>
|
||||
<th>Scheduled Start</th>
|
||||
<th>Actual Start</th>
|
||||
<th>Scheduled End</th>
|
||||
<th>Actual End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedReport.length === 0 && (
|
||||
<Panel.TableEmpty label='Reports are generated when running through the show.' />
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<tr key={entry.index}>
|
||||
<th>{entry.index}</th>
|
||||
<th>{entry.cue}</th>
|
||||
<th>{entry.title}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div className={style.buttonContainer}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: PropsWithChildren<PlaybackTimerProp
|
||||
const { playback, children } = props;
|
||||
const timer = useTimer();
|
||||
|
||||
const started = millisToString(timer.startedAt);
|
||||
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
@@ -58,19 +55,55 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return (
|
||||
<a className={style.reportLink} href='/editor?settings=feature_settings__report'>
|
||||
Go to report management
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: $green-500;
|
||||
color: $playback-ahead;
|
||||
}
|
||||
|
||||
.behind {
|
||||
|
||||
@@ -22,7 +22,8 @@ export type EventItemActions =
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
| 'swap';
|
||||
| 'swap'
|
||||
| 'clear-report';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -125,6 +125,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
isLinkedAndNext={isNext && linkStart !== null}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
border-radius: 2px;
|
||||
|
||||
&.over {
|
||||
color: $playback-negative;
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
&.under {
|
||||
|
||||
@@ -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 <EventReport className={className} id={id} duration={duration} />;
|
||||
}
|
||||
|
||||
if (playbackActive) {
|
||||
@@ -65,3 +69,55 @@ function EventUntil(props: EventUntilProps) {
|
||||
|
||||
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
|
||||
<div className={cx([style.chip, style[overUnderStyle], className])}>
|
||||
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<OntimeReport>) {
|
||||
res.json(report.generate());
|
||||
}
|
||||
|
||||
export function deleteAll(_req: Request, res: Response<OntimeReport>) {
|
||||
report.clear();
|
||||
res.status(200).send();
|
||||
}
|
||||
|
||||
export function deleteWithId(req: Request, res: Response<OntimeReport>) {
|
||||
const { eventId } = req.params;
|
||||
report.clear(eventId);
|
||||
res.status(200).send();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<string, OntimeEventReport>();
|
||||
|
||||
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<RuntimeState>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
|
||||
export type OntimeEventReport = {
|
||||
startedAt: MaybeNumber;
|
||||
endedAt: MaybeNumber;
|
||||
};
|
||||
|
||||
export type OntimeReport = Record<string, OntimeEventReport>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user