fix: utilizing new total/completed fields in /lists endpoint to lazy load items on demand

This commit is contained in:
2026-08-21 21:15:42 +02:00
parent d00361c4ac
commit 7273fe85d3
5 changed files with 69 additions and 22 deletions
+9 -2
View File
@@ -13,8 +13,15 @@ const listsStore = useListsStore()
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null) const menuContainerRef = ref<HTMLElement | null>(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 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) { function toggleMenu(event: Event) {
event.preventDefault() event.preventDefault()
@@ -58,7 +65,7 @@ onUnmounted(() => {
<div class="list-card-main"> <div class="list-card-main">
<h3>{{ list.name }}</h3> <h3>{{ list.name }}</h3>
<p class="meta"> <p class="meta">
{{ completedCount }}/{{ items.length }} done {{ completedCount }}/{{ totalCount }} done
<span v-if="list.pendingSync" class="pending-tag">syncing</span> <span v-if="list.pendingSync" class="pending-tag">syncing</span>
</p> </p>
</div> </div>
+19 -5
View File
@@ -235,16 +235,30 @@ describe('useListsStore', () => {
expect(updated?.pendingSync).toBe(false) expect(updated?.pendingSync).toBe(false)
}) })
it('pulls lists and items from the server and merges them locally', async () => { 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' }]) listsApiMocks.getListsApi.mockResolvedValueOnce([
listsApiMocks.getListItemsApi.mockResolvedValueOnce([ { id: 'server-list-1', name: 'Groceries', total_items: 3, completed_items: 1 },
{ id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false },
]) ])
const store = useListsStore() const store = useListsStore()
await store.pullFromServer() 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() expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
}) })
+38 -15
View File
@@ -317,9 +317,14 @@ export const useListsStore = defineStore('lists', () => {
} }
} }
// Pulls the authoritative lists/items from the server and merges them into // Pulls the authoritative lists from the server and merges them into local
// local storage. Entries that still have local unsynced changes // storage. Entries that still have local unsynced changes (pendingSync)
// (pendingSync) are left untouched so we never clobber pending edits. // 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<void> { async function pullFromServer(): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
@@ -331,18 +336,6 @@ export const useListsStore = defineStore('lists', () => {
if (!existingList || !existingList.pendingSync) { if (!existingList || !existingList.pendingSync) {
await db.lists.put({ ...serverList, pendingSync: false }) 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) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load lists from server' 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<void> {
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 { return {
lists, lists,
listItems, listItems,
@@ -361,6 +382,7 @@ export const useListsStore = defineStore('lists', () => {
error, error,
itemsForList, itemsForList,
loadLists, loadLists,
loadListItems,
refresh, refresh,
createList, createList,
createListItem, createListItem,
@@ -370,5 +392,6 @@ export const useListsStore = defineStore('lists', () => {
removeUserFromList, removeUserFromList,
sync, sync,
pullFromServer, pullFromServer,
pullListItems,
} }
}) })
+2
View File
@@ -3,6 +3,8 @@ export interface List {
name: string name: string
created_at?: string created_at?: string
modified_at?: string modified_at?: string
total_items?: number
completed_items?: number
} }
export interface ListItem { export interface ListItem {
+1
View File
@@ -15,6 +15,7 @@ const itemError = ref('')
onMounted(() => { onMounted(() => {
listsStore.loadLists() listsStore.loadLists()
listsStore.loadListItems(props.id)
}) })
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id)) const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))