From 359c170b277915fd0d76fe4aacefbeb91fe63b75 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 25 Mar 2026 15:30:00 -0400 Subject: [PATCH] fix: implement fetch generation counter to handle concurrent refreshes in ScrollingList - Added a fetch generation counter to the fetchItems method to discard stale results from concurrent fetches. - Updated the loading state management to ensure it only updates if the fetch is the latest. - Enhanced the refresh method to prevent stale data from being rendered when multiple refreshes occur. - Added a test case to verify that stale fetches are discarded correctly when concurrent refreshes happen. - Fixed CSS for mobile banners to prevent overflow and adjusted font sizes for better visibility. - Resolved issues with task icons disappearing when extending time on overdue chores by ensuring only one fetch is active at a time. --- .../specs/bugs-1.0.6-02/bugs-1.0.6-set02.md | 38 +++++++++++++ .github/specs/bugs-1.0.6-02/image.png | Bin 0 -> 13169 bytes frontend/vue-app/e2e/.auth/user-cc.json | 10 ++-- frontend/vue-app/e2e/.auth/user-delete.json | 10 ++-- frontend/vue-app/e2e/.auth/user.json | 10 ++-- .../task-activation/chore-activation.spec.ts | 9 ++++ .../task-assignment/chore-assignment.spec.ts | 8 +-- .../kindness-assignment.spec.ts | 8 +-- .../penalty-assignment.spec.ts | 8 +-- .../task-assignment/reward-assignment.spec.ts | 8 +-- .../task-modification/chore-reset.spec.ts | 9 ++++ .../src/components/child/ChildView.vue | 8 +++ .../src/components/child/ParentView.vue | 11 +++- .../src/components/shared/ScrollingList.vue | 14 ++++- .../shared/__tests__/ScrollingList.spec.ts | 50 ++++++++++++++++++ 15 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 .github/specs/bugs-1.0.6-02/bugs-1.0.6-set02.md create mode 100644 .github/specs/bugs-1.0.6-02/image.png 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 0000000000000000000000000000000000000000..f15fb65dd0af18ef02d428b96bc75358fd283442 GIT binary patch literal 13169 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 { 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', () => {