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}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -38,7 +38,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
wakeTimes: [],
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
@@ -27,7 +27,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',
+1 -1
View File
@@ -9,7 +9,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',
+115 -25
View File
@@ -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',
}))
})
+1 -1
View File
@@ -80,7 +80,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',
+1 -1
View File
@@ -56,7 +56,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
wakeTimes: [],
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
+2 -1
View File
@@ -12,7 +12,8 @@ export interface Device {
name: string
orientation: 'landscape' | 'portrait'
rotationIntervalMinutes: number
wakeHour: number | null
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
wakeTimes: number[]
timezone: string
uniquenessWindow: number
linkedAt: string
+376 -58
View File
@@ -88,16 +88,80 @@
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Update time</p>
<div class="home-view__interval-grid">
<p class="home-view__sheet-label">Update frequency</p>
<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
v-for="opt in WAKE_TIME_OPTIONS"
:key="opt.hour"
type="button"
:class="['home-view__interval-chip', { 'home-view__interval-chip--on': editWakeHour === opt.hour }]"
@click="editWakeHour = opt.hour"
>{{ opt.label }}</button>
class="home-view__time-remove"
:aria-label="`Remove ${formatTime(m)}`"
@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>
<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">
<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>
@@ -105,6 +169,27 @@
</select>
</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
variant="primary"
class="home-view__sheet-save"
@@ -117,16 +202,17 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import type { Device } from '@/types'
// Sync interval for status comparisons. Devices configured with a daily wake
// hour use a 24h window; otherwise the rotation interval drives it.
// Sync interval for status comparisons. Devices configured with explicit wake
// times use a 24h window (the longest possible gap between slots); otherwise
// the rotation interval drives it.
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
}
@@ -150,23 +236,50 @@ function lastSyncLabel(device: Device): string | null {
return `${days} days ago`
}
function formatHour(h: number): string {
if (h === 0) return '12 AM'
if (h < 12) return `${h} AM`
if (h === 12) return '12 PM'
return `${h - 12} PM`
function formatTime(minutes: number): string {
const h24 = Math.floor(minutes / 60)
const mm = minutes % 60
const p = h24 >= 12 ? 'PM' : 'AM'
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 {
if (device.wakeHour !== null) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: device.timezone || 'UTC',
hour: 'numeric',
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.wakeTimes.length > 0) {
const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC')
if (!next) return null
return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
}
if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
@@ -244,18 +357,26 @@ function onAddPhoto(deviceId: number) {
// Settings sheet
const WAKE_TIME_OPTIONS = [
{ hour: 0, label: '12 AM' },
{ hour: 2, label: '2 AM' },
{ hour: 4, label: '4 AM' },
{ hour: 6, label: '6 AM' },
{ hour: 8, label: '8 AM' },
{ hour: 10, label: '10 AM' },
{ hour: 12, label: '12 PM' },
{ hour: 18, label: '6 PM' },
{ hour: 20, label: '8 PM' },
{ hour: 22, label: '10 PM' },
]
const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
const MINUTE_OPTIONS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
type FrequencyMode = 'times' | 'interval'
type AmPm = 'AM' | 'PM'
function hmpFromMinutes(m: number): { h: number; mm: number; p: AmPm } {
const h24 = Math.floor(m / 60)
const mm = m % 60
const p: AmPm = h24 >= 12 ? 'PM' : 'AM'
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 = [
{ label: 'Americas', zones: [
@@ -314,17 +435,93 @@ const saving = ref(false)
const editingDevice = ref<Device | null>(null)
const editName = ref('')
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')
// 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) {
const device = devicesStore.devices.find(d => d.id === deviceId)
if (!device) return
editingDevice.value = device
editName.value = device.name
editOrientation.value = device.orientation
editWakeHour.value = device.wakeHour ?? 4
editTimezone.value = device.timezone ?? 'UTC'
editIntervalMinutes.value = device.rotationIntervalMinutes
editWakeTimes.value = [...device.wakeTimes]
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
sheetOpen.value = true
}
@@ -332,12 +529,18 @@ async function saveSettings() {
if (!editingDevice.value) return
saving.value = true
try {
await devicesStore.updateDevice(editingDevice.value.id, {
const patch: Parameters<typeof devicesStore.updateDevice>[1] = {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
wakeHour: editWakeHour.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
} finally {
saving.value = false
@@ -471,15 +674,9 @@ async function saveSettings() {
margin-bottom: var(--space-2);
}
&__interval-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
&__mode-select,
&__tz-select {
width: 100%;
margin-top: var(--space-3);
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
@@ -497,25 +694,146 @@ async function saveSettings() {
}
}
&__interval-chip {
padding: 6px 14px;
border-radius: 999px;
&__tz-select {
margin-top: var(--space-3);
}
&__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-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-weight: 600;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition: all var(--duration-fast);
min-height: var(--touch-min);
&--on {
background: var(--color-primary);
&:hover, &:focus-visible {
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 {
width: 100%;
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="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-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="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>
+7 -7
View File
@@ -81,11 +81,11 @@ final class SeedFakeDevicesCommand extends Command
// Five fakes covering each status state.
$now = new \DateTimeImmutable();
$fakes = [
['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeHour' => null],
['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeHour' => null],
['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeHour' => null],
['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeHour' => null],
['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeHour' => 4],
['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeTimes' => []],
['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeTimes' => []],
['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeTimes' => []],
['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeTimes' => []],
['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeTimes' => [4 * 60]],
];
$reflLastSeen = new \ReflectionProperty(Device::class, 'lastSeenAt');
@@ -100,8 +100,8 @@ final class SeedFakeDevicesCommand extends Command
$device->setRotationIntervalMinutes(60);
$device->setTimezone('America/New_York');
$device->setUser($user);
if ($cfg['wakeHour'] !== null) {
$device->setWakeHour($cfg['wakeHour']);
if (!empty($cfg['wakeTimes'])) {
$device->setWakeTimes($cfg['wakeTimes']);
}
if ($cfg['lastSeen'] !== null) {
$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']));
}
if (array_key_exists('wakeHour', $body)) {
$device->setWakeHour($body['wakeHour'] === null ? null : (int) $body['wakeHour']);
if (array_key_exists('wakeTimes', $body)) {
$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'])) {
@@ -165,7 +178,7 @@ class DeviceApiController extends AbstractController
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeHour' => $d->getWakeHour(),
'wakeTimes' => $d->getWakeTimes(),
'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
+12 -5
View File
@@ -28,14 +28,21 @@ class DeviceImageController extends AbstractController
private function computeIntervalMs(Device $device): int
{
if ($device->getWakeHour() !== null) {
$wakeTimes = $device->getWakeTimes();
if (!empty($wakeTimes)) {
$tz = new \DateTimeZone($device->getTimezone());
$now = new \DateTimeImmutable('now', $tz);
$next = $now->setTime($device->getWakeHour(), 0, 0);
if ($next->getTimestamp() <= $now->getTimestamp()) {
$next = $next->modify('+1 day');
$earliest = null;
foreach ($wakeTimes as $minutes) {
$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;
+29 -7
View File
@@ -30,15 +30,21 @@ class Device
#[ORM\Column(enumType: Orientation::class)]
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]
private int $rotationIntervalMinutes = 1440;
/** Hour of day (0-23, local time) at which the device should wake; null = use rotationIntervalMinutes. */
#[ORM\Column(nullable: true)]
private ?int $wakeHour = null;
/**
* Wake times stored as minutes-since-midnight (0-1439) in `timezone`.
* 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)]
private string $timezone = 'UTC';
@@ -105,8 +111,24 @@ class Device
public function getRotationIntervalMinutes(): int { return $this->rotationIntervalMinutes; }
public function setRotationIntervalMinutes(int $m): static { $this->rotationIntervalMinutes = $m; return $this; }
public function getWakeHour(): ?int { return $this->wakeHour; }
public function setWakeHour(?int $hour): static { $this->wakeHour = ($hour !== null) ? max(0, min(23, $hour)) : null; return $this; }
/** @return int[] */
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 setTimezone(string $tz): static { $this->timezone = $tz; return $this; }
@@ -40,23 +40,31 @@ class AdvanceRotationMessageHandler
private function isDue(Device $device): bool
{
if ($device->getWakeHour() !== null) {
$wakeTimes = $device->getWakeTimes();
if (!empty($wakeTimes)) {
$tz = new \DateTimeZone($device->getTimezone());
$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;
}
// Due if no history entry exists since wakeHour today
$entry = $this->em->createQueryBuilder()
->select('h')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->andWhere('h.servedAt >= :wakeTime')
->setParameter('device', $device)
->setParameter('wakeTime', $todayWake)
->setParameter('wakeTime', $boundary)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
+3 -2
View File
@@ -20,8 +20,9 @@ class Schedule implements ScheduleProviderInterface
public function getSchedule(): SymfonySchedule
{
// 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,
// so rotation still happens correctly (isDue() fires on that single daily poll).
// DEV/PROD note: when switching to wakeTimes mode, the device only polls
// at each configured time, so rotation still happens correctly (isDue()
// fires on each scheduled poll).
return (new SymfonySchedule())
->stateful($this->cache)
->processOnlyLastMissedRun(true)
@@ -209,19 +209,52 @@ class DeviceApiControllerTest extends AppWebTestCase
$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');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $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(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeHour' => 8]));
], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));
$this->assertResponseIsSuccessful();
$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
@@ -336,13 +336,13 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204);
}
// When wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_hour_interval_used_when_set(): void
// When wakeTimes is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_times_interval_used_when_set(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$device->setWakeHour(3)->setTimezone('UTC');
$device->setWakeTimes([3 * 60])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
@@ -353,6 +353,26 @@ class DeviceImageControllerTest extends AppWebTestCase
$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)
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());
}
// AR-04: wakeHour=0 (midnight, always past) + no history today → rotation occurs
public function test_ar04_wake_hour_past_no_history_rotates(): void
// AR-04: wakeTimes=[00:00] (always past) + no history today → rotation occurs
public function test_ar04_wake_time_past_no_history_rotates(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -138,11 +138,11 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()->getId());
}
// AR-05: wakeHour=0 (midnight) + history exists since midnight → already served today → not due
public function test_ar05_wake_hour_already_served_today_is_skipped(): void
// AR-05: wakeTimes=[00:00] + history exists since midnight → already served today → not due
public function test_ar05_wake_time_already_served_today_is_skipped(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -163,10 +163,10 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()?->getId());
}
// AR-06: wakeHour in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before wakeHour=22
// AR-06: wakeTime in future → isDue returns false → no rotation
// 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.
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');
if ($utcHour >= 9 && $utcHour <= 10) {
@@ -174,8 +174,8 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
}
$device = $this->makeDevice();
// UTC-11: local time is at most 12:59 when UTC is 23:59 → wakeHour=23 is always future
$device->setWakeHour(23)->setTimezone('Etc/GMT+11');
// UTC-11: local time is at most 12:59 when UTC is 23:59 → 23:00 always future
$device->setWakeTimes([23 * 60])->setTimezone('Etc/GMT+11');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -183,6 +183,27 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->em()->clear();
$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',
);
}
}