fix: debouncing sync and other improvements

This commit is contained in:
2026-08-22 22:58:20 +02:00
parent e27e7a679e
commit c6e45cd067
17 changed files with 350 additions and 207 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+2 -10
View File
@@ -1,15 +1,7 @@
import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth' import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth'
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1' export { API_BASE_URL }
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
async function postJson( async function postJson(
path: string, path: string,
+9 -3
View File
@@ -1,6 +1,8 @@
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth'
import router from '@/router' import router from '@/router'
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
export { API_BASE_URL, extractErrorMessage }
let refreshPromise: Promise<unknown> | null = null let refreshPromise: Promise<unknown> | null = null
@@ -94,6 +96,10 @@ export const apiClient = {
method: 'PUT', method: 'PUT',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}), }),
delete: (endpoint: string, options?: FetchOptions) => delete: (endpoint: string, body?: unknown, options?: FetchOptions) =>
fetchWithAuth(endpoint, { ...options, method: 'DELETE' }), fetchWithAuth(endpoint, {
...options,
method: 'DELETE',
body: body ? JSON.stringify(body) : undefined,
}),
} }
+10
View File
@@ -0,0 +1,10 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'
export async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
+2 -12
View File
@@ -1,4 +1,5 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { import type {
List, List,
ListItem, ListItem,
@@ -10,15 +11,6 @@ import type {
RemoveUserFromListPayload, RemoveUserFromListPayload,
} from '@/types/list' } from '@/types/list'
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
export async function getListsApi(): Promise<List[]> { export async function getListsApi(): Promise<List[]> {
const response = await apiClient.get('/lists') const response = await apiClient.get('/lists')
if (!response.ok) { if (!response.ok) {
@@ -76,9 +68,7 @@ export async function addUserToListApi(payload: AddUserToListPayload): Promise<v
} }
export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> { export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> {
const response = await apiClient.delete('/lists/user', { const response = await apiClient.delete('/lists/user', payload)
body: JSON.stringify(payload),
})
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to remove user from list')) throw new Error(await extractErrorMessage(response, 'Failed to remove user from list'))
} }
+6
View File
@@ -29,6 +29,7 @@ async function handleLogout() {
<span v-if="listsStore.pendingCount > 0" class="pending"> <span v-if="listsStore.pendingCount > 0" class="pending">
{{ listsStore.pendingCount }} pending {{ listsStore.pendingCount }} pending
</span> </span>
<span v-if="listsStore.error" class="sync-error" :title="listsStore.error"> sync error</span>
<button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout"> <button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout">
Log out Log out
</button> </button>
@@ -85,6 +86,11 @@ async function handleLogout() {
background-color: var(--c-text-soft); background-color: var(--c-text-soft);
} }
.sync-error {
color: var(--c-danger);
cursor: help;
}
.logout { .logout {
background: none; background: none;
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
+4 -16
View File
@@ -1,29 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import { useEscapeKey } from '@/composables/useEscapeKey'
defineProps<{ defineProps<{
list: LocalList list: LocalList
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void close: []
(e: 'confirm'): void confirm: []
}>() }>()
onMounted(() => { useEscapeKey(handleClose)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClose()
}
}
function handleClose() { function handleClose() {
emit('close') emit('close')
+25 -28
View File
@@ -1,24 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useClickOutside } from '@/composables/useClickOutside'
import { useEscapeKey } from '@/composables/useEscapeKey'
const props = defineProps<{ list: LocalList }>() const props = defineProps<{ list: LocalList }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'share', list: LocalList): void share: [list: LocalList]
(e: 'delete', list: LocalList): void delete: [list: LocalList]
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null) const menuContainerRef = ref<HTMLElement | null>(null)
const items = computed(() => listsStore.itemsForList(props.list.id)) // The server always reports total_items/completed_items once a list has
const totalCount = computed(() => props.list.total_items ?? items.value.length) // synced at least once, so the common case never touches the store's full
const completedCount = computed( // item list at all - only a list that hasn't synced yet falls back to
() => props.list.completed_items ?? items.value.filter((item) => item.is_completed).length, // scanning its own items.
const totalCount = computed(() =>
props.list.total_items !== undefined
? props.list.total_items
: listsStore.itemsForList(props.list.id).length,
) )
const completedCount = computed(() =>
props.list.completed_items !== undefined
? props.list.completed_items
: listsStore.itemsForList(props.list.id).filter((item) => item.is_completed).length,
)
useClickOutside(menuContainerRef, () => {
isMenuOpen.value = false
})
useEscapeKey(() => {
if (isMenuOpen.value) isMenuOpen.value = false
})
function toggleMenu(event: Event) { function toggleMenu(event: Event) {
event.preventDefault() event.preventDefault()
@@ -40,27 +58,6 @@ function handleDelete(event: Event) {
emit('delete', props.list) emit('delete', props.list)
} }
function handleClickOutside(event: MouseEvent) {
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) {
isMenuOpen.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isMenuOpen.value) {
isMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
+11 -23
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref } from 'vue'
import type { LocalListItem } from '@/database/db' import type { LocalListItem } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useClickOutside } from '@/composables/useClickOutside'
import { useEscapeKey } from '@/composables/useEscapeKey'
const props = defineProps<{ item: LocalListItem }>() const props = defineProps<{ item: LocalListItem }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'delete', item: LocalListItem): void delete: [item: LocalListItem]
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -14,6 +16,13 @@ const editedTitle = ref(props.item.title)
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null) const menuContainerRef = ref<HTMLElement | null>(null)
useClickOutside(menuContainerRef, () => {
isMenuOpen.value = false
})
useEscapeKey(() => {
if (isMenuOpen.value) isMenuOpen.value = false
})
function toggleCompleted() { function toggleCompleted() {
listsStore.setListItemCompleted(props.item.id, !props.item.is_completed) listsStore.setListItemCompleted(props.item.id, !props.item.is_completed)
} }
@@ -45,27 +54,6 @@ function handleDelete(event: Event) {
listsStore.deleteListItem(props.item.id) listsStore.deleteListItem(props.item.id)
} }
function handleClickOutside(event: MouseEvent) {
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) {
isMenuOpen.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isMenuOpen.value) {
isMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
+5 -13
View File
@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useEscapeKey } from '@/composables/useEscapeKey'
const props = defineProps<{ const props = defineProps<{
list: LocalList list: LocalList
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void close: []
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -19,23 +20,14 @@ const isSubmitting = ref(false)
const error = ref('') const error = ref('')
const successMessage = ref('') const successMessage = ref('')
useEscapeKey(handleClose)
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handleKeydown)
nextTick(() => { nextTick(() => {
emailInput.value?.focus() emailInput.value?.focus()
}) })
}) })
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClose()
}
}
function handleClose() { function handleClose() {
emit('close') emit('close')
} }
+37
View File
@@ -0,0 +1,37 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
type OutsideHandler = (event: MouseEvent) => void
const handlers = new Set<OutsideHandler>()
function dispatch(event: MouseEvent) {
for (const handler of handlers) {
handler(event)
}
}
// Backs every call with a single shared `document` click listener instead of
// one per component instance, so a page rendering many dismissable menus
// (e.g. one per row in a list) doesn't fan out into one global listener per
// row.
export function useClickOutside(target: Ref<HTMLElement | null>, onOutside: () => void): void {
function handler(event: MouseEvent) {
if (target.value && !target.value.contains(event.target as Node)) {
onOutside()
}
}
onMounted(() => {
handlers.add(handler)
if (handlers.size === 1) {
document.addEventListener('click', dispatch)
}
})
onUnmounted(() => {
handlers.delete(handler)
if (handlers.size === 0) {
document.removeEventListener('click', dispatch)
}
})
}
+30
View File
@@ -0,0 +1,30 @@
import { onMounted, onUnmounted } from 'vue'
type EscapeHandler = () => void
const handlers = new Set<EscapeHandler>()
function dispatch(event: KeyboardEvent) {
if (event.key !== 'Escape') return
for (const handler of handlers) {
handler()
}
}
// Backs every call with a single shared `document` keydown listener instead
// of one per component instance.
export function useEscapeKey(onEscape: EscapeHandler): void {
onMounted(() => {
handlers.add(onEscape)
if (handlers.size === 1) {
document.addEventListener('keydown', dispatch)
}
})
onUnmounted(() => {
handlers.delete(onEscape)
if (handlers.size === 0) {
document.removeEventListener('keydown', dispatch)
}
})
}
-1
View File
@@ -14,7 +14,6 @@ export type SyncOperationType =
| 'createListItem' | 'createListItem'
| 'updateListItem' | 'updateListItem'
| 'setListItemCompleted' | 'setListItemCompleted'
| 'removeUserFromList'
| 'deleteList' | 'deleteList'
| 'deleteListItem' | 'deleteListItem'
+30 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
type Record = { id?: unknown; [key: string]: unknown } type Record = { id?: unknown; [key: string]: unknown }
@@ -37,6 +37,17 @@ function createFakeTable(autoIncrement = false) {
async count() { async count() {
return store.size return store.size
}, },
async bulkPut(records: Record[]) {
for (const record of records) {
store.set(record.id, { ...record })
}
return records.map((record) => record.id)
},
async bulkDelete(ids: unknown[]) {
for (const id of ids) {
store.delete(id)
}
},
where(field: string) { where(field: string) {
return { return {
equals(value: unknown) { equals(value: unknown) {
@@ -46,6 +57,16 @@ function createFakeTable(autoIncrement = false) {
.filter((v) => v[field] === value) .filter((v) => v[field] === value)
.map((v) => ({ ...v })) .map((v) => ({ ...v }))
}, },
async delete() {
const matches = Array.from(store.entries()).filter(([, v]) => v[field] === value)
for (const [key] of matches) store.delete(key)
return matches.length
},
async modify(changes: Record) {
const matches = Array.from(store.entries()).filter(([, v]) => v[field] === value)
for (const [key, v] of matches) store.set(key, { ...v, ...changes })
return matches.length
},
} }
}, },
} }
@@ -102,6 +123,10 @@ const { useListsStore } = await import('../lists')
describe('useListsStore', () => { describe('useListsStore', () => {
beforeEach(async () => { beforeEach(async () => {
// Mutations schedule a debounced sync() via setTimeout; faking timers
// keeps that pending timer from firing against a later test's mocks
// instead of the ones set up here (tests trigger sync() explicitly).
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.restoreAllMocks() vi.restoreAllMocks()
Object.values(listsApiMocks).forEach((mock) => mock.mockReset()) Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
@@ -121,6 +146,10 @@ describe('useListsStore', () => {
listsApiMocks.getListItemsApi.mockResolvedValue([]) listsApiMocks.getListItemsApi.mockResolvedValue([])
}) })
afterEach(() => {
vi.useRealTimers()
})
it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => { it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' }) listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' })
// The chained pullFromServer() call needs to report the just-created list // The chained pullFromServer() call needs to report the just-created list
-12
View File
@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+169 -68
View File
@@ -14,6 +14,12 @@ import {
deleteListItemApi, deleteListItemApi,
} from '@/api/lists' } from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a
// few titles) would otherwise trigger one full sync pass - queue drain plus
// a GET /lists - per edit. Debouncing collapses a burst into a single pass a
// short moment after the last edit.
const SYNC_DEBOUNCE_MS = 400
function generateId(): string { function generateId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID() return crypto.randomUUID()
@@ -39,6 +45,40 @@ export const useListsStore = defineStore('lists', () => {
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? '')) .sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
} }
// --- targeted local-state helpers -----------------------------------
// Mutations patch `lists`/`listItems` in place instead of reloading the
// whole table from Dexie after every write. This keeps unrelated rows'
// object identity stable (so unrelated components don't re-render) and
// avoids two full-table scans per keystroke-triggered save.
function upsertList(record: LocalList) {
const existing = lists.value.find((entry) => entry.id === record.id)
if (existing) {
Object.assign(existing, record)
} else {
lists.value.push(record)
}
}
function removeLocalList(id: string) {
const idx = lists.value.findIndex((entry) => entry.id === id)
if (idx !== -1) lists.value.splice(idx, 1)
}
function upsertListItem(record: LocalListItem) {
const existing = listItems.value.find((entry) => entry.id === record.id)
if (existing) {
Object.assign(existing, record)
} else {
listItems.value.push(record)
}
}
function removeLocalListItem(id: string) {
const idx = listItems.value.findIndex((entry) => entry.id === id)
if (idx !== -1) listItems.value.splice(idx, 1)
}
async function refresh() { async function refresh() {
lists.value = await db.lists.toArray() lists.value = await db.lists.toArray()
listItems.value = await db.listItems.toArray() listItems.value = await db.listItems.toArray()
@@ -59,6 +99,19 @@ export const useListsStore = defineStore('lists', () => {
createdAt: Date.now(), createdAt: Date.now(),
attempts: 0, attempts: 0,
}) })
pendingCount.value++
}
let syncDebounceHandle: ReturnType<typeof setTimeout> | null = null
function scheduleSync() {
if (syncDebounceHandle !== null) {
clearTimeout(syncDebounceHandle)
}
syncDebounceHandle = setTimeout(() => {
syncDebounceHandle = null
void sync()
}, SYNC_DEBOUNCE_MS)
} }
async function createList(name: string, userIds: string[] = []): Promise<LocalList> { async function createList(name: string, userIds: string[] = []): Promise<LocalList> {
@@ -72,13 +125,13 @@ export const useListsStore = defineStore('lists', () => {
} }
await db.lists.add(localList) await db.lists.add(localList)
upsertList(localList)
await enqueue({ await enqueue({
type: 'createList', type: 'createList',
payload: { name, user_ids: userIds }, payload: { name, user_ids: userIds },
localListId: localList.id, localListId: localList.id,
}) })
await refresh() scheduleSync()
void sync()
return localList return localList
} }
@@ -96,13 +149,13 @@ export const useListsStore = defineStore('lists', () => {
} }
await db.listItems.add(localItem) await db.listItems.add(localItem)
upsertListItem(localItem)
await enqueue({ await enqueue({
type: 'createListItem', type: 'createListItem',
payload: { list_id: listId, title }, payload: { list_id: listId, title },
localListItemId: localItem.id, localListItemId: localItem.id,
}) })
await refresh() scheduleSync()
void sync()
return localItem return localItem
} }
@@ -111,33 +164,37 @@ export const useListsStore = defineStore('lists', () => {
itemId: string, itemId: string,
changes: { title?: string; is_completed?: boolean }, changes: { title?: string; is_completed?: boolean },
) { ) {
await db.listItems.update(itemId, { const patch = {
...changes, ...changes,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
}) }
await db.listItems.update(itemId, patch)
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch)
await enqueue({ await enqueue({
type: 'updateListItem', type: 'updateListItem',
payload: { list_item_id: itemId, ...changes }, payload: { list_item_id: itemId, ...changes },
localListItemId: itemId, localListItemId: itemId,
}) })
await refresh() scheduleSync()
void sync()
} }
async function setListItemCompleted(itemId: string, isCompleted: boolean) { async function setListItemCompleted(itemId: string, isCompleted: boolean) {
await db.listItems.update(itemId, { const patch = {
is_completed: isCompleted, is_completed: isCompleted,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
}) }
await db.listItems.update(itemId, patch)
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch)
await enqueue({ await enqueue({
type: 'setListItemCompleted', type: 'setListItemCompleted',
payload: { is_completed: isCompleted }, payload: { is_completed: isCompleted },
localListItemId: itemId, localListItemId: itemId,
}) })
await refresh() scheduleSync()
void sync()
} }
async function addUserToList(listId: string, email: string) { async function addUserToList(listId: string, email: string) {
@@ -147,40 +204,39 @@ export const useListsStore = defineStore('lists', () => {
await addUserToListApi({ list_id: listId, email }) await addUserToListApi({ list_id: listId, email })
} }
// Sharing/unsharing a list has no local representation to keep
// optimistically in sync (there's no cached "shared users" list), so
// there's nothing offline queuing would buy here - both directions of
// this mutation go straight to the server, same as addUserToList.
async function removeUserFromList(listId: string, email: string) { async function removeUserFromList(listId: string, email: string) {
await enqueue({ if (typeof navigator !== 'undefined' && !navigator.onLine) {
type: 'removeUserFromList', throw new Error('Cannot remove user from list while offline')
payload: { list_id: listId, email }, }
localListId: listId, await removeUserFromListApi({ list_id: listId, email })
})
await refresh()
void sync()
} }
async function deleteList(listId: string) { async function deleteList(listId: string) {
await db.lists.delete(listId) await db.lists.delete(listId)
const affectedItems = await db.listItems.where('list_id').equals(listId).toArray() await db.listItems.where('list_id').equals(listId).delete()
for (const item of affectedItems) { removeLocalList(listId)
await db.listItems.delete(item.id) listItems.value = listItems.value.filter((item) => item.list_id !== listId)
}
await enqueue({ await enqueue({
type: 'deleteList', type: 'deleteList',
payload: { id: listId }, payload: { id: listId },
localListId: listId, localListId: listId,
}) })
await refresh() scheduleSync()
void sync()
} }
async function deleteListItem(itemId: string) { async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId) await db.listItems.delete(itemId)
removeLocalListItem(itemId)
await enqueue({ await enqueue({
type: 'deleteListItem', type: 'deleteListItem',
payload: { id: itemId }, payload: { id: itemId },
localListItemId: itemId, localListItemId: itemId,
}) })
await refresh() scheduleSync()
void sync()
} }
// Remaps a client-generated temporary list id to the id assigned by the // Remaps a client-generated temporary list id to the id assigned by the
@@ -192,12 +248,15 @@ export const useListsStore = defineStore('lists', () => {
const existing = await db.lists.get(oldId) const existing = await db.lists.get(oldId)
if (existing) { if (existing) {
await db.lists.delete(oldId) await db.lists.delete(oldId)
await db.lists.put({ ...existing, id: newId, pendingSync: false }) const updated = { ...existing, id: newId, pendingSync: false }
await db.lists.put(updated)
removeLocalList(oldId)
upsertList(updated)
} }
const affectedItems = await db.listItems.where('list_id').equals(oldId).toArray() await db.listItems.where('list_id').equals(oldId).modify({ list_id: newId })
for (const item of affectedItems) { for (const item of listItems.value) {
await db.listItems.update(item.id, { list_id: newId }) if (item.list_id === oldId) item.list_id = newId
} }
const affectedQueueEntries = await db.syncQueue const affectedQueueEntries = await db.syncQueue
@@ -226,7 +285,10 @@ export const useListsStore = defineStore('lists', () => {
const existing = await db.listItems.get(oldId) const existing = await db.listItems.get(oldId)
if (existing) { if (existing) {
await db.listItems.delete(oldId) await db.listItems.delete(oldId)
await db.listItems.put({ ...existing, id: newId, pendingSync: false }) const updated = { ...existing, id: newId, pendingSync: false }
await db.listItems.put(updated)
removeLocalListItem(oldId)
upsertListItem(updated)
} }
const affectedQueueEntries = await db.syncQueue const affectedQueueEntries = await db.syncQueue
@@ -245,6 +307,12 @@ export const useListsStore = defineStore('lists', () => {
} }
} }
async function markListItemSynced(itemId: string) {
await db.listItems.update(itemId, { pendingSync: false })
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) existingItem.pendingSync = false
}
async function processSyncEntry(entry: SyncQueueEntry) { async function processSyncEntry(entry: SyncQueueEntry) {
switch (entry.type) { switch (entry.type) {
case 'createList': { case 'createList': {
@@ -270,7 +338,7 @@ export const useListsStore = defineStore('lists', () => {
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean }, entry.payload as { list_item_id: string; title?: string; is_completed?: boolean },
) )
if (entry.localListItemId) { if (entry.localListItemId) {
await db.listItems.update(entry.localListItemId, { pendingSync: false }) await markListItemSynced(entry.localListItemId)
} }
break break
} }
@@ -280,11 +348,7 @@ export const useListsStore = defineStore('lists', () => {
entry.localListItemId, entry.localListItemId,
entry.payload as { is_completed: boolean }, entry.payload as { is_completed: boolean },
) )
await db.listItems.update(entry.localListItemId, { pendingSync: false }) await markListItemSynced(entry.localListItemId)
break
}
case 'removeUserFromList': {
await removeUserFromListApi(entry.payload as { list_id: string; email: string })
break break
} }
case 'deleteList': { case 'deleteList': {
@@ -300,8 +364,6 @@ export const useListsStore = defineStore('lists', () => {
} }
} }
let ongoingSync: Promise<void> | null = null
async function runSync() { async function runSync() {
isSyncing.value = true isSyncing.value = true
error.value = null error.value = null
@@ -314,6 +376,7 @@ export const useListsStore = defineStore('lists', () => {
await processSyncEntry(entry) await processSyncEntry(entry)
if (entry.id !== undefined) { if (entry.id !== undefined) {
await db.syncQueue.delete(entry.id) await db.syncQueue.delete(entry.id)
pendingCount.value = Math.max(0, pendingCount.value - 1)
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed' const message = err instanceof Error ? err.message : 'Sync failed'
@@ -331,10 +394,26 @@ export const useListsStore = defineStore('lists', () => {
} }
} finally { } finally {
isSyncing.value = false isSyncing.value = false
await refresh()
} }
} }
// Serializes sync() and pullListItems() against each other so a queued
// mutation is never pushed to the server (runSync) at the same moment a
// pull is merging fresh server state into the same rows - both touch
// Dexie and the reactive local state without any other coordination.
let operationChain: Promise<void> = Promise.resolve()
function enqueueOperation<T>(fn: () => Promise<T>): Promise<T> {
const result = operationChain.then(fn, fn)
operationChain = result.then(
() => undefined,
() => undefined,
)
return result
}
let ongoingSync: Promise<void> | null = null
// Ensures overlapping calls to sync() (e.g. one triggered automatically by // Ensures overlapping calls to sync() (e.g. one triggered automatically by
// a mutation while another is triggered by the "online" event) share the // a mutation while another is triggered by the "online" event) share the
// same in-flight run instead of silently no-oping. // same in-flight run instead of silently no-oping.
@@ -344,7 +423,7 @@ export const useListsStore = defineStore('lists', () => {
} }
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
ongoingSync = runSync().then(() => pullFromServer()) ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer()))
try { try {
await ongoingSync await ongoingSync
} finally { } finally {
@@ -364,15 +443,20 @@ export const useListsStore = defineStore('lists', () => {
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
try { try {
const serverLists = await getListsApi() const [serverLists, localLists] = await Promise.all([getListsApi(), db.lists.toArray()])
const localById = new Map(localLists.map((list) => [list.id, list]))
const serverListIds = new Set(serverLists.map((serverList) => serverList.id)) const serverListIds = new Set(serverLists.map((serverList) => serverList.id))
const toPut: LocalList[] = []
for (const serverList of serverLists) { for (const serverList of serverLists) {
const existingList = await db.lists.get(serverList.id) const existingList = localById.get(serverList.id)
if (!existingList || !existingList.pendingSync) { if (!existingList || !existingList.pendingSync) {
await db.lists.put({ ...serverList, pendingSync: false }) toPut.push({ ...serverList, pendingSync: false })
} }
} }
if (toPut.length > 0) {
await db.lists.bulkPut(toPut)
}
// Lists that were already synced but are no longer reported by the // Lists that were already synced but are no longer reported by the
// server have been deleted there (e.g. from another device), so // server have been deleted there (e.g. from another device), so
@@ -380,57 +464,74 @@ export const useListsStore = defineStore('lists', () => {
// have pending local changes (not yet synced, e.g. a not-yet-pushed // have pending local changes (not yet synced, e.g. a not-yet-pushed
// "createList") are left alone since the server doesn't know about // "createList") are left alone since the server doesn't know about
// them yet. // them yet.
const localLists = await db.lists.toArray() const idsToDelete = localLists
for (const localList of localLists) { .filter((list) => !list.pendingSync && !serverListIds.has(list.id))
if (!localList.pendingSync && !serverListIds.has(localList.id)) { .map((list) => list.id)
await db.lists.delete(localList.id)
const orphanedItems = await db.listItems.where('list_id').equals(localList.id).toArray() if (idsToDelete.length > 0) {
for (const item of orphanedItems) { await db.lists.bulkDelete(idsToDelete)
await db.listItems.delete(item.id) for (const id of idsToDelete) {
await db.listItems.where('list_id').equals(id).delete()
} }
} }
for (const record of toPut) upsertList(record)
if (idsToDelete.length > 0) {
for (const id of idsToDelete) removeLocalList(id)
const deletedIds = new Set(idsToDelete)
listItems.value = listItems.value.filter((item) => !deletedIds.has(item.list_id))
} }
} 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'
} finally {
await refresh()
} }
} }
// Pulls the authoritative items of a single list from the server and // 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 // 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. // the only place that needs the full item set for a list.
async function pullListItems(listId: string): Promise<void> { async function pullListItemsInternal(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
try { try {
const serverItems = await getListItemsApi(listId) const [serverItems, localItemsForList] = await Promise.all([
const serverItemIds = new Set(serverItems.map((serverItem) => serverItem.id)) getListItemsApi(listId),
db.listItems.where('list_id').equals(listId).toArray(),
])
const localById = new Map(localItemsForList.map((item) => [item.id, item]))
const serverItemIds = new Set(serverItems.map((item) => item.id))
const toPut: LocalListItem[] = []
for (const serverItem of serverItems) { for (const serverItem of serverItems) {
const existingItem = await db.listItems.get(serverItem.id) const existingItem = localById.get(serverItem.id)
if (!existingItem || !existingItem.pendingSync) { if (!existingItem || !existingItem.pendingSync) {
await db.listItems.put({ ...serverItem, pendingSync: false }) toPut.push({ ...serverItem, pendingSync: false })
} }
} }
if (toPut.length > 0) {
await db.listItems.bulkPut(toPut)
}
// Items that were already synced but are no longer reported by the // Items that were already synced but are no longer reported by the
// server for this list have been deleted there, so remove them // server for this list have been deleted there, so remove them
// locally too. Items with pending local changes are left alone since // locally too. Items with pending local changes are left alone since
// the server doesn't know about them yet. // the server doesn't know about them yet.
const localItems = await db.listItems.where('list_id').equals(listId).toArray() const idsToDelete = localItemsForList
for (const localItem of localItems) { .filter((item) => !item.pendingSync && !serverItemIds.has(item.id))
if (!localItem.pendingSync && !serverItemIds.has(localItem.id)) { .map((item) => item.id)
await db.listItems.delete(localItem.id) if (idsToDelete.length > 0) {
} await db.listItems.bulkDelete(idsToDelete)
} }
for (const record of toPut) upsertListItem(record)
for (const id of idsToDelete) removeLocalListItem(id)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load list items from server' error.value = err instanceof Error ? err.message : 'Failed to load list items from server'
} finally {
await refresh()
} }
} }
async function pullListItems(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
return enqueueOperation(() => pullListItemsInternal(listId))
}
async function loadListItems(listId: string) { async function loadListItems(listId: string) {
if (!isLoaded.value) { if (!isLoaded.value) {
await refresh() await refresh()
+8 -18
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db' import type { LocalListItem } from '@/database/db'
import ListItemRow from '@/components/ListItemRow.vue' import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue' import DeleteListModal from '@/components/DeleteListModal.vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { useEscapeKey } from '@/composables/useEscapeKey'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
@@ -18,28 +20,16 @@ const isMenuOpen = ref(false)
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null) const menuContainerRef = ref<HTMLElement | null>(null)
function handleClickOutside(event: MouseEvent) { useClickOutside(menuContainerRef, () => {
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) {
isMenuOpen.value = false isMenuOpen.value = false
} })
} useEscapeKey(() => {
if (isMenuOpen.value) isMenuOpen.value = false
function handleKeydown(event: KeyboardEvent) { })
if (event.key === 'Escape' && isMenuOpen.value) {
isMenuOpen.value = false
}
}
onMounted(() => { onMounted(() => {
listsStore.loadLists() listsStore.loadLists()
listsStore.loadListItems(props.id) listsStore.loadListItems(props.id)
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
}) })
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id)) const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))