// src/stores/offline.ts (Example modification) import { defineStore } from 'pinia' import { ref, computed } from 'vue' // import { LocalStorage } from 'quasar'; // REMOVE import { useStorage } from '@vueuse/core' // VueUse alternative import { useNotificationStore } from '@/stores/notifications' // Your custom notification store import { api } from '@/services/api' import { API_ENDPOINTS } from '@/config/api-config' export type CreateListPayload = { name: string; description?: string /* other list properties */ } export type UpdateListPayload = { listId: string data: Partial version?: number } export type DeleteListPayload = { listId: string } export type CreateListItemPayload = { listId: string itemData: { name: string quantity?: number | string completed?: boolean price?: number | null /* other item properties */ } } export type UpdateListItemPayload = { listId: string itemId: string data: Partial version?: number } export type DeleteListItemPayload = { listId: string; itemId: string } export type OfflineAction = { id: string timestamp: number type: | 'create_list' | 'update_list' | 'delete_list' | 'create_list_item' | 'update_list_item' | 'delete_list_item' payload: | CreateListPayload | UpdateListPayload | DeleteListPayload | CreateListItemPayload | UpdateListItemPayload | DeleteListItemPayload } export type ConflictData = { localVersion: { data: Record; timestamp: number } serverVersion: { data: Record; timestamp: number } action: OfflineAction } interface ServerListData { id: string version: number name: string [key: string]: unknown } interface ServerItemData { id: string version: number name: string [key: string]: unknown } export const useOfflineStore = defineStore('offline', () => { // const $q = useQuasar(); // REMOVE const notificationStore = useNotificationStore() const isOnline = ref(navigator.onLine) // Use useStorage for reactive localStorage const pendingActions = useStorage('offline-actions', []) const isProcessingQueue = ref(false) const showConflictDialog = ref(false) // You'll need to implement this dialog const currentConflict = ref(null) // init is now handled by useStorage automatically loading the value // saveToStorage is also handled by useStorage automatically saving on change const addAction = (action: Omit) => { const newAction = { ...action, id: crypto.randomUUID(), timestamp: Date.now(), } as OfflineAction pendingActions.value.push(newAction) } const processQueue = async () => { if (isProcessingQueue.value || !isOnline.value) return isProcessingQueue.value = true const actionsToProcess = [...pendingActions.value] // Create a copy to iterate for (const action of actionsToProcess) { try { await processAction(action) pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id) } catch (error: any) { // Catch error as any to check for our custom flag if (error && error.isConflict && error.serverVersionData) { notificationStore.addNotification({ type: 'warning', message: `Conflict detected for action ${action.type}. Please review.`, }) let localData: Record // Extract local data based on action type if (action.type === 'update_list' || action.type === 'update_list_item') { localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data } else if (action.type === 'create_list' || action.type === 'create_list_item') { localData = action.payload as CreateListPayload | CreateListItemPayload } else { console.error( 'Conflict detected for unhandled action type for data extraction:', action.type, ) localData = {} // Fallback } currentConflict.value = { localVersion: { data: localData, timestamp: action.timestamp, }, serverVersion: { data: error.serverVersionData, // Assumes API 409 response body is the server item timestamp: error.serverVersionData.updated_at ? new Date(error.serverVersionData.updated_at).getTime() : action.timestamp + 1, // Prefer server updated_at }, action: action, } showConflictDialog.value = true console.warn('Conflict detected by processQueue for action:', action.id, error) // Stop processing queue on first conflict to await resolution isProcessingQueue.value = false // Allow queue to be re-triggered after resolution return // Stop processing further actions } else { console.error('processQueue: Action failed, remains in queue:', action.id, error) } } } isProcessingQueue.value = false } const processAction = async (action: OfflineAction) => { try { let request: Request let endpoint: string let method: 'POST' | 'PUT' | 'DELETE' = 'POST' let body: any switch (action.type) { case 'create_list': endpoint = API_ENDPOINTS.LISTS.BASE body = action.payload break case 'update_list': { const { listId, data } = action.payload as UpdateListPayload endpoint = API_ENDPOINTS.LISTS.BY_ID(listId) method = 'PUT' body = data break } case 'delete_list': { const { listId } = action.payload as DeleteListPayload endpoint = API_ENDPOINTS.LISTS.BY_ID(listId) method = 'DELETE' break } case 'create_list_item': { const { listId, itemData } = action.payload as CreateListItemPayload endpoint = API_ENDPOINTS.LISTS.ITEMS(listId) body = itemData break } case 'update_list_item': { const { listId, itemId, data } = action.payload as UpdateListItemPayload endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId) method = 'PUT' body = data break } case 'delete_list_item': { const { listId, itemId } = action.payload as DeleteListItemPayload endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId) method = 'DELETE' break } default: throw new Error(`Unknown action type: ${action.type}`) } // Create the request with the action metadata request = new Request(endpoint, { method, headers: { 'Content-Type': 'application/json', 'X-Offline-Action': action.id, }, body: method !== 'DELETE' ? JSON.stringify(body) : undefined, }) // Use fetch with the request const response = await fetch(request) if (!response.ok) { if (response.status === 409) { const error = new Error('Conflict detected') as any error.isConflict = true error.serverVersionData = await response.json() throw error } throw new Error(`HTTP error! status: ${response.status}`) } // If successful, remove from pending actions pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id) return await response.json() } catch (error: any) { if (error.isConflict) { throw error } // For other errors, let Workbox handle the retry throw error } } const setupNetworkListeners = () => { window.addEventListener('online', () => { isOnline.value = true processQueue().catch((err) => console.error('Error processing queue on online event:', err)) }) window.addEventListener('offline', () => { isOnline.value = false }) } setupNetworkListeners() // Call this once const hasPendingActions = computed(() => pendingActions.value.length > 0) const pendingActionCount = computed(() => pendingActions.value.length) const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge' action: OfflineAction mergedData?: Record }) => { if (!resolution.action || !currentConflict.value) { console.error('handleConflictResolution called without an action or active conflict.') showConflictDialog.value = false currentConflict.value = null return } const { action, version, mergedData } = resolution const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version try { let success = false if (version === 'local') { let dataToPush: any let endpoint: string let method: 'post' | 'put' = 'put' if (action.type === 'update_list') { const payload = action.payload as UpdateListPayload dataToPush = { ...payload.data, version: serverVersionNumber } endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId) } else if (action.type === 'update_list_item') { const payload = action.payload as UpdateListItemPayload dataToPush = { ...payload.data, version: serverVersionNumber } endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId) } else if (action.type === 'create_list') { const serverData = currentConflict.value.serverVersion.data as ServerListData | null if (serverData?.id) { // Server returned existing list, update it instead dataToPush = { ...action.payload, version: serverData.version } endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id) } else { // True conflict, need to modify the data dataToPush = { ...action.payload, name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`, } endpoint = API_ENDPOINTS.LISTS.BASE method = 'post' } } else if (action.type === 'create_list_item') { const serverData = currentConflict.value.serverVersion.data as ServerItemData | null if (serverData?.id) { // Server returned existing item, update it instead dataToPush = { ...action.payload, version: serverData.version } endpoint = API_ENDPOINTS.LISTS.ITEM( (action.payload as CreateListItemPayload).listId, serverData.id, ) } else { // True conflict, need to modify the data dataToPush = { ...action.payload, name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`, } endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId) method = 'post' } } else { console.error("Unsupported action type for 'keep local' resolution:", action.type) throw new Error("Unsupported action for 'keep local'") } if (method === 'put') { await api.put(endpoint, dataToPush) } else { await api.post(endpoint, dataToPush) } success = true notificationStore.addNotification({ type: 'success', message: 'Your version was saved to the server.', }) } else if (version === 'server') { success = true notificationStore.addNotification({ type: 'info', message: 'Local changes discarded; server version kept.', }) } else if (version === 'merge' && mergedData) { let dataWithVersion: any let endpoint: string if (action.type === 'update_list') { const payload = action.payload as UpdateListPayload dataWithVersion = { ...mergedData, version: serverVersionNumber } endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId) } else if (action.type === 'update_list_item') { const payload = action.payload as UpdateListItemPayload dataWithVersion = { ...mergedData, version: serverVersionNumber } endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId) } else if (action.type === 'create_list' || action.type === 'create_list_item') { // For create actions, merging means updating the existing item const serverData = currentConflict.value.serverVersion.data as | (ServerListData | ServerItemData) | null if (!serverData?.id) { throw new Error('Cannot merge create action: server data is missing or invalid') } if (action.type === 'create_list') { dataWithVersion = { ...mergedData, version: serverData.version } endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id) } else { dataWithVersion = { ...mergedData, version: serverData.version } endpoint = API_ENDPOINTS.LISTS.ITEM( (action.payload as CreateListItemPayload).listId, serverData.id, ) } } else { console.error('Merge resolution for unsupported action type:', action.type) throw new Error('Merge for this action type is not supported') } await api.put(endpoint, dataWithVersion) success = true notificationStore.addNotification({ type: 'success', message: 'Merged version saved to the server.', }) } if (success) { pendingActions.value = pendingActions.value.filter((a) => a.id !== action.id) } } catch (error) { console.error('Error during conflict resolution API call:', error) notificationStore.addNotification({ type: 'error', message: `Failed to resolve conflict for ${action.type}. Please try again.`, }) } finally { showConflictDialog.value = false currentConflict.value = null processQueue().catch((err) => console.error('Error processing queue after conflict resolution:', err), ) } } return { isOnline, pendingActions, isProcessingQueue, showConflictDialog, currentConflict, addAction, processAction, processQueue, handleConflictResolution, hasPendingActions, pendingActionCount, } })