Files
pictureFrame-webApp/frontend/src/views/HomeView.vue
T
football2801 08d0968af0
CI / test (push) Has been cancelled
feat(setup): post-link redirects to SPA so first-setup matches live UI
Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.

Fixes Matt's report:
  - "every 6 hours" lost on save: the configure form posted
    rotation_interval_hours but the controller read
    rotation_interval_minutes, so the value silently defaulted to
    1440 every time. Now the SPA's PATCH flow handles it correctly.
  - "old settings still there in live settings": SPA settings sheet
    pre-populates from the device's current state via onEdit.
  - "uniqueness window in setup but not live settings": removed
    from the (now-deleted) Twig form; both surfaces are consistent.
  - "color scheme didn't match account": SPA respects the user's
    theme natively (data-theme on <html>), so the first-setup screen
    looks like the rest of the app.

Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.

Tests:
  - SetupControllerTest: S-03/04/05/06/08 updated for new redirect
    targets, S-CLAIM-03 updated.
  - HomeView.test.ts: useRoute now mockable per-test, two new cases
    pinning the ?setup=<id> auto-open and its absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:51:31 -04:00

1187 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"
: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. 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>
<a href="/logout" class="home-view__logout">Sign out of pictureFrame</a>
</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 })
}
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 = [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;
}
&__logout {
display: block;
margin-top: var(--space-3);
text-align: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
text-decoration: none;
padding: var(--space-2);
&:hover, &:focus-visible {
color: var(--color-text);
text-decoration: underline;
outline: none;
}
}
}
</style>