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:
Claude
2026-08-25 20:23:18 +00:00
parent c91b6c020e
commit d2f2cc0bff
12 changed files with 579 additions and 405 deletions
@@ -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);
+15 -16
View File
@@ -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 };
}
}