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:
@@ -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 () => {
|
it('trash button removes a wake time from the list', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 60] })]
|
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 60] })]
|
||||||
|
|||||||
@@ -506,17 +506,21 @@ const DEFAULT_TIME_CANDIDATES = [
|
|||||||
0, // 12:00 AM
|
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() {
|
function addTime() {
|
||||||
for (const c of DEFAULT_TIME_CANDIDATES) {
|
for (const c of DEFAULT_TIME_CANDIDATES) {
|
||||||
if (!editWakeTimes.value.includes(c)) {
|
if (!editWakeTimes.value.includes(c)) {
|
||||||
editWakeTimes.value = [...editWakeTimes.value, c].sort((a, b) => a - b)
|
editWakeTimes.value = [...editWakeTimes.value, c]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: pick the next free 5-minute slot.
|
// Fallback: pick the next free 5-minute slot.
|
||||||
for (let m = 0; m < 24 * 60; m += 5) {
|
for (let m = 0; m < 24 * 60; m += 5) {
|
||||||
if (!editWakeTimes.value.includes(m)) {
|
if (!editWakeTimes.value.includes(m)) {
|
||||||
editWakeTimes.value = [...editWakeTimes.value, m].sort((a, b) => a - b)
|
editWakeTimes.value = [...editWakeTimes.value, m]
|
||||||
return
|
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 h = part === 'h' ? parseInt(raw, 10) : cur.h
|
||||||
const mm = part === 'mm' ? parseInt(raw, 10) : cur.mm
|
const mm = part === 'mm' ? parseInt(raw, 10) : cur.mm
|
||||||
const p = part === 'p' ? (raw as AmPm) : cur.p
|
const p = part === 'p' ? (raw as AmPm) : cur.p
|
||||||
// Update in-place; don't dedupe — leave it to the user to clean up duplicates.
|
// Update in-place — don't reorder, don't dedupe. Reordering a row while
|
||||||
// (Backend's setWakeTimes() dedupes on save, so the persisted state stays clean.)
|
// the user's mid-edit would yank their focus to the new position. Backend
|
||||||
|
// setWakeTimes() sorts and dedupes on save.
|
||||||
const arr = [...editWakeTimes.value]
|
const arr = [...editWakeTimes.value]
|
||||||
arr[idx] = minutesFromHmp(h, mm, p)
|
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
|
// "Next update" is when the device will *actually* next sync — and the new
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||||
+1
-1
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{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};
|
||||||
+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-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="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.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