fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled
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:
@@ -43,6 +43,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -32,6 +32,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -14,6 +14,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -71,6 +71,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
@@ -586,6 +587,33 @@ describe('HomeView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
// The card's "next sync" must use the server-stamped nextPollExpectedAt
|
||||
// when present, ignoring the locally-saved (but not yet device-applied)
|
||||
// schedule. This is the bug Matt hit: changing 5 min → 3 min in the sheet
|
||||
// made the card jump to "in 3m" even though the device is still asleep
|
||||
// on the 5-min schedule and will wake at lastSeenAt + 5 min.
|
||||
it('nextSync uses server nextPollExpectedAt when present, not local schedule', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
// Locally we just saved every-3-min, but the device is still on
|
||||
// every-5-min until it next polls. The server's expected-next-poll
|
||||
// (set under the old schedule at the last poll) is 4 minutes out.
|
||||
rotationIntervalMinutes: 3,
|
||||
wakeTimes: [],
|
||||
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
nextPollExpectedAt: new Date(Date.now() + 4 * 60_000).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
|
||||
// 4 minutes — what the server actually plans, NOT 2 min from
|
||||
// (lastSeenAt + 3 min - now), which would be the bug.
|
||||
expect(props.nextSync).toMatch(/in 4m/)
|
||||
})
|
||||
|
||||
// The "next update" preview must reflect when the device will *actually*
|
||||
// next sync — that's when it picks up the new settings, not the first hit
|
||||
// of the new schedule. The device is asleep on its CURRENT schedule.
|
||||
|
||||
@@ -85,6 +85,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -61,6 +61,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Device {
|
||||
uniquenessWindow: number
|
||||
linkedAt: string
|
||||
lastSeenAt: string | null
|
||||
/** Server-stamped expected next poll time. Drives the "next sync" label. */
|
||||
nextPollExpectedAt: string | null
|
||||
lockedImageId: number | null
|
||||
currentImageId: number | null
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user