fix(home): preview tracks frame state even with locked images and 304 polls
CI / test (push) Has been cancelled

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>
This commit is contained in:
2026-05-06 19:24:50 -04:00
parent 2cd558bac3
commit 8beb7331dd
18 changed files with 63 additions and 21 deletions
+7 -2
View File
@@ -7,8 +7,13 @@ export const useDevicesStore = defineStore('devices', () => {
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchDevices() {
loading.value = true
/**
* Fetch the device list. Pass `silent: true` from background refreshes
* (pull-to-refresh, visibility-change polling) so the loading spinner
* doesn't blink and replace the existing cards mid-fetch.
*/
async function fetchDevices(opts: { silent?: boolean } = {}) {
if (!opts.silent) loading.value = true
error.value = null
try {
const res = await fetch('/api/devices')
+7 -2
View File
@@ -15,8 +15,13 @@ export const useImagesStore = defineStore('images', () => {
const error = ref<string | null>(null)
const pendingCount = ref(0)
async function fetchImages() {
loading.value = true
/**
* Fetch the image list. Pass `silent: true` from background refreshes
* (pull-to-refresh) so the loading spinner doesn't blink and replace the
* existing grid mid-fetch.
*/
async function fetchImages(opts: { silent?: boolean } = {}) {
if (!opts.silent) loading.value = true
error.value = null
try {
const res = await fetch('/api/images')
+16 -2
View File
@@ -117,7 +117,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
@@ -198,8 +198,22 @@ const uploadStore = useUploadStore()
onMounted(() => {
devicesStore.fetchDevices()
document.addEventListener('visibilitychange', onVisibility)
})
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)
@@ -210,7 +224,7 @@ function isAtTop(): boolean {
}
async function refreshDevices() {
await devicesStore.fetchDevices()
await devicesStore.fetchDevices({ silent: true })
}
function onAddPhoto(deviceId: number) {
+2 -2
View File
@@ -322,9 +322,9 @@ function isAtTop(): boolean {
async function refreshLibrary() {
await Promise.all([
imagesStore.fetchImages(),
imagesStore.fetchImages({ silent: true }),
imagesStore.fetchPendingCount(),
devicesStore.fetchDevices(),
devicesStore.fetchDevices({ silent: true }),
activeTab.value === 'shared' ? loadShared(sharedTab.value, sharedPage.value) : Promise.resolve(),
])
}