diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index c010d8eab..cbe7a03b9 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -1,3 +1,4 @@ +import { isOntimeEvent } from 'ontime-types'; import { countPlannedEvents, getGroupReports, getRunSummary } from 'ontime-utils'; import { useMemo } from 'react'; import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5'; @@ -40,6 +41,13 @@ export default function ReportSettings() { return getGroupReports(reportData, data.entries, data.order); }, [reportData, data.entries, data.order]); + // the summary knows which event ran longest over, the rundown knows its name + const worstOverrunTitle = useMemo(() => { + if (summary.worstOverrun === null) return null; + const entry = data.entries[summary.worstOverrun.id]; + return entry && isOntimeEvent(entry) ? entry.title : null; + }, [summary.worstOverrun, data.entries]); + const hasReport = combinedReport.length > 0; return ( @@ -69,7 +77,14 @@ export default function ReportSettings() { ) : ( <> - + + + diff --git a/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts index 7561dd85f..895cec2a5 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -8,7 +8,7 @@ import { TimerType, } from 'ontime-types'; -import { formatOffset, getCombinedReport, makeReportCSV } from '../reportSettings.utils'; +import { deviationBreakdown, formatOffset, getCombinedReport, makeReportCSV } from '../reportSettings.utils'; function makeEvent(patch: Partial): OntimeEvent { return { @@ -50,7 +50,12 @@ describe('getCombinedReport()', () => { // the rundown was edited after the show, the report must not follow it const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 }); const report: OntimeReport = { - a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000 }, + a: { + startedAt: 100, + endedAt: 10100, + scheduledStart: 0, + scheduledDuration: 10000, + }, }; const result = getCombinedReport(report, { a: entry }, ['a']); @@ -67,7 +72,12 @@ describe('getCombinedReport()', () => { const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 }); const report: OntimeReport = { - b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000 }, + b: { + startedAt: 10000, + endedAt: 20000, + scheduledStart: 10000, + scheduledDuration: 10000, + }, }; const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']); @@ -85,7 +95,12 @@ describe('getCombinedReport()', () => { const ran = makeEvent({ id: 'a' }); const skipped = makeEvent({ id: 'b', skip: true }); const report: OntimeReport = { - a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000 }, + a: { + startedAt: 0, + endedAt: 10000, + scheduledStart: 0, + scheduledDuration: 10000, + }, }; expect(getCombinedReport(report, { a: ran, b: skipped }, ['a', 'b']).map((row) => row.id)).toEqual(['a']); @@ -95,10 +110,20 @@ describe('getCombinedReport()', () => { const entry = makeEvent({ id: 'a' }); const rundownEntries: RundownEntries = { a: entry, - delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null }, + delay: { + type: SupportedEntry.Delay, + id: 'delay', + duration: 1000, + parent: null, + }, }; const report: OntimeReport = { - a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000 }, + a: { + startedAt: 0, + endedAt: 10000, + scheduledStart: 0, + scheduledDuration: 10000, + }, }; expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']); @@ -166,3 +191,27 @@ describe('makeReportCSV()', () => { expect(rows[1]).toContain('Act 1'); }); }); + +describe('deviationBreakdown()', () => { + it('separates a late start from time the show itself lost', () => { + // finished 4m12s behind, but only 2m12s of that was the show's doing + expect(deviationBreakdown(120000, 132000)).toBe('started +2m, +2m12s during the show'); + }); + + it('credits a show which made up a late start', () => { + expect(deviationBreakdown(300000, -120000)).toBe('started +5m, -2m during the show'); + }); + + it('reads a punctual start as such rather than as an offset of zero', () => { + expect(deviationBreakdown(0, 60000)).toBe('started on time, +1m during the show'); + }); + + it('says the show held schedule when it neither lost nor gained', () => { + expect(deviationBreakdown(120000, 0)).toBe('started +2m, held schedule from there'); + }); + + it('has nothing to say without both halves', () => { + expect(deviationBreakdown(null, 1000)).toBe(''); + expect(deviationBreakdown(1000, null)).toBe(''); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss index e3e6c693f..0ad33fba0 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss @@ -1,137 +1,11 @@ -// a block within the panel card, matching how Info nests inside a card -.summary { - margin: 0 var(--panel-card-padding, 2rem); - padding: 1.25rem; - background-color: $gray-1200; - border-radius: 3px; - - display: flex; - flex-direction: column; - gap: 1rem; +// Panel.Table nests its own padding inside the section it sits in, so the +// summary takes the same inset to keep one left edge down the whole panel +.inset { + padding: 0 var(--panel-card-padding, 2rem); } -.top { - display: flex; - align-items: flex-start; - justify-content: space-between; - flex-wrap: wrap; - gap: 1rem 2.5rem; -} - -// the conclusion, given the most weight in the block -.verdict { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; -} - -.verdictLabel { - font-size: calc(1rem - 3px); - text-transform: uppercase; - letter-spacing: 0.04em; - color: $gray-300; -} - -.verdictValue { - font-size: 1.75rem; - font-weight: 600; - line-height: 1.1; - font-variant-numeric: tabular-nums; - - &.over { - color: $playback-over; - } - - &.under { - color: $playback-under; - } - - &.none { - color: $ui-white; - } -} - -.incompleteNote { - max-width: 34rem; - color: $warning-orange; - font-size: calc(1rem - 2px); - line-height: 1.4; -} - -// label | planned -> actual | offset, so every offset lands in one column -.lines { - display: grid; - grid-template-columns: auto auto auto; - align-items: baseline; - gap: 0.375rem 1.5rem; -} - -.label { - color: $gray-300; - font-size: calc(1rem - 2px); -} - -.times { - display: flex; - align-items: baseline; - gap: 0.5rem; - font-variant-numeric: tabular-nums; -} - -.planned { - color: $gray-300; -} - -.arrow { - color: $gray-500; -} - -.actual { - color: $ui-white; - font-weight: 600; -} - -.offset { - justify-self: end; +.overrun { + color: $playback-over; font-weight: 600; font-variant-numeric: tabular-nums; - - &.over { - color: $playback-over; - } - - &.under { - color: $playback-under; - } - - &.none { - color: $gray-300; - } -} - -.counts { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - - padding-top: 0.875rem; - border-top: 1px solid $white-10; - - color: $gray-300; - font-size: calc(1rem - 2px); -} - -.count { - white-space: nowrap; - - b { - color: $ui-white; - font-weight: 600; - } -} - -.separator { - color: $gray-500; } diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx index e496bd81a..dd7f8b08d 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx @@ -1,27 +1,31 @@ import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types'; -import { getShowOffsets, MILLIS_PER_SECOND } from 'ontime-utils'; -import { Fragment, useMemo } from 'react'; +import { getShowOffsets } from 'ontime-utils'; +import { useMemo } from 'react'; import Tooltip from '../../../../../common/components/tooltip/Tooltip'; -import { cx, enDash } from '../../../../../common/utils/styleUtils'; -import { formatTime } from '../../../../../common/utils/time'; -import { formatOffset, offsetTone } from '../reportSettings.utils'; +import { enDash } from '../../../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../../../common/utils/time'; +import { deviationBreakdown } from '../reportSettings.utils'; +import ReportSummaryCard, { FooterItem, Metric } from './ReportSummaryCard'; import style from './ReportShowSummary.module.scss'; interface ReportShowSummaryProps { + rundownTitle: string; show: ShowReport; summary: RunSummary; + worstOverrunTitle: string | null; } /** - * Leads the report with how the show sat against its plan. + * Leads the report with how far the show finished from where it was planned to. * - * Start and end offsets are kept apart because they have different causes: a - * late start is rarely the show's doing, while time lost between the two is. - * That difference is the conclusion, so it carries the weight in the block. + * That single figure is what a producer is asked about afterwards, so it is + * the headline. It is then split into the part inherited from a late start and + * the part the show itself moved, which have different causes and different + * remedies. */ -export default function ReportShowSummary({ show, summary }: ReportShowSummaryProps) { +export default function ReportShowSummary({ rundownTitle, show, summary, worstOverrunTitle }: ReportShowSummaryProps) { const offsets = useMemo(() => getShowOffsets(show), [show]); /** @@ -33,100 +37,61 @@ export default function ReportShowSummary({ show, summary }: ReportShowSummaryPr const hasPlan = offsets.startOffset !== null; return ( -
- {hasPlan && ( -
-
- {didReachEnd ? ( - <> - } - className={style.verdictLabel} - > - {verdictLabel(offsets.duringShow)} - - - {formatOffset(offsets.duringShow)} - - - ) : ( - <> - Show incomplete - - The show did not reach the end of the rundown, so there is nothing to compare the finish against. - - +
+ } + > + {deviationBreakdown(offsets.startOffset, offsets.duringShow)} + + ) : ( + 'The show did not reach the end of the rundown, so there is nothing to compare the finish against.' + ) + } + footer={ + <> + + + {summary.eventsRun} of {summary.eventsPlanned} + {' '} + events run + + {summary.worstOverrun !== null && ( + + longest overrun {worstOverrunTitle || 'an event'}{' '} + +{formatDuration(summary.worstOverrun.delta, false)} + )} -
- -
- - {didReachEnd && ( - - )} -
-
- )} - -
- - - {summary.eventsRun} of {summary.eventsPlanned} - {' '} - events run - - - - -
+ + } + > + {hasPlan && ( + + )} + {hasPlan && didReachEnd && ( + + )} +
); } -function verdictLabel(duringShow: MaybeNumber): string { - if (duringShow === null || Math.abs(duringShow) < MILLIS_PER_SECOND) { - return 'Held schedule during the show'; - } - return duringShow > 0 ? 'Lost during the show' : 'Recovered during the show'; -} - -function TimeLine({ - label, - planned, - actual, - offset, -}: { - label: string; - planned: MaybeNumber; - actual: MaybeNumber; - offset: MaybeNumber; -}) { - return ( - - {label} - - {planned === null ? enDash : formatTime(planned)} - - {actual === null ? enDash : formatTime(actual)} - - {formatOffset(offset)} - - ); -} - -function Count({ label, value }: { label: string; value: number }) { - return ( - <> - · - - {value} {label} - - - ); +function formatMaybeTime(value: MaybeNumber): string { + return value === null ? enDash : formatTime(value); } diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.module.scss new file mode 100644 index 000000000..8e9ec4c10 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.module.scss @@ -0,0 +1,193 @@ +// an inset surface within the panel card, matching how Info nests inside one +.card { + padding: 1.25rem; + background-color: $gray-1200; + border-radius: 3px; + + display: flex; + flex-direction: column; + gap: 1rem; +} + +// a subordinate tier carries the same shape at less weight, so a group heading +// reads as belonging to the show summary rather than competing with it +.compact { + padding: 0.875rem 1rem; + gap: 0.625rem; + + // at this weight the conclusion fits beside its label rather than under it, + // keeping a group heading to two lines however many of them the table has + .headline { + flex-direction: row; + align-items: baseline; + gap: 0.625rem; + } + + .headlineValue { + font-size: 1.25rem; + } + + .body { + align-items: baseline; + } + + .footer { + padding-top: 0.625rem; + } +} + +.identity { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; +} + +.title { + display: flex; + align-items: center; + gap: 0.5rem; + + color: $ui-white; + font-weight: 600; +} + +.subtitle { + color: $gray-500; + font-size: calc(1rem - 2px); + font-variant-numeric: tabular-nums; +} + +.body { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem 2.5rem; +} + +// the conclusion, given the most weight in the card +.headline { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.headlineLabel { + font-size: calc(1rem - 3px); + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-300; +} + +.headlineValue { + font-size: 1.75rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + + &.over { + color: $playback-over; + } + + &.under { + color: $playback-under; + } + + &.none { + color: $ui-white; + } +} + +.note { + max-width: 32rem; + color: $gray-300; + font-size: calc(1rem - 2px); + line-height: 1.4; + + // nothing to conclude yet is a caveat on the report, not a detail + &.unavailable { + color: $warning-orange; + } +} + +// label | planned -> actual | offset, so every offset lands in one column +.metrics { + display: grid; + grid-template-columns: auto auto auto; + align-items: baseline; + gap: 0.375rem 1.5rem; +} + +.metricLabel { + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.metricValue { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-variant-numeric: tabular-nums; +} + +.planned { + color: $gray-300; +} + +.arrow { + color: $gray-500; +} + +.actual { + color: $ui-white; + font-weight: 600; +} + +.metricOffset { + justify-self: end; + font-weight: 600; + font-variant-numeric: tabular-nums; + + &.over { + color: $playback-over; + } + + &.under { + color: $playback-under; + } + + &.none { + color: $gray-300; + } +} + +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + + padding-top: 0.875rem; + border-top: 1px solid $white-10; + + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.footerItem { + white-space: nowrap; + + // a dot between neighbours, rather than a separator element per gap + & + &::before { + content: "·"; + margin-right: 0.75rem; + color: $gray-500; + } + + b { + color: $ui-white; + font-weight: 600; + } +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.tsx new file mode 100644 index 000000000..d81d4a6ab --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportSummaryCard.tsx @@ -0,0 +1,103 @@ +import { MaybeNumber } from 'ontime-types'; +import { PropsWithChildren, ReactNode } from 'react'; + +import { cx } from '../../../../../common/utils/styleUtils'; +import { formatOffset, offsetTone } from '../reportSettings.utils'; + +import style from './ReportSummaryCard.module.scss'; + +interface ReportSummaryCardProps { + /** what is being summarised: a rundown name, a group title */ + title: ReactNode; + /** optional context for the title, eg the window the group ran in */ + subtitle?: ReactNode; + /** what the headline figure measures, eg "Total plan deviation" */ + headlineLabel: string; + /** the conclusion. Null renders the note instead, for a run with nothing to conclude */ + headline: MaybeNumber; + /** shown under the headline: the workings when there is a figure, the reason when there is not */ + note?: ReactNode; + footer?: ReactNode; + /** a subordinate tier, eg a group inside the show it belongs to */ + compact?: boolean; +} + +/** + * One shape for every tier of the report. + * + * A group header is a summary of the events inside it exactly as the show + * summary is a summary of the groups, so both are read the same way: the + * conclusion on the left at full weight, the workings behind it on the right, + * and how much of it ran along the bottom. + */ +export default function ReportSummaryCard({ + title, + subtitle, + headlineLabel, + headline, + note, + footer, + compact, + children, +}: PropsWithChildren) { + return ( +
+
+ {title} + {subtitle && {subtitle}} +
+ +
+
+ {headlineLabel} + {headline !== null && ( + {formatOffset(headline)} + )} + {note && {note}} +
+ +
{children}
+
+ + {footer &&
{footer}
} +
+ ); +} + +interface MetricProps { + label: string; + /** the value it was measured against, omitted for a figure which stands alone */ + planned?: string; + /** what actually happened */ + actual: string; + offset?: MaybeNumber; +} + +/** + * A row of the workings: what was planned, what happened, and the distance + * between them. Every offset lands in the same column so they read as a stack. + */ +export function Metric({ label, planned, actual, offset }: MetricProps) { + return ( + <> + {label} + + {planned !== undefined && ( + <> + {planned} + + + )} + {actual} + + + {offset === undefined ? '' : formatOffset(offset)} + + + ); +} + +/** A fact in the card footer, separated from its neighbours by a dot */ +export function FooterItem({ children }: PropsWithChildren) { + return {children}; +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss index 9ee8c08a1..3abcfb00b 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss @@ -6,30 +6,16 @@ th.under { color: $playback-under; } +// the group heading is a card rather than a table row treatment, so the cell +// only has to give it room and stay out of the table's striping .groupRow { - background-color: $white-3; + background-color: transparent; td { - padding-top: 0.75rem; - padding-bottom: 0.75rem; + padding: 1.25rem 0 0.5rem; } } -.groupHeading { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem 0.75rem; -} - -.groupTitle { - display: flex; - align-items: center; - gap: 0.5rem; - font-weight: 600; - color: $ui-white; -} - .groupColour { width: 0.25rem; height: 1rem; @@ -38,44 +24,6 @@ th.under { flex-shrink: 0; } -.groupStats { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.375rem 0.625rem; - color: $gray-300; - font-variant-numeric: tabular-nums; -} - -.stat b { - color: $ui-white; - font-weight: 600; -} - -.variance { - font-weight: 600; - - &.over { - color: $playback-over; - } - - &.under { - color: $playback-under; - } - - &.none { - color: $gray-300; - } -} - -.separator { - color: $gray-500; -} - -.incomplete { - color: $warning-orange; -} - .eventCue, .eventIndex { color: $gray-300; diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx index 99d6aa4f6..a93fadce7 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx @@ -5,7 +5,8 @@ import Tooltip from '../../../../../common/components/tooltip/Tooltip'; import { cx, enDash } from '../../../../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../../../common/utils/time'; import * as Panel from '../../../panel-utils/PanelUtils'; -import { CombinedReport, formatOffset, offsetTone } from '../reportSettings.utils'; +import { CombinedReport } from '../reportSettings.utils'; +import ReportSummaryCard, { FooterItem, Metric } from './ReportSummaryCard'; import style from './ReportTable.module.scss'; @@ -50,69 +51,68 @@ export default function ReportTable({ rows, groups }: ReportTableProps) { ); } +/** + * A group read the same way as the show above it: what it was measured + * against, what it actually did, and how much of it ran. + */ function GroupRow({ group }: { group: GroupReport }) { const isComplete = group.eventsRun === group.eventsPlanned; - const measure = group.targetDuration === null ? 'scheduled' : 'target'; - const measureValue = group.targetDuration ?? group.scheduledDuration; + const hasTarget = group.targetDuration !== null; + const measuredAgainst = group.targetDuration ?? group.scheduledDuration; return ( -
- - - {group.title || 'Untitled group'} - - - - } - className={style.stat} - > - {formatDuration(measureValue, false)} {measure} - - - · - - {group.elapsed === null ? enDash : formatDuration(group.elapsed, false)} actual - - - {group.variance !== null && ( - <> - · - - {formatOffset(group.variance)} - - - )} - - {group.changeover !== null && group.changeover > 0 && ( - <> - · - } - className={style.stat} - > - {formatDuration(group.changeover, false)} changeover - - - )} - - · - - - {group.eventsRun}/{group.eventsPlanned} - {' '} - {isComplete ? 'events' : 'events run'} - - -
+ + + {group.title || 'Untitled group'} + + } + subtitle={ + group.actualStart !== null && group.actualEnd !== null + ? `ran ${formatTime(group.actualStart)} to ${formatTime(group.actualEnd)}` + : undefined + } + compact + headlineLabel={hasTarget ? 'Against target' : 'Against schedule'} + headline={group.variance} + note={ + group.variance !== null + ? undefined + : isComplete + ? 'This group has not run.' + : 'Still to finish, so there is nothing to measure the group against yet.' + } + footer={ + <> + + + {group.eventsRun} of {group.eventsPlanned} + {' '} + events run + + {group.untimed !== null && group.untimed > 0 && ( + + } + > + {formatDuration(group.untimed, false)} untimed + + + )} + + } + > + {/* the headline already carries the variance, so this is the workings behind it */} + + ); @@ -141,7 +141,11 @@ function punctuality(actual: number | null, scheduled: number): 'under' | 'over' return actual <= scheduled ? 'under' : 'over'; } -type Section = { key: string; group: GroupReport | null; rows: CombinedReport[] }; +type Section = { + key: string; + group: GroupReport | null; + rows: CombinedReport[]; +}; /** * Splits the rows into the blocks they belong to, keeping rundown order and diff --git a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts index 1ede84fa1..e23d8aa84 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts @@ -79,6 +79,26 @@ export function formatOffset(value: MaybeNumber): string { return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`; } +/** + * Reads a show's total plan deviation back as the two parts it is made of, so + * a show which finished late because it began late is not mistaken for one + * which overran. + */ +export function deviationBreakdown(startOffset: MaybeNumber, duringShow: MaybeNumber): string { + if (startOffset === null || duringShow === null) { + return ''; + } + + const fromStart = + Math.abs(startOffset) < MILLIS_PER_SECOND ? 'started on time' : `started ${formatOffset(startOffset)}`; + const inShow = + Math.abs(duringShow) < MILLIS_PER_SECOND + ? 'held schedule from there' + : `${formatOffset(duringShow)} during the show`; + + return `${fromStart}, ${inShow}`; +} + /** Whether an offset is behind, ahead, or neither, for colouring */ export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' { if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none'; @@ -90,16 +110,7 @@ function csvTime(value: MaybeNumber): string { return value === null ? '' : formatTime(value); } -const csvHeader = [ - 'Index', - 'Group', - 'Cue', - 'Title', - 'Scheduled Start', - 'Actual Start', - 'Scheduled End', - 'Actual End', -]; +const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; /** * Transforms a CombinedReport into a CSV string. diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index 862068f26..40b954242 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -63,23 +63,32 @@ export type GroupReport = { /** wall time from the first event starting to the last one ending */ elapsed: MaybeNumber; /** - * Time inside the block not covered by a running event, ie changeovers. - * elapsed minus the sum of the events' own durations. + * Time inside the block with no event running: elapsed minus the sum of the + * events' own durations. Deliberately named for what was measured rather + * than for a cause, since the same gap can be a changeover, a timer started + * late, or an event run without the timer at all. + */ + untimed: MaybeNumber; + /** + * Measured against targetDuration where set, otherwise scheduledDuration. + * Null while the group is incomplete: the events still to run would make a + * partial group read as a large underrun. */ - changeover: MaybeNumber; - /** measured against targetDuration where set, otherwise scheduledDuration */ variance: MaybeNumber; eventsRun: number; eventsPlanned: number; }; -/** Counts across the events in the report */ +/** + * Counts across the events in the report. + * + * Deliberately not a tally of events over, under and on time: events rarely + * land on the exact second, so those buckets describe rounding more than they + * describe the show. The single worst overrun is the fact that acts on. + */ export type RunSummary = { eventsRun: number; eventsPlanned: number; - eventsOver: number; - eventsUnder: number; - eventsOnTime: number; /** largest single overrun, answers "what blew the schedule" */ worstOverrun: { id: EntryId; delta: number } | null; }; diff --git a/packages/utils/src/report-utils/reportUtils.test.ts b/packages/utils/src/report-utils/reportUtils.test.ts index f2f3bef12..6358624d8 100644 --- a/packages/utils/src/report-utils/reportUtils.test.ts +++ b/packages/utils/src/report-utils/reportUtils.test.ts @@ -172,8 +172,8 @@ describe('getGroupReports()', () => { }); }); - it('reports the time a block spent not running an event', () => { - // 5 minute changeover between the two events + it('reports the time a block spent with no event running', () => { + // 5 minutes between one event ending and the next starting const report: OntimeReport = { a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }), b: makeEntry({ startedAt: 15 * MIN, endedAt: 25 * MIN, scheduledDuration: 10 * MIN }), @@ -182,7 +182,7 @@ describe('getGroupReports()', () => { const [group] = getGroupReports(report, entries, ['act1']); expect(group.elapsed).toBe(25 * MIN); - expect(group.changeover).toBe(5 * MIN); + expect(group.untimed).toBe(5 * MIN); }); it('falls back to what was scheduled when no target was set', () => { @@ -200,7 +200,9 @@ describe('getGroupReports()', () => { expect(group.variance).toBe(4 * MIN); }); - it('reads a partly run block as incomplete rather than as an overrun', () => { + it('withholds variance while a block still has events to run', () => { + // one of two events run: the block has not spent its 30 minute budget yet, + // reporting it now would read as a 20 minute underrun const report: OntimeReport = { a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }), }; @@ -209,12 +211,15 @@ describe('getGroupReports()', () => { expect(group.eventsRun).toBe(1); expect(group.eventsPlanned).toBe(2); + expect(group.variance).toBeNull(); + // what did happen is still reported + expect(group.elapsed).toBe(10 * MIN); }); it('has no actuals for a block which never ran', () => { const [group] = getGroupReports({}, entries, ['act1']); - expect(group).toMatchObject({ elapsed: null, changeover: null, variance: null, eventsRun: 0 }); + expect(group).toMatchObject({ elapsed: null, untimed: null, variance: null, eventsRun: 0 }); }); it('leaves skipped events out of the block', () => { @@ -232,20 +237,14 @@ describe('getGroupReports()', () => { }); describe('getRunSummary()', () => { - it('counts how events landed', () => { + it('counts how much of the show ran', () => { const report: OntimeReport = { - a: makeEntry({ endedAt: 15000 }), // over - b: makeEntry({ endedAt: 6000 }), // under - c: makeEntry({ endedAt: 10000 }), // on time + a: makeEntry({ endedAt: 15000 }), + b: makeEntry({ endedAt: 6000 }), + c: makeEntry({ endedAt: 10000 }), }; - expect(getRunSummary(report, 4)).toMatchObject({ - eventsRun: 3, - eventsPlanned: 4, - eventsOver: 1, - eventsUnder: 1, - eventsOnTime: 1, - }); + expect(getRunSummary(report, 4)).toMatchObject({ eventsRun: 3, eventsPlanned: 4 }); }); it('identifies the worst overrun', () => { @@ -258,6 +257,11 @@ describe('getRunSummary()', () => { expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 }); }); + it('has no worst overrun when nothing ran long', () => { + const report: OntimeReport = { a: makeEntry({ endedAt: 6000 }), b: makeEntry({ endedAt: 10000 }) }; + expect(getRunSummary(report, 2).worstOverrun).toBeNull(); + }); + it('ignores events which did not complete', () => { const report: OntimeReport = { a: makeEntry({ endedAt: 15000 }), b: makeEntry({ endedAt: null }) }; expect(getRunSummary(report, 2).eventsRun).toBe(1); diff --git a/packages/utils/src/report-utils/reportUtils.ts b/packages/utils/src/report-utils/reportUtils.ts index e33fd4299..6fadc666d 100644 --- a/packages/utils/src/report-utils/reportUtils.ts +++ b/packages/utils/src/report-utils/reportUtils.ts @@ -164,6 +164,9 @@ export function getGroupReports(report: OntimeReport, entries: RundownEntries, o const elapsed = actualStart === null || actualEnd === null ? null : elapsedBetween(actualStart, actualEnd); // the budget if the user set one, otherwise what they scheduled into it const measuredAgainst = group.targetDuration ?? scheduledDuration; + // a group with events still to run has not spent its budget yet: comparing + // what it has used so far against the whole would report a large underrun + const isComplete = eventsRun > 0 && eventsRun === eventsPlanned; groups.push({ id: group.id, @@ -174,9 +177,9 @@ export function getGroupReports(report: OntimeReport, entries: RundownEntries, o actualStart, actualEnd, elapsed, - // whatever the block spent not running an event - changeover: elapsed === null ? null : Math.max(0, elapsed - ranDuration), - variance: elapsed === null ? null : elapsed - measuredAgainst, + // whatever the block spent with no event running + untimed: elapsed === null ? null : Math.max(0, elapsed - ranDuration), + variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst, eventsRun, eventsPlanned, }); @@ -185,14 +188,17 @@ export function getGroupReports(report: OntimeReport, entries: RundownEntries, o return groups; } -/** Counts across the events in the report */ +/** + * Counts across the events in the report. + * + * Only how much of the show ran, and which single event ran longest over its + * schedule. Bucketing every event as over, under or on time reads as precision + * the data does not have: almost nothing lands on the exact second. + */ export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary { const summary: RunSummary = { eventsRun: 0, eventsPlanned, - eventsOver: 0, - eventsUnder: 0, - eventsOnTime: 0, worstOverrun: null, }; @@ -204,15 +210,8 @@ export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunS summary.eventsRun += 1; - if (variance.status === 'over') { - summary.eventsOver += 1; - if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) { - summary.worstOverrun = { id, delta: variance.delta }; - } - } else if (variance.status === 'under') { - summary.eventsUnder += 1; - } else { - summary.eventsOnTime += 1; + if (variance.status === 'over' && (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta)) { + summary.worstOverrun = { id, delta: variance.delta }; } }