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:
+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);
|
||||
|
||||
Reference in New Issue
Block a user