Skip to content

Fix roll midnight#1990

Merged
cpvalente merged 9 commits intomasterfrom
fix-roll-midnight
Mar 3, 2026
Merged

Fix roll midnight#1990
cpvalente merged 9 commits intomasterfrom
fix-roll-midnight

Conversation

@cpvalente
Copy link
Owner

No description provided.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 2, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR introduces the Day type across the codebase and refactors day offset tracking. It updates component prop signatures to use Day for dayOffset, modifies runtime state management to support day-offset backdating for accurate progression during event rolls, and adds utility functions for elapsed time and day calculations, particularly around midnight boundaries.

Changes

Cohort / File(s) Summary
Client-side type refinement
apps/client/src/common/hooks/useSocket.ts, apps/client/src/features/operator/operator-event/OperatorEvent.tsx, apps/client/src/features/rundown/rundown-event/RundownEvent.tsx, apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx, apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx, apps/client/src/views/editor/title-list/TitleListItem.tsx, apps/client/src/views/timeline/TimelineEntry.tsx
Updated dayOffset prop types from number to Day across UI components and hooks. Changed currentDay source in useSocket from state.eventNow?.dayOffset to state.rundown.currentDay.
Server-side type updates
apps/server/src/api-data/db/migration/db.migration.v3.ts, apps/server/src/api-data/rundown/rundown.parser.ts, apps/server/src/models/demoProject.ts
Added Day type imports and updated dayOffset type annotations in migration, parser, and demo project data structures with appropriate type casts.
Time utility functions
apps/server/src/lib/time-core/timeCore.ts
Added two new exported functions: elapsedTime() to compute elapsed clock time handling overnight crossing, and daysSinceStart() to compute full days elapsed since a start epoch using start time-of-day as boundary reference.
Time core tests
apps/server/src/lib/time-core/__tests__/timeCore.test.ts
Expanded test imports and added comprehensive test cases for elapsedTime() (same day, midnight crossing, same start/end) and daysSinceStart() (multiple scenarios including DST-aware and timezone scenarios).
Roll and timer logic
apps/server/src/services/rollUtils.ts, apps/server/src/services/timerUtils.ts
Modified overnight event handling: rollUtils now scans for currently-running future overnight events; timerUtils refactored skip detection to use checkIsNow guard and updated findDayOffset() return type to Day. Updated midnight-crossing logic for accurate event progression.
Roll and timer tests
apps/server/src/services/__tests__/rollUtils.test.ts, apps/server/src/services/__tests__/timerUtils.test.ts
Added test cases for events crossing midnight with prior events present and verified skip detection across overnight windows.
Runtime state management
apps/server/src/stores/runtimeState.ts
Refactored _startDayOffset type from MaybeNumber to Maybe<Day>; introduced backdating mechanism for _startEpoch and currentDay calculation using daysSinceStart(); updated roll-wait condition and pending state handling to reset start state fields; aligns calculations to use plannedStart for accurate day tracking during rolls and pauses.
Runtime state tests
apps/server/src/stores/__tests__/runtimeState.test.ts
Added extensive test block for overnight event scenarios covering currentDay progression, pending rolls, rollover behavior, and added new time handling using Instant type with string-based dates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Fix multiday #1850 — Directly modifies findDayOffset() signature in timerUtils.ts and related runtimeState usage, with overlapping day offset refactoring.
  • Refactor: split runtime state #1725 — Related to useSocket.ts changes where currentDay source switches from event-based to state.rundown.currentDay.
  • Expected time #1798 — Related to TimelineEntry.tsx dayOffset prop type changes and expected-time calculation logic.

Suggested labels

priority

Suggested reviewers

  • alex-Arc
🚥 Pre-merge checks | ❌ 3

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Fix roll midnight' is vague and lacks specificity about what aspect of rolling or midnight handling is being fixed. Provide a more descriptive title that clarifies the specific issue being addressed, such as 'Fix day offset tracking when rolling events across midnight' or similar.
Description check ❓ Inconclusive No pull request description was provided by the author, making it impossible to assess whether the intended changes align with the changeset. Add a detailed pull request description explaining the midnight handling issues being fixed and the approach used to resolve them.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-roll-midnight

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/server/src/services/rollUtils.ts (1)

69-85: Consider reducing worst-case complexity in overnight lookup.

