fix(home): preview reflects what's on the frame, not what's queued
CI / test (push) Has been cancelled

Both the backend preview endpoint and the frontend cache-buster were
preferring lockedImage over currentImage. Locking is a queued override
that doesn't take effect until the device's next poll, so showing it on
Home before the device has actually pulled it lied about the frame's
state. Always use currentImage now.

Also: add a primary "+ Add Photo" button at the top of the Library page
so users can upload without bouncing back to Home; updates the empty-
state copy to point at the new button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 19:15:14 -04:00
parent 328ad632d3
commit 2cd558bac3
17 changed files with 125 additions and 21 deletions
+14 -2
View File
@@ -472,13 +472,25 @@ describe('HomeView', () => {
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('prefers lockedImageId over currentImageId for the thumbnail', async () => {
it('always uses currentImageId for the thumbnail — lockedImageId is ignored', async () => {
// Locked-but-not-yet-pulled is the bug we explicitly fixed: the home
// preview must reflect what the frame is actually showing, not what's
// queued for the next poll.
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=99')
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('omits the thumbnail when the device has no currentImageId, even if a lock is queued', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: null })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined()
})
it('updates editWakeHour when the user picks a different hour chip', async () => {
+53 -1
View File
@@ -56,8 +56,9 @@ vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: toastShow }),
}))
const uploadInitEdit = vi.fn()
const uploadInit = vi.fn()
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: uploadInitEdit }),
useUploadStore: () => ({ initEdit: uploadInitEdit, init: uploadInit }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
@@ -100,6 +101,7 @@ describe('LibraryView', () => {
mockRoute.query = {}
toastShow.mockClear()
uploadInitEdit.mockClear()
uploadInit.mockClear()
routerPush.mockClear()
// Default fetch stub — returns empty lists so onMounted doesn't error
@@ -117,6 +119,56 @@ describe('LibraryView', () => {
})
}
// LV-00: Add Photo button at the top
it('renders an Add Photo button at the top of the page', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const addBtn = wrapper.find('.library__add-btn')
expect(addBtn.exists()).toBe(true)
expect(addBtn.text()).toContain('Add Photo')
})
it('clicking the Add Photo button primes the upload store and routes to /upload', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__add-btn').trigger('click')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change'))
expect(uploadInit).toHaveBeenCalledWith(file)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
// LV-01: Default tab shows "All" tab active
it('renders the All tab as active by default', async () => {
const imagesStore = useImagesStore()
+6 -2
View File
@@ -177,9 +177,13 @@ function nextSyncLabel(device: Device): string | null {
return `next sync in ${Math.round(fromNow / 3_600_000)}h`
}
// 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 {
const imageId = device.lockedImageId ?? device.currentImageId
return imageId ? `/api/devices/${device.id}/preview?v=${imageId}` : 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'
+32 -1
View File
@@ -1,6 +1,12 @@
<template>
<main class="library">
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshLibrary">
<!-- Top action bar -->
<div class="library__header">
<BaseButton variant="primary" class="library__add-btn" @click="onAddPhoto">
+ Add Photo
</BaseButton>
</div>
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
@@ -26,7 +32,7 @@
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="library__empty-title">No photos yet</p>
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
<p class="library__empty-sub">Tap "+ Add Photo" above to upload your first one.</p>
</div>
<div v-else class="library__grid">
@@ -291,6 +297,23 @@ onMounted(() => {
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// ── Add Photo ─────────────────────────────────────────────────────────────────
function onAddPhoto() {
// 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)
router.push('/upload')
}
input.click()
}
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
function isAtTop(): boolean {
@@ -414,6 +437,14 @@ async function doDelete() {
.library {
padding-bottom: calc(var(--bottom-nav-height) + var(--space-4));
&__header {
padding: var(--space-4) var(--space-4) var(--space-3);
}
&__add-btn {
width: 100%;
}
&__tabs {
display: flex;
border-bottom: 1px solid var(--color-border);