Merge pull request 'AI audit improvements' (#7) from dev into main
This commit was merged in pull request #7.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -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
@@ -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
@@ -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,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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 loadListItems(listId: string) {
|
async function pullListItems(listId: string): Promise<void> {
|
||||||
if (!isLoaded.value) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return
|
||||||
await refresh()
|
return enqueueOperation(() => pullListItemsInternal(listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadListItems(listId: string) {
|
||||||
|
await ensureLoaded()
|
||||||
void pullListItems(listId)
|
void pullListItems(listId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user