diff --git a/.github/specs/bugs-1.0.6-02/bugs-1.0.6-set02.md b/.github/specs/bugs-1.0.6-02/bugs-1.0.6-set02.md new file mode 100644 index 0000000..b063222 --- /dev/null +++ b/.github/specs/bugs-1.0.6-02/bugs-1.0.6-set02.md @@ -0,0 +1,38 @@ +# Bug List for 1.0.6 Set 2 + +## On mobile, the COMPLETED (and possible other banners) overflow it's bounding box + +- [x] Fixed + +![alt text](image.png) + +We need the banner text to be a little smaller on mobile screens so it does not go outside the dark border box. + +**Fix:** Added `@media (max-width: 480px)` CSS rule for `.chore-stamp` in both ChildView.vue and ParentView.vue. Reduces `font-size` to `0.82rem`, `letter-spacing` to `1px`, and `padding` to `0.3rem 0` on small screens. + +## Extending time on a chore that is too late causes task icons to disappear. + +- [x] Fixed + +When the user extends time on a chore that is TOO LATE, I think 2 identical calls are being made to the api /child/[id]/list-tasks. This is causing a condition where images no longer appear until the user refreshes. + +Could SSE calls be causing this? Could also be a childChoreListRef.value?.refresh() is called in the UI too many times too quickly. + +**Root Cause:** Two issues combined: + +1. `doExtendTime` in ParentView called `childChoreListRef.value?.refresh()` directly AND the SSE `chore_time_extended` event handler also called `refresh()`, causing two concurrent fetches. +2. `ScrollingList.fetchItems()` had no protection against concurrent calls. When two fetches raced, `items.value` was set before images were resolved, and the second call's `loading.value = false` didn't trigger a re-render (already false), leaving images from the second fetch invisible. + +**Fix:** + +- Removed the manual `refresh()` call from `doExtendTime` in ParentView — the SSE event handler already handles it. +- Added a fetch generation counter to `ScrollingList.fetchItems()` that discards stale results from superseded fetches. +- After image resolution, `items.value` is re-assigned (`[...itemList]`) to trigger Vue reactivity for image URLs set on raw objects. + +## When adding a schedule to a task and submitting, icons dissappear. + +- [x] Fixed + +This is related to the previous bug. API calls to /child[id]/list-tasks are called very quickly causing a problem. + +**Fix:** The `ScrollingList` generation counter fix (described above) resolves this for all callers. Any concurrent `refresh()` calls — whether from SSE events, manual triggers, or timer-based refreshes — are now safely handled. diff --git a/.github/specs/bugs-1.0.6-02/image.png b/.github/specs/bugs-1.0.6-02/image.png new file mode 100644 index 0000000..f15fb65 Binary files /dev/null and b/.github/specs/bugs-1.0.6-02/image.png differ diff --git a/frontend/vue-app/e2e/.auth/user-cc.json b/frontend/vue-app/e2e/.auth/user-cc.json index dde098e..d1644de 100644 --- a/frontend/vue-app/e2e/.auth/user-cc.json +++ b/frontend/vue-app/e2e/.auth/user-cc.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "55lJfL_-PofI1V-NQ2E3Jlo4f_IE1aXqOF-wX54ZVSY", + "value": "8p4HLWbALh9Qwg9Znl1q8Z0Ohteik28-6H8Z5qWnaZo", "domain": "localhost", "path": "/api/auth", - "expires": 1782155883.275106, + "expires": 1782241430.50454, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZGQ0YTI1Yy04ZDI1LTQwNGEtODU0NS1mYjM2ZGRkYTU5NmYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3ODN9.b0ACVC63OobLzraiOcn4QDXipHXUVMW-Wn42XJFDOn0", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJiZWM5NTZiMi01MTZlLTQzZmQtYWUzYS1iZjNjN2RmNzA4M2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMzB9.5TdKuDgcuTmwvA7lQ4VOKXMwkviRNn24utyuWqD8IPA", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774379882977}" + "value": "{\"type\":\"logout\",\"at\":1774465430181}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774552683610}" + "value": "{\"expiresAt\":1774638230733}" } ] } diff --git a/frontend/vue-app/e2e/.auth/user-delete.json b/frontend/vue-app/e2e/.auth/user-delete.json index baf2c5c..2e6f91e 100644 --- a/frontend/vue-app/e2e/.auth/user-delete.json +++ b/frontend/vue-app/e2e/.auth/user-delete.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "us7D-X0NRlXld6bpCl4epHaruPIWbkI895jFn3o84LY", + "value": "TagN0dCg26ZQ9TEQxpQARign6bxZQxrSTZmDtNi288U", "domain": "localhost", "path": "/api/auth", - "expires": 1782155883.187584, + "expires": 1782241430.203752, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNmU0MmNhNGQtMGNkNC00MmUzLWFkOGMtMWYyOTZmMzg5NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0MzgwNzgzfQ.AthhvOE2qsaJo6x37QbGXbT9oIP2pnFks3Pj14MjmcI", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjI5NDNjZWMtYzNmNS00N2MyLTg0YTItZTI2ODI3NDZlNDY5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDY2MzMwfQ.z0nN78IK3DqAepk20FS5xUzIIt7mZeGtdFhYePAOrxc", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774379882932}" + "value": "{\"type\":\"logout\",\"at\":1774465429926}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774552683575}" + "value": "{\"expiresAt\":1774638230517}" } ] } diff --git a/frontend/vue-app/e2e/.auth/user.json b/frontend/vue-app/e2e/.auth/user.json index 504b916..d1322f3 100644 --- a/frontend/vue-app/e2e/.auth/user.json +++ b/frontend/vue-app/e2e/.auth/user.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "VqeLgoC9z5NgU2AtA-9xjl4DaRt0hYKDkmAJMN3dcnw", + "value": "GiDwb1pZ1oqvwzO16LokRkqIyCZ8nPJLzpzowwZRW-I", "domain": "localhost", "path": "/api/auth", - "expires": 1782155879.453865, + "expires": 1782241425.893418, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3ZjA2Njc5OS03NjI1LTQ0ZGQtODIwYi0zNWRjZmFlYWVjNTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3Nzl9.o9urIT0cfexJY8OarcJ4gtJKqN-_DZOw50VToPl25LU", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI0MzEwZGI1MC04NDJjLTQ2OWQtOTg4YS0wMzFjMTliMWMwM2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMjV9.5fn4UtlACnDCk2-O_n5f3HO9y1ksXDEnQjJl4TdJ-F4", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1774379879245}" + "value": "{\"type\":\"logout\",\"at\":1774465425688}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1774552679625}" + "value": "{\"expiresAt\":1774638226066}" } ] } diff --git a/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts b/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts index e346102..bf6f430 100644 --- a/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-activation/chore-activation.spec.ts @@ -74,6 +74,15 @@ test.describe('Chore activation', () => { await request.put(`/api/child/${childId}/set-tasks`, { data: { task_ids: [kindId], type: 'kindness' }, }) + // Add a schedule so COMPLETED status persists after trigger + await request.put(`/api/child/${childId}/task/${choreId}/schedule`, { + data: { + mode: 'interval', + interval_days: 1, + anchor_date: new Date().toISOString().slice(0, 10), + interval_has_deadline: false, + }, + }) }) test.afterAll(async ({ request }) => { diff --git a/frontend/vue-app/e2e/mode_parent/task-assignment/chore-assignment.spec.ts b/frontend/vue-app/e2e/mode_parent/task-assignment/chore-assignment.spec.ts index 2222cbe..0062cea 100644 --- a/frontend/vue-app/e2e/mode_parent/task-assignment/chore-assignment.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-assignment/chore-assignment.spec.ts @@ -66,7 +66,7 @@ test.describe('Chore assignment', () => { await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible() await page.getByRole('button', { name: 'Assign Chores' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible() @@ -83,7 +83,7 @@ test.describe('Chore assignment', () => { test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Chores' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible() // Select two chores @@ -126,7 +126,7 @@ test.describe('Chore assignment', () => { test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Chores' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible() // Uncheck the two previously assigned chores @@ -151,7 +151,7 @@ test.describe('Chore assignment', () => { test('Cancel does not persist selection changes', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Chores' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible() // Check an item diff --git a/frontend/vue-app/e2e/mode_parent/task-assignment/kindness-assignment.spec.ts b/frontend/vue-app/e2e/mode_parent/task-assignment/kindness-assignment.spec.ts index a37393d..8193fcc 100644 --- a/frontend/vue-app/e2e/mode_parent/task-assignment/kindness-assignment.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-assignment/kindness-assignment.spec.ts @@ -56,7 +56,7 @@ test.describe('Kindness act assignment', () => { await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible() await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible() @@ -72,7 +72,7 @@ test.describe('Kindness act assignment', () => { test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible() await page.getByText(KIND1_NAME, { exact: true }).click() @@ -101,7 +101,7 @@ test.describe('Kindness act assignment', () => { }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible() // Uncheck the previously assigned item @@ -120,7 +120,7 @@ test.describe('Kindness act assignment', () => { test('Cancel does not persist selection changes', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible() // Check the item diff --git a/frontend/vue-app/e2e/mode_parent/task-assignment/penalty-assignment.spec.ts b/frontend/vue-app/e2e/mode_parent/task-assignment/penalty-assignment.spec.ts index acb3f93..4a60901 100644 --- a/frontend/vue-app/e2e/mode_parent/task-assignment/penalty-assignment.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-assignment/penalty-assignment.spec.ts @@ -66,7 +66,7 @@ test.describe('Penalty assignment', () => { await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible() await page.getByRole('button', { name: 'Assign Penalties' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible() @@ -85,7 +85,7 @@ test.describe('Penalty assignment', () => { }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Penalties' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible() // Select two penalties @@ -128,7 +128,7 @@ test.describe('Penalty assignment', () => { test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Penalties' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible() // Uncheck the previously assigned penalties @@ -153,7 +153,7 @@ test.describe('Penalty assignment', () => { test('Cancel does not persist selection changes', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Penalties' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible() // Check an item diff --git a/frontend/vue-app/e2e/mode_parent/task-assignment/reward-assignment.spec.ts b/frontend/vue-app/e2e/mode_parent/task-assignment/reward-assignment.spec.ts index 6904d67..25e0488 100644 --- a/frontend/vue-app/e2e/mode_parent/task-assignment/reward-assignment.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-assignment/reward-assignment.spec.ts @@ -62,7 +62,7 @@ test.describe('Reward assignment', () => { await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible() await page.getByRole('button', { name: 'Assign Rewards' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible() @@ -81,7 +81,7 @@ test.describe('Reward assignment', () => { }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Rewards' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible() // Select two rewards @@ -124,7 +124,7 @@ test.describe('Reward assignment', () => { test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Rewards' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible() // Uncheck the previously assigned rewards @@ -149,7 +149,7 @@ test.describe('Reward assignment', () => { test('Cancel does not persist selection changes', async ({ page }) => { await page.goto(`/parent/${childId}`) await page.getByRole('button', { name: 'Assign Rewards' }).click() - await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`)) + await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`)) await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible() // Check an item diff --git a/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts b/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts index 9dbcf59..b7799c0 100644 --- a/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/task-modification/chore-reset.spec.ts @@ -70,6 +70,15 @@ test.describe('Chore reset', () => { await request.put(`/api/child/${childId}/set-tasks`, { data: { task_ids: [helperKindId], type: 'kindness' }, }) + // Add a schedule so COMPLETED status persists after trigger + await request.put(`/api/child/${childId}/task/${choreId}/schedule`, { + data: { + mode: 'interval', + interval_days: 1, + anchor_date: new Date().toISOString().slice(0, 10), + interval_has_deadline: false, + }, + }) }) test.afterAll(async ({ request }) => { diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 2dfe29f..000ac6f 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -913,6 +913,14 @@ onUnmounted(() => { color: #22c55e; } +@media (max-width: 480px) { + .chore-stamp { + font-size: 0.82rem; + letter-spacing: 1px; + padding: 0.3rem 0; + } +} + .due-label { font-size: 0.85rem; font-weight: 600; diff --git a/frontend/vue-app/src/components/child/ParentView.vue b/frontend/vue-app/src/components/child/ParentView.vue index 69a851d..57b1baa 100644 --- a/frontend/vue-app/src/components/child/ParentView.vue +++ b/frontend/vue-app/src/components/child/ParentView.vue @@ -431,8 +431,7 @@ async function doExtendTime(item: ChildTask, e: MouseEvent) { alert(`Error: ${msg}`) return } - childChoreListRef.value?.refresh() - setTimeout(() => resetExpiryTimers(), 300) + // SSE event 'chore_time_extended' triggers handleChoreTimeExtended which refreshes the list } // ── Schedule state helpers (for item-slot use) ──────────────────────────────── @@ -1353,6 +1352,14 @@ function goToAssignRewards() { color: #22c55e; } +@media (max-width: 480px) { + .chore-stamp { + font-size: 0.82rem; + letter-spacing: 1px; + padding: 0.3rem 0; + } +} + /* Due time sub-text */ .due-label { font-size: 0.85rem; diff --git a/frontend/vue-app/src/components/shared/ScrollingList.vue b/frontend/vue-app/src/components/shared/ScrollingList.vue index 1a9e0de..23d98d0 100644 --- a/frontend/vue-app/src/components/shared/ScrollingList.vue +++ b/frontend/vue-app/src/components/shared/ScrollingList.vue @@ -39,14 +39,19 @@ const scrollWrapper = ref(null) const itemRefs = ref>({}) const lastCenteredItemId = ref(null) +let fetchGeneration = 0 + const fetchItems = async () => { + const thisGeneration = ++fetchGeneration loading.value = true error.value = null try { const resp = await fetch(fetchUrl.value) + if (thisGeneration !== fetchGeneration) return if (!resp.ok) throw new Error(`HTTP ${resp.status}`) const data = await resp.json() + if (thisGeneration !== fetchGeneration) return // Try to use 'tasks', 'reward_status', or 'items' as fallback let itemList = data[props.itemKey || 'items'] || [] if (props.filterFn) itemList = itemList.filter(props.filterFn) @@ -75,18 +80,23 @@ const fetchItems = async () => { } }), ) + if (thisGeneration !== fetchGeneration) return + // Re-assign to trigger Vue reactivity for image URLs set on raw objects + items.value = [...itemList] } catch (err) { + if (thisGeneration !== fetchGeneration) return error.value = err instanceof Error ? err.message : 'Failed to fetch items' items.value = [] console.error('Error fetching items:', err) } finally { - loading.value = false + if (thisGeneration === fetchGeneration) { + loading.value = false + } } } async function refresh() { await fetchItems() - loading.value = false } const scrollToItem = async (itemId: string) => { diff --git a/frontend/vue-app/src/components/shared/__tests__/ScrollingList.spec.ts b/frontend/vue-app/src/components/shared/__tests__/ScrollingList.spec.ts index ee1da21..696535a 100644 --- a/frontend/vue-app/src/components/shared/__tests__/ScrollingList.spec.ts +++ b/frontend/vue-app/src/components/shared/__tests__/ScrollingList.spec.ts @@ -344,6 +344,56 @@ describe('ScrollingList', () => { expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2') }) + + it('discards stale fetch when concurrent refreshes occur', async () => { + const { getCachedImageUrl } = await import('@/common/imageCache') + + const staleItems = [{ id: 'stale-1', name: 'Stale', points: 1, image_id: 'stale-img' }] + const freshItems = [{ id: 'fresh-1', name: 'Fresh', points: 2, image_id: 'fresh-img' }] + + wrapper = mount(ScrollingList, { props: defaultProps }) + await nextTick() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Set up two concurrent fetches: first one is slow, second one is fast + let resolveFirst: (v: any) => void + const firstFetchPromise = new Promise((r) => { + resolveFirst = r + }) + + vi.clearAllMocks() + ;(global.fetch as any) + .mockReturnValueOnce(firstFetchPromise) // slow call + .mockResolvedValueOnce({ + // fast call + ok: true, + json: () => Promise.resolve({ items: freshItems }), + }) + + // Start first refresh (slow) + const first = wrapper.vm.refresh() + // Start second refresh (fast) — should supersede the first + const second = wrapper.vm.refresh() + + // Resolve the fast call first (already resolved via mockResolvedValueOnce) + await second + await nextTick() + + // Items should be from the fast (second) call + expect(wrapper.vm.items).toHaveLength(1) + expect(wrapper.vm.items[0].name).toBe('Fresh') + + // Now resolve the slow (stale) call + resolveFirst!({ ok: true, json: () => Promise.resolve({ items: staleItems }) }) + await first + await nextTick() + + // Items should still be from the fast (second) call — stale result discarded + expect(wrapper.vm.items).toHaveLength(1) + expect(wrapper.vm.items[0].name).toBe('Fresh') + // Image was loaded for fresh item, not stale + expect(getCachedImageUrl).toHaveBeenCalledWith('fresh-img') + }) }) describe('Image Loading', () => {