fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled

The card's "next sync" was computed locally as `lastSeenAt + interval`,
which broke the moment the user PATCHed a new interval: the device is
still asleep on whatever schedule was active at its last poll, but the
local record now has the new interval, so we'd display a misleading
"in 2m" after a 5→3 min change.

Fix: server stamps `nextPollExpectedAt` on every poll (200/304/204),
PWA reads it directly. The timestamp doesn't move when settings are
edited — only when the device actually polls and picks up a new
schedule. Same field also drives the settings-sheet "Next update"
preview, which had the same flaw.

Side effects:
- `markSeen()` now flushes on the 204 paths too — they previously
  set lastSeenAt without flushing (latent bug for devices with no
  approved images / missing assets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:52:04 -04:00
parent eedd50b95c
commit 995445ed9e
22 changed files with 136 additions and 26 deletions
+38 -13
View File
@@ -277,19 +277,38 @@ function nextWakeMatch(times: number[], tz: string): { minutes: number; today: b
return best
}
// Prefer the server-stamped nextPollExpectedAt — that's the schedule the
// device is *actually* on, set every poll. Falls back to a local computation
// for devices that haven't polled since the column was added.
function nextSyncLabel(device: Device): string | null {
if (device.wakeTimes.length > 0) {
let nextMs: number | null = null
if (device.nextPollExpectedAt) {
nextMs = new Date(device.nextPollExpectedAt).getTime()
} else if (device.wakeTimes.length > 0) {
const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC')
if (!next) return null
return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
} else if (device.lastSeenAt) {
nextMs = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
} else {
return null
}
if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
const fromNow = next - Date.now()
const fromNow = nextMs - Date.now()
if (fromNow <= 0) return null
if (fromNow < 60_000) return 'next sync in <1m'
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
return `next sync in ${Math.round(fromNow / 3_600_000)}h`
if (fromNow < 86_400_000) {
// Long horizons read better as a clock time than "in 14h".
const tz = device.timezone || 'UTC'
const minOfDay = getMinuteOfDayInTz(new Date(nextMs), tz)
const dayDelta = daysFromTodayInTz(new Date(nextMs), tz)
const dayLabel = dayDelta === 0 ? 'today'
: dayDelta === 1 ? 'tomorrow'
: `in ${dayDelta}d`
return `next sync ~${formatTime(minOfDay)} ${dayLabel}`
}
return `next sync in ${Math.round(fromNow / 86_400_000)}d`
}
// Home shows what's actually on the frame right now — the last image the
@@ -507,17 +526,23 @@ const nextUpdatePreview = computed<string>(() => {
// The preview is about when the device will *next sync* — it does NOT
// depend on the proposed new settings, only on the device's current saved
// schedule. The "no update times yet" hint already lives in the time list.
if (!device.lastSeenAt) {
return 'Next update: when the frame next connects'
}
const tz = device.timezone || 'UTC'
const tz = device.timezone || 'UTC'
const lastSeen = new Date(device.lastSeenAt).getTime()
// Prefer the server-stamped expected-next-poll: that timestamp was set
// under the schedule active at the device's last poll, and isn't disturbed
// by the user's PATCHes — exactly what we want for "when will the new
// settings reach the frame?"
let nextPollMs: number
if (device.wakeTimes.length > 0) {
nextPollMs = nextWakeAfter(lastSeen, device.wakeTimes, tz)
if (device.nextPollExpectedAt) {
nextPollMs = new Date(device.nextPollExpectedAt).getTime()
} else if (!device.lastSeenAt) {
return 'Next update: when the frame next connects'
} else {
nextPollMs = lastSeen + device.rotationIntervalMinutes * 60_000
// Legacy fallback for devices that haven't polled since the column was added.
const lastSeen = new Date(device.lastSeenAt).getTime()
nextPollMs = device.wakeTimes.length > 0
? nextWakeAfter(lastSeen, device.wakeTimes, tz)
: lastSeen + device.rotationIntervalMinutes * 60_000
}
// Already-overdue device: it'll poll any moment now.
if (nextPollMs < Date.now()) nextPollMs = Date.now()