mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 09:59:08 +00:00
refactor(report): one summary shape for the show and its groups
A group heading is a summary of the events inside it in the same way the show summary is a summary of the groups, so both are now built on one shell and read the same way: the conclusion at full weight on the left, the workings beside it, and how much of it ran along the bottom. The group's dense dot separated stat row is gone; it carries the same shape at less weight so it belongs to the summary rather than competing with it. The show's headline becomes its total plan deviation, the figure a producer is actually asked about afterwards, read back underneath as the two parts it is made of: the lateness it inherited from its start and the time the show itself lost or made up. The previous "recovered during the show" phrasing named only the second part while showing a figure for neither. Drops the counts of events over, under and on time. Almost nothing lands on the exact second, so those buckets described rounding rather than the show. The single worst overrun, which was already computed but never shown, replaces them: it names the event that blew the schedule. Also: - a group with events still to run no longer reports a variance, which otherwise read as a large underrun for a block that had simply not finished. This matches the guard already in place at show level. - GroupReport.changeover becomes untimed. The same gap can be a changeover, a timer started late, or an item run without a timer at all, and in roll mode it is the scheduled gap. Naming it for what was measured rather than for a cause avoids asserting a workflow. - the summary names the rundown it reports on, since a project can hold several. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
@@ -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() {
|
||||
</Panel.Section>
|
||||
) : (
|
||||
<>
|
||||
<ReportShowSummary show={showReport} summary={summary} />
|
||||
<Panel.Section>
|
||||
<ReportShowSummary
|
||||
rundownTitle={data.title}
|
||||
show={showReport}
|
||||
summary={summary}
|
||||
worstOverrunTitle={worstOverrunTitle}
|
||||
/>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<ReportTable rows={combinedReport} groups={groups} />
|
||||
</Panel.Section>
|
||||
|
||||
+55
-6
@@ -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>): 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('');
|
||||
});
|
||||
});
|
||||
|
||||
+6
-132
@@ -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;
|
||||
}
|
||||
|
||||
+66
-101
@@ -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 (
|
||||
<div className={style.summary}>
|
||||
{hasPlan && (
|
||||
<div className={style.top}>
|
||||
<div className={style.verdict}>
|
||||
{didReachEnd ? (
|
||||
<>
|
||||
<Tooltip
|
||||
text='The end offset less the start offset: time the show itself lost or made up, setting aside how late it began.'
|
||||
render={<span />}
|
||||
className={style.verdictLabel}
|
||||
>
|
||||
{verdictLabel(offsets.duringShow)}
|
||||
</Tooltip>
|
||||
<span className={cx([style.verdictValue, style[offsetTone(offsets.duringShow)]])}>
|
||||
{formatOffset(offsets.duringShow)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={style.verdictLabel}>Show incomplete</span>
|
||||
<span className={style.incompleteNote}>
|
||||
The show did not reach the end of the rundown, so there is nothing to compare the finish against.
|
||||
</span>
|
||||
</>
|
||||
<div className={style.inset}>
|
||||
<ReportSummaryCard
|
||||
title={rundownTitle || 'Untitled rundown'}
|
||||
headlineLabel={didReachEnd ? 'Total plan deviation' : 'Show incomplete'}
|
||||
headline={didReachEnd ? offsets.endOffset : null}
|
||||
note={
|
||||
didReachEnd ? (
|
||||
<Tooltip
|
||||
text='The deviation splits in two: the show inherits however late it began, then loses or makes up time of its own between start and end.'
|
||||
render={<span />}
|
||||
>
|
||||
{deviationBreakdown(offsets.startOffset, offsets.duringShow)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
'The show did not reach the end of the rundown, so there is nothing to compare the finish against.'
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<FooterItem>
|
||||
<b>
|
||||
{summary.eventsRun} of {summary.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</FooterItem>
|
||||
{summary.worstOverrun !== null && (
|
||||
<FooterItem>
|
||||
longest overrun <b>{worstOverrunTitle || 'an event'}</b>{' '}
|
||||
<span className={style.overrun}>+{formatDuration(summary.worstOverrun.delta, false)}</span>
|
||||
</FooterItem>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.lines}>
|
||||
<TimeLine
|
||||
label='Started'
|
||||
planned={show.plannedStart}
|
||||
actual={show.actualStart}
|
||||
offset={offsets.startOffset}
|
||||
/>
|
||||
{didReachEnd && (
|
||||
<TimeLine label='Ended' planned={show.plannedEnd} actual={show.actualEnd} offset={offsets.endOffset} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={style.counts}>
|
||||
<span className={style.count}>
|
||||
<b>
|
||||
{summary.eventsRun} of {summary.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</span>
|
||||
<Count label='over' value={summary.eventsOver} />
|
||||
<Count label='under' value={summary.eventsUnder} />
|
||||
<Count label='on time' value={summary.eventsOnTime} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{hasPlan && (
|
||||
<Metric
|
||||
label='Started'
|
||||
planned={formatMaybeTime(show.plannedStart)}
|
||||
actual={formatMaybeTime(show.actualStart)}
|
||||
offset={offsets.startOffset}
|
||||
/>
|
||||
)}
|
||||
{hasPlan && didReachEnd && (
|
||||
<Metric
|
||||
label='Ended'
|
||||
planned={formatMaybeTime(show.plannedEnd)}
|
||||
actual={formatMaybeTime(show.actualEnd)}
|
||||
offset={offsets.endOffset}
|
||||
/>
|
||||
)}
|
||||
</ReportSummaryCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={style.times}>
|
||||
<span className={style.planned}>{planned === null ? enDash : formatTime(planned)}</span>
|
||||
<span className={style.arrow}>→</span>
|
||||
<span className={style.actual}>{actual === null ? enDash : formatTime(actual)}</span>
|
||||
</span>
|
||||
<span className={cx([style.offset, style[offsetTone(offset)]])}>{formatOffset(offset)}</span>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={style.count}>
|
||||
<b>{value}</b> {label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
function formatMaybeTime(value: MaybeNumber): string {
|
||||
return value === null ? enDash : formatTime(value);
|
||||
}
|
||||
|
||||
+193
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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<ReportSummaryCardProps>) {
|
||||
return (
|
||||
<div className={cx([style.card, compact && style.compact])}>
|
||||
<div className={style.identity}>
|
||||
<span className={style.title}>{title}</span>
|
||||
{subtitle && <span className={style.subtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className={style.body}>
|
||||
<div className={style.headline}>
|
||||
<span className={style.headlineLabel}>{headlineLabel}</span>
|
||||
{headline !== null && (
|
||||
<span className={cx([style.headlineValue, style[offsetTone(headline)]])}>{formatOffset(headline)}</span>
|
||||
)}
|
||||
{note && <span className={cx([style.note, headline === null && style.unavailable])}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div className={style.metrics}>{children}</div>
|
||||
</div>
|
||||
|
||||
{footer && <div className={style.footer}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<span className={style.metricLabel}>{label}</span>
|
||||
<span className={style.metricValue}>
|
||||
{planned !== undefined && (
|
||||
<>
|
||||
<span className={style.planned}>{planned}</span>
|
||||
<span className={style.arrow}>→</span>
|
||||
</>
|
||||
)}
|
||||
<span className={style.actual}>{actual}</span>
|
||||
</span>
|
||||
<span className={cx([style.metricOffset, offset !== undefined && style[offsetTone(offset)]])}>
|
||||
{offset === undefined ? '' : formatOffset(offset)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact in the card footer, separated from its neighbours by a dot */
|
||||
export function FooterItem({ children }: PropsWithChildren) {
|
||||
return <span className={style.footerItem}>{children}</span>;
|
||||
}
|
||||
+4
-56
@@ -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;
|
||||
|
||||
+63
-59
@@ -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 (
|
||||
<tr className={style.groupRow}>
|
||||
<td colSpan={7}>
|
||||
<div className={style.groupHeading}>
|
||||
<span className={style.groupTitle}>
|
||||
<span className={style.groupColour} style={{ '--group-colour': group.colour } as React.CSSProperties} />
|
||||
{group.title || 'Untitled group'}
|
||||
</span>
|
||||
|
||||
<span className={style.groupStats}>
|
||||
<Tooltip
|
||||
text={
|
||||
group.targetDuration === null
|
||||
? 'No target set for this group, measured against what was scheduled into it'
|
||||
: 'The target duration set for this group'
|
||||
}
|
||||
render={<span />}
|
||||
className={style.stat}
|
||||
>
|
||||
<b>{formatDuration(measureValue, false)}</b> {measure}
|
||||
</Tooltip>
|
||||
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={style.stat}>
|
||||
<b>{group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}</b> actual
|
||||
</span>
|
||||
|
||||
{group.variance !== null && (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={cx([style.variance, style[offsetTone(group.variance)]])}>
|
||||
{formatOffset(group.variance)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{group.changeover !== null && group.changeover > 0 && (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<Tooltip
|
||||
text='Time inside this group that no event was running, ie changeovers'
|
||||
render={<span />}
|
||||
className={style.stat}
|
||||
>
|
||||
<b>{formatDuration(group.changeover, false)}</b> changeover
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={cx([style.stat, !isComplete && style.incomplete])}>
|
||||
<b>
|
||||
{group.eventsRun}/{group.eventsPlanned}
|
||||
</b>{' '}
|
||||
{isComplete ? 'events' : 'events run'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ReportSummaryCard
|
||||
title={
|
||||
<>
|
||||
<span className={style.groupColour} style={{ '--group-colour': group.colour } as React.CSSProperties} />
|
||||
{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={
|
||||
<>
|
||||
<FooterItem>
|
||||
<b>
|
||||
{group.eventsRun} of {group.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</FooterItem>
|
||||
{group.untimed !== null && group.untimed > 0 && (
|
||||
<FooterItem>
|
||||
<Tooltip
|
||||
text='Time inside this group with no event running. It covers anything the timer did not: changeovers, a late start on an event, or an item run without a timer at all.'
|
||||
render={<span />}
|
||||
>
|
||||
<b>{formatDuration(group.untimed, false)}</b> untimed
|
||||
</Tooltip>
|
||||
</FooterItem>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* the headline already carries the variance, so this is the workings behind it */}
|
||||
<Metric
|
||||
label={hasTarget ? 'Target' : 'Scheduled'}
|
||||
planned={formatDuration(measuredAgainst, false)}
|
||||
actual={group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}
|
||||
/>
|
||||
</ReportSummaryCard>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user