feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Frame settings now offer two update-frequency modes: "at specific times" or "every X minutes". Times are stored as an int[] of minutes-since-midnight, allowing multiple slots per day at minute granularity. Backend computes the earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM dropdowns with + Add / trash, a live "next update" preview, and a note that changes only take effect at the device's next sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+383
-65
@@ -88,21 +88,106 @@
|
||||
</div>
|
||||
|
||||
<div class="home-view__sheet-field">
|
||||
<p class="home-view__sheet-label">Update time</p>
|
||||
<div class="home-view__interval-grid">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</optgroup>
|
||||
<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
|
||||
type="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>
|
||||
</optgroup>
|
||||
</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
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="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>
|
||||
|
||||
@@ -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'])));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
if ($earliest === null || $candidate < $earliest) {
|
||||
$earliest = $candidate;
|
||||
}
|
||||
}
|
||||
return (int) (($next->getTimestamp() - $now->getTimestamp()) * 1000);
|
||||
return (int) (($earliest->getTimestamp() - $now->getTimestamp()) * 1000);
|
||||
}
|
||||
|
||||
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
||||
|
||||
+29
-7
@@ -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) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
$todayWake = $now->setTime($device->getWakeHour(), 0, 0);
|
||||
$wakeTimes = $device->getWakeTimes();
|
||||
if (!empty($wakeTimes)) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
|
||||
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
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user