feat(report): tie events to their group, split running time from finishing time

Three changes from reviewing the panel against a real run.

Events are bracketed by the group they belong to. A coloured rail in the
group's own colour runs down the heading and every event under it, closed
by a foot where the group ends. This is the shape the rundown editor
already uses for a group, so the report reads with the same grammar as
the rundown it reports on, and an event can no longer be mistaken for
belonging to the group above it.

The show summary no longer reduces the run to one figure. Finishing time
and running time answer different questions and can point opposite ways:
a show which starts early and runs over still finishes early, and calling
that a single "total plan deviation" reported it as having come in under
when it had in fact overrun. The two are now separate: the headline is
the show against its planned duration, which is what the team controls
and what carries into the next run of the same rundown, and the finishing
time sits beside it as its own row alongside the start it inherited.
ShowOffsets gains plannedDuration, actualDuration and durationOffset,
replacing duringShow, which was the same figure under a name that
described neither.

Nothing is forced into upper case any more. The summary labels are
sentence case, and event rows carry their values in td rather than th:
the panel's th styling is meant for column headings, so every event
title in the report was being rendered small, bold and shouting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-26 05:43:48 +00:00
parent d2f2cc0bff
commit ef472b513a
10 changed files with 219 additions and 132 deletions
@@ -29,19 +29,28 @@ export type ShowReport = {
};
/**
* How the show sat against its plan.
* How the show sat against its plan, as the two separate questions it answers.
*
* Follows Ontime's offset convention: positive means behind schedule.
* `duringShow` separates a late start from a badly run show, which have
* different causes and different remedies.
*
* `endOffset` asks whether the show came off air when it promised to, which is
* what an audience or a venue booking is measured against. `durationOffset`
* asks whether the show itself ran long, which is what the team controls and
* what carries over to the next run of the same rundown. They differ by
* exactly `startOffset`: a show can run over and still finish early if it
* started early, so reporting either one alone is misleading.
*/
export type ShowOffsets = {
/** actual start against planned start */
startOffset: MaybeNumber;
/** actual end against planned end */
endOffset: MaybeNumber;
/** time lost (positive) or recovered (negative) between start and end */
duringShow: MaybeNumber;
/** how long the show was planned to take */
plannedDuration: MaybeNumber;
/** how long it actually took */
actualDuration: MaybeNumber;
/** actualDuration against plannedDuration, ie whether the show ran long */
durationOffset: MaybeNumber;
};
/**
@@ -78,56 +78,106 @@ describe('getEventVariance()', () => {
});
describe('getShowOffsets()', () => {
const HOUR = 60 * MIN;
it('reports a late start carried through to a late end', () => {
// started 8 late, ended 8 late: the show itself ran clean
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 8 * MIN,
actualEnd: 21 * 60 * MIN + 8 * MIN,
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 8 * MIN,
actualEnd: 21 * HOUR + 8 * MIN,
});
expect(offsets).toEqual({ startOffset: 8 * MIN, endOffset: 8 * MIN, duringShow: 0 });
expect(offsets).toEqual({
startOffset: 8 * MIN,
endOffset: 8 * MIN,
plannedDuration: 2 * HOUR,
actualDuration: 2 * HOUR,
durationOffset: 0,
});
});
it('separates time lost during the show from a late start', () => {
it('separates how long the show ran from when it finished', () => {
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 8 * MIN,
actualEnd: 21 * 60 * MIN + 12 * MIN,
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 8 * MIN,
actualEnd: 21 * HOUR + 12 * MIN,
});
expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, duringShow: 4 * MIN });
expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, durationOffset: 4 * MIN });
});
it('reports time recovered as a negative', () => {
it('reports a show which ran short as a negative', () => {
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 10 * MIN,
actualEnd: 21 * 60 * MIN + 2 * MIN,
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 10 * MIN,
actualEnd: 21 * HOUR + 2 * MIN,
});
expect(offsets.duringShow).toBe(-8 * MIN);
expect(offsets.durationOffset).toBe(-8 * MIN);
});
it('can report a show which ran over and still finished early', () => {
// the case which makes reporting either figure alone misleading:
// started 10 early, ran 4 over, so came off 6 early
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR - 10 * MIN,
actualEnd: 21 * HOUR - 6 * MIN,
});
expect(offsets.endOffset).toBe(-6 * MIN);
expect(offsets.durationOffset).toBe(4 * MIN);
});
it('keeps the two offsets exactly one start offset apart', () => {
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 3 * MIN,
actualEnd: 21 * HOUR + 11 * MIN,
});
expect(offsets.endOffset! - offsets.startOffset!).toBe(offsets.durationOffset);
});
it('does not read a show ending after midnight as a day early', () => {
const offsets = getShowOffsets({
plannedStart: 23 * 60 * MIN,
plannedStart: 23 * HOUR,
plannedEnd: dayInMs - 10 * MIN,
actualStart: 23 * 60 * MIN,
actualStart: 23 * HOUR,
actualEnd: 5 * MIN, // ran past midnight
});
expect(offsets.endOffset).toBe(15 * MIN);
// 23:00 to 00:05 is an hour and five minutes, not a negative
expect(offsets.actualDuration).toBe(HOUR + 5 * MIN);
expect(offsets.durationOffset).toBe(15 * MIN);
});
it('has nothing to report without a plan', () => {
expect(getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 1, actualEnd: 2 })).toEqual({
it('still reports how long a show ran when it had no plan to run against', () => {
const offsets = getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 0, actualEnd: 5 * MIN });
// how long it took is knowable, whether that was long or short is not
expect(offsets).toEqual({
startOffset: null,
endOffset: null,
duringShow: null,
plannedDuration: null,
actualDuration: 5 * MIN,
durationOffset: null,
});
});
it('has nothing to report for a show which has not run', () => {
expect(getShowOffsets({ plannedStart: 0, plannedEnd: MIN, actualStart: null, actualEnd: null })).toMatchObject({
startOffset: null,
endOffset: null,
actualDuration: null,
durationOffset: null,
});
});
});
+21 -1
View File
@@ -65,18 +65,38 @@ export function getEventVariance(entry: OntimeEventReport | undefined): EventVar
* A sum of event overruns cannot answer this: gaps absorb overrun, skipped
* events give time back, and a late start moves the whole show without any
* event running long.
*
* Finishing time and running time are reported separately because they answer
* different questions and can point opposite ways: a show which starts early
* and runs over still finishes early.
*/
export function getShowOffsets(show: ShowReport): ShowOffsets {
const startOffset = offsetBetween(show.plannedStart, show.actualStart);
const endOffset = offsetBetween(show.plannedEnd, show.actualEnd);
const plannedDuration = durationBetween(show.plannedStart, show.plannedEnd);
const actualDuration = durationBetween(show.actualStart, show.actualEnd);
return {
startOffset,
endOffset,
duringShow: startOffset === null || endOffset === null ? null : endOffset - startOffset,
plannedDuration,
actualDuration,
durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration,
};
}
/**
* Time from one point in the day to another, or null if either is missing.
* @private
*/
function durationBetween(from: number | null, to: number | null): number | null {
if (from === null || to === null) {
return null;
}
return elapsedBetween(from, to);
}
/**
* Signed distance from a planned time to the time it happened.
* Positive means late, matching Ontime's offset convention.