From 2cd558bac3d350c446a74e1b84c727382bab8552 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 6 May 2026 19:15:14 -0400 Subject: [PATCH] fix(home): preview reflects what's on the frame, not what's queued 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) --- frontend/src/test/views/HomeView.test.ts | 16 +++++- frontend/src/test/views/LibraryView.test.ts | 54 ++++++++++++++++++- frontend/src/views/HomeView.vue | 8 ++- frontend/src/views/LibraryView.vue | 33 +++++++++++- ...az8jxBn.js => BaseBottomSheet-vZ7hFF0Y.js} | 2 +- ...r-B0Ct0KJt.js => DevicePicker-BAb-kxko.js} | 2 +- ...eView-DvMqv--T.js => HomeView-CIMPKeWy.js} | 2 +- ...iew-CvKnxi1X.css => HomeView-CSdCe5ba.css} | 2 +- public/build/assets/LibraryView-BXLhtHev.js | 1 - public/build/assets/LibraryView-COLjtUDo.css | 1 - public/build/assets/LibraryView-D_UZfGD5.js | 1 + public/build/assets/LibraryView-kUqy3usw.css | 1 + ...w-9_y7I0FR.js => SettingsView-CPVpIA6P.js} | 2 +- ...iew-BmbRnTmn.js => UploadView-BYIjcQti.js} | 2 +- .../{index-BvMU-pbo.js => index-BGc6BnG_.js} | 4 +- public/build/index.html | 2 +- src/Controller/DeviceApiController.php | 13 +++-- 17 files changed, 125 insertions(+), 21 deletions(-) rename public/build/assets/{BaseBottomSheet-Baz8jxBn.js => BaseBottomSheet-vZ7hFF0Y.js} (98%) rename public/build/assets/{DevicePicker-B0Ct0KJt.js => DevicePicker-BAb-kxko.js} (92%) rename public/build/assets/{HomeView-DvMqv--T.js => HomeView-CIMPKeWy.js} (52%) rename public/build/assets/{HomeView-CvKnxi1X.css => HomeView-CSdCe5ba.css} (84%) delete mode 100644 public/build/assets/LibraryView-BXLhtHev.js delete mode 100644 public/build/assets/LibraryView-COLjtUDo.css create mode 100644 public/build/assets/LibraryView-D_UZfGD5.js create mode 100644 public/build/assets/LibraryView-kUqy3usw.css rename public/build/assets/{SettingsView-9_y7I0FR.js => SettingsView-CPVpIA6P.js} (92%) rename public/build/assets/{UploadView-BmbRnTmn.js => UploadView-BYIjcQti.js} (98%) rename public/build/assets/{index-BvMU-pbo.js => index-BGc6BnG_.js} (99%) diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 1c63572..3c019c4 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -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 () => { diff --git a/frontend/src/test/views/LibraryView.test.ts b/frontend/src/test/views/LibraryView.test.ts index cd2f850..8c01572 100644 --- a/frontend/src/test/views/LibraryView.test.ts +++ b/frontend/src/test/views/LibraryView.test.ts @@ -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 => ({ @@ -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() diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index cad98f4..7b52211 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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' diff --git a/frontend/src/views/LibraryView.vue b/frontend/src/views/LibraryView.vue index fcee538..3b711d6 100644 --- a/frontend/src/views/LibraryView.vue +++ b/frontend/src/views/LibraryView.vue @@ -1,6 +1,12 @@