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

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

BIN
.github/specs/bugs-1.0.6-02/image.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -2,17 +2,17 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "55lJfL_-PofI1V-NQ2E3Jlo4f_IE1aXqOF-wX54ZVSY", "value": "8p4HLWbALh9Qwg9Znl1q8Z0Ohteik28-6H8Z5qWnaZo",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1782155883.275106, "expires": 1782241430.50454,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZGQ0YTI1Yy04ZDI1LTQwNGEtODU0NS1mYjM2ZGRkYTU5NmYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3ODN9.b0ACVC63OobLzraiOcn4QDXipHXUVMW-Wn42XJFDOn0", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJiZWM5NTZiMi01MTZlLTQzZmQtYWUzYS1iZjNjN2RmNzA4M2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMzB9.5TdKuDgcuTmwvA7lQ4VOKXMwkviRNn24utyuWqD8IPA",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1774379882977}" "value": "{\"type\":\"logout\",\"at\":1774465430181}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1774552683610}" "value": "{\"expiresAt\":1774638230733}"
} }
] ]
} }

View File

@@ -2,17 +2,17 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "us7D-X0NRlXld6bpCl4epHaruPIWbkI895jFn3o84LY", "value": "TagN0dCg26ZQ9TEQxpQARign6bxZQxrSTZmDtNi288U",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1782155883.187584, "expires": 1782241430.203752,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNmU0MmNhNGQtMGNkNC00MmUzLWFkOGMtMWYyOTZmMzg5NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0MzgwNzgzfQ.AthhvOE2qsaJo6x37QbGXbT9oIP2pnFks3Pj14MjmcI", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjI5NDNjZWMtYzNmNS00N2MyLTg0YTItZTI2ODI3NDZlNDY5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDY2MzMwfQ.z0nN78IK3DqAepk20FS5xUzIIt7mZeGtdFhYePAOrxc",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1774379882932}" "value": "{\"type\":\"logout\",\"at\":1774465429926}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1774552683575}" "value": "{\"expiresAt\":1774638230517}"
} }
] ]
} }

View File

