From 7273fe85d3bf35eeb1a59b1f4d7248de28809dd8 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 21:15:42 +0200 Subject: [PATCH] fix: utilizing new total/completed fields in /lists endpoint to lazy load items on demand --- src/components/ListCard.vue | 11 +++++-- src/stores/__tests__/lists.spec.ts | 24 +++++++++++--- src/stores/lists.ts | 53 +++++++++++++++++++++--------- src/types/list.ts | 2 ++ src/views/ListDetailView.vue | 1 + 5 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/components/ListCard.vue b/src/components/ListCard.vue index 2a09e28..8f66c24 100644 --- a/src/components/ListCard.vue +++ b/src/components/ListCard.vue @@ -13,8 +13,15 @@ const listsStore = useListsStore() const isMenuOpen = ref(false) const menuContainerRef = ref(null) +// The server now reports total_items/completed_items directly on the list, +// so the overview can show progress without having to load every item of +// every list. Fall back to counting locally cached items for lists that +// haven't synced to the server yet (e.g. just created while offline). const items = computed(() => listsStore.itemsForList(props.list.id)) -const completedCount = computed(() => items.value.filter((item) => item.is_completed).length) +const totalCount = computed(() => props.list.total_items ?? items.value.length) +const completedCount = computed( + () => props.list.completed_items ?? items.value.filter((item) => item.is_completed).length, +) function toggleMenu(event: Event) { event.preventDefault() @@ -58,7 +65,7 @@ onUnmounted(() => {

{{ list.name }}

- {{ completedCount }}/{{ items.length }} done + {{ completedCount }}/{{ totalCount }} done syncing…

diff --git a/src/stores/__tests__/lists.spec.ts b/src/stores/__tests__/lists.spec.ts index cbe26e7..b28ad5f 100644 --- a/src/stores/__tests__/lists.spec.ts +++ b/src/stores/__tests__/lists.spec.ts @@ -235,16 +235,30 @@ describe('useListsStore', () => { expect(updated?.pendingSync).toBe(false) }) - it('pulls lists and items from the server and merges them locally', async () => { - listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-list-1', name: 'Groceries' }]) - listsApiMocks.getListItemsApi.mockResolvedValueOnce([ - { id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false }, + it('pulls lists from the server, including total/completed item counts, without fetching every item', async () => { + listsApiMocks.getListsApi.mockResolvedValueOnce([ + { id: 'server-list-1', name: 'Groceries', total_items: 3, completed_items: 1 }, ]) const store = useListsStore() await store.pullFromServer() - expect(store.lists.find((list) => list.id === 'server-list-1')).toBeDefined() + const pulledList = store.lists.find((list) => list.id === 'server-list-1') + expect(pulledList).toBeDefined() + expect(pulledList?.total_items).toBe(3) + expect(pulledList?.completed_items).toBe(1) + expect(listsApiMocks.getListItemsApi).not.toHaveBeenCalled() + }) + + it('pulls the items of a single list on demand via pullListItems', async () => { + listsApiMocks.getListItemsApi.mockResolvedValueOnce([ + { id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false }, + ]) + + const store = useListsStore() + await store.pullListItems('server-list-1') + + expect(listsApiMocks.getListItemsApi).toHaveBeenCalledWith('server-list-1') expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined() }) diff --git a/src/stores/lists.ts b/src/stores/lists.ts index 9055252..3dd1c56 100644 --- a/src/stores/lists.ts +++ b/src/stores/lists.ts @@ -317,9 +317,14 @@ export const useListsStore = defineStore('lists', () => { } } - // Pulls the authoritative lists/items from the server and merges them into - // local storage. Entries that still have local unsynced changes - // (pendingSync) are left untouched so we never clobber pending edits. + // Pulls the authoritative lists from the server and merges them into local + // storage. Entries that still have local unsynced changes (pendingSync) + // are left untouched so we never clobber pending edits. + // + // This no longer eagerly fetches every item of every list: the server now + // reports total_items/completed_items directly on each list, which is all + // the overview page needs. Items for a specific list are only pulled on + // demand via pullListItems (e.g. when opening its detail view). async function pullFromServer(): Promise { if (typeof navigator !== 'undefined' && !navigator.onLine) return @@ -331,18 +336,6 @@ export const useListsStore = defineStore('lists', () => { if (!existingList || !existingList.pendingSync) { await db.lists.put({ ...serverList, pendingSync: false }) } - - try { - const serverItems = await getListItemsApi(serverList.id) - for (const serverItem of serverItems) { - const existingItem = await db.listItems.get(serverItem.id) - if (!existingItem || !existingItem.pendingSync) { - await db.listItems.put({ ...serverItem, pendingSync: false }) - } - } - } catch { - // Ignore per-list failures so one broken list doesn't block the rest. - } } } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load lists from server' @@ -351,6 +344,34 @@ export const useListsStore = defineStore('lists', () => { } } + // Pulls the authoritative items of a single list from the server and + // merges them into local storage. Used by the list detail view, which is + // the only place that needs the full item set for a list. + async function pullListItems(listId: string): Promise { + if (typeof navigator !== 'undefined' && !navigator.onLine) return + + try { + const serverItems = await getListItemsApi(listId) + for (const serverItem of serverItems) { + const existingItem = await db.listItems.get(serverItem.id) + if (!existingItem || !existingItem.pendingSync) { + await db.listItems.put({ ...serverItem, pendingSync: false }) + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load list items from server' + } finally { + await refresh() + } + } + + async function loadListItems(listId: string) { + if (!isLoaded.value) { + await refresh() + } + void pullListItems(listId) + } + return { lists, listItems, @@ -361,6 +382,7 @@ export const useListsStore = defineStore('lists', () => { error, itemsForList, loadLists, + loadListItems, refresh, createList, createListItem, @@ -370,5 +392,6 @@ export const useListsStore = defineStore('lists', () => { removeUserFromList, sync, pullFromServer, + pullListItems, } }) diff --git a/src/types/list.ts b/src/types/list.ts index 0bf7c75..646848b 100644 --- a/src/types/list.ts +++ b/src/types/list.ts @@ -3,6 +3,8 @@ export interface List { name: string created_at?: string modified_at?: string + total_items?: number + completed_items?: number } export interface ListItem { diff --git a/src/views/ListDetailView.vue b/src/views/ListDetailView.vue index d338c7d..6f6cf5d 100644 --- a/src/views/ListDetailView.vue +++ b/src/views/ListDetailView.vue @@ -15,6 +15,7 @@ const itemError = ref('') onMounted(() => { listsStore.loadLists() + listsStore.loadListItems(props.id) }) const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))