More rundown metadata (#1429)

* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2025-01-09 13:35:11 +01:00
committed by GitHub
parent 9ba40c35a1
commit e9a7cd0400
30 changed files with 866 additions and 660 deletions
@@ -2,66 +2,51 @@ import { checkIsNextDay } from './checkIsNextDay';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNextDay', () => {
it('returns false if the previous event duration is 0', () => {
const previousStart = 0;
const previousDuration = 0;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
it('returns false if there is no previous event', () => {
const current = { timeStart: 0, dayOffset: 0 };
const previous = undefined;
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns false if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 11;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 11, dayOffset: 0 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns true if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 9;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 9, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('returns true if event starts at the same time as one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 10;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 10, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('should account for an event that crossed midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 1 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight and there is a gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const previousDuration = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight with no overlaps', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 19 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that finishes exactly at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
it('should account for normalised start over multiple days', () => {
const previousStart = 90000000; // 25:00:00
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
});
+16 -30
View File
@@ -1,41 +1,27 @@
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event is the day after from its predecessor
* We consider an event to be the day after, if it begins before the start of the previous
* @example day after
* 09:00 - 10:00
* 08:00 - 10:30
* @example day after
* 23:00 - 00:00
* 02:00 - 03:00
* @example same day
* 09:00 - 10:00
* 09:30 - 10:30
* @example same day, but previous crosses midnight
* 23:00 - 01:00
* 02:00 - 03:00
* @example same day, but previous crosses midnight (with overlap)
* 22:00 - 02:00
* 01:00 - 03:00
*/
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration: number): boolean {
if (previousDuration === 0) {
export function checkIsNextDay(
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): boolean {
if (!previous) {
return false;
}
const cappedStart = previousStart % dayInMs;
if (timeStart <= cappedStart) {
const normalisedPreviousEnd = cappedStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) {
return true;
}
// handle exception for an event that finishes exactly at midnight
if (normalisedPreviousEnd > dayInMs) {
return false;
}
return true;
// if the day offsets are the same it can't be the next day
if (current.dayOffset <= previous.dayOffset) {
return false;
}
return false;
// if the previous event crossed midnight then the current is the same day
if (previous.timeStart + previous.duration > dayInMs) {
return false;
}
return true;
}
@@ -1,64 +1,58 @@
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
import { getTimeFromPrevious } from './getTimeFromPrevious';
describe('getTimeFromPrevious', () => {
it('returns the time elapsed (gap or overlap) from the previous', () => {
const previousStart = 69600000; // 19:20
const previousEnd = 71700000; // 19:55
const previousDuration = 2100000; // 35 minutes
const currentStart = 75600000; // 21:00
const expected = 75600000 - 71700000; // current start - previousEnd
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for partially overlapping events', () => {
const previousStart = 10;
const previousEnd = 12;
const previousDuration = 2;
const currentStart = 11;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -1;
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
expected,
);
});
it('accounts for events that are fully contained', () => {
const previousStart = 8;
const previousEnd = 16;
const previousDuration = 8;
const currentStart = 10;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -6;
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
expected,
);
});
it('fully overlapping events are the next day', () => {
const previousStart = 10 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const previousDuration = previousEnd - previousStart;
const currentStart = 10 * MILLIS_PER_HOUR;
const expected = dayInMs - previousDuration;
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
expect(
getTimeFromPrevious(
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that are the day after', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const previousDuration = 3 * MILLIS_PER_HOUR;
const currentStart = 22 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that cross midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR;
const currentStart = 1 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
});
@@ -1,45 +1,33 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility returns the time elapsed (gap or overlap) from the previous
* It uses deconstructed parameters to simplify implementation in UI
* Utility returns the gap from previous event
*/
export function getTimeFromPrevious(
currentStart: number,
previousStart?: number,
previousEnd?: number,
previousDuration?: number,
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): number {
// there is no previous event
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
if (!previous) {
return 0;
}
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
// event is linked to previous
if (currentStart === previousEnd) {
if (normalisedCurrentStart === normalisedPreviousEnd) {
return 0;
}
// event is the day after
if (checkIsNextDay(previousStart, currentStart, previousDuration)) {
// time from previous is difference between normalised start and previous end
return currentStart + dayInMs - previousEnd;
}
// event has a gap from previous
if (currentStart > previousEnd) {
if (normalisedCurrentStart > normalisedPreviousEnd) {
// time from previous is difference between start and previous end
return currentStart - previousEnd;
return normalisedCurrentStart - normalisedPreviousEnd;
}
// event overlaps with previous
const overlap = previousEnd - currentStart;
if (overlap > 0) {
// time is a negative number indicating the amount of overlap
return -overlap;
}
// we need to make sure we return a number, but there are no business cases for this
return 0;
return normalisedCurrentStart - normalisedPreviousEnd;
}
@@ -1,48 +1,52 @@
import { MILLIS_PER_HOUR } from './conversionUtils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
import { isNewLatest } from './isNewLatest';
describe('isNewLatest', () => {
it('should be true if there is no previous', () => {
expect(isNewLatest(0, 60000)).toBeTruthy();
const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = undefined;
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be false if current is contained in the previous', () => {
const current = {
timeStart: 21 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
duration: 10 * MILLIS_PER_MINUTE,
dayOffset: 0,
};
const previous = { timeStart: 21 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(false);
});
it('should be true if it starts when the previous finishes', () => {
const nowStart = 10 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 10 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it starts the same day the previous finishes', () => {
const nowStart = 22 * MILLIS_PER_HOUR;
const nowEnd = 23 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 20 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 22 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
const nowStart = 1 * MILLIS_PER_HOUR;
const nowEnd = 3 * MILLIS_PER_HOUR;
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 1 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it the next day', () => {
const nowStart = 8 * MILLIS_PER_HOUR;
const nowEnd = 10 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 8 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it the next day (2)', () => {
const nowStart = 9 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 11 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
});
+11 -14
View File
@@ -1,24 +1,21 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Checks whether a new element is the latest in the list
*/
export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean {
export function isNewLatest(
currentEvent: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
previousEvent?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
) {
// true if there is no previous
if (previousStart === undefined || previousEnd === undefined) {
if (!previousEvent) {
return true;
}
// true if it starts after the previous is finished
if (timeStart >= previousEnd) {
return true;
}
const normalisedCurrentEnd = currentEvent.timeStart + currentEvent.duration + currentEvent.dayOffset * dayInMs;
const normalisedPreviousEnd = previousEvent.timeStart + previousEvent.duration + previousEvent.dayOffset * dayInMs;
// true if it finishes later than previous
if (timeEnd > previousEnd) {
return true;
}
// true if it is the day after
return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart);
return normalisedCurrentEnd >= normalisedPreviousEnd;
}