mitlist/fe/src/stores/offline.ts
mohamad a0d67f6c66 feat: Add comprehensive notes and tasks for project stabilization and enhancements
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application.
- Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews.
- Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations.
- Updated the logging configuration to change the log level to WARNING for production readiness.
- Enhanced the database connection settings to disable SQL query logging in production.
- Added a new endpoint to list all chores for improved user experience and optimized database queries.
- Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion.
- Updated frontend components and services to support new chore assignment features and improved error handling.
- Enhanced the expense management system with new fields and improved API interactions for better user experience.
2025-05-24 21:36:57 +02:00

412 lines
14 KiB
TypeScript

// 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<CreateListPayload>
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<CreateListItemPayload['itemData']>
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<string, unknown>; timestamp: number }
serverVersion: { data: Record<string, unknown>; 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<OfflineAction[]>('offline-actions', [])
const isProcessingQueue = ref(false)
const showConflictDialog = ref(false) // You'll need to implement this dialog
const currentConflict = ref<ConflictData | null>(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<OfflineAction, 'id' | 'timestamp'>) => {
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<string, unknown>
// 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<string, unknown>
}) => {
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,
}
})