Merge pull request 'AI audit improvements' (#7) from dev into main

This commit was merged in pull request #7.
This commit is contained in:
2026-08-22 23:17:39 +02:00
22 changed files with 626 additions and 308 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { fetchWithAuth, apiClient } from '../client' import { fetchWithAuth, apiClient, SessionExpiredError } from '../client'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import * as authApi from '@/api/auth' import * as authApi from '@/api/auth'
import router from '@/router' import router from '@/router'
@@ -122,7 +122,7 @@ describe('api client (fetchWithAuth)', () => {
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
await fetchWithAuth('/lists') await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.name).toBe('login') expect(router.currentRoute.value.name).toBe('login')
@@ -144,7 +144,7 @@ describe('api client (fetchWithAuth)', () => {
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
await fetchWithAuth('/lists') await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1)
expect(authStore.accessToken).toBeNull() expect(authStore.accessToken).toBeNull()
+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,
+22 -5
View File
@@ -1,9 +1,22 @@
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
// Thrown instead of returning the stale 401 Response when redirecting to
// login, so callers show an accurate message rather than reading `!response.ok`
// and flashing an unrelated "Failed to ..." banner in the instant before
// navigation away completes.
export class SessionExpiredError extends Error {
constructor() {
super('Your session has expired. Please log in again.')
this.name = 'SessionExpiredError'
}
}
async function redirectToLogin(): Promise<void> { async function redirectToLogin(): Promise<void> {
const currentRoute = router.currentRoute.value const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') { if (currentRoute.name === 'login') {
@@ -55,7 +68,7 @@ export async function fetchWithAuth(
if (response.status === 401 && !skipRefresh) { if (response.status === 401 && !skipRefresh) {
if (!authStore.refreshToken) { if (!authStore.refreshToken) {
await redirectToLogin() await redirectToLogin()
return response throw new SessionExpiredError()
} }
try { try {
@@ -72,7 +85,7 @@ export async function fetchWithAuth(
}) })
} catch { } catch {
await redirectToLogin() await redirectToLogin()
return response throw new SessionExpiredError()
} }
} }
@@ -94,6 +107,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);
+6 -17
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')
@@ -50,7 +38,8 @@ function handleConfirm() {
</div> </div>
<p class="modal-description"> <p class="modal-description">
Are you sure you want to delete this list? This action cannot be undone and all items in this list will be deleted. Are you sure you want to delete this list? This action cannot be undone and all items in
this list will be deleted.
</p> </p>
<div class="modal-footer"> <div class="modal-footer">
+25 -42
View File
@@ -1,34 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed } 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 { useDismissableMenu } from '@/composables/useDismissableMenu'
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 {
const menuContainerRef = ref<HTMLElement | null>(null) isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
// The server now reports total_items/completed_items directly on the list, // The server always reports total_items/completed_items once a list has
// so the overview can show progress without having to load every item of // synced at least once, so the common case never touches the store's full
// every list. Fall back to counting locally cached items for lists that // item list at all - only a list that hasn't synced yet falls back to
// haven't synced to the server yet (e.g. just created while offline). // scanning its own items.
const items = computed(() => listsStore.itemsForList(props.list.id)) const totalCount = computed(() =>
const totalCount = computed(() => props.list.total_items ?? items.value.length) props.list.total_items !== undefined
const completedCount = computed( ? props.list.total_items
() => props.list.completed_items ?? items.value.filter((item) => item.is_completed).length, : 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,
) )
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleShare(event: Event) { function handleShare(event: Event) {
event.preventDefault() event.preventDefault()
@@ -43,28 +46,6 @@ function handleDelete(event: Event) {
isMenuOpen.value = false isMenuOpen.value = false
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>
@@ -132,7 +113,9 @@ onUnmounted(() => {
stroke-linejoin="round" stroke-linejoin="round"
> >
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" /> <line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" /> <line x1="14" y1="11" x2="14" y2="17" />
</svg> </svg>
+19 -37
View File
@@ -1,18 +1,26 @@
<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 { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ item: LocalListItem }>() const props = defineProps<{ item: LocalListItem }>()
const emit = defineEmits<{
(e: 'delete', item: LocalListItem): void
}>()
const listsStore = useListsStore() const listsStore = useListsStore()
const isEditing = ref(false) const isEditing = ref(false)
const editedTitle = ref(props.item.title) const editedTitle = ref(props.item.title)
const isMenuOpen = ref(false) // Captured separately from `editedTitle` at the moment editing starts: if the
const menuContainerRef = ref<HTMLElement | null>(null) // item is updated remotely (another device) while the field is open,
// `props.item.title` moves but this doesn't, so saveTitle() can tell "user
// didn't touch it" apart from "server changed underneath us" instead of
// diffing against the live (possibly just-changed) prop and overwriting the
// remote edit with the untouched original text.
const originalTitle = ref(props.item.title)
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
function toggleCompleted() { function toggleCompleted() {
listsStore.setListItemCompleted(props.item.id, !props.item.is_completed) listsStore.setListItemCompleted(props.item.id, !props.item.is_completed)
@@ -20,52 +28,24 @@ function toggleCompleted() {
function startEditing() { function startEditing() {
editedTitle.value = props.item.title editedTitle.value = props.item.title
originalTitle.value = props.item.title
isEditing.value = true isEditing.value = true
} }
function saveTitle() { function saveTitle() {
const title = editedTitle.value.trim() const title = editedTitle.value.trim()
if (title && title !== props.item.title) { if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title }) listsStore.updateListItem(props.item.id, { title })
} }
isEditing.value = false isEditing.value = false
} }
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleDelete(event: Event) { function handleDelete(event: Event) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
isMenuOpen.value = false isMenuOpen.value = false
emit('delete', props.item)
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>
@@ -126,7 +106,9 @@ onUnmounted(() => {
stroke-linejoin="round" stroke-linejoin="round"
> >
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" /> <line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" /> <line x1="14" y1="11" x2="14" y2="17" />
</svg> </svg>
+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')
} }
@@ -65,8 +65,6 @@ describe('ListItemRow', () => {
await wrapper.find('.submenu-item-danger').trigger('click') await wrapper.find('.submenu-item-danger').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1') expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')?.[0]).toEqual([sampleItem])
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
}) })
}) })
+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)
}
})
}
+28
View File
@@ -0,0 +1,28 @@
import { ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { useEscapeKey } from '@/composables/useEscapeKey'
// Bundles the isOpen/containerRef/toggle wiring shared by every dropdown menu
// (list card, list item row, list detail header) so it isn't hand-rolled per
// component on top of useClickOutside/useEscapeKey.
export function useDismissableMenu() {
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
function close() {
isOpen.value = false
}
function toggle(event: Event) {
event.preventDefault()
event.stopPropagation()
isOpen.value = !isOpen.value
}
useClickOutside(containerRef, close)
useEscapeKey(() => {
if (isOpen.value) close()
})
return { isOpen, containerRef, toggle, close }
}
+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)
}
})
}
+39 -12
View File
@@ -1,5 +1,12 @@
import Dexie, { type Table } from 'dexie' import Dexie, { type Table } from 'dexie'
import type { List, ListItem } from '@/types/list' import type {
List,
ListItem,
CreateListPayload,
CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload,
} from '@/types/list'
export interface LocalList extends List { export interface LocalList extends List {
pendingSync?: boolean pendingSync?: boolean
@@ -9,19 +16,16 @@ export interface LocalListItem extends ListItem {
pendingSync?: boolean pendingSync?: boolean
} }
export type SyncOperationType = export interface DeleteListPayload {
| 'createList' id: string
| 'createListItem' }
| 'updateListItem'
| 'setListItemCompleted'
| 'removeUserFromList'
| 'deleteList'
| 'deleteListItem'
export interface SyncQueueEntry { export interface DeleteListItemPayload {
id: string
}
interface SyncQueueEntryBase {
id?: number id?: number
type: SyncOperationType
payload: unknown
localListId?: string localListId?: string
localListItemId?: string localListItemId?: string
createdAt: number createdAt: number
@@ -29,6 +33,29 @@ export interface SyncQueueEntry {
lastError?: string lastError?: string
} }
type SyncOperationPayloads = {
createList: CreateListPayload
createListItem: CreateListItemPayload
updateListItem: UpdateListItemPayload
setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload
}
export type SyncOperationType = keyof SyncOperationPayloads
// A discriminated union keyed on `type` instead of a single `payload: unknown`
// shape, so `processSyncEntry`'s switch narrows `entry.payload` to the right
// type per case without a manual cast - and adding/changing an operation type
// here is a compile error everywhere it's handled inconsistently.
export type SyncQueueEntry = {
[K in SyncOperationType]: SyncQueueEntryBase & { type: K; payload: SyncOperationPayloads[K] }
}[SyncOperationType]
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
export type NewSyncQueueEntry = DistributiveOmit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>
class AppDatabase extends Dexie { class AppDatabase extends Dexie {
lists!: Table<LocalList, string> lists!: Table<LocalList, string>
listItems!: Table<LocalListItem, string> listItems!: Table<LocalListItem, string>
-3
View File
@@ -26,9 +26,6 @@ const router = createRouter({
{ {
path: '/about', path: '/about',
name: 'about', name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'), component: () => import('../views/AboutView.vue'),
}, },
], ],
+145 -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
@@ -407,4 +436,119 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(0) expect(store.pendingCount).toBe(0)
}) })
it('removes user from list on the server when online without adding to sync queue', async () => {
listsApiMocks.removeUserFromListApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.removeUserFromList('list-1', 'friend@example.com')
expect(listsApiMocks.removeUserFromListApi).toHaveBeenCalledWith({
list_id: 'list-1',
email: 'friend@example.com',
})
expect(store.pendingCount).toBe(0)
})
it('throws error when removing user from list while offline without calling API', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const store = useListsStore()
await expect(store.removeUserFromList('list-1', 'friend@example.com')).rejects.toThrow(
'Cannot remove user from list while offline',
)
expect(listsApiMocks.removeUserFromListApi).not.toHaveBeenCalled()
expect(store.pendingCount).toBe(0)
})
it('propagates error when removing user from list fails on server', async () => {
listsApiMocks.removeUserFromListApi.mockRejectedValueOnce(new Error('User not found'))
const store = useListsStore()
await expect(store.removeUserFromList('list-1', 'unknown@example.com')).rejects.toThrow(
'User not found',
)
expect(store.pendingCount).toBe(0)
})
it('does not sync before the debounce delay elapses, then syncs once it does', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-1',
list_id: 'list-1',
title: 'Milk',
is_completed: false,
})
const store = useListsStore()
await store.createListItem('list-1', 'Milk')
await vi.advanceTimersByTimeAsync(399)
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(1)
})
it('collapses a burst of mutations within the debounce window into a single sync pass', async () => {
listsApiMocks.createListItemApi
.mockResolvedValueOnce({
id: 'server-item-a',
list_id: 'list-1',
title: 'A',
is_completed: false,
})
.mockResolvedValueOnce({
id: 'server-item-b',
list_id: 'list-1',
title: 'B',
is_completed: false,
})
const store = useListsStore()
await store.createListItem('list-1', 'A')
await store.createListItem('list-1', 'B')
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(400)
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(2)
expect(store.pendingCount).toBe(0)
})
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveGetLists = resolve
}),
)
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
{ id: 'server-item-1', list_id: 'list-1', title: 'Milk', is_completed: false },
])
const store = useListsStore()
const syncPromise = store.sync()
const pullPromise = store.pullListItems('list-1')
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
// pullListItems is chained behind sync() via the shared operation queue,
// so its own (already-mocked, instantly resolvable) API call must not
// fire while sync()'s getListsApi call is still pending.
expect(listsApiMocks.getListItemsApi).not.toHaveBeenCalled()
resolveGetLists([])
await syncPromise
await pullPromise
expect(listsApiMocks.getListItemsApi).toHaveBeenCalledWith('list-1')
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
})
}) })
-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 }
})
+217 -106
View File
@@ -1,6 +1,12 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { db, type LocalList, type LocalListItem, type SyncQueueEntry } from '@/database/db' import {
db,
type LocalList,
type LocalListItem,
type SyncQueueEntry,
type NewSyncQueueEntry,
} from '@/database/db'
import { import {
getListsApi, getListsApi,
createListApi, createListApi,
@@ -14,6 +20,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 +51,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()
@@ -46,23 +92,45 @@ export const useListsStore = defineStore('lists', () => {
isLoaded.value = true isLoaded.value = true
} }
async function loadLists() { // Dedupes concurrent first-load refreshes: loadLists() and loadListItems()
if (!isLoaded.value) { // are both called from ListDetailView's onMounted and would otherwise each
await refresh() // see isLoaded === false and kick off their own full-table Dexie scan.
let loadPromise: Promise<void> | null = null
async function ensureLoaded() {
if (isLoaded.value) return
if (!loadPromise) {
loadPromise = refresh().finally(() => {
loadPromise = null
})
} }
// The server is the source of truth: even though we already have a await loadPromise
// local snapshot to render instantly (including while offline), always }
// kick off a background sync/pull so views reflect the latest server
// state on every visit, not just on the first load of the session. async function loadLists() {
await ensureLoaded()
void sync() void sync()
} }
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) { async function enqueue(entry: NewSyncQueueEntry) {
await db.syncQueue.add({ await db.syncQueue.add({
...entry, ...entry,
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> {
@@ -76,13 +144,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
} }
@@ -100,13 +168,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
} }
@@ -115,33 +183,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) {
@@ -151,40 +223,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
@@ -196,26 +267,31 @@ 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
.filter((entry) => entry.localListId === oldId) .filter((entry) => entry.localListId === oldId)
.toArray() .toArray()
for (const entry of affectedQueueEntries) { for (const entry of affectedQueueEntries) {
const payload = entry.payload as { list_id?: string; id?: string } const payload = entry.payload
const updatedPayload =
'list_id' in payload
? { ...payload, list_id: newId }
: 'id' in payload
? { ...payload, id: newId }
: payload
await db.syncQueue.update(entry.id!, { await db.syncQueue.update(entry.id!, {
localListId: newId, localListId: newId,
payload: payload?.list_id payload: updatedPayload,
? { ...payload, list_id: newId }
: payload?.id
? { ...payload, id: newId }
: entry.payload,
}) })
} }
} }
@@ -230,29 +306,40 @@ 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
.filter((queueEntry) => queueEntry.localListItemId === oldId) .filter((queueEntry) => queueEntry.localListItemId === oldId)
.toArray() .toArray()
for (const queueEntry of affectedQueueEntries) { for (const queueEntry of affectedQueueEntries) {
const payload = queueEntry.payload as { list_item_id?: string; id?: string } const payload = queueEntry.payload
const updatedPayload =
'list_item_id' in payload
? { ...payload, list_item_id: newId }
: 'id' in payload
? { ...payload, id: newId }
: payload
await db.syncQueue.update(queueEntry.id!, { await db.syncQueue.update(queueEntry.id!, {
localListItemId: newId, localListItemId: newId,
payload: payload?.list_item_id payload: updatedPayload,
? { ...payload, list_item_id: newId }
: payload?.id
? { ...payload, id: newId }
: queueEntry.payload,
}) })
} }
} }
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': {
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] }) const created = await createListApi(entry.payload)
if (entry.localListId) { if (entry.localListId) {
await remapListId(entry.localListId, created.id) await remapListId(entry.localListId, created.id)
} }
@@ -263,49 +350,36 @@ export const useListsStore = defineStore('lists', () => {
// created item (with its own real id) in the response body, so we // created item (with its own real id) in the response body, so we
// remap our client-generated placeholder id to it instead of keeping // remap our client-generated placeholder id to it instead of keeping
// the made-up one around. // the made-up one around.
const created = await createListItemApi(entry.payload as { list_id: string; title: string }) const created = await createListItemApi(entry.payload)
if (entry.localListItemId) { if (entry.localListItemId) {
await remapListItemId(entry.localListItemId, created.id) await remapListItemId(entry.localListItemId, created.id)
} }
break break
} }
case 'updateListItem': { case 'updateListItem': {
await updateListItemApi( await updateListItemApi(entry.payload)
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
} }
case 'setListItemCompleted': { case 'setListItemCompleted': {
if (!entry.localListItemId) break if (!entry.localListItemId) break
await setListItemCompletedApi( await setListItemCompletedApi(entry.localListItemId, entry.payload)
entry.localListItemId, await markListItemSynced(entry.localListItemId)
entry.payload as { is_completed: boolean },
)
await db.listItems.update(entry.localListItemId, { pendingSync: false })
break
}
case 'removeUserFromList': {
await removeUserFromListApi(entry.payload as { list_id: string; email: string })
break break
} }
case 'deleteList': { case 'deleteList': {
const payload = entry.payload as { id: string } await deleteListApi(entry.payload.id)
await deleteListApi(payload.id)
break break
} }
case 'deleteListItem': { case 'deleteListItem': {
const payload = entry.payload as { id: string } await deleteListItemApi(entry.payload.id)
await deleteListItemApi(payload.id)
break break
} }
} }
} }
let ongoingSync: Promise<void> | null = null
async function runSync() { async function runSync() {
isSyncing.value = true isSyncing.value = true
error.value = null error.value = null
@@ -318,6 +392,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'
@@ -335,10 +410,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.
@@ -348,7 +439,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 {
@@ -368,15 +459,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
@@ -384,61 +480,76 @@ 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) { await ensureLoaded()
await refresh()
}
void pullListItems(listId) void pullListItems(listId)
} }
+29 -28
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, watch } 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 ListItemRow from '@/components/ListItemRow.vue' import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue' import DeleteListModal from '@/components/DeleteListModal.vue'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
@@ -13,45 +15,42 @@ const listsStore = useListsStore()
const newItemTitle = ref('') const newItemTitle = ref('')
const isAddingItem = ref(false) const isAddingItem = ref(false)
const itemError = ref('') const itemError = ref('')
const isMenuOpen = ref(false)
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null) const {
isOpen: isMenuOpen,
function handleClickOutside(event: MouseEvent) { containerRef: menuContainerRef,
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) { toggle: toggleMenu,
isMenuOpen.value = false } = useDismissableMenu()
}
}
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(() => { // Vue Router reuses this component instance when navigating between two
document.removeEventListener('click', handleClickOutside) // list-detail routes, so the initial onMounted load alone would leave a
document.removeEventListener('keydown', handleKeydown) // newly-navigated-to list's items unfetched.
}) watch(
() => props.id,
(newId) => {
listsStore.loadListItems(newId)
},
)
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id)) const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))
const items = computed(() => listsStore.itemsForList(props.id)) const items = computed(() => listsStore.itemsForList(props.id))
const pendingItems = computed(() => items.value.filter((item) => !item.is_completed))
const completedItems = computed(() => items.value.filter((item) => item.is_completed))
function toggleMenu(event: Event) { function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
event.preventDefault() return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
} }
const pendingItems = computed(() =>
items.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
)
const completedItems = computed(() =>
items.value.filter((item) => item.is_completed).sort(byModifiedDesc),
)
function handleOpenDelete() { function handleOpenDelete() {
isMenuOpen.value = false isMenuOpen.value = false
showDeleteModal.value = true showDeleteModal.value = true
@@ -131,7 +130,9 @@ async function handleAddItem() {
stroke-linejoin="round" stroke-linejoin="round"
> >
<polyline points="3 6 5 6 21 6" /> <polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" /> <line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" /> <line x1="14" y1="11" x2="14" y2="17" />
</svg> </svg>
+1 -5
View File
@@ -88,11 +88,7 @@ async function handleCreateList() {
<ul v-if="listsStore.sortedLists.length > 0" class="lists"> <ul v-if="listsStore.sortedLists.length > 0" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id"> <li v-for="list in listsStore.sortedLists" :key="list.id">
<ListCard <ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" />
:list="list"
@share="handleOpenShare(list)"
@delete="handleOpenDelete(list)"
/>
</li> </li>
</ul> </ul>