fix(home): "Next update" preview reflects when settings actually reach the frame
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:
2026-05-07 15:28:07 -04:00
parent c9b05a53b2
commit eedd50b95c
12 changed files with 155 additions and 30 deletions
+77
View File
@@ -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 })]
+68 -20
View File
@@ -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
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-B0VY4nmP.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-DohSPmvo.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-CUXjhfPi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,o(e.label),1),E.value===e.id?(n(),l(`span`,x,``)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-B5Obd-7x.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,o(e.label),1),E.value===e.id?(n(),l(`span`,x,``)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-CUXjhfPi.js"></script>
<script type="module" crossorigin src="/build/assets/index-B5Obd-7x.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>