From 470c700358bf48e2633daa5dcac871ce46fc700c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 16 Jul 2026 15:27:30 +0200 Subject: [PATCH] fix(schedule): keep the layout reference live instead of freezing at first render (#9064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../schedule/schedule.component.spec.ts | 77 +++++++++++++++++-- .../schedule/schedule/schedule.component.ts | 26 ++++++- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/app/features/schedule/schedule/schedule.component.spec.ts b/src/app/features/schedule/schedule/schedule.component.spec.ts index 561a986067..e02a7920bd 100644 --- a/src/app/features/schedule/schedule/schedule.component.spec.ts +++ b/src/app/features/schedule/schedule/schedule.component.spec.ts @@ -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); }); }); diff --git a/src/app/features/schedule/schedule/schedule.component.ts b/src/app/features/schedule/schedule/schedule.component.ts index 73ab11d1f6..527501aa64 100644 --- a/src/app/features/schedule/schedule/schedule.component.ts +++ b/src/app/features/schedule/schedule/schedule.component.ts @@ -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(() => {