fix(home): wake-time list never reorders mid-edit
CI / test (push) Has been cancelled

Symptom: clicking + Add time would insert the new entry sorted into
the list, hiding it among the existing rows. Editing an existing
row's hour/minute/AM-PM moved the row mid-keystroke.

Both behaviors made the user lose track of what they were editing.
The list now only sorts at save time (which the backend already
canonicalizes via setWakeTimes()). New entries land at the end,
edits stay in place. Two regression tests pin this:
  - + Add appends; the new row is the last DOM row even when its
    minutes-of-day are smaller than an existing entry.
  - Editing a row's hour from 9 to 1 keeps the row at the same
    index (would have moved to index 0 under the old sort-on-edit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:25:36 -04:00
parent e2a8ea4a7e
commit 91b148c271
11 changed files with 93 additions and 15 deletions
+73
View File
@@ -529,6 +529,79 @@ describe('HomeView', () => {
}))
})
// Locks the UX choice that newly-added entries land at the END of the
// list and stay there during editing. Sorting only happens at save time
// (server-side via setWakeTimes), so the user can always see "the one I
// just added" without it jumping around.
it('+ Add time appends to the end and does NOT reorder existing entries', async () => {
const devicesStore = useDevicesStore()
// 8 PM is later than the first default candidate (9 AM). If the list
// sorted on add, 9 AM would land first; we want to verify it doesn't.
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [20 * 60] })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const addBtn = wrapper.find('.home-view__time-add')
await addBtn.trigger('click')
await flushPromises()
// Row order in the DOM: the existing 8 PM stays first, the new 9 AM
// appears second (at the end), even though 9 AM < 8 PM.
const rows = wrapper.findAll('.home-view__time-row')
expect(rows).toHaveLength(2)
const firstHour = (rows[0].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
const firstAmpm = (rows[0].find('select[aria-label="AM or PM"]').element as HTMLSelectElement).value
const secondHour = (rows[1].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
const secondAmpm = (rows[1].find('select[aria-label="AM or PM"]').element as HTMLSelectElement).value
expect({ h: firstHour, p: firstAmpm }).toEqual({ h: '8', p: 'PM' })
expect({ h: secondHour, p: secondAmpm }).toEqual({ h: '9', p: 'AM' })
// Save sends the order as-edited; backend's setWakeTimes() sorts on
// persist. The PWA payload itself isn't pre-sorted.
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
wakeTimes: [20 * 60, 9 * 60],
}))
})
// Editing a row's hour/minute/AM-PM dropdown must not move the row.
// Reordering on every keystroke would yank the focus and confuse the user
// about which row they're editing.
it('editing a wake time does NOT reorder the row mid-edit', async () => {
const devicesStore = useDevicesStore()
// Two rows: 6 AM, 9 AM. Editing the second to "1 AM" would normally
// sort it to position 0; we want it to stay at position 1.
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 9 * 60] })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// Change the second row's hour from 9 to 1.
const rows = wrapper.findAll('.home-view__time-row')
const secondHourSelect = rows[1].find('select[aria-label="Hour"]')
;(secondHourSelect.element as HTMLSelectElement).value = '1'
await secondHourSelect.trigger('change')
await flushPromises()
const rowsAfter = wrapper.findAll('.home-view__time-row')
const firstHour = (rowsAfter[0].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
const secondHour = (rowsAfter[1].find('select[aria-label="Hour"]').element as HTMLSelectElement).value
expect(firstHour).toBe('6') // still the 6 AM row, unchanged
expect(secondHour).toBe('1') // edited row stays at position 1, NOT moved to 0
})
it('trash button removes a wake time from the list', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 60] })]
+10 -5
View File
@@ -506,17 +506,21 @@ const DEFAULT_TIME_CANDIDATES = [
0, // 12:00 AM
]
// Appends to the end of the list — never sorts mid-edit. Sorting a row away
// from where the user just clicked is disorienting; they lose track of "the
// one I just added." Backend's setWakeTimes() sorts on save, so the persisted
// state stays canonical.
function addTime() {
for (const c of DEFAULT_TIME_CANDIDATES) {
if (!editWakeTimes.value.includes(c)) {
editWakeTimes.value = [...editWakeTimes.value, c].sort((a, b) => a - b)
editWakeTimes.value = [...editWakeTimes.value, c]
return
}
}
// Fallback: pick the next free 5-minute slot.
for (let m = 0; m < 24 * 60; m += 5) {
if (!editWakeTimes.value.includes(m)) {
editWakeTimes.value = [...editWakeTimes.value, m].sort((a, b) => a - b)
editWakeTimes.value = [...editWakeTimes.value, m]
return
}
}
@@ -531,11 +535,12 @@ function onTimePart(idx: number, part: 'h' | 'mm' | 'p', raw: string) {
const h = part === 'h' ? parseInt(raw, 10) : cur.h
const mm = part === 'mm' ? parseInt(raw, 10) : cur.mm
const p = part === 'p' ? (raw as AmPm) : cur.p
// Update in-place; don't dedupe — leave it to the user to clean up duplicates.
// (Backend's setWakeTimes() dedupes on save, so the persisted state stays clean.)
// Update in-place — don't reorder, don't dedupe. Reordering a row while
// the user's mid-edit would yank their focus to the new position. Backend
// setWakeTimes() sorts and dedupes on save.
const arr = [...editWakeTimes.value]
arr[idx] = minutesFromHmp(h, mm, p)
editWakeTimes.value = arr.sort((a, b) => a - b)
editWakeTimes.value = arr
}
// "Next update" is when the device will *actually* next sync — and the new
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-CHak0WUZ.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,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=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-DsLKl74W.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,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=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.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
@@ -1 +1 @@
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-DNhLsB-t.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(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-DK_JsCTY.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(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`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-DNhLsB-t.js"></script>
<script type="module" crossorigin src="/build/assets/index-DK_JsCTY.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>