This nested scan can become O(n²) in large rundowns. Precomputing overnight candidates (or caching the next overnight index) would keep loadRoll() closer to linear.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/services/rollUtils.ts` around lines 69 - 85, The nested scan
over metadata.playableEventOrder inside loadRoll() makes overnight lookup O(n²);
before the main loop, build a precomputed map/array of overnight candidate
indices (e.g., nextOvernightIndexByPlayableIndex or overnightCandidates list) by
scanning metadata.playableEventOrder once to record indices j where
rundown.entries[j].timeStart > timeEnd, then in the inner logic replace the
for-loop that checks futureEventId/futureEvent/crossesMidnight with a
constant-time lookup into that precomputed structure and call checkIsNow(...)
and getTimedIndexFromPlayableIndex(metadata, j) only for those candidates so the
overall routine stays near-linear.
apps/server/src/stores/__tests__/runtimeState.test.ts (1)

301-397: Please add a pending-roll midnight test path.

These tests cover crossing midnight while already running an event, but not while Playback.Roll is pending on secondaryTimer. That branch has separate rollover logic and should be regression-covered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/stores/__tests__/runtimeState.test.ts` around lines 301 -
397, Add a test that simulates the "pending-roll on secondaryTimer" path: init
the same overnight mockRundown (use makeRundown, initRundown,
rundownCache.get()), set the system clock to before midnight, mark the playback
as pending roll by setting metadata.playback.secondaryTimer to Playback.Roll
(and secondaryTimerUntil appropriately) or invoking the helper that schedules a
secondary roll, then advance the clock past midnight, call update(), and assert
that the rollover logic ran (currentDay incremented to 1 and the roll result
reflects the event start). Use the existing helpers roll, update, getState,
Playback.Roll, and rundownCache to locate where to set the pending-roll state
and to verify the outcome.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/stores/runtimeState.ts`:
- Around line 693-695: The calculation for runtimeState._startDayOffset uses
runtimeState.clock but should use the roll-mode-adjusted time (offsetClock) so
day bucket selection matches roll-mode event start logic; update the call to
findDayOffset(runtimeState.eventNow.timeStart, ...) to pass
runtimeState.offsetClock (or the variable named offsetClock) instead of
runtimeState.clock, and make the same change in the other occurrence that sets
runtimeState._startDayOffset / runtimeState.rundown.currentDay (the similar
block around the later assignment).
- Around line 551-553: The early return inside the block that checks
runtimeState.timer.playback === Playback.Roll and
runtimeState.timer.secondaryTimer !== null (which calls updateIfWaitingToRoll)
prevents advancing rundown.currentDay when midnight is crossed; move or
duplicate the logic that increments currentDay so it runs before returning or
ensure updateIfWaitingToRoll itself advances currentDay when hasCrossedMidnight
is true. Concretely: in the function containing the snippet, update the code
paths at the Playback.Roll early-return (the call to updateIfWaitingToRoll) and
the similar blocks around lines noted (559-562, 601-620) so that
hasCrossedMidnight triggers an increment of rundown.currentDay (or delegates to
updateIfWaitingToRoll to do so) before any early return, and keep the
updateId/state-change behavior consistent with existing increments.
- Line 548: The current midnight detection uses hasCrossedMidnight =
previousClock > now which treats any backward clock move as a day boundary;
change it to detect an actual calendar rollover by comparing dates and ensuring
time progressed forward: compute hasCrossedMidnight as
(previousClock.toDateString() !== now.toDateString()) && (now > previousClock).
Update the logic around previousClock/now/currentDay (referenced symbols:
hasCrossedMidnight, previousClock, now, currentDay) so you only bump currentDay
when the calendar date changed and now is strictly after previousClock, avoiding
DST or manual/NTP backward adjustments being treated as midnight.

---

Nitpick comments:
In `@apps/server/src/services/rollUtils.ts`:
- Around line 69-85: The nested scan over metadata.playableEventOrder inside
loadRoll() makes overnight lookup O(n²); before the main loop, build a
precomputed map/array of overnight candidate indices (e.g.,
nextOvernightIndexByPlayableIndex or overnightCandidates list) by scanning
metadata.playableEventOrder once to record indices j where
rundown.entries[j].timeStart > timeEnd, then in the inner logic replace the
for-loop that checks futureEventId/futureEvent/crossesMidnight with a
constant-time lookup into that precomputed structure and call checkIsNow(...)
and getTimedIndexFromPlayableIndex(metadata, j) only for those candidates so the
overall routine stays near-linear.

In `@apps/server/src/stores/__tests__/runtimeState.test.ts`:
- Around line 301-397: Add a test that simulates the "pending-roll on
secondaryTimer" path: init the same overnight mockRundown (use makeRundown,
initRundown, rundownCache.get()), set the system clock to before midnight, mark
the playback as pending roll by setting metadata.playback.secondaryTimer to
Playback.Roll (and secondaryTimerUntil appropriately) or invoking the helper
that schedules a secondary roll, then advance the clock past midnight, call
update(), and assert that the rollover logic ran (currentDay incremented to 1
and the roll result reflects the event start). Use the existing helpers roll,
update, getState, Playback.Roll, and rundownCache to locate where to set the
pending-roll state and to verify the outcome.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1fcdf7 and d4c8fcb.

⛔ Files ignored due to path filters (2)
  • packages/utils/src/date-utils/checkIsNow.test.ts is excluded by none and included by none
  • packages/utils/src/date-utils/checkIsNow.ts is excluded by none and included by none
📒 Files selected for processing (6)
  • apps/server/src/services/__tests__/rollUtils.test.ts
  • apps/server/src/services/__tests__/timerUtils.test.ts
  • apps/server/src/services/rollUtils.ts
  • apps/server/src/services/timerUtils.ts
  • apps/server/src/stores/__tests__/runtimeState.test.ts
  • apps/server/src/stores/runtimeState.ts

@cpvalente cpvalente force-pushed the fix-roll-midnight branch 3 times, most recently from 7066ed8 to 87e98fe Compare March 2, 2026 21:33
@cpvalente
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 2, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/client/src/features/operator/operator-event/OperatorEvent.tsx (1)

2-2: Use a type-only import for Day (Line 2).

Day is only used as a type here, and the file already uses import type for other type imports. Prefer import type to be explicit about type-only imports and maintain consistency with the existing pattern in this file.

Proposed change
-import { Day } from 'ontime-types';
+import type { Day } from 'ontime-types';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/features/operator/operator-event/OperatorEvent.tsx` at line
2, The import of Day should be a type-only import: replace the current value
import of Day with an explicit type-only import (import type { Day } from
'ontime-types') so it matches the file's other type imports and avoids pulling
runtime code; update the import statement that references Day in OperatorEvent
(symbol: Day) accordingly.
apps/server/src/lib/time-core/__tests__/timeCore.test.ts (1)

