91b148c271
CI / test (push) Has been cancelled
Symptom: clicking + Add time would insert the new entry sorted into
the list, hiding it among the existing rows. Editing an existing
row's hour/minute/AM-PM moved the row mid-keystroke.
Both behaviors made the user lose track of what they were editing.
The list now only sorts at save time (which the backend already
canonicalizes via setWakeTimes()). New entries land at the end,
edits stay in place. Two regression tests pin this:
- + Add appends; the new row is the last DOM row even when its
minutes-of-day are smaller than an existing entry.
- Editing a row's hour from 9 to 1 keeps the row at the same
index (would have moved to index 0 under the old sort-on-edit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
977 lines
32 KiB
Vue
977 lines
32 KiB
Vue
<template>
|
|
<main class="home-view">
|
|
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshDevices">
|
|
<!-- Loading -->
|
|
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
|
|
Loading…
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
|
|
<div class="home-view__empty-card">
|
|
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<polyline points="21,15 16,10 5,21"/>
|
|
</svg>
|
|
<p class="home-view__empty-title">Set up your first frame</p>
|
|
<p class="home-view__empty-sub">
|
|
Power on your pictureFrame device and scan the QR code it displays to get started.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Single device — large card -->
|
|
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
|
|
<FrameCard
|
|
:deviceId="devicesStore.devices[0].id"
|
|
:name="devicesStore.devices[0].name"
|
|
size="large"
|
|
:status="deviceStatus(devicesStore.devices[0])"
|
|
:orientation="devicesStore.devices[0].orientation"
|
|
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
|
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
|
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
|
@add-photo="onAddPhoto"
|
|
@edit="onEdit"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
|
<div
|
|
v-else
|
|
ref="stackEl"
|
|
class="home-view__stack"
|
|
role="list"
|
|
aria-label="Frames"
|
|
>
|
|
<div
|
|
v-for="device in devicesStore.devices"
|
|
:key="device.id"
|
|
class="home-view__slide"
|
|
role="listitem"
|
|
:aria-label="device.name"
|
|
>
|
|
<FrameCard
|
|
:deviceId="device.id"
|
|
:name="device.name"
|
|
size="large"
|
|
:status="deviceStatus(device)"
|
|
:orientation="device.orientation"
|
|
:thumbnailUrl="previewUrl(device)"
|
|
:lastSync="lastSyncLabel(device)"
|
|
:nextSync="nextSyncLabel(device)"
|
|
@add-photo="onAddPhoto"
|
|
@edit="onEdit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PullToRefresh>
|
|
</main>
|
|
|
|
<!-- Frame settings sheet -->
|
|
<BaseBottomSheet v-model="sheetOpen" label="Frame settings">
|
|
<h2 class="home-view__sheet-title">Frame settings</h2>
|
|
|
|
<div class="home-view__sheet-field">
|
|
<BaseInput
|
|
v-model="editName"
|
|
label="Frame name"
|
|
maxlength="100"
|
|
/>
|
|
</div>
|
|
|
|
<div class="home-view__sheet-field">
|
|
<p class="home-view__sheet-label">Orientation</p>
|
|
<OrientationPicker v-model="editOrientation" />
|
|
</div>
|
|
|
|
<div class="home-view__sheet-field">
|
|
<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"
|
|
inputmode="numeric"
|
|
pattern="[0-9]*"
|
|
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>
|
|
|
|
<div class="home-view__sheet-field">
|
|
<p class="home-view__sheet-label">Image selection</p>
|
|
|
|
<select
|
|
class="home-view__mode-select"
|
|
v-model="editRotationMode"
|
|
aria-label="Image selection mode"
|
|
>
|
|
<option value="oldest_upload">Oldest upload first</option>
|
|
<option value="newest_upload">Newest upload first</option>
|
|
<option value="least_recently_shown">Least recently shown</option>
|
|
<option value="random">Random</option>
|
|
</select>
|
|
|
|
<label class="home-view__rotation-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
v-model="editPrioritizeNeverShown"
|
|
/>
|
|
<span>Show never-shown images first</span>
|
|
</label>
|
|
</div>
|
|
|
|
<BaseButton
|
|
variant="primary"
|
|
class="home-view__sheet-save"
|
|
:disabled="saving"
|
|
@click="saveSettings"
|
|
>
|
|
{{ saving ? 'Saving…' : 'Save' }}
|
|
</BaseButton>
|
|
</BaseBottomSheet>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useDevicesStore } from '@/stores/devices'
|
|
import { useUploadStore } from '@/stores/upload'
|
|
import { useDeviceMercure } from '@/composables/useDeviceMercure'
|
|
import type { Device } from '@/types'
|
|
|
|
// 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.wakeTimes.length > 0) return 24 * 60 * 60 * 1000
|
|
return device.rotationIntervalMinutes * 60_000
|
|
}
|
|
|
|
function deviceStatus(device: Device): 'ok' | 'sync-fail' | 'offline' {
|
|
if (!device.lastSeenAt) return 'offline'
|
|
const elapsed = Date.now() - new Date(device.lastSeenAt).getTime()
|
|
const interval = syncIntervalMs(device)
|
|
if (elapsed <= interval) return 'ok'
|
|
if (elapsed <= 2 * interval) return 'sync-fail'
|
|
return 'offline'
|
|
}
|
|
|
|
function lastSyncLabel(device: Device): string | null {
|
|
if (!device.lastSeenAt) return null
|
|
const ago = Date.now() - new Date(device.lastSeenAt).getTime()
|
|
if (ago < 60_000) return 'just now'
|
|
if (ago < 3_600_000) return `${Math.round(ago / 60_000)}m ago`
|
|
if (ago < 86_400_000) return `${Math.round(ago / 3_600_000)}h ago`
|
|
const days = Math.round(ago / 86_400_000)
|
|
if (days === 1) return 'yesterday'
|
|
return `${days} days ago`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Prefer the server-stamped nextPollExpectedAt — that's the schedule the
|
|
// device is *actually* on, set every poll. Falls back to a local computation
|
|
// for devices that haven't polled since the column was added.
|
|
function nextSyncLabel(device: Device): string | null {
|
|
let nextMs: number | null = null
|
|
if (device.nextPollExpectedAt) {
|
|
nextMs = new Date(device.nextPollExpectedAt).getTime()
|
|
} else 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'}`
|
|
} else if (device.lastSeenAt) {
|
|
nextMs = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
|
|
} else {
|
|
return null
|
|
}
|
|
|
|
const fromNow = nextMs - Date.now()
|
|
if (fromNow <= 0) return null
|
|
if (fromNow < 60_000) return 'next sync in <1m'
|
|
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
|
|
if (fromNow < 86_400_000) {
|
|
// Long horizons read better as a clock time than "in 14h".
|
|
const tz = device.timezone || 'UTC'
|
|
const minOfDay = getMinuteOfDayInTz(new Date(nextMs), tz)
|
|
const dayDelta = daysFromTodayInTz(new Date(nextMs), tz)
|
|
const dayLabel = dayDelta === 0 ? 'today'
|
|
: dayDelta === 1 ? 'tomorrow'
|
|
: `in ${dayDelta}d`
|
|
return `next sync ~${formatTime(minOfDay)} ${dayLabel}`
|
|
}
|
|
return `next sync in ${Math.round(fromNow / 86_400_000)}d`
|
|
}
|
|
|
|
// Home shows what's actually on the frame right now — the last image the
|
|
// device pulled. Lock/queue state is intentionally ignored; the preview
|
|
// won't change until the frame next polls and switches to the locked image.
|
|
function previewUrl(device: Device): string | undefined {
|
|
return device.currentImageId
|
|
? `/api/devices/${device.id}/preview?v=${device.currentImageId}`
|
|
: undefined
|
|
}
|
|
import FrameCard from '@/components/FrameCard.vue'
|
|
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
|
import BaseButton from '@/components/BaseButton.vue'
|
|
import BaseInput from '@/components/BaseInput.vue'
|
|
import OrientationPicker from '@/components/OrientationPicker.vue'
|
|
import PullToRefresh from '@/components/PullToRefresh.vue'
|
|
|
|
const router = useRouter()
|
|
const devicesStore = useDevicesStore()
|
|
const uploadStore = useUploadStore()
|
|
|
|
// Live updates: server publishes a fresh device payload after every poll
|
|
// (and on PATCH/lock/unlock); the composable splats it into the store.
|
|
useDeviceMercure()
|
|
|
|
onMounted(() => {
|
|
devicesStore.fetchDevices()
|
|
document.addEventListener('visibilitychange', onVisibility)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('visibilitychange', onVisibility)
|
|
})
|
|
|
|
// Quietly re-fetch device state every time the PWA returns to the foreground,
|
|
// so the user doesn't have to pull-to-refresh just because a frame cycled
|
|
// while the app was backgrounded.
|
|
function onVisibility() {
|
|
if (document.visibilityState === 'visible') {
|
|
devicesStore.fetchDevices({ silent: true })
|
|
}
|
|
}
|
|
|
|
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
|
|
|
const stackEl = ref<HTMLElement | null>(null)
|
|
|
|
function isAtTop(): boolean {
|
|
if (window.scrollY > 0) return false
|
|
return (stackEl.value?.scrollTop ?? 0) === 0
|
|
}
|
|
|
|
async function refreshDevices() {
|
|
await devicesStore.fetchDevices({ silent: true })
|
|
}
|
|
|
|
function onAddPhoto(deviceId: number) {
|
|
// File picker must be triggered in the user-gesture context (the click handler)
|
|
// before navigating, otherwise browsers block it as a popup.
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
|
input.onchange = () => {
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
uploadStore.init(file, deviceId)
|
|
router.push('/upload')
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
// ── Settings sheet ────────────────────────────────────────────────────────────
|
|
|
|
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: [
|
|
{ value: 'America/New_York', label: 'Eastern — New York, Toronto' },
|
|
{ value: 'America/Chicago', label: 'Central — Chicago, Mexico City' },
|
|
{ value: 'America/Denver', label: 'Mountain — Denver, Calgary' },
|
|
{ value: 'America/Phoenix', label: 'Mountain (no DST) — Phoenix' },
|
|
{ value: 'America/Los_Angeles', label: 'Pacific — Los Angeles, Vancouver' },
|
|
{ value: 'America/Anchorage', label: 'Alaska — Anchorage' },
|
|
{ value: 'Pacific/Honolulu', label: 'Hawaii — Honolulu' },
|
|
{ value: 'America/Sao_Paulo', label: 'Brasília — São Paulo' },
|
|
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina — Buenos Aires' },
|
|
{ value: 'America/Bogota', label: 'Colombia — Bogotá' },
|
|
]},
|
|
{ label: 'Europe', zones: [
|
|
{ value: 'Europe/London', label: 'GMT/BST — London, Dublin' },
|
|
{ value: 'Europe/Lisbon', label: 'WET/WEST — Lisbon' },
|
|
{ value: 'Europe/Paris', label: 'CET/CEST — Paris, Brussels, Amsterdam' },
|
|
{ value: 'Europe/Berlin', label: 'CET/CEST — Berlin, Vienna, Zurich' },
|
|
{ value: 'Europe/Stockholm', label: 'CET/CEST — Stockholm, Oslo, Copenhagen'},
|
|
{ value: 'Europe/Helsinki', label: 'EET/EEST — Helsinki, Tallinn, Riga' },
|
|
{ value: 'Europe/Warsaw', label: 'CET/CEST — Warsaw, Prague, Budapest' },
|
|
{ value: 'Europe/Rome', label: 'CET/CEST — Rome, Madrid' },
|
|
{ value: 'Europe/Athens', label: 'EET/EEST — Athens, Bucharest' },
|
|
{ value: 'Europe/Istanbul', label: 'TRT — Istanbul' },
|
|
{ value: 'Europe/Moscow', label: 'MSK — Moscow' },
|
|
]},
|
|
{ label: 'Asia & Pacific', zones: [
|
|
{ value: 'Asia/Dubai', label: 'GST — Dubai, Abu Dhabi' },
|
|
{ value: 'Asia/Karachi', label: 'PKT — Karachi, Islamabad' },
|
|
{ value: 'Asia/Kolkata', label: 'IST — India' },
|
|
{ value: 'Asia/Dhaka', label: 'BST — Dhaka, Bangladesh' },
|
|
{ value: 'Asia/Bangkok', label: 'ICT — Bangkok, Jakarta, Hanoi' },
|
|
{ value: 'Asia/Singapore', label: 'SGT — Singapore, Kuala Lumpur' },
|
|
{ value: 'Asia/Shanghai', label: 'CST — Beijing, Shanghai, Taipei' },
|
|
{ value: 'Asia/Seoul', label: 'KST — Seoul' },
|
|
{ value: 'Asia/Tokyo', label: 'JST — Tokyo' },
|
|
{ value: 'Australia/Sydney', label: 'AEDT/AEST — Sydney, Melbourne' },
|
|
{ value: 'Australia/Brisbane',label: 'AEST (no DST) — Brisbane' },
|
|
{ value: 'Australia/Perth', label: 'AWST — Perth' },
|
|
{ value: 'Pacific/Auckland', label: 'NZDT/NZST — Auckland' },
|
|
]},
|
|
{ label: 'Africa & Middle East', zones: [
|
|
{ value: 'Africa/Cairo', label: 'EET — Cairo' },
|
|
{ value: 'Africa/Nairobi', label: 'EAT — Nairobi, East Africa'},
|
|
{ value: 'Africa/Johannesburg', label: 'SAST — Johannesburg' },
|
|
{ value: 'Africa/Lagos', label: 'WAT — Lagos, West Africa' },
|
|
]},
|
|
{ label: 'UTC', zones: [
|
|
{ value: 'UTC', label: 'UTC — Coordinated Universal Time' },
|
|
]},
|
|
]
|
|
|
|
const sheetOpen = ref(false)
|
|
const saving = ref(false)
|
|
const editingDevice = ref<Device | null>(null)
|
|
const editName = ref('')
|
|
const editOrientation = ref<Device['orientation']>('landscape')
|
|
const editFrequencyMode = ref<FrequencyMode>('interval')
|
|
const editWakeTimes = ref<number[]>([])
|
|
const editIntervalMinutes = ref<number>(60)
|
|
const editTimezone = ref('UTC')
|
|
const editRotationMode = ref<Device['rotationMode']>('oldest_upload')
|
|
const editPrioritizeNeverShown = ref<boolean>(false)
|
|
|
|
// 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
|
|
]
|
|
|
|
// Appends to the end of the list — never sorts mid-edit. Sorting a row away
|
|
// from where the user just clicked is disorienting; they lose track of "the
|
|
// one I just added." Backend's setWakeTimes() sorts on save, so the persisted
|
|
// state stays canonical.
|
|
function addTime() {
|
|
for (const c of DEFAULT_TIME_CANDIDATES) {
|
|
if (!editWakeTimes.value.includes(c)) {
|
|
editWakeTimes.value = [...editWakeTimes.value, c]
|
|
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]
|
|
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 reorder, don't dedupe. Reordering a row while
|
|
// the user's mid-edit would yank their focus to the new position. Backend
|
|
// setWakeTimes() sorts and dedupes on save.
|
|
const arr = [...editWakeTimes.value]
|
|
arr[idx] = minutesFromHmp(h, mm, p)
|
|
editWakeTimes.value = arr
|
|
}
|
|
|
|
// "Next update" is when the device will *actually* next sync — and the new
|
|
// settings only reach the device on that sync, since it's currently asleep
|
|
// under whatever schedule was active at its last poll. So we compute this from
|
|
// the device's CURRENT (saved) settings, ignoring the in-progress edit state.
|
|
//
|
|
// Examples this gets right:
|
|
// - Currently every 1 min, new = "at 4 AM" → "in ~1 min" (next existing
|
|
// poll under current schedule, not 4 AM tomorrow).
|
|
// - Currently "at 4 AM", new = "at 4 AM and 6 PM" → "~4 AM tomorrow"
|
|
// (device is asleep until then; can't act on the 6 PM slot today because
|
|
// it won't learn about it until the 4 AM check-in).
|
|
const nextUpdatePreview = computed<string>(() => {
|
|
const device = editingDevice.value
|
|
if (!device) return ''
|
|
|
|
// The preview is about when the device will *next sync* — it does NOT
|
|
// depend on the proposed new settings, only on the device's current saved
|
|
// schedule. The "no update times yet" hint already lives in the time list.
|
|
const tz = device.timezone || 'UTC'
|
|
|
|
// Prefer the server-stamped expected-next-poll: that timestamp was set
|
|
// under the schedule active at the device's last poll, and isn't disturbed
|
|
// by the user's PATCHes — exactly what we want for "when will the new
|
|
// settings reach the frame?"
|
|
let nextPollMs: number
|
|
if (device.nextPollExpectedAt) {
|
|
nextPollMs = new Date(device.nextPollExpectedAt).getTime()
|
|
} else if (!device.lastSeenAt) {
|
|
return 'Next update: when the frame next connects'
|
|
} else {
|
|
// Legacy fallback for devices that haven't polled since the column was added.
|
|
const lastSeen = new Date(device.lastSeenAt).getTime()
|
|
nextPollMs = device.wakeTimes.length > 0
|
|
? nextWakeAfter(lastSeen, device.wakeTimes, tz)
|
|
: lastSeen + device.rotationIntervalMinutes * 60_000
|
|
}
|
|
// Already-overdue device: it'll poll any moment now.
|
|
if (nextPollMs < Date.now()) nextPollMs = Date.now()
|
|
|
|
return formatNextUpdate(nextPollMs, tz)
|
|
})
|
|
|
|
// Next absolute timestamp (ms) at which any of `times` (minutes-of-day) occurs
|
|
// strictly AFTER `refMs` in `tz`. Approximates DST transitions away.
|
|
function nextWakeAfter(refMs: number, times: number[], tz: string): number {
|
|
const refMin = getMinuteOfDayInTz(new Date(refMs), tz)
|
|
let bestDelta = Infinity
|
|
for (const m of times) {
|
|
let delta = m - refMin
|
|
if (delta <= 0) delta += 24 * 60
|
|
if (delta < bestDelta) bestDelta = delta
|
|
}
|
|
return refMs + bestDelta * 60_000
|
|
}
|
|
|
|
// 0 = today, 1 = tomorrow, 2 = day after, ... in the given timezone.
|
|
function daysFromTodayInTz(date: Date, tz: string): number {
|
|
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
})
|
|
const todayMs = Date.parse(fmt.format(new Date()) + 'T00:00:00Z')
|
|
const targetMs = Date.parse(fmt.format(date) + 'T00:00:00Z')
|
|
return Math.round((targetMs - todayMs) / 86_400_000)
|
|
}
|
|
|
|
function formatNextUpdate(tsMs: number, tz: string): string {
|
|
const fromNow = tsMs - Date.now()
|
|
if (fromNow <= 0) return 'Next update: any moment'
|
|
if (fromNow < 90_000) return 'Next update: in <1 min'
|
|
if (fromNow < 3_600_000) return `Next update: in ~${Math.round(fromNow / 60_000)} min`
|
|
|
|
const minOfDay = getMinuteOfDayInTz(new Date(tsMs), tz)
|
|
const dayDelta = daysFromTodayInTz(new Date(tsMs), tz)
|
|
const dayLabel = dayDelta === 0 ? 'today'
|
|
: dayDelta === 1 ? 'tomorrow'
|
|
: `in ${dayDelta} days`
|
|
return `Next update: ~${formatTime(minOfDay)} ${dayLabel}`
|
|
}
|
|
|
|
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
|
|
editTimezone.value = device.timezone ?? 'UTC'
|
|
editIntervalMinutes.value = device.rotationIntervalMinutes
|
|
editWakeTimes.value = [...device.wakeTimes]
|
|
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
|
|
editRotationMode.value = device.rotationMode
|
|
editPrioritizeNeverShown.value = device.prioritizeNeverShown
|
|
sheetOpen.value = true
|
|
}
|
|
|
|
async function saveSettings() {
|
|
if (!editingDevice.value) return
|
|
saving.value = true
|
|
try {
|
|
const patch: Parameters<typeof devicesStore.updateDevice>[1] = {
|
|
name: editName.value.trim() || editingDevice.value.name,
|
|
orientation: editOrientation.value,
|
|
timezone: editTimezone.value,
|
|
rotationMode: editRotationMode.value,
|
|
prioritizeNeverShown: editPrioritizeNeverShown.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
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.home-view {
|
|
flex: 1;
|
|
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
|
|
&__loading {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-sm);
|
|
padding: var(--space-4) 0;
|
|
text-align: center;
|
|
}
|
|
|
|
&__empty {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding-top: var(--space-6);
|
|
}
|
|
|
|
&__empty-card {
|
|
background: var(--color-surface);
|
|
border: 2px dashed var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--space-6) var(--space-5);
|
|
text-align: center;
|
|
max-width: 320px;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
&__empty-icon {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
&__empty-title {
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
}
|
|
|
|
&__empty-sub {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
&__single {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
// Vertical scroll-snap stack of full-size cards. Each slide takes a full
|
|
// screen-port height and snaps as the user scrolls, so flipping between
|
|
// frames feels deliberate (like swiping pages) rather than continuous
|
|
// scrolling through a feed.
|
|
&__stack {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
overflow-y: auto;
|
|
scroll-snap-type: y mandatory;
|
|
scroll-behavior: smooth;
|
|
scrollbar-width: none;
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
&::-webkit-scrollbar { display: none; }
|
|
}
|
|
|
|
&__slide {
|
|
flex: 0 0 auto;
|
|
min-height: 100%;
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
flex-direction: column;
|
|
scroll-snap-align: start;
|
|
scroll-snap-stop: always;
|
|
}
|
|
|
|
// Landscape phone: horizontal carousel of smaller cards. Vertical space is
|
|
// tight (~240-280px usable) so we side-scroll instead of paging vertically,
|
|
// and each card shrinks to ~320px wide so 2-3 are visible at a time.
|
|
@media (orientation: landscape) and (max-height: 600px) {
|
|
&__stack {
|
|
flex-direction: row;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
scroll-snap-type: x mandatory;
|
|
gap: var(--space-3);
|
|
margin: 0 calc(-1 * var(--space-4));
|
|
padding: 0 var(--space-4);
|
|
}
|
|
|
|
&__slide {
|
|
flex: 0 0 min(320px, 70vw);
|
|
min-height: 0;
|
|
height: 100%;
|
|
scroll-snap-align: center;
|
|
}
|
|
}
|
|
|
|
&__sheet-title {
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
margin-bottom: var(--space-4);
|
|
}
|
|
|
|
&__sheet-field {
|
|
margin-bottom: var(--space-4);
|
|
}
|
|
|
|
&__sheet-label {
|
|
display: block;
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
|
|
&__mode-select,
|
|
&__tz-select {
|
|
width: 100%;
|
|
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;
|
|
cursor: pointer;
|
|
appearance: auto;
|
|
|
|
&:focus {
|
|
outline: 2px solid var(--color-primary);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
|
|
&__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;
|
|
transition: all var(--duration-fast);
|
|
|
|
&:hover, &:focus-visible {
|
|
border-color: var(--color-primary);
|
|
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);
|
|
// iOS Safari auto-zooms <input> when font-size < 16px. Use 16px+ to prevent
|
|
// the page from zooming when the user taps the number field.
|
|
font-size: 16px;
|
|
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);
|
|
}
|
|
|
|
&__rotation-checkbox {
|
|
margin-top: var(--space-3);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
font-size: var(--text-sm);
|
|
cursor: pointer;
|
|
min-height: var(--touch-min);
|
|
|
|
input[type="checkbox"] {
|
|
// Bigger than browser default so it remains a comfortable touch target.
|
|
width: 22px;
|
|
height: 22px;
|
|
cursor: pointer;
|
|
accent-color: var(--color-primary);
|
|
}
|
|
}
|
|
|
|
&__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);
|
|
}
|
|
}
|
|
</style>
|