fix(home): "Next update" preview reflects when settings actually reach the frame
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
The frame is asleep on whatever schedule was active at its last poll — saving new settings here does NOT reach it until that next scheduled sync. The preview was claiming the *new* schedule's next slot, which was misleading: setting "at 4 AM" while the frame is on every-1-min should preview "in ~1 min" (next existing poll), not "at 4 AM". Now compute the next sync from the device's CURRENT saved schedule (lastSeenAt + interval, or next saved wakeTime in tz). Falls back to "when the frame next connects" for never-seen devices and "any moment" for already-overdue ones. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -586,6 +586,83 @@ describe('HomeView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
// 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.
|
||||
it('next-update preview uses the current device schedule, not the proposed new one', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
// Currently every 1 min, last seen 30s ago — next poll under current
|
||||
// schedule is ~30s away. User is editing to "at 4 AM only", but that
|
||||
// wake time is irrelevant until the device polls and learns about it.
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 5,
|
||||
wakeTimes: [],
|
||||
rotationIntervalMinutes: 1,
|
||||
lastSeenAt: new Date(Date.now() - 30_000).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
// Switch the proposed new schedule to "at 4 AM only".
|
||||
const modeSelect = wrapper.find('.home-view__mode-select')
|
||||
;(modeSelect.element as HTMLSelectElement).value = 'times'
|
||||
await modeSelect.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
// Preview must still reflect the every-1-min current cadence — "<1 min" or
|
||||
// "any moment", NOT "4 AM today/tomorrow".
|
||||
const preview = wrapper.find('.home-view__next-update').text()
|
||||
expect(preview).toMatch(/(any moment|<1 min)/i)
|
||||
expect(preview).not.toMatch(/4 AM/)
|
||||
})
|
||||
|
||||
it('next-update preview waits for the current daily wake time before showing the new schedule', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
// Device is on "at 4 AM only" and last polled ~1h ago (so last poll was
|
||||
// today's 4 AM, sleeping till tomorrow's 4 AM). User is adding a 6 PM slot.
|
||||
// The preview should show "~4 AM tomorrow" — the device can't act on the
|
||||
// new 6 PM slot until it wakes at 4 AM tomorrow.
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 5,
|
||||
wakeTimes: [4 * 60],
|
||||
timezone: 'UTC',
|
||||
lastSeenAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
// Add a 6 PM slot to the proposed schedule.
|
||||
const addBtn = wrapper.find('.home-view__time-add')
|
||||
await addBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const preview = wrapper.find('.home-view__next-update').text()
|
||||
expect(preview).toMatch(/4 AM/)
|
||||
expect(preview).not.toMatch(/6 PM/)
|
||||
})
|
||||
|
||||
it('next-update preview falls back to a friendly note when the device has never connected', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, lastSeenAt: null })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.home-view__next-update').text())
|
||||
.toMatch(/never connect|when the frame next/i)
|
||||
})
|
||||
|
||||
it('shows the propagation note in the settings sheet', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5 })]
|
||||
|
||||
@@ -489,31 +489,79 @@ function onTimePart(idx: number, part: 'h' | 'mm' | 'p', raw: string) {
|
||||
editWakeTimes.value = arr.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// "Next update" is when the device will *actually* next sync — and the new
|
||||
// settings only reach the device on that sync, since it's currently asleep
|
||||
// under whatever schedule was active at its last poll. So we compute this from
|
||||
// the device's CURRENT (saved) settings, ignoring the in-progress edit state.
|
||||
//
|
||||
// Examples this gets right:
|
||||
// - Currently every 1 min, new = "at 4 AM" → "in ~1 min" (next existing
|
||||
// poll under current schedule, not 4 AM tomorrow).
|
||||
// - Currently "at 4 AM", new = "at 4 AM and 6 PM" → "~4 AM tomorrow"
|
||||
// (device is asleep until then; can't act on the 6 PM slot today because
|
||||
// it won't learn about it until the 4 AM check-in).
|
||||
const nextUpdatePreview = computed<string>(() => {
|
||||
if (editFrequencyMode.value === 'times') {
|
||||
if (editWakeTimes.value.length === 0) return 'Next update: no times configured'
|
||||
const next = nextWakeMatch(editWakeTimes.value, editTimezone.value)
|
||||
if (!next) return ''
|
||||
return `Next update: ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
|
||||
const device = editingDevice.value
|
||||
if (!device) return ''
|
||||
|
||||
// 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 interval = editIntervalMinutes.value
|
||||
if (!interval || interval <= 0) return ''
|
||||
const anchor = editingDevice.value?.lastSeenAt
|
||||
? new Date(editingDevice.value.lastSeenAt).getTime()
|
||||
: Date.now()
|
||||
const next = anchor + interval * 60_000
|
||||
const fromNow = next - Date.now()
|
||||
if (fromNow <= 0) return 'Next update: imminent'
|
||||
if (fromNow < 60_000) return 'Next update: <1 min'
|
||||
if (fromNow < 3_600_000) return `Next update: ~${Math.round(fromNow / 60_000)} min`
|
||||
if (fromNow < 86_400_000) {
|
||||
const h = Math.floor(fromNow / 3_600_000)
|
||||
const m = Math.round((fromNow % 3_600_000) / 60_000)
|
||||
return m > 0 ? `Next update: ~${h}h ${m}m` : `Next update: ~${h}h`
|
||||
|
||||
const tz = device.timezone || 'UTC'
|
||||
const lastSeen = new Date(device.lastSeenAt).getTime()
|
||||
let nextPollMs: number
|
||||
if (device.wakeTimes.length > 0) {
|
||||
nextPollMs = nextWakeAfter(lastSeen, device.wakeTimes, tz)
|
||||
} else {
|
||||
nextPollMs = lastSeen + device.rotationIntervalMinutes * 60_000
|
||||
}
|
||||
return `Next update: ~${Math.round(fromNow / 86_400_000)}d`
|
||||
// Already-overdue device: it'll poll any moment now.
|
||||
if (nextPollMs < Date.now()) nextPollMs = Date.now()
|
||||
|
||||
return formatNextUpdate(nextPollMs, tz)
|
||||
})
|
||||
|
||||
// Next absolute timestamp (ms) at which any of `times` (minutes-of-day) occurs
|
||||
// strictly AFTER `refMs` in `tz`. Approximates DST transitions away.
|
||||
function nextWakeAfter(refMs: number, times: number[], tz: string): number {
|
||||
const refMin = getMinuteOfDayInTz(new Date(refMs), tz)
|
||||
let bestDelta = Infinity
|
||||
for (const m of times) {
|
||||
let delta = m - refMin
|
||||
if (delta <= 0) delta += 24 * 60
|
||||
if (delta < bestDelta) bestDelta = delta
|
||||
}
|
||||
return refMs + bestDelta * 60_000
|
||||
}
|
||||
|
||||
// 0 = today, 1 = tomorrow, 2 = day after, ... in the given timezone.
|
||||
function daysFromTodayInTz(date: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
})
|
||||
const todayMs = Date.parse(fmt.format(new Date()) + 'T00:00:00Z')
|
||||
const targetMs = Date.parse(fmt.format(date) + 'T00:00:00Z')
|
||||
return Math.round((targetMs - todayMs) / 86_400_000)
|
||||
}
|
||||
|
||||
function formatNextUpdate(tsMs: number, tz: string): string {
|
||||
const fromNow = tsMs - Date.now()
|
||||
if (fromNow <= 0) return 'Next update: any moment'
|
||||
if (fromNow < 90_000) return 'Next update: in <1 min'
|
||||
if (fromNow < 3_600_000) return `Next update: in ~${Math.round(fromNow / 60_000)} min`
|
||||
|
||||
const minOfDay = getMinuteOfDayInTz(new Date(tsMs), tz)
|
||||
const dayDelta = daysFromTodayInTz(new Date(tsMs), tz)
|
||||
const dayLabel = dayDelta === 0 ? 'today'
|
||||
: dayDelta === 1 ? 'tomorrow'
|
||||
: `in ${dayDelta} days`
|
||||
return `Next update: ~${formatTime(minOfDay)} ${dayLabel}`
|
||||
}
|
||||
|
||||
function onEdit(deviceId: number) {
|
||||
const device = devicesStore.devices.find(d => d.id === deviceId)
|
||||
if (!device) return
|
||||
|
||||
Reference in New Issue
Block a user