214-263: Add DST boundary coverage for daysSinceStart().

Current cases don’t protect against spring-forward/fall-back day-length changes; add at least one case that crosses midnight on a DST transition day.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/lib/time-core/__tests__/timeCore.test.ts` around lines 214 -
263, Add a test to cover a DST boundary for daysSinceStart by simulating system
times that cross a DST transition (spring-forward or fall-back) using
vi.setSystemTime and timeCore.now; call timeCore.daysSinceStart(startEpoch,
currentEpoch) and assert the expected day count. Specifically, create a test
that sets startEpoch just before the local DST clock change and currentEpoch
just after it (use appropriate UTC timestamps that map to the local DST
transition), and verify daysSinceStart returns 1 (or the correct number of full
days) to ensure the function handles variable day lengths across DST shifts.
apps/server/src/stores/runtimeState.ts (1)

550-556: Consider using timeCore.daysSinceStart to avoid duplication.

This calculation duplicates the logic already encapsulated in timeCore.daysSinceStart() (used at lines 709 and 813). Using the utility function improves maintainability and ensures consistent behavior.

♻️ Suggested refactor
   // calculate currentDay from epoch (days elapsed since playback was started)
   if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
-    const startClock = timeCore.toTimeOfDay(runtimeState._startEpoch);
-    const elapsedMs = epoch - runtimeState._startEpoch;
-    const daysSinceStart = Math.floor((elapsedMs + startClock) / dayInMs);
-    runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
+    runtimeState.rundown.currentDay = runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/stores/runtimeState.ts` around lines 550 - 556, Replace the
inline days-since-start calculation with the existing utility: call
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._startDayOffset,
epoch) (or the exact signature used elsewhere) and assign its result to
runtimeState.rundown.currentDay; remove the manual computation that uses
timeCore.toTimeOfDay, elapsedMs, daysSinceStart and dayInMs, and ensure you only
run this when runtimeState._startEpoch and _startDayOffset are non-null,
matching the original guard and the usages at lines where
timeCore.daysSinceStart is already used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/services/__tests__/rollUtils.test.ts`:
- Around line 725-731: The test fixture for event id '2' in rollUtils.test.ts
has inconsistent duration: timeStart uses 11 * MILLIS_PER_HOUR and timeEnd uses
2 * MILLIS_PER_HOUR (overnight span), but duration is set to 14 *
MILLIS_PER_HOUR; update the duration on the mockEvent for key '2' to 15 *
MILLIS_PER_HOUR so it matches the 11:00 -> 02:00 span (use the same
MILLIS_PER_HOUR constant and modify the duration property on the mockEvent
object).

---

Nitpick comments:
In `@apps/client/src/features/operator/operator-event/OperatorEvent.tsx`:
- Line 2: The import of Day should be a type-only import: replace the current
value import of Day with an explicit type-only import (import type { Day } from
'ontime-types') so it matches the file's other type imports and avoids pulling
runtime code; update the import statement that references Day in OperatorEvent
(symbol: Day) accordingly.

In `@apps/server/src/lib/time-core/__tests__/timeCore.test.ts`:
- Around line 214-263: Add a test to cover a DST boundary for daysSinceStart by
simulating system times that cross a DST transition (spring-forward or
fall-back) using vi.setSystemTime and timeCore.now; call
timeCore.daysSinceStart(startEpoch, currentEpoch) and assert the expected day
count. Specifically, create a test that sets startEpoch just before the local
DST clock change and currentEpoch just after it (use appropriate UTC timestamps
that map to the local DST transition), and verify daysSinceStart returns 1 (or
the correct number of full days) to ensure the function handles variable day
lengths across DST shifts.

In `@apps/server/src/stores/runtimeState.ts`:
- Around line 550-556: Replace the inline days-since-start calculation with the
existing utility: call timeCore.daysSinceStart(runtimeState._startEpoch,
runtimeState._startDayOffset, epoch) (or the exact signature used elsewhere) and
assign its result to runtimeState.rundown.currentDay; remove the manual
computation that uses timeCore.toTimeOfDay, elapsedMs, daysSinceStart and
dayInMs, and ensure you only run this when runtimeState._startEpoch and
_startDayOffset are non-null, matching the original guard and the usages at
lines where timeCore.daysSinceStart is already used.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 358ad79 and 87e98fe.

⛔ Files ignored due to path filters (4)
  • packages/types/src/definitions/core/OntimeEntry.ts is excluded by none and included by none
  • packages/utils/src/date-utils/checkIsNow.test.ts is excluded by none and included by none
  • packages/utils/src/date-utils/checkIsNow.ts is excluded by none and included by none
  • packages/utils/src/rundown-utils/entryDefinitions.ts is excluded by none and included by none
📒 Files selected for processing (18)
  • apps/client/src/common/hooks/useSocket.ts
  • apps/client/src/features/operator/operator-event/OperatorEvent.tsx
  • apps/client/src/features/rundown/rundown-event/RundownEvent.tsx
  • apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx
  • apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx
  • apps/client/src/views/editor/title-list/TitleListItem.tsx
  • apps/client/src/views/timeline/TimelineEntry.tsx
  • apps/server/src/api-data/db/migration/db.migration.v3.ts
  • apps/server/src/api-data/rundown/rundown.parser.ts
  • apps/server/src/lib/time-core/__tests__/timeCore.test.ts
  • apps/server/src/lib/time-core/timeCore.ts
  • apps/server/src/models/demoProject.ts
  • apps/server/src/services/__tests__/rollUtils.test.ts
  • apps/server/src/services/__tests__/timerUtils.test.ts
  • apps/server/src/services/rollUtils.ts
  • apps/server/src/services/timerUtils.ts
  • apps/server/src/stores/__tests__/runtimeState.test.ts
  • apps/server/src/stores/runtimeState.ts

Comment on lines +74 to +77
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const startClock = toTimeOfDay(startEpoch);
const elapsedMs = currentEpoch - startEpoch;
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

daysSinceStart() can miscount day transitions across DST.

This formula assumes every day is exactly dayInMs. That breaks around DST boundaries (e.g., Europe/Copenhagen from March 30, 2025 00:10 local to March 31, 2025 00:05 local crosses midnight once but is <24h in epoch time, so it returns 0).

💡 Suggested fix
 export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
-  const startClock = toTimeOfDay(startEpoch);
-  const elapsedMs = currentEpoch - startEpoch;
-  return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
+  const start = new Date(startEpoch);
+  const current = new Date(currentEpoch);
+
+  const startDayKey = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
+  const currentDayKey = Date.UTC(current.getFullYear(), current.getMonth(), current.getDate());
+
+  return ((currentDayKey - startDayKey) / dayInMs) as Day;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const startClock = toTimeOfDay(startEpoch);
const elapsedMs = currentEpoch - startEpoch;
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const start = new Date(startEpoch);
const current = new Date(currentEpoch);
const startDayKey = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
const currentDayKey = Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate());
return ((currentDayKey - startDayKey) / dayInMs) as Day;
}

@cpvalente cpvalente force-pushed the fix-roll-midnight branch from 87e98fe to 00f12f6 Compare March 3, 2026 06:03
@cpvalente cpvalente merged commit 2ef0b81 into master Mar 3, 2026
4 checks passed
@cpvalente cpvalente deleted the fix-roll-midnight branch March 3, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants