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
+383 -65
View File
@@ -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);