fix: implement fetch generation counter to handle concurrent refreshes in ScrollingList
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled

- 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.
This commit is contained in:
2026-03-25 15:30:00 -04:00
parent f64311689b
commit 359c170b27
15 changed files with 166 additions and 35 deletions

View File

@@ -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}"
}
]
}

View File

@@ -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}"
}
]
}

View File

@@ -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}"
}
]
}

View File

@@ -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 }) => {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -39,14 +39,19 @@ const scrollWrapper = ref<HTMLDivElement | null>(null)
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(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) => {

View File

@@ -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', () => {