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 { setActivePinia, createPinia } from 'pinia'
import { fetchWithAuth, apiClient } from '../client'
import { fetchWithAuth, apiClient, SessionExpiredError } from '../client'
import { useAuthStore } from '@/stores/auth'
import * as authApi from '@/api/auth'
import router from '@/router'
@@ -122,7 +122,7 @@ describe('api client (fetchWithAuth)', () => {
} as unknown as Response)
global.fetch = fetchMock
await fetchWithAuth('/lists')
await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.name).toBe('login')
@@ -144,7 +144,7 @@ describe('api client (fetchWithAuth)', () => {
} as unknown as Response)
global.fetch = fetchMock
await fetchWithAuth('/lists')
await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(authStore.accessToken).toBeNull()
+2 -10
View File
@@ -1,15 +1,7 @@
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'
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 { API_BASE_URL }
async function postJson(
path: string,
+22 -5
View File
@@ -1,9 +1,22 @@
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth'
import router from '@/router'
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
export { API_BASE_URL, extractErrorMessage }
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> {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') {
@@ -55,7 +68,7 @@ export async function fetchWithAuth(
if (response.status === 401 && !skipRefresh) {
if (!authStore.refreshToken) {
await redirectToLogin()
return response
throw new SessionExpiredError()
}
try {
@@ -72,7 +85,7 @@ export async function fetchWithAuth(
})
} catch {
await redirectToLogin()
return response
throw new SessionExpiredError()
}
}
@@ -94,6 +107,10 @@ export const apiClient = {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: (endpoint: string, options?: FetchOptions) =>
fetchWithAuth(endpoint, { ...options, method: 'DELETE' }),
delete: (endpoint: string, body?: unknown, options?: FetchOptions) =>
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 { extractErrorMessage } from '@/api/http'
import type {
List,
ListItem,
@@ -10,15 +11,6 @@ import type {
RemoveUserFromListPayload,
} 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[]> {
const response = await apiClient.get('/lists')
if (!response.ok) {
@@ -76,9 +68,7 @@ export async function addUserToListApi(payload: AddUserToListPayload): Promise<v
}
export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> {
const response = await apiClient.delete('/lists/user', {
body: JSON.stringify(payload),
})
const response = await apiClient.delete('/lists/user', payload)
if (!response.ok) {
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">
{{ listsStore.pendingCount }} pending
</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">
Log out
</button>
@@ -85,6 +86,11 @@ async function handleLogout() {
background-color: var(--c-text-soft);
}
.sync-error {
color: var(--c-danger);
cursor: help;
}
.logout {
background: none;
border: 1px solid var(--c-border);
+6 -17
View File
@@ -1,29 +1,17 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import type { LocalList } from '@/database/db'
import { useEscapeKey } from '@/composables/useEscapeKey'
defineProps<{
list: LocalList
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'confirm'): void
close: []
confirm: []
}>()
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClose()
}
}
useEscapeKey(handleClose)
function handleClose() {
emit('close')
@@ -50,7 +38,8 @@ function handleConfirm() {
</div>
<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>
<div class="modal-footer">
+25 -42
View File
@@ -1,34 +1,37 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>()
const emit = defineEmits<{
(e: 'share', list: LocalList): void
(e: 'delete', list: LocalList): void
share: [list: LocalList]
delete: [list: LocalList]
}>()
const listsStore = useListsStore()
const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null)
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
// The server now reports total_items/completed_items directly on the list,
// so the overview can show progress without having to load every item of
// every list. Fall back to counting locally cached items for lists that
// haven't synced to the server yet (e.g. just created while offline).
const items = computed(() => listsStore.itemsForList(props.list.id))
const totalCount = computed(() => props.list.total_items ?? items.value.length)
const completedCount = computed(
() => props.list.completed_items ?? items.value.filter((item) => item.is_completed).length,
// The server always reports total_items/completed_items once a list has
// synced at least once, so the common case never touches the store's full
// item list at all - only a list that hasn't synced yet falls back to
// 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,
)
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleShare(event: Event) {
event.preventDefault()
@@ -43,28 +46,6 @@ function handleDelete(event: Event) {
isMenuOpen.value = false
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>
<template>
@@ -132,7 +113,9 @@ onUnmounted(() => {
stroke-linejoin="round"
>
<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="14" y1="11" x2="14" y2="17" />
</svg>
+19 -37
View File
@@ -1,18 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref } from 'vue'
import type { LocalListItem } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ item: LocalListItem }>()
const emit = defineEmits<{
(e: 'delete', item: LocalListItem): void
}>()
const listsStore = useListsStore()
const isEditing = ref(false)
const editedTitle = ref(props.item.title)
const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null)
// Captured separately from `editedTitle` at the moment editing starts: if the
// 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() {
listsStore.setListItemCompleted(props.item.id, !props.item.is_completed)
@@ -20,52 +28,24 @@ function toggleCompleted() {
function startEditing() {
editedTitle.value = props.item.title
originalTitle.value = props.item.title
isEditing.value = true
}
function saveTitle() {
const title = editedTitle.value.trim()
if (title && title !== props.item.title) {
if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title })
}
isEditing.value = false
}
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleDelete(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = false
emit('delete', props.item)
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>
<template>
@@ -126,7 +106,9 @@ onUnmounted(() => {
stroke-linejoin="round"
>
<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="14" y1="11" x2="14" y2="17" />
</svg>
+5 -13
View File
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useEscapeKey } from '@/composables/useEscapeKey'
const props = defineProps<{
list: LocalList
}>()
const emit = defineEmits<{
(e: 'close'): void
close: []
}>()
const listsStore = useListsStore()
@@ -19,23 +20,14 @@ const isSubmitting = ref(false)
const error = ref('')
const successMessage = ref('')
useEscapeKey(handleClose)
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
emailInput.value?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClose()
}
}
function handleClose() {
emit('close')
}
@@ -65,8 +65,6 @@ describe('ListItemRow', () => {
await wrapper.find('.submenu-item-danger').trigger('click')
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)
})
})
+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 type { List, ListItem } from '@/types/list'
import type {
List,
ListItem,
CreateListPayload,
CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload,
} from '@/types/list'
export interface LocalList extends List {
pendingSync?: boolean
@@ -9,19 +16,16 @@ export interface LocalListItem extends ListItem {
pendingSync?: boolean
}
export type SyncOperationType =
| 'createList'
| 'createListItem'
| 'updateListItem'
| 'setListItemCompleted'
| 'removeUserFromList'
| 'deleteList'
| 'deleteListItem'
export interface DeleteListPayload {
id: string
}
export interface SyncQueueEntry {
export interface DeleteListItemPayload {
id: string
}
interface SyncQueueEntryBase {
id?: number
type: SyncOperationType
payload: unknown
localListId?: string
localListItemId?: string
createdAt: number
@@ -29,6 +33,29 @@ export interface SyncQueueEntry {
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 {
lists!: Table<LocalList, string>
listItems!: Table<LocalListItem, string>
-3
View File
@@ -26,9 +26,6 @@ const router = createRouter({
{
path: '/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'),
},
],
+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'
type Record = { id?: unknown; [key: string]: unknown }
@@ -37,6 +37,17 @@ function createFakeTable(autoIncrement = false) {
async count() {
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) {
return {
equals(value: unknown) {
@@ -46,6 +57,16 @@ function createFakeTable(autoIncrement = false) {
.filter((v) => v[field] === value)
.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', () => {
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())
vi.restoreAllMocks()
Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
@@ -121,6 +146,10 @@ describe('useListsStore', () => {
listsApiMocks.getListItemsApi.mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
})
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' })
// The chained pullFromServer() call needs to report the just-created list
@@ -407,4 +436,119 @@ describe('useListsStore', () => {
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 }
})
+216 -105
View File
@@ -1,6 +1,12 @@
import { ref, computed } from 'vue'
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 {
getListsApi,
createListApi,
@@ -14,6 +20,12 @@ import {
deleteListItemApi,
} 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 {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
@@ -39,6 +51,40 @@ export const useListsStore = defineStore('lists', () => {
.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() {
lists.value = await db.lists.toArray()
listItems.value = await db.listItems.toArray()
@@ -46,23 +92,45 @@ export const useListsStore = defineStore('lists', () => {
isLoaded.value = true
}
async function loadLists() {
if (!isLoaded.value) {
await refresh()
// Dedupes concurrent first-load refreshes: loadLists() and loadListItems()
// are both called from ListDetailView's onMounted and would otherwise each
// 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
// 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.
await loadPromise
}
async function loadLists() {
await ensureLoaded()
void sync()
}
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) {
async function enqueue(entry: NewSyncQueueEntry) {
await db.syncQueue.add({
...entry,
createdAt: Date.now(),
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> {
@@ -76,13 +144,13 @@ export const useListsStore = defineStore('lists', () => {
}
await db.lists.add(localList)
upsertList(localList)
await enqueue({
type: 'createList',
payload: { name, user_ids: userIds },
localListId: localList.id,
})
await refresh()
void sync()
scheduleSync()
return localList
}
@@ -100,13 +168,13 @@ export const useListsStore = defineStore('lists', () => {
}
await db.listItems.add(localItem)
upsertListItem(localItem)
await enqueue({
type: 'createListItem',
payload: { list_id: listId, title },
localListItemId: localItem.id,
})
await refresh()
void sync()
scheduleSync()
return localItem
}
@@ -115,33 +183,37 @@ export const useListsStore = defineStore('lists', () => {
itemId: string,
changes: { title?: string; is_completed?: boolean },
) {
await db.listItems.update(itemId, {
const patch = {
...changes,
modified_at: new Date().toISOString(),
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({
type: 'updateListItem',
payload: { list_item_id: itemId, ...changes },
localListItemId: itemId,
})
await refresh()
void sync()
scheduleSync()
}
async function setListItemCompleted(itemId: string, isCompleted: boolean) {
await db.listItems.update(itemId, {
const patch = {
is_completed: isCompleted,
modified_at: new Date().toISOString(),
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({
type: 'setListItemCompleted',
payload: { is_completed: isCompleted },
localListItemId: itemId,
})
await refresh()
void sync()
scheduleSync()
}
async function addUserToList(listId: string, email: string) {
@@ -151,40 +223,39 @@ export const useListsStore = defineStore('lists', () => {
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) {
await enqueue({
type: 'removeUserFromList',
payload: { list_id: listId, email },
localListId: listId,
})
await refresh()
void sync()
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new Error('Cannot remove user from list while offline')
}
await removeUserFromListApi({ list_id: listId, email })
}
async function deleteList(listId: string) {
await db.lists.delete(listId)
const affectedItems = await db.listItems.where('list_id').equals(listId).toArray()
for (const item of affectedItems) {
await db.listItems.delete(item.id)
}
await db.listItems.where('list_id').equals(listId).delete()
removeLocalList(listId)
listItems.value = listItems.value.filter((item) => item.list_id !== listId)
await enqueue({
type: 'deleteList',
payload: { id: listId },
localListId: listId,
})
await refresh()
void sync()
scheduleSync()
}
async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId)
removeLocalListItem(itemId)
await enqueue({
type: 'deleteListItem',
payload: { id: itemId },
localListItemId: itemId,
})
await refresh()
void sync()
scheduleSync()
}
// 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)
if (existing) {
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()
for (const item of affectedItems) {
await db.listItems.update(item.id, { list_id: newId })
await db.listItems.where('list_id').equals(oldId).modify({ list_id: newId })
for (const item of listItems.value) {
if (item.list_id === oldId) item.list_id = newId
}
const affectedQueueEntries = await db.syncQueue
.filter((entry) => entry.localListId === oldId)
.toArray()
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!, {
localListId: newId,
payload: payload?.list_id
? { ...payload, list_id: newId }
: payload?.id
? { ...payload, id: newId }
: entry.payload,
payload: updatedPayload,
})
}
}
@@ -230,29 +306,40 @@ export const useListsStore = defineStore('lists', () => {
const existing = await db.listItems.get(oldId)
if (existing) {
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
.filter((queueEntry) => queueEntry.localListItemId === oldId)
.toArray()
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!, {
localListItemId: newId,
payload: payload?.list_item_id
? { ...payload, list_item_id: newId }
: payload?.id
? { ...payload, id: newId }
: queueEntry.payload,
payload: updatedPayload,
})
}
}
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) {
switch (entry.type) {
case 'createList': {
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] })
const created = await createListApi(entry.payload)
if (entry.localListId) {
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
// remap our client-generated placeholder id to it instead of keeping
// 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) {
await remapListItemId(entry.localListItemId, created.id)
}
break
}
case 'updateListItem': {
await updateListItemApi(
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean },
)
await updateListItemApi(entry.payload)
if (entry.localListItemId) {
await db.listItems.update(entry.localListItemId, { pendingSync: false })
await markListItemSynced(entry.localListItemId)
}
break
}
case 'setListItemCompleted': {
if (!entry.localListItemId) break
await setListItemCompletedApi(
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 })
await setListItemCompletedApi(entry.localListItemId, entry.payload)
await markListItemSynced(entry.localListItemId)
break
}
case 'deleteList': {
const payload = entry.payload as { id: string }
await deleteListApi(payload.id)
await deleteListApi(entry.payload.id)
break
}
case 'deleteListItem': {
const payload = entry.payload as { id: string }
await deleteListItemApi(payload.id)
await deleteListItemApi(entry.payload.id)
break
}
}
}
let ongoingSync: Promise<void> | null = null
async function runSync() {
isSyncing.value = true
error.value = null
@@ -318,6 +392,7 @@ export const useListsStore = defineStore('lists', () => {
await processSyncEntry(entry)
if (entry.id !== undefined) {
await db.syncQueue.delete(entry.id)
pendingCount.value = Math.max(0, pendingCount.value - 1)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed'
@@ -335,10 +410,26 @@ export const useListsStore = defineStore('lists', () => {
}
} finally {
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
// a mutation while another is triggered by the "online" event) share the
// 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
ongoingSync = runSync().then(() => pullFromServer())
ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer()))
try {
await ongoingSync
} finally {
@@ -368,15 +459,20 @@ export const useListsStore = defineStore('lists', () => {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
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 toPut: LocalList[] = []
for (const serverList of serverLists) {
const existingList = await db.lists.get(serverList.id)
const existingList = localById.get(serverList.id)
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
// 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
// "createList") are left alone since the server doesn't know about
// them yet.
const localLists = await db.lists.toArray()
for (const localList of localLists) {
if (!localList.pendingSync && !serverListIds.has(localList.id)) {
await db.lists.delete(localList.id)
const orphanedItems = await db.listItems.where('list_id').equals(localList.id).toArray()
for (const item of orphanedItems) {
await db.listItems.delete(item.id)
const idsToDelete = localLists
.filter((list) => !list.pendingSync && !serverListIds.has(list.id))
.map((list) => list.id)
if (idsToDelete.length > 0) {
await db.lists.bulkDelete(idsToDelete)
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) {
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
// merges them into local storage. Used by the list detail view, which is
// the only place that needs the full item set for a list.
async function pullListItems(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
async function pullListItemsInternal(listId: string): Promise<void> {
try {
const serverItems = await getListItemsApi(listId)
const serverItemIds = new Set(serverItems.map((serverItem) => serverItem.id))
const [serverItems, localItemsForList] = await Promise.all([
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) {
const existingItem = await db.listItems.get(serverItem.id)
const existingItem = localById.get(serverItem.id)
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
// server for this list have been deleted there, so remove them
// locally too. Items with pending local changes are left alone since
// the server doesn't know about them yet.
const localItems = await db.listItems.where('list_id').equals(listId).toArray()
for (const localItem of localItems) {
if (!localItem.pendingSync && !serverItemIds.has(localItem.id)) {
await db.listItems.delete(localItem.id)
}
const idsToDelete = localItemsForList
.filter((item) => !item.pendingSync && !serverItemIds.has(item.id))
.map((item) => item.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) {
error.value = err instanceof Error ? err.message : 'Failed to load list items from server'
} finally {
await refresh()
}
}
async function loadListItems(listId: string) {
if (!isLoaded.value) {
await refresh()
async function pullListItems(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
return enqueueOperation(() => pullListItemsInternal(listId))
}
async function loadListItems(listId: string) {
await ensureLoaded()
void pullListItems(listId)
}
+29 -28
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db'
import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ id: string }>()
@@ -13,45 +15,42 @@ const listsStore = useListsStore()
const newItemTitle = ref('')
const isAddingItem = ref(false)
const itemError = ref('')
const isMenuOpen = ref(false)
const showDeleteModal = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null)
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
}
}
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
onMounted(() => {
listsStore.loadLists()
listsStore.loadListItems(props.id)
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
// Vue Router reuses this component instance when navigating between two
// list-detail routes, so the initial onMounted load alone would leave a
// 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 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) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
}
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() {
isMenuOpen.value = false
showDeleteModal.value = true
@@ -131,7 +130,9 @@ async function handleAddItem() {
stroke-linejoin="round"
>
<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="14" y1="11" x2="14" y2="17" />
</svg>
+1 -5
View File
@@ -88,11 +88,7 @@ async function handleCreateList() {
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id">
<ListCard
:list="list"
@share="handleOpenShare(list)"
@delete="handleOpenDelete(list)"
/>
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" />
</li>
</ul>