fix(schedule): keep the layout reference live instead of freezing at first render (#9064)

* fix(schedule): keep the layout reference live instead of freezing

_contextNow decides the reference time the Schedule lays events out
against. It is a computed that returns Date.now(), but nothing it reads
advances with the clock — so the value it returns is cached until
_selectedDate changes. In practice that means the layout reference
freezes at whatever instant the view was first rendered.

Leave the Schedule open from 09:00 to 17:00 and msLeftToday() still
budgets 15h, and today's unscheduled tasks still lay out from 09:00 —
above a now-line that has since marched down the grid. scheduleDays
already refreshes every 2 minutes via scheduleRefreshTick, so the
layout re-runs all day while being handed a stale reference.

Read scheduleRefreshTick() in _contextNow, the same 2-min cadence
currentTimeRow and scheduleDays already ride. No added change-detection
work: scheduleDays recomputes on that tick regardless.

This also fixes the midnight-rollover case. The view does not move when
the clock does, so a day picked as "tomorrow" silently becomes today
while _contextNow still reports its 00:00.

Rather than comparing day strings to detect that, decide by where the
wall clock sits inside the selected day. Comparing daysToShow()[0]
against _todayDateStr() would mix two notions of "today": todayStr()
applies the start-of-next-day offset, todayStr(date) does not (see
date.service.ts). With a custom rollover the guard could then return a
now outside day 0, and since create-schedule-days anchors dayDates[0]
with `startTime = i == 0 ? now`, every day-0 entry would be pushed past
its boundary and the first column would empty.

Testing the clock's position in the day makes that structural: the
reference can never land outside day 0. It also drops _todayDateStr and
daysToShow from _contextNow, leaving _selectedDate as the single source.

Tests: the rollover spec now moves only the refresh tick, as production
does; a spec pins the reference staying live as time passes. The
future-date spec asserted only that contextNow !== realNow, which passes
for arbitrary wrong values — it now names both timestamps exactly.

* test(schedule): pin that day 0 is never anchored outside itself

The invariant the _contextNow rewrite rests on: contextNow anchors
dayDates[0] via `startTime = i == 0 ? now`, so a now past that day's
end would push every day-0 entry over its boundary and empty the
column. It was guaranteed by construction but not by a spec.

Reachable with a custom start-of-next-day, where the logical today is
still Jan 20 while the wall clock already reads 02:00 on Jan 21.
This commit is contained in:
Johannes Millan 2026-07-16 15:27:30 +02:00 committed by GitHub
parent 542da1eb6c
commit 470c700358
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 12 deletions

View file

@ -482,6 +482,67 @@ describe('ScheduleComponent', () => {
expect(contextDate.getMonth()).toBe(0);
expect(contextDate.getFullYear()).toBe(2026);
});
it('should keep the reference live as time passes rather than freezing at first read', () => {
// The computed caches, and Date.now() is not reactive: without the refresh
// tick this pins the layout to whenever the view was first rendered.
let clock = new Date(2026, 0, 20, 9, 0, 0).getTime();
spyOn(Date, 'now').and.callFake(() => clock);
// Round-trip through a date: the computed already ran against the real
// clock on init, and re-setting null over null would not invalidate it.
component['_selectedDate'].set(new Date(2026, 0, 21));
component['_selectedDate'].set(null);
expect(component['_contextNow']()).toBe(clock);
clock = new Date(2026, 0, 20, 17, 0, 0).getTime();
(mockScheduleService as any).scheduleRefreshTick.set(1);
expect(component['_contextNow']()).toBe(clock);
});
it('should keep using midnight when today sits later in the displayed week', () => {
// Week view can show a range that starts before today; day 0 is fully
// elapsed, so it stays the layout reference.
const clock = new Date(2026, 0, 20, 9, 0, 0).getTime();
spyOn(Date, 'now').and.callFake(() => clock);
component['_selectedDate'].set(new Date(2026, 0, 19));
expect(component['_contextNow']()).toBe(new Date(2026, 0, 19).setHours(0, 0, 0, 0));
});
it('should never anchor day 0 with a now that falls outside it', () => {
// contextNow anchors dayDates[0], so a now past that day's end would push
// every day-0 entry over its boundary and empty the column. Reachable with
// a custom start-of-next-day, where the logical "today" is still Jan 20
// while the wall clock already reads 02:00 on Jan 21.
const clock = new Date(2026, 0, 21, 2, 0, 0).getTime();
spyOn(Date, 'now').and.callFake(() => clock);
component['_selectedDate'].set(new Date(2026, 0, 20));
const contextNow = component['_contextNow']();
expect(contextNow).toBeGreaterThanOrEqual(
new Date(2026, 0, 20).setHours(0, 0, 0, 0),
);
expect(contextNow).toBeLessThan(new Date(2026, 0, 21).setHours(0, 0, 0, 0));
});
it('should switch to the real now once the viewed day rolls over into today', () => {
// Viewing tomorrow at 22:00, then the app is left open past midnight. The
// view does not move, so the day it shows silently becomes today.
let clock = new Date(2026, 0, 20, 22, 0, 0).getTime();
spyOn(Date, 'now').and.callFake(() => clock);
component['_selectedDate'].set(new Date(2026, 0, 21));
expect(component['_contextNow']()).toBe(new Date(2026, 0, 21).setHours(0, 0, 0, 0));
// Rollover happens at 00:00 and the user comes back at 09:00; only the
// refresh tick moves, exactly as in production.
clock = new Date(2026, 0, 21, 9, 0, 0).getTime();
(mockScheduleService as any).scheduleRefreshTick.set(1);
expect(component['_contextNow']()).toBe(clock);
});
});
describe('scheduleDays computed', () => {
@ -524,10 +585,12 @@ describe('ScheduleComponent', () => {
});
it('should pass both contextNow and realNow when viewing a future date', () => {
// Arrange
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
component['_selectedDate'].set(futureDate);
// Arrange - a week past the mocked today (2026-01-20). The clock is pinned
// so both timestamps can be named exactly; asserting only that they differ
// would pass for arbitrary wrong values.
const clock = new Date(2026, 0, 20, 9, 0, 0).getTime();
spyOn(Date, 'now').and.callFake(() => clock);
component['_selectedDate'].set(new Date(2026, 0, 27));
mockScheduleService.createScheduleDaysWithContext.calls.reset();
// Act
@ -536,10 +599,8 @@ describe('ScheduleComponent', () => {
// Assert
const callArgs =
mockScheduleService.createScheduleDaysWithContext.calls.mostRecent().args[0];
expect(callArgs.contextNow).toBeDefined();
expect(callArgs.realNow).toBeDefined();
// contextNow should be different from realNow when viewing future
expect(callArgs.contextNow).not.toBe(callArgs.realNow);
expect(callArgs.contextNow).toBe(new Date(2026, 0, 27).setHours(0, 0, 0, 0));
expect(callArgs.realNow).toBe(clock);
});
});

View file

@ -225,15 +225,33 @@ export class ScheduleComponent {
// Calculate context-aware "now" based on selected date
// When viewing a future week, use the start of that week as reference time
private _contextNow = computed(() => {
// Date.now() is not reactive and computeds cache, so without a time-varying
// dependency the reference would freeze at whatever instant this last ran.
// Same 2-min tick currentTimeRow and scheduleDays already refresh on.
this.scheduleService.scheduleRefreshTick();
const selectedDate = this._selectedDate();
if (selectedDate === null) {
return Date.now();
}
// Viewing a different date - use that date's midnight as reference
const contextDate = new Date(selectedDate);
contextDate.setHours(0, 0, 0, 0);
return contextDate.getTime();
// contextNow anchors dayDates[0] (`startTime = i == 0 ? now` in
// create-schedule-days), so it has to stay inside that day. Testing where the
// wall clock sits within the selected day - rather than comparing day strings -
// lets the view self-correct once it drifts under a midnight rollover (a day
// picked as "tomorrow" becomes today while the view stays put), and can never
// hand the mapper a now past day 0's end, which would push every entry out of
// the column.
const dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0);
// setDate rather than +24h: DST-safe day advancement.
const nextDayStart = new Date(dayStart);
nextDayStart.setDate(nextDayStart.getDate() + 1);
const now = Date.now();
return now >= dayStart.getTime() && now < nextDayStart.getTime()
? now
: dayStart.getTime();
});
scheduleDays = computed(() => {