feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Frame settings now offer two update-frequency modes: "at specific times" or "every X minutes". Times are stored as an int[] of minutes-since-midnight, allowing multiple slots per day at minute granularity. Backend computes the earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM dropdowns with + Add / trash, a live "next update" preview, and a note that changes only take effect at the device's next sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
name: 'Living Room',
|
||||
orientation: 'landscape',
|
||||
rotationIntervalMinutes: 60,
|
||||
wakeHour: null,
|
||||
wakeTimes: [],
|
||||
timezone: 'America/Chicago',
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
@@ -253,7 +253,7 @@ describe('HomeView', () => {
|
||||
// HV-05: edit opens the settings sheet pre-filled from the device record
|
||||
it('edit emits open the settings sheet pre-populated from the device', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeHour: 22, timezone: 'America/Chicago' })]
|
||||
devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeTimes: [22 * 60], timezone: 'America/Chicago' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
@@ -287,7 +287,7 @@ describe('HomeView', () => {
|
||||
// HV-06: saving the sheet calls updateDevice and closes it
|
||||
it('saving the settings sheet PATCHes via the store and closes', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeHour: 4, timezone: 'UTC' })]
|
||||
devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeTimes: [4 * 60], timezone: 'UTC' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('HomeView', () => {
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
|
||||
orientation: 'landscape',
|
||||
wakeHour: 4,
|
||||
wakeTimes: [4 * 60],
|
||||
timezone: 'UTC',
|
||||
}))
|
||||
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||
@@ -347,13 +347,13 @@ describe('HomeView', () => {
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
|
||||
})
|
||||
|
||||
it('uses a 24h window for devices configured with a daily wakeHour', async () => {
|
||||
it('uses a 24h window for devices configured with explicit wake times', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
// wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
|
||||
// wakeTimes set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
wakeHour: 4,
|
||||
rotationIntervalMinutes: 5, // ignored when wakeHour is set
|
||||
wakeTimes: [4 * 60],
|
||||
rotationIntervalMinutes: 5, // ignored when wakeTimes is non-empty
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
@@ -377,11 +377,11 @@ describe('HomeView', () => {
|
||||
expect(props.nextSync).toMatch(/next sync in/)
|
||||
})
|
||||
|
||||
it('passes a wakeHour-based nextSync label when the device wakes daily', async () => {
|
||||
it('passes a wakeTimes-based nextSync label when the device has explicit wake times', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
wakeHour: 4,
|
||||
wakeTimes: [4 * 60],
|
||||
timezone: 'UTC',
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
|
||||
})]
|
||||
@@ -432,24 +432,29 @@ describe('HomeView', () => {
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => {
|
||||
it('formats wake times 12 PM, 12 AM, 8 PM, and 6:30 AM correctly', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })]
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [12 * 60], timezone: 'UTC' })]
|
||||
let wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })]
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [0], timezone: 'UTC' })]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })]
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [20 * 60], timezone: 'UTC' })]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [6 * 60 + 30], timezone: 'UTC' })]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/6:30 AM/)
|
||||
})
|
||||
|
||||
it('returns null lastSync when the device has no recorded last-seen time', async () => {
|
||||
@@ -493,9 +498,9 @@ describe('HomeView', () => {
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates editWakeHour when the user picks a different hour chip', async () => {
|
||||
it('+ Add time appends a new wake time and saves it', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, wakeHour: 4 })]
|
||||
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [4 * 60] })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
|
||||
|
||||
@@ -504,16 +509,95 @@ describe('HomeView', () => {
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
const chips = wrapper.findAll('.home-view__interval-chip')
|
||||
const chip8pm = chips.find(c => c.text() === '8 PM')!
|
||||
await chip8pm.trigger('click')
|
||||
expect(chip8pm.classes()).toContain('home-view__interval-chip--on')
|
||||
// Sheet opens in 'times' mode (because device.wakeTimes is non-empty).
|
||||
// Click the + Add time button — it should add 9:00 AM (first default
|
||||
// candidate not already in the list).
|
||||
const addBtn = wrapper.find('.home-view__time-add')
|
||||
await addBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(2)
|
||||
|
||||
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({ wakeHour: 20 }))
|
||||
expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
|
||||
wakeTimes: [4 * 60, 9 * 60],
|
||||
}))
|
||||
})
|
||||
|
||||
it('trash button removes a wake time from the list', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 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 removeButtons = wrapper.findAll('.home-view__time-remove')
|
||||
expect(removeButtons).toHaveLength(2)
|
||||
// Remove the first row (6 AM)
|
||||
await removeButtons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(1)
|
||||
|
||||
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: [18 * 60],
|
||||
}))
|
||||
})
|
||||
|
||||
it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 5,
|
||||
wakeTimes: [4 * 60],
|
||||
rotationIntervalMinutes: 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 modeSelect = wrapper.find('.home-view__mode-select')
|
||||
;(modeSelect.element as HTMLSelectElement).value = 'interval'
|
||||
await modeSelect.trigger('change')
|
||||
|
||||
const intervalInput = wrapper.find('.home-view__interval-input')
|
||||
;(intervalInput.element as HTMLInputElement).value = '15'
|
||||
await intervalInput.trigger('input')
|
||||
|
||||
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: [],
|
||||
rotationIntervalMinutes: 15,
|
||||
}))
|
||||
})
|
||||
|
||||
it('shows the propagation note in the settings sheet', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5 })]
|
||||
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__propagation-note').text())
|
||||
.toMatch(/take effect at the next device update/i)
|
||||
})
|
||||
|
||||
it('saving while no device is being edited is a no-op (defensive guard)', async () => {
|
||||
@@ -536,7 +620,7 @@ describe('HomeView', () => {
|
||||
|
||||
it('updates editName/orientation/timezone when their components emit changes', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeHour: 4, timezone: 'UTC' })]
|
||||
devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeTimes: [4 * 60], timezone: 'UTC' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
|
||||
|
||||
@@ -562,12 +646,13 @@ describe('HomeView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('edit defaults wakeHour to 4 and timezone to UTC when the device has neither', async () => {
|
||||
it('opens in interval mode and defaults timezone to UTC when device has empty wakeTimes', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 5,
|
||||
name: 'Den',
|
||||
wakeHour: null,
|
||||
wakeTimes: [],
|
||||
rotationIntervalMinutes: 60,
|
||||
timezone: null as any,
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
@@ -578,12 +663,17 @@ describe('HomeView', () => {
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
// Sheet opens in interval mode — interval input is shown, time-list is not.
|
||||
expect(wrapper.find('.home-view__interval-input').exists()).toBe(true)
|
||||
expect(wrapper.find('.home-view__time-add').exists()).toBe(false)
|
||||
|
||||
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
|
||||
.find(b => b.text().toLowerCase().includes('sav'))!
|
||||
await saveBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
|
||||
wakeHour: 4,
|
||||
wakeTimes: [],
|
||||
rotationIntervalMinutes: 60,
|
||||
timezone: 'UTC',
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user