@@ -2,17 +2,17 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "VqeLgoC9z5NgU2AtA-9xjl4DaRt0hYKDkmAJMN3dcnw", "value": "GiDwb1pZ1oqvwzO16LokRkqIyCZ8nPJLzpzowwZRW-I",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1782155879.453865, "expires": 1782241425.893418,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3ZjA2Njc5OS03NjI1LTQ0ZGQtODIwYi0zNWRjZmFlYWVjNTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3Nzl9.o9urIT0cfexJY8OarcJ4gtJKqN-_DZOw50VToPl25LU", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI0MzEwZGI1MC04NDJjLTQ2OWQtOTg4YS0wMzFjMTliMWMwM2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMjV9.5fn4UtlACnDCk2-O_n5f3HO9y1ksXDEnQjJl4TdJ-F4",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": -1, "expires": -1,
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1774379879245}" "value": "{\"type\":\"logout\",\"at\":1774465425688}"
}, },
{ {
"name": "parentAuth", "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`, { await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [kindId], type: 'kindness' }, 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 }) => { test.afterAll(async ({ request }) => {

View File

@@ -66,7 +66,7 @@ test.describe('Chore assignment', () => {
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible() await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Chores' }).click() 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() 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 }) => { test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Select two chores // Select two chores
@@ -126,7 +126,7 @@ test.describe('Chore assignment', () => {
test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => { test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Uncheck the two previously assigned chores // Uncheck the two previously assigned chores
@@ -151,7 +151,7 @@ test.describe('Chore assignment', () => {
test('Cancel does not persist selection changes', async ({ page }) => { test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Chores' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
// Check an item // 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 expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() 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 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 }) => { test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() 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 expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
await page.getByText(KIND1_NAME, { exact: true }).click() await page.getByText(KIND1_NAME, { exact: true }).click()
@@ -101,7 +101,7 @@ test.describe('Kindness act assignment', () => {
}) => { }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() 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 expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
// Uncheck the previously assigned item // Uncheck the previously assigned item
@@ -120,7 +120,7 @@ test.describe('Kindness act assignment', () => {
test('Cancel does not persist selection changes', async ({ page }) => { test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click() 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 expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
// Check the item // Check the item

View File

@@ -66,7 +66,7 @@ test.describe('Penalty assignment', () => {
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible() await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Penalties' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
@@ -85,7 +85,7 @@ test.describe('Penalty assignment', () => {
}) => { }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Select two penalties // Select two penalties
@@ -128,7 +128,7 @@ test.describe('Penalty assignment', () => {
test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => { test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Uncheck the previously assigned penalties // Uncheck the previously assigned penalties
@@ -153,7 +153,7 @@ test.describe('Penalty assignment', () => {
test('Cancel does not persist selection changes', async ({ page }) => { test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Penalties' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
// Check an item // Check an item

View File

@@ -62,7 +62,7 @@ test.describe('Reward assignment', () => {
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible() await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
await page.getByRole('button', { name: 'Assign Rewards' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
@@ -81,7 +81,7 @@ test.describe('Reward assignment', () => {
}) => { }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Select two rewards // Select two rewards
@@ -124,7 +124,7 @@ test.describe('Reward assignment', () => {
test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => { test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Uncheck the previously assigned rewards // Uncheck the previously assigned rewards
@@ -149,7 +149,7 @@ test.describe('Reward assignment', () => {
test('Cancel does not persist selection changes', async ({ page }) => { test('Cancel does not persist selection changes', async ({ page }) => {
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
await page.getByRole('button', { name: 'Assign Rewards' }).click() 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() await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
// Check an item // Check an item

View File

@@ -70,6 +70,15 @@ test.describe('Chore reset', () => {
await request.put(`/api/child/${childId}/set-tasks`, { await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [helperKindId], type: 'kindness' }, 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 }) => { test.afterAll(async ({ request }) => {

View File

@@ -913,6 +913,14 @@ onUnmounted(() => {
color: #22c55e; color: #22c55e;
} }
@media (max-width: 480px) {
.chore-stamp {
font-size: 0.82rem;
letter-spacing: 1px;
padding: 0.3rem 0;
}
}
.due-label { .due-label {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;

View File

@@ -431,8 +431,7 @@ async function doExtendTime(item: ChildTask, e: MouseEvent) {
alert(`Error: ${msg}`) alert(`Error: ${msg}`)
return return
} }
childChoreListRef.value?.refresh() // SSE event 'chore_time_extended' triggers handleChoreTimeExtended which refreshes the list
setTimeout(() => resetExpiryTimers(), 300)
} }
// ── Schedule state helpers (for item-slot use) ──────────────────────────────── // ── Schedule state helpers (for item-slot use) ────────────────────────────────
@@ -1353,6 +1352,14 @@ function goToAssignRewards() {
color: #22c55e; color: #22c55e;
} }
@media (max-width: 480px) {
.chore-stamp {
font-size: 0.82rem;
letter-spacing: 1px;
padding: 0.3rem 0;
}
}
/* Due time sub-text */ /* Due time sub-text */
.due-label { .due-label {
font-size: 0.85rem; 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 itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(null) const lastCenteredItemId = ref<string | null>(null)
let fetchGeneration = 0
const fetchItems = async () => { const fetchItems = async () => {
const thisGeneration = ++fetchGeneration
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp = await fetch(fetchUrl.value) const resp = await fetch(fetchUrl.value)
if (thisGeneration !== fetchGeneration) return
if (!resp.ok) throw new Error(`HTTP ${resp.status}`) if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json() const data = await resp.json()
if (thisGeneration !== fetchGeneration) return
// Try to use 'tasks', 'reward_status', or 'items' as fallback // Try to use 'tasks', 'reward_status', or 'items' as fallback
let itemList = data[props.itemKey || 'items'] || [] let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn) 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) { } catch (err) {
if (thisGeneration !== fetchGeneration) return
error.value = err instanceof Error ? err.message : 'Failed to fetch items' error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = [] items.value = []
console.error('Error fetching items:', err) console.error('Error fetching items:', err)
} finally { } finally {
if (thisGeneration === fetchGeneration) {
loading.value = false loading.value = false
} }
}
} }
async function refresh() { async function refresh() {
await fetchItems() await fetchItems()
loading.value = false
} }
const scrollToItem = async (itemId: string) => { const scrollToItem = async (itemId: string) => {

View File

@@ -344,6 +344,56 @@ describe('ScrollingList', () => {
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2') 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', () => { describe('Image Loading', () => {