mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
542da1eb6c
commit
470c700358
2 changed files with 91 additions and 12 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue