feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
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:
2026-05-07 14:32:58 -04:00
parent 100e101d05
commit d11ddff912
29 changed files with 720 additions and 156 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ export const useDevicesStore = defineStore('devices', () => {
} }
} }
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeHour' | 'timezone' | 'uniquenessWindow'>>) { async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeTimes' | 'timezone' | 'uniquenessWindow'>>) {
const res = await fetch(`/api/devices/${id}`, { const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -38,7 +38,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'UTC', timezone: 'UTC',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
@@ -27,7 +27,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
+1 -1
View File
@@ -9,7 +9,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
+115 -25
View File
@@ -66,7 +66,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', 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 // 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 () => { it('edit emits open the settings sheet pre-populated from the device', async () => {
const devicesStore = useDevicesStore() 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() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView() const wrapper = mountView()
@@ -287,7 +287,7 @@ describe('HomeView', () => {
// HV-06: saving the sheet calls updateDevice and closes it // HV-06: saving the sheet calls updateDevice and closes it
it('saving the settings sheet PATCHes via the store and closes', async () => { it('saving the settings sheet PATCHes via the store and closes', async () => {
const devicesStore = useDevicesStore() 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() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
@@ -304,7 +304,7 @@ describe('HomeView', () => {
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
orientation: 'landscape', orientation: 'landscape',
wakeHour: 4, wakeTimes: [4 * 60],
timezone: 'UTC', timezone: 'UTC',
})) }))
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' }) const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
@@ -347,13 +347,13 @@ describe('HomeView', () => {
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail') 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() 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({ devicesStore.devices = [makeDevice({
id: 1, id: 1,
wakeHour: 4, wakeTimes: [4 * 60],
rotationIntervalMinutes: 5, // ignored when wakeHour is set rotationIntervalMinutes: 5, // ignored when wakeTimes is non-empty
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(), lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
})] })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
@@ -377,11 +377,11 @@ describe('HomeView', () => {
expect(props.nextSync).toMatch(/next sync in/) 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() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ devicesStore.devices = [makeDevice({
id: 1, id: 1,
wakeHour: 4, wakeTimes: [4 * 60],
timezone: 'UTC', timezone: 'UTC',
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
})] })]
@@ -432,24 +432,29 @@ describe('HomeView', () => {
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull() 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() const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() 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() let wrapper = mountView()
await flushPromises() await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/) 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() wrapper = mountView()
await flushPromises() await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/) 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() wrapper = mountView()
await flushPromises() await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/) 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 () => { 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() 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() 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, 'fetchDevices').mockResolvedValue()
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
@@ -504,16 +509,95 @@ describe('HomeView', () => {
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises() await flushPromises()
const chips = wrapper.findAll('.home-view__interval-chip') // Sheet opens in 'times' mode (because device.wakeTimes is non-empty).
const chip8pm = chips.find(c => c.text() === '8 PM')! // Click the + Add time button — it should add 9:00 AM (first default
await chip8pm.trigger('click') // candidate not already in the list).
expect(chip8pm.classes()).toContain('home-view__interval-chip--on') 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' }) const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))! .find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click') await saveBtn.trigger('click')
await flushPromises() 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 () => { 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 () => { it('updates editName/orientation/timezone when their components emit changes', async () => {
const devicesStore = useDevicesStore() 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() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) 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() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ devicesStore.devices = [makeDevice({
id: 5, id: 5,
name: 'Den', name: 'Den',
wakeHour: null, wakeTimes: [],
rotationIntervalMinutes: 60,
timezone: null as any, timezone: null as any,
})] })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
@@ -578,12 +663,17 @@ describe('HomeView', () => {
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises() 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' }) const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))! .find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click') await saveBtn.trigger('click')
await flushPromises() await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
wakeHour: 4, wakeTimes: [],
rotationIntervalMinutes: 60,
timezone: 'UTC', timezone: 'UTC',
})) }))
}) })
+1 -1
View File
@@ -80,7 +80,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'America/Chicago', timezone: 'America/Chicago',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
+1 -1
View File
@@ -56,7 +56,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room', name: 'Living Room',
orientation: 'landscape', orientation: 'landscape',
rotationIntervalMinutes: 60, rotationIntervalMinutes: 60,
wakeHour: null, wakeTimes: [],
timezone: 'UTC', timezone: 'UTC',
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
+2 -1
View File
@@ -12,7 +12,8 @@ export interface Device {
name: string name: string
orientation: 'landscape' | 'portrait' orientation: 'landscape' | 'portrait'
rotationIntervalMinutes: number rotationIntervalMinutes: number
wakeHour: number | null /** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
wakeTimes: number[]
timezone: string timezone: string
uniquenessWindow: number uniquenessWindow: number
linkedAt: string linkedAt: string
+376 -58
View File
@@ -88,16 +88,80 @@
</div> </div>
<div class="home-view__sheet-field"> <div class="home-view__sheet-field">
<p class="home-view__sheet-label">Update time</p> <p class="home-view__sheet-label">Update frequency</p>
<div class="home-view__interval-grid">
<select
class="home-view__mode-select"
v-model="editFrequencyMode"
aria-label="Update frequency mode"
>
<option value="times">At specific time(s)</option>
<option value="interval">Every X minutes</option>
</select>
<div v-if="editFrequencyMode === 'times'" class="home-view__times-mode">
<div class="home-view__times-list">
<div
v-for="(m, idx) in editWakeTimes"
:key="idx"
class="home-view__time-row"
>
<select
class="home-view__time-part"
:value="hmpFromMinutes(m).h"
aria-label="Hour"
@change="onTimePart(idx, 'h', ($event.target as HTMLSelectElement).value)"
>
<option v-for="h in HOUR_OPTIONS" :key="h" :value="h">{{ h }}</option>
</select>
<span class="home-view__time-sep">:</span>
<select
class="home-view__time-part"
:value="hmpFromMinutes(m).mm"
aria-label="Minutes"
@change="onTimePart(idx, 'mm', ($event.target as HTMLSelectElement).value)"
>
<option v-for="mm in MINUTE_OPTIONS" :key="mm" :value="mm">
{{ String(mm).padStart(2, '0') }}
</option>
</select>
<select
class="home-view__time-part home-view__time-part--ampm"
:value="hmpFromMinutes(m).p"
aria-label="AM or PM"
@change="onTimePart(idx, 'p', ($event.target as HTMLSelectElement).value)"
>
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
<button <button
v-for="opt in WAKE_TIME_OPTIONS"
:key="opt.hour"
type="button" type="button"
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]" class="home-view__time-remove"
@click="editWakeHour = opt.hour" :aria-label="`Remove ${formatTime(m)}`"
>{{ opt.label }}</button> @click="removeTime(idx)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>
</button>
</div> </div>
<p
v-if="editWakeTimes.length === 0"
class="home-view__times-empty"
>No update times yet add one below.</p>
</div>
<button
type="button"
class="home-view__time-add"
@click="addTime"
>+ Add time</button>
<select class="home-view__tz-select" v-model="editTimezone"> <select class="home-view__tz-select" v-model="editTimezone">
<optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label"> <optgroup v-for="group in TIMEZONE_GROUPS" :key="group.label" :label="group.label">
<option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option> <option v-for="tz in group.zones" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
@@ -105,6 +169,27 @@
</select> </select>
</div> </div>
<div v-else class="home-view__interval-mode">
<div class="home-view__interval-input-row">
<span>Every</span>
<input
type="number"
min="1"
max="1440"
class="home-view__interval-input"
v-model.number="editIntervalMinutes"
aria-label="Update interval in minutes"
/>
<span>minutes</span>
</div>
</div>
<p class="home-view__next-update" aria-live="polite">{{ nextUpdatePreview }}</p>
<p class="home-view__propagation-note">
Changes will only take effect at the next device update.
</p>
</div>
<BaseButton <BaseButton
variant="primary" variant="primary"
class="home-view__sheet-save" class="home-view__sheet-save"
@@ -117,16 +202,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload' import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types' import type { Device } from '@/types'
// Sync interval for status comparisons. Devices configured with a daily wake // Sync interval for status comparisons. Devices configured with explicit wake
// hour use a 24h window; otherwise the rotation interval drives it. // times use a 24h window (the longest possible gap between slots); otherwise
// the rotation interval drives it.
function syncIntervalMs(device: Device): number { function syncIntervalMs(device: Device): number {
if (device.wakeHour !== null) return 24 * 60 * 60 * 1000 if (device.wakeTimes.length > 0) return 24 * 60 * 60 * 1000
return device.rotationIntervalMinutes * 60_000 return device.rotationIntervalMinutes * 60_000
} }
@@ -150,23 +236,50 @@ function lastSyncLabel(device: Device): string | null {
return `${days} days ago` return `${days} days ago`
} }
function formatHour(h: number): string { function formatTime(minutes: number): string {
if (h === 0) return '12 AM' const h24 = Math.floor(minutes / 60)
if (h < 12) return `${h} AM` const mm = minutes % 60
if (h === 12) return '12 PM' const p = h24 >= 12 ? 'PM' : 'AM'
return `${h - 12} PM` let h = h24 % 12
if (h === 0) h = 12
const mmStr = mm === 0 ? '' : `:${String(mm).padStart(2, '0')}`
return `${h}${mmStr} ${p}`
}
function getMinuteOfDayInTz(date: Date, tz: string): number {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const parts = fmt.formatToParts(date)
const h = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0', 10)
const mm = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0', 10)
return h * 60 + mm
}
// Find the next wake time after now (in tz). Returns null if `times` is empty.
function nextWakeMatch(times: number[], tz: string): { minutes: number; today: boolean } | null {
if (times.length === 0) return null
const nowMin = getMinuteOfDayInTz(new Date(), tz)
let best: { minutes: number; today: boolean } | null = null
let bestDelta = Infinity
for (const m of times) {
const delta = m > nowMin ? m - nowMin : (24 * 60) + (m - nowMin)
if (delta < bestDelta) {
bestDelta = delta
best = { minutes: m, today: m > nowMin }
}
}
return best
} }
function nextSyncLabel(device: Device): string | null { function nextSyncLabel(device: Device): string | null {
if (device.wakeHour !== null) { if (device.wakeTimes.length > 0) {
const fmt = new Intl.DateTimeFormat('en-US', { const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC')
timeZone: device.timezone || 'UTC', if (!next) return null
hour: 'numeric', return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
hour12: false,
})
const currentHour = parseInt(fmt.format(new Date()), 10)
const tag = currentHour < device.wakeHour ? 'today' : 'tomorrow'
return `next sync ~${formatHour(device.wakeHour)} ${tag}`
} }
if (!device.lastSeenAt) return null if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000 const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
@@ -244,18 +357,26 @@ function onAddPhoto(deviceId: number) {
// ── Settings sheet ──────────────────────────────────────────────────────────── // ── Settings sheet ────────────────────────────────────────────────────────────
const WAKE_TIME_OPTIONS = [ const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
{ hour: 0, label: '12 AM' }, const MINUTE_OPTIONS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
{ hour: 2, label: '2 AM' },
{ hour: 4, label: '4 AM' }, type FrequencyMode = 'times' | 'interval'
{ hour: 6, label: '6 AM' }, type AmPm = 'AM' | 'PM'
{ hour: 8, label: '8 AM' },
{ hour: 10, label: '10 AM' }, function hmpFromMinutes(m: number): { h: number; mm: number; p: AmPm } {
{ hour: 12, label: '12 PM' }, const h24 = Math.floor(m / 60)
{ hour: 18, label: '6 PM' }, const mm = m % 60
{ hour: 20, label: '8 PM' }, const p: AmPm = h24 >= 12 ? 'PM' : 'AM'
{ hour: 22, label: '10 PM' }, let h = h24 % 12
] if (h === 0) h = 12
return { h, mm, p }
}
function minutesFromHmp(h: number, mm: number, p: AmPm): number {
let h24 = h % 12
if (p === 'PM') h24 += 12
return h24 * 60 + mm
}
const TIMEZONE_GROUPS = [ const TIMEZONE_GROUPS = [
{ label: 'Americas', zones: [ { label: 'Americas', zones: [
@@ -314,17 +435,93 @@ const saving = ref(false)
const editingDevice = ref<Device | null>(null) const editingDevice = ref<Device | null>(null)
const editName = ref('') const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape') const editOrientation = ref<Device['orientation']>('landscape')
const editWakeHour = ref<number>(4) const editFrequencyMode = ref<FrequencyMode>('interval')
const editWakeTimes = ref<number[]>([])
const editIntervalMinutes = ref<number>(60)
const editTimezone = ref('UTC') const editTimezone = ref('UTC')
// Default candidates tried (in order) when adding a new time slot — picks the
// first one that isn't already in the list, so repeated +Add gives a sensible
// progression rather than stacking duplicates.
const DEFAULT_TIME_CANDIDATES = [
9 * 60, // 9:00 AM
18 * 60, // 6:00 PM
12 * 60, // 12:00 PM
21 * 60, // 9:00 PM
6 * 60, // 6:00 AM
15 * 60, // 3:00 PM
7 * 60 + 30, // 7:30 AM
19 * 60 + 30, // 7:30 PM
0, // 12:00 AM
]
function addTime() {
for (const c of DEFAULT_TIME_CANDIDATES) {
if (!editWakeTimes.value.includes(c)) {
editWakeTimes.value = [...editWakeTimes.value, c].sort((a, b) => a - b)
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)
return
}
}
}
function removeTime(idx: number) {
editWakeTimes.value = editWakeTimes.value.filter((_, i) => i !== idx)
}
function onTimePart(idx: number, part: 'h' | 'mm' | 'p', raw: string) {
const cur = hmpFromMinutes(editWakeTimes.value[idx])
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.)
const arr = [...editWakeTimes.value]
arr[idx] = minutesFromHmp(h, mm, p)
editWakeTimes.value = arr.sort((a, b) => a - b)
}
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 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`
}
return `Next update: ~${Math.round(fromNow / 86_400_000)}d`
})
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
editingDevice.value = device editingDevice.value = device
editName.value = device.name editName.value = device.name
editOrientation.value = device.orientation editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC' editTimezone.value = device.timezone ?? 'UTC'
editIntervalMinutes.value = device.rotationIntervalMinutes
editWakeTimes.value = [...device.wakeTimes]
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
sheetOpen.value = true sheetOpen.value = true
} }
@@ -332,12 +529,18 @@ async function saveSettings() {
if (!editingDevice.value) return if (!editingDevice.value) return
saving.value = true saving.value = true
try { try {
await devicesStore.updateDevice(editingDevice.value.id, { const patch: Parameters<typeof devicesStore.updateDevice>[1] = {
name: editName.value.trim() || editingDevice.value.name, name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value, orientation: editOrientation.value,
wakeHour: editWakeHour.value,
timezone: editTimezone.value, timezone: editTimezone.value,
}) }
if (editFrequencyMode.value === 'times') {
patch.wakeTimes = [...editWakeTimes.value]
} else {
patch.wakeTimes = []
patch.rotationIntervalMinutes = Math.max(1, Math.min(1440, editIntervalMinutes.value || 1))
}
await devicesStore.updateDevice(editingDevice.value.id, patch)
sheetOpen.value = false sheetOpen.value = false
} finally { } finally {
saving.value = false saving.value = false
@@ -471,15 +674,9 @@ async function saveSettings() {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
&__interval-grid { &__mode-select,
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__tz-select { &__tz-select {
width: 100%; width: 100%;
margin-top: var(--space-3);
min-height: var(--touch-min); min-height: var(--touch-min);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
@@ -497,25 +694,146 @@ async function saveSettings() {
} }
} }
&__interval-chip { &__tz-select {
padding: 6px 14px; margin-top: var(--space-3);
border-radius: 999px; }
&__times-mode,
&__interval-mode {
margin-top: var(--space-3);
}
&__times-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
&__times-empty {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-2) 0;
}
&__time-row {
display: flex;
align-items: center;
gap: var(--space-1);
}
&__time-part {
min-height: var(--touch-min);
padding: 0 var(--space-2);
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
cursor: pointer;
appearance: auto;
flex: 1 1 0;
min-width: 0;
&--ampm {
flex: 0 0 auto;
min-width: 64px;
}
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__time-sep {
font-weight: 700;
color: var(--color-text-muted);
padding: 0 2px;
}
&__time-remove {
flex: 0 0 auto;
width: var(--touch-min);
height: var(--touch-min);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: all var(--duration-fast);
&:hover, &:focus-visible {
border-color: var(--color-danger, #c0392b);
color: var(--color-danger, #c0392b);
outline: none;
}
}
&__time-add {
margin-top: var(--space-3);
width: 100%;
min-height: var(--touch-min);
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast); transition: all var(--duration-fast);
min-height: var(--touch-min);
&--on { &:hover, &:focus-visible {
background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary-fg); color: var(--color-primary);
outline: none;
} }
} }
&__interval-input-row {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
}
&__interval-input {
flex: 0 0 96px;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
text-align: center;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__next-update {
margin-top: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
&__propagation-note {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
line-height: 1.4;
}
&__sheet-save { &__sheet-save {
width: 100%; width: 100%;
margin-top: var(--space-2); margin-top: var(--space-2);
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507230001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace device.wake_hour (single int hour) with device.wake_times (JSON array of minutes-since-midnight, 0-1439)';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE device ADD wake_times JSON NOT NULL DEFAULT '[]'");
$this->addSql("UPDATE device SET wake_times = json_build_array(wake_hour * 60) WHERE wake_hour IS NOT NULL");
$this->addSql('ALTER TABLE device DROP COLUMN wake_hour');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD wake_hour INT DEFAULT NULL');
$this->addSql("UPDATE device SET wake_hour = ((wake_times->>0)::int / 60) WHERE jsonb_array_length(wake_times::jsonb) > 0");
$this->addSql('ALTER TABLE device DROP COLUMN wake_times');
}
}
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-lUhdoq2-.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-DEL-6DQj.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-DwuxDERh.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-Drxuo2vC.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="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-DwuxDERh.js"></script> <script type="module" crossorigin src="/build/assets/index-Drxuo2vC.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>
+7 -7
View File
@@ -81,11 +81,11 @@ final class SeedFakeDevicesCommand extends Command
// Five fakes covering each status state. // Five fakes covering each status state.
$now = new \DateTimeImmutable(); $now = new \DateTimeImmutable();
$fakes = [ $fakes = [
['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeHour' => null], ['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeTimes' => []],
['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeHour' => null], ['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeTimes' => []],
['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeHour' => null], ['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeTimes' => []],
['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeHour' => null], ['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeTimes' => []],
['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeHour' => 4], ['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeTimes' => [4 * 60]],
]; ];
$reflLastSeen = new \ReflectionProperty(Device::class, 'lastSeenAt'); $reflLastSeen = new \ReflectionProperty(Device::class, 'lastSeenAt');
@@ -100,8 +100,8 @@ final class SeedFakeDevicesCommand extends Command
$device->setRotationIntervalMinutes(60); $device->setRotationIntervalMinutes(60);
$device->setTimezone('America/New_York'); $device->setTimezone('America/New_York');
$device->setUser($user); $device->setUser($user);
if ($cfg['wakeHour'] !== null) { if (!empty($cfg['wakeTimes'])) {
$device->setWakeHour($cfg['wakeHour']); $device->setWakeTimes($cfg['wakeTimes']);
} }
if ($cfg['lastSeen'] !== null) { if ($cfg['lastSeen'] !== null) {
$reflLastSeen->setValue($device, $now->sub(new \DateInterval($cfg['lastSeen']))); $reflLastSeen->setValue($device, $now->sub(new \DateInterval($cfg['lastSeen'])));
+16 -3
View File
@@ -85,8 +85,21 @@ class DeviceApiController extends AbstractController
$device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes'])); $device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes']));
} }
if (array_key_exists('wakeHour', $body)) { if (array_key_exists('wakeTimes', $body)) {
$device->setWakeHour($body['wakeHour'] === null ? null : (int) $body['wakeHour']); $times = $body['wakeTimes'];
if (!is_array($times)) {
return $this->json(['error' => 'wakeTimes must be an array'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
foreach ($times as $t) {
if (!is_int($t) && !(is_string($t) && ctype_digit($t))) {
return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$ti = (int) $t;
if ($ti < 0 || $ti > 1439) {
return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$device->setWakeTimes(array_map('intval', $times));
} }
if (isset($body['timezone'])) { if (isset($body['timezone'])) {
@@ -165,7 +178,7 @@ class DeviceApiController extends AbstractController
'name' => $d->getName(), 'name' => $d->getName(),
'orientation' => $d->getOrientation()->value, 'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(), 'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeHour' => $d->getWakeHour(), 'wakeTimes' => $d->getWakeTimes(),
'timezone' => $d->getTimezone(), 'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(), 'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM), 'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
+12 -5
View File
@@ -28,14 +28,21 @@ class DeviceImageController extends AbstractController
private function computeIntervalMs(Device $device): int private function computeIntervalMs(Device $device): int
{ {
if ($device->getWakeHour() !== null) { $wakeTimes = $device->getWakeTimes();
if (!empty($wakeTimes)) {
$tz = new \DateTimeZone($device->getTimezone()); $tz = new \DateTimeZone($device->getTimezone());
$now = new \DateTimeImmutable('now', $tz); $now = new \DateTimeImmutable('now', $tz);
$next = $now->setTime($device->getWakeHour(), 0, 0); $earliest = null;
if ($next->getTimestamp() <= $now->getTimestamp()) { foreach ($wakeTimes as $minutes) {
$next = $next->modify('+1 day'); $candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
if ($candidate->getTimestamp() <= $now->getTimestamp()) {
$candidate = $candidate->modify('+1 day');
} }
return (int) (($next->getTimestamp() - $now->getTimestamp()) * 1000); if ($earliest === null || $candidate < $earliest) {
$earliest = $candidate;
}
}
return (int) (($earliest->getTimestamp() - $now->getTimestamp()) * 1000);
} }
return $device->getRotationIntervalMinutes() * 60 * 1000; return $device->getRotationIntervalMinutes() * 60 * 1000;
+29 -7
View File
@@ -30,15 +30,21 @@ class Device
#[ORM\Column(enumType: Orientation::class)] #[ORM\Column(enumType: Orientation::class)]
private Orientation $orientation = Orientation::Landscape; private Orientation $orientation = Orientation::Landscape;
/** Minutes between rotation cycles (used when wakeHour is null). */ /** Minutes between rotation cycles (used when wakeTimes is empty). */
#[ORM\Column] #[ORM\Column]
private int $rotationIntervalMinutes = 1440; private int $rotationIntervalMinutes = 1440;
/** Hour of day (0-23, local time) at which the device should wake; null = use rotationIntervalMinutes. */ /**
#[ORM\Column(nullable: true)] * Wake times stored as minutes-since-midnight (0-1439) in `timezone`.
private ?int $wakeHour = null; * Empty array = use rotationIntervalMinutes (interval mode).
* Non-empty = wake at each listed time of day.
*
* @var int[]
*/
#[ORM\Column(type: 'json')]
private array $wakeTimes = [];
/** IANA timezone for wakeHour scheduling (e.g. 'Europe/Stockholm'). */ /** IANA timezone for wakeTimes scheduling (e.g. 'Europe/Stockholm'). */
#[ORM\Column(length: 60)] #[ORM\Column(length: 60)]
private string $timezone = 'UTC'; private string $timezone = 'UTC';
@@ -105,8 +111,24 @@ class Device
public function getRotationIntervalMinutes(): int { return $this->rotationIntervalMinutes; } public function getRotationIntervalMinutes(): int { return $this->rotationIntervalMinutes; }
public function setRotationIntervalMinutes(int $m): static { $this->rotationIntervalMinutes = $m; return $this; } public function setRotationIntervalMinutes(int $m): static { $this->rotationIntervalMinutes = $m; return $this; }
public function getWakeHour(): ?int { return $this->wakeHour; } /** @return int[] */
public function setWakeHour(?int $hour): static { $this->wakeHour = ($hour !== null) ? max(0, min(23, $hour)) : null; return $this; } public function getWakeTimes(): array { return $this->wakeTimes; }
/**
* @param int[] $minutes minutes-since-midnight, 0-1439
*/
public function setWakeTimes(array $minutes): static
{
$clean = [];
foreach ($minutes as $m) {
$m = (int) $m;
if ($m >= 0 && $m <= 1439) $clean[$m] = true;
}
$clean = array_keys($clean);
sort($clean);
$this->wakeTimes = $clean;
return $this;
}
public function getTimezone(): string { return $this->timezone; } public function getTimezone(): string { return $this->timezone; }
public function setTimezone(string $tz): static { $this->timezone = $tz; return $this; } public function setTimezone(string $tz): static { $this->timezone = $tz; return $this; }
@@ -40,23 +40,31 @@ class AdvanceRotationMessageHandler
private function isDue(Device $device): bool private function isDue(Device $device): bool
{ {
if ($device->getWakeHour() !== null) { $wakeTimes = $device->getWakeTimes();
if (!empty($wakeTimes)) {
$tz = new \DateTimeZone($device->getTimezone()); $tz = new \DateTimeZone($device->getTimezone());
$now = new \DateTimeImmutable('now', $tz); $now = new \DateTimeImmutable('now', $tz);
$todayWake = $now->setTime($device->getWakeHour(), 0, 0);
if ($now < $todayWake) { // Find the most recent wake time that has already passed today.
// If none have hit yet, the next slot is in the future — not due.
$boundary = null;
foreach ($wakeTimes as $minutes) {
$candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
if ($candidate <= $now && ($boundary === null || $candidate > $boundary)) {
$boundary = $candidate;
}
}
if ($boundary === null) {
return false; return false;
} }
// Due if no history entry exists since wakeHour today
$entry = $this->em->createQueryBuilder() $entry = $this->em->createQueryBuilder()
->select('h') ->select('h')
->from(DeviceImageHistory::class, 'h') ->from(DeviceImageHistory::class, 'h')
->where('h.device = :device') ->where('h.device = :device')
->andWhere('h.servedAt >= :wakeTime') ->andWhere('h.servedAt >= :wakeTime')
->setParameter('device', $device) ->setParameter('device', $device)
->setParameter('wakeTime', $todayWake) ->setParameter('wakeTime', $boundary)
->setMaxResults(1) ->setMaxResults(1)
->getQuery() ->getQuery()
->getOneOrNullResult(); ->getOneOrNullResult();
+3 -2
View File
@@ -20,8 +20,9 @@ class Schedule implements ScheduleProviderInterface
public function getSchedule(): SymfonySchedule public function getSchedule(): SymfonySchedule
{ {
// Rotation is handled at poll time in DeviceImageController — no scheduler needed. // Rotation is handled at poll time in DeviceImageController — no scheduler needed.
// DEV/PROD note: when switching to wakeHour mode, the device only polls once per day, // DEV/PROD note: when switching to wakeTimes mode, the device only polls
// so rotation still happens correctly (isDue() fires on that single daily poll). // at each configured time, so rotation still happens correctly (isDue()
// fires on each scheduled poll).
return (new SymfonySchedule()) return (new SymfonySchedule())
->stateful($this->cache) ->stateful($this->cache)
->processOnlyLastMissedRun(true) ->processOnlyLastMissedRun(true)
@@ -209,19 +209,52 @@ class DeviceApiControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(422); $this->assertResponseStatusCodeSame(422);
} }
public function test_patch_sets_wake_hour(): void public function test_patch_sets_wake_times(): void
{ {
$user = $this->createUser('patchwake@example.com'); $user = $this->createUser('patchwake@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user); $device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
$client = $this->loginAs($user); $client = $this->loginAs($user);
// 6:00 AM, 3:00 PM, 7:30 PM expressed as minutes since midnight
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [ $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json', 'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeHour' => 8])); ], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true); $data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(8, $data['wakeHour']); $this->assertSame([360, 900, 1170], $data['wakeTimes']);
}
public function test_patch_rejects_out_of_range_wake_times(): void
{
$user = $this->createUser('patchwakebad@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B7', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => [1500]]));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_clears_wake_times_with_empty_array(): void
{
$user = $this->createUser('patchwakeclear@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B8', $user);
$device->setWakeTimes([6 * 60, 18 * 60]);
$em = static::getContainer()->get('doctrine')->getManager();
$em->flush();
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => []]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['wakeTimes']);
} }
public function test_patch_sets_uniqueness_window(): void public function test_patch_sets_uniqueness_window(): void
@@ -336,13 +336,13 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204); $this->assertResponseStatusCodeSame(204);
} }
// When wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms // When wakeTimes is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_hour_interval_used_when_set(): void public function test_wake_times_interval_used_when_set(): void
{ {
$setup = $this->createTestSetup(); $setup = $this->createTestSetup();
$device = $setup['device']; $device = $setup['device'];
$device->setWakeHour(3)->setTimezone('UTC'); $device->setWakeTimes([3 * 60])->setTimezone('UTC');
$this->em()->flush(); $this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image'); $this->client->request('GET', '/api/device/' . self::MAC . '/image');
@@ -353,6 +353,26 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs); $this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
} }
// With multiple wake times, X-Interval-Ms must point to the *earliest*
// upcoming time, not just the first in the list.
public function test_wake_times_picks_earliest_upcoming(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Use a fixed UTC tz; with three slots evenly spread, the gap to the
// next slot can never exceed 24h / count = 8h.
$device->setWakeTimes([6 * 60, 14 * 60, 22 * 60])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
$this->assertGreaterThan(0, $intervalMs);
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
}
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path) // Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
public function test_returns_204_when_ready_asset_has_null_file_path(): void public function test_returns_204_when_ready_asset_has_null_file_path(): void
{ {
@@ -119,11 +119,11 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertNotNull($reloaded->getCurrentImage()); $this->assertNotNull($reloaded->getCurrentImage());
} }
// AR-04: wakeHour=0 (midnight, always past) + no history today → rotation occurs // AR-04: wakeTimes=[00:00] (always past) + no history today → rotation occurs
public function test_ar04_wake_hour_past_no_history_rotates(): void public function test_ar04_wake_time_past_no_history_rotates(): void
{ {
$device = $this->makeDevice(); $device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC'); $device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device); $image = $this->makeReadyImage($device);
$this->em()->flush(); $this->em()->flush();
@@ -138,11 +138,11 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()->getId()); $this->assertSame($imageId, $reloaded->getCurrentImage()->getId());
} }
// AR-05: wakeHour=0 (midnight) + history exists since midnight → already served today → not due // AR-05: wakeTimes=[00:00] + history exists since midnight → already served today → not due
public function test_ar05_wake_hour_already_served_today_is_skipped(): void public function test_ar05_wake_time_already_served_today_is_skipped(): void
{ {
$device = $this->makeDevice(); $device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC'); $device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device); $image = $this->makeReadyImage($device);
$this->em()->flush(); $this->em()->flush();
@@ -163,10 +163,10 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()?->getId()); $this->assertSame($imageId, $reloaded->getCurrentImage()?->getId());
} }
// AR-06: wakeHour in future → isDue returns false → no rotation // AR-06: wakeTime in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before wakeHour=22 // Uses 'Etc/GMT+11' (UTC-11) so local time is always before 23:00 local
// except during UTC 09:00-10:59; test is skipped then. // except during UTC 09:00-10:59; test is skipped then.
public function test_ar06_wake_hour_in_future_is_not_due(): void public function test_ar06_wake_time_in_future_is_not_due(): void
{ {
$utcHour = (int)(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('G'); $utcHour = (int)(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('G');
if ($utcHour >= 9 && $utcHour <= 10) { if ($utcHour >= 9 && $utcHour <= 10) {
@@ -174,8 +174,8 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
} }
$device = $this->makeDevice(); $device = $this->makeDevice();
// UTC-11: local time is at most 12:59 when UTC is 23:59 → wakeHour=23 is always future // UTC-11: local time is at most 12:59 when UTC is 23:59 → 23:00 always future
$device->setWakeHour(23)->setTimezone('Etc/GMT+11'); $device->setWakeTimes([23 * 60])->setTimezone('Etc/GMT+11');
$image = $this->makeReadyImage($device); $image = $this->makeReadyImage($device);
$this->em()->flush(); $this->em()->flush();
@@ -183,6 +183,27 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->em()->clear(); $this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId()); $reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wakeHour is still in the future'); $this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wake time is still in the future');
}
// AR-07: multiple wakeTimes — 00:00 has passed, so device is due even
// though later slots haven't fired yet. Validates that we use the most
// recent past slot as the boundary, not the earliest.
public function test_ar07_multiple_wake_times_uses_most_recent_past_slot(): void
{
$device = $this->makeDevice();
// 00:00 always past, 23:00 future for most of the day
$device->setWakeTimes([0, 23 * 60])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNotNull(
$reloaded->getCurrentImage(),
'Device with multiple wake times should rotate when at least one has passed today and no history exists since',
);
} }
} }