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 () => {
|
it('shows the propagation note in the settings sheet', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({ id: 5 })]
|
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)
|
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>(() => {
|
const nextUpdatePreview = computed<string>(() => {
|
||||||
if (editFrequencyMode.value === 'times') {
|
const device = editingDevice.value
|
||||||
if (editWakeTimes.value.length === 0) return 'Next update: no times configured'
|
if (!device) return ''
|
||||||
const next = nextWakeMatch(editWakeTimes.value, editTimezone.value)
|
|
||||||
if (!next) return ''
|
// The preview is about when the device will *next sync* — it does NOT
|
||||||
return `Next update: ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
|
// 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 tz = device.timezone || 'UTC'
|
||||||
const anchor = editingDevice.value?.lastSeenAt
|
const lastSeen = new Date(device.lastSeenAt).getTime()
|
||||||
? new Date(editingDevice.value.lastSeenAt).getTime()
|
let nextPollMs: number
|
||||||
: Date.now()
|
if (device.wakeTimes.length > 0) {
|
||||||
const next = anchor + interval * 60_000
|
nextPollMs = nextWakeAfter(lastSeen, device.wakeTimes, tz)
|
||||||
const fromNow = next - Date.now()
|
} else {
|
||||||
if (fromNow <= 0) return 'Next update: imminent'
|
nextPollMs = lastSeen + device.rotationIntervalMinutes * 60_000
|
||||||
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`
|
|
||||||
}
|
}
|
||||||
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) {
|
function onEdit(deviceId: number) {
|
||||||
const device = devicesStore.devices.find(d => d.id === deviceId)
|
const device = devicesStore.devices.find(d => d.id === deviceId)
|
||||||
if (!device) return
|
if (!device) return
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||||
+1
-1
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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<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-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<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="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user