Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.
Backend:
- DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
image-device approvals, drops history rows, removes the Device row
entirely so the MAC is unclaimed. The next poll from that physical
frame returns 404 → setup QR for the next owner.
- DeviceService::deleteDeviceForOwner extracts the cleanup so the
controller stays thin.
- Mercure publish on delete sends {id, deleted: true} so any other
open PWA tabs splice the row out instantly.
Frontend:
- Settings sheet (BaseBottomSheet): "Remove this frame" link below
Save, in danger red with an explanatory hint about when to use it.
- Native window.confirm gate — destructive + irreversible, the
weight of native-confirm is honest. (A bespoke modal would be
polish.)
- useDeviceMercure: handles the {id, deleted: true} sentinel — splices
the device out + closes its own EventSource for that topic.
- useDevicesStore.removeDevice: DELETE + local store filter.
Tests added:
- DeviceApiControllerTest: 4 cases — happy-path delete purges
everything, 404 cross-tenant, anon redirects to login, and
post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
- HomeView.test.ts: confirm-yes calls store + closes sheet,
confirm-cancel does NOT call removeDevice.
- useDeviceMercure.test.ts: deletion sentinel splices the device
out and closes the EventSource.
Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two settings exposed in the PWA frame-settings sheet:
- rotationMode (enum: random | least_recently_shown | oldest_upload |
newest_upload). Default oldest_upload preserves the legacy
hard-coded sort, so existing devices behave identically until the
user changes it.
- prioritizeNeverShown (bool). When set, the candidate set is narrowed
to never-shown images first (if any exist) before the mode runs —
useful for "burn through new uploads before re-shuffling the catalog."
RotationService pipeline:
1. Pull approved/ready pool.
2. Drop the last `uniquenessWindow` served (existing).
3. If prioritizeNeverShown AND any candidates have never been served,
narrow to those.
4. Apply the selection mode.
Backend: enum, entity columns + accessors, migration, serializer,
PATCH validator. Frontend: types, stores, settings sheet section
(dropdown + checkbox), test fixtures, save-flow test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frame settings now offer two update-frequency modes: "at specific times" or
"every X minutes". Times are stored as an int[] of minutes-since-midnight,
allowing multiple slots per day at minute granularity. Backend computes the
earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot
as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM
dropdowns with + Add / trash, a live "next update" preview, and a note
that changes only take effect at the device's next sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three problems were stacked:
1. The 200 serving path didn't set currentImage when a locked image was
served (RotationService.advance bypassed). The frame got the locked
photo; the DB kept the previous one; Home showed the old one.
2. The 304 path didn't flush at all. lastSeenAt (markSeen) was lost on
every no-change poll, and any drift in currentImage couldn't self-heal.
For a frame that's been locked for a while, polls cycle as 304 forever
and the DB stays wrong indefinitely.
3. Pull-to-refresh fetched via fetchDevices(), which flips loading=true
and replaces the cards with "Loading…" mid-fetch. The PTR spinner was
working but users couldn't see the result of their refresh.
Fixes:
- Both 200 and 304 paths now set currentImage = $image and flush. The
304 path becomes self-healing for any device whose currentImage drifted
from reality (e.g., from before the 200-path fix).
- fetchDevices / fetchImages take an optional { silent: true } that
skips toggling loading.value. PTR refresh callbacks pass silent so
the cards stay visible during background refresh.
- HomeView also listens on visibilitychange and silently re-fetches when
the PWA returns to foreground, so reopening the app shows current
state without a manual pull.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Collapse orientation to landscape/portrait (ribbon left = portrait standard)
- Add OrientationPicker component and wire settings sheet in HomeView
- Add password confirmation field to registration form (RepeatedType)
- Build frontend SPA to public/build/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>