Files
pictureFrame-webApp/frontend/src/views/HomeView.vue
T
football2801 82a42011d8
CI / test (push) Has been cancelled
fix(upload): persistent file <input> to survive iOS PWA cold launch
A dynamically-created <input type="file"> that's never attached to the
DOM drops its first `change` event on a cold-launched iOS PWA — the
native photo picker resolves out of the original user-gesture context
and the closure that captured the input is gone. Symptom Matt hit
2026-05-14: first image-pick after hard-close + reopen of the PWA
silently failed to advance to the crop tool; the second attempt worked.

HomeView and LibraryView now keep a hidden <input ref="fileInputEl"
type="file"> live in their templates. onAddPhoto clicks that input
inside the user-gesture context; @change fires reliably even on cold
launches. The picker resets input.value between selections so picking
the same file twice still fires.

Tests updated to query the template input via wrapper.find() instead
of stubbing document.createElement; 347/347 frontend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:26 -04:00

1193 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
:model="devicesStore.devices[0].model"
: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"
:model="device.model"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</div>
</PullToRefresh>
<!-- Hidden persistent file picker. iOS Safari's PWA shell drops the
first `change` event of a dynamically-created (DOM-detached)
input element on cold launches — the photo picker resolves
out-of-gesture-context and the closure never fires. Keeping the
input live in the template sidesteps that. -->
<input
ref="fileInputEl"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onFileSelected"
/>
</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. To force
an immediate refresh, briefly disconnect and reconnect the frames
power.
</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>
<button
type="button"
class="home-view__remove"
@click="removeConfirmOpen = true"
>Remove this frame</button>
</BaseBottomSheet>
<Teleport to="body">
<Transition name="home-view__remove-modal">
<div
v-if="removeConfirmOpen"
class="home-view__remove-modal"
role="alertdialog"
aria-labelledby="remove-confirm-title"
@click.self="removeConfirmOpen = false"
>
<div class="home-view__remove-modal-card">
<p class="home-view__remove-confirm-title" id="remove-confirm-title">
Remove this frame?
</p>
<p class="home-view__remove-confirm-body">
Use this if youre selling or giving away the frame. It deletes
this frame from your account and unlinks it from your photos so
the next owner can claim it fresh. This cant be undone.
</p>
<p class="home-view__remove-confirm-aside">
On the frame itself, the new owner can also do a factory reset
by holding the small button on the back until the screen starts
to flash. (If they only tap it briefly, the frame just refreshes
its current image — keep holding until it flashes.)
</p>
<div class="home-view__remove-confirm-actions">
<button
type="button"
class="home-view__remove-cancel"
:disabled="removing"
@click="removeConfirmOpen = false"
>Cancel</button>
<button
type="button"
class="home-view__remove-confirm-btn"
:disabled="removing"
@click="performRemove"
>{{ removing ? 'Removing' : 'Yes, remove' }}</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter, useRoute } 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 route = useRoute()
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(async () => {
await devicesStore.fetchDevices()
document.addEventListener('visibilitychange', onVisibility)
// First-time setup landing: SetupController redirects new users to
// /?setup=<deviceId> after register/login, instead of a separate
// Twig configure page. Auto-open the settings sheet for that device
// so the user sees the same rich UI everyone else uses for live edits
// — pre-populated, themed, with no duplicated form to maintain.
const setupId = Number(route.query.setup)
if (setupId) {
onEdit(setupId)
// Clean the query param off the URL so a refresh doesn't keep
// re-opening the sheet, but keep the user on the home view.
router.replace({ query: { ...route.query, setup: undefined } })
}
})
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 })
}
// Hidden file <input> lives in the template so iOS PWA cold launches
// can't drop the first `change` event on a detached node. Click handlers
// route through this single input.
const fileInputEl = ref<HTMLInputElement | null>(null)
let pendingAddDeviceId: number | null = null
function onAddPhoto(deviceId: number) {
// File picker must be triggered in the user-gesture context (the click
// handler), otherwise browsers block it as a popup.
pendingAddDeviceId = deviceId
fileInputEl.value?.click()
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset right away so re-selecting the same file later still fires change.
input.value = ''
if (!file) return
uploadStore.init(file, pendingAddDeviceId ?? undefined)
pendingAddDeviceId = null
router.push('/upload')
}
// ── Settings sheet ────────────────────────────────────────────────────────────
const HOUR_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
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 removing = ref(false)
const removeConfirmOpen = 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
removeConfirmOpen.value = false
sheetOpen.value = true
}
async function performRemove() {
if (!editingDevice.value) return
removing.value = true
try {
await devicesStore.removeDevice(editingDevice.value.id)
sheetOpen.value = false
removeConfirmOpen.value = false
} finally {
removing.value = false
}
}
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);
}
&__remove {
width: 100%;
margin-top: var(--space-5);
padding: var(--space-2) var(--space-3);
min-height: var(--touch-min);
border: 1.5px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-danger, #c0392b);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--duration-fast);
&:hover, &:focus-visible {
border-color: var(--color-danger, #c0392b);
outline: none;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__remove-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: rgba(20, 14, 8, 0.55);
backdrop-filter: blur(2px);
}
&__remove-modal-card {
width: 100%;
max-width: 360px;
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
// Transition: card scales up subtly + fades; backdrop fades.
&__remove-modal-enter-active,
&__remove-modal-leave-active {
transition: opacity var(--duration-fast) ease;
}
&__remove-modal-enter-active .home-view__remove-modal-card,
&__remove-modal-leave-active .home-view__remove-modal-card {
transition: transform var(--duration-fast) ease;
}
&__remove-modal-enter-from,
&__remove-modal-leave-to { opacity: 0; }
&__remove-modal-enter-from .home-view__remove-modal-card,
&__remove-modal-leave-to .home-view__remove-modal-card {
transform: scale(0.96);
}
&__remove-confirm-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-danger, #c0392b);
margin-bottom: var(--space-2);
}
&__remove-confirm-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.5;
margin-bottom: var(--space-2);
}
&__remove-confirm-aside {
font-size: var(--text-xs);
color: var(--color-text-muted);
line-height: 1.5;
margin-bottom: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
&__remove-confirm-actions {
display: flex;
gap: var(--space-2);
}
&__remove-cancel,
&__remove-confirm-btn {
flex: 1 1 0;
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-weight: 600;
cursor: pointer;
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__remove-confirm-btn {
background: var(--color-danger, #c0392b);
border-color: var(--color-danger, #c0392b);
color: #fff;
}
}
</style>