
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m17s
This commit includes several key updates and new features: - Enhanced WebSocket functionality across various components, improving real-time communication and user experience. - Introduced new components for managing settlements, including `SettlementCard.vue`, `SettlementForm.vue`, and `SuggestedSettlementsCard.vue`, to streamline financial interactions. - Updated existing components and services to support the new settlement features, ensuring consistency and improved performance. - Added advanced performance optimizations to enhance loading times and responsiveness throughout the application. These changes aim to provide a more robust and user-friendly experience in managing financial settlements and real-time interactions.
286 lines
9.8 KiB
TypeScript
286 lines
9.8 KiB
TypeScript
// fe/src/composables/useConflictResolution.ts
|
|
// Unified conflict resolution system for optimistic updates
|
|
|
|
import { ref, computed } from 'vue'
|
|
import type { ConflictResolution, OptimisticUpdate } from '@/types/shared'
|
|
|
|
interface ConflictState {
|
|
pendingConflicts: Map<string, ConflictResolution<any>>
|
|
optimisticUpdates: Map<string, OptimisticUpdate<any>>
|
|
resolutionStrategies: Map<string, 'local' | 'server' | 'merge' | 'manual'>
|
|
}
|
|
|
|
const state = ref<ConflictState>({
|
|
pendingConflicts: new Map(),
|
|
optimisticUpdates: new Map(),
|
|
resolutionStrategies: new Map()
|
|
})
|
|
|
|
export function useConflictResolution() {
|
|
|
|
// Computed properties
|
|
const hasConflicts = computed(() => state.value.pendingConflicts.size > 0)
|
|
const conflictCount = computed(() => state.value.pendingConflicts.size)
|
|
const pendingUpdates = computed(() => state.value.optimisticUpdates.size)
|
|
|
|
// Track an optimistic update
|
|
function trackOptimisticUpdate<T>(
|
|
entityType: string,
|
|
entityId: number,
|
|
operation: 'create' | 'update' | 'delete',
|
|
data: T
|
|
): string {
|
|
const updateId = `${entityType}_${entityId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
|
|
const update: OptimisticUpdate<T> = {
|
|
id: updateId,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
operation,
|
|
data,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'pending'
|
|
}
|
|
|
|
state.value.optimisticUpdates.set(updateId, update)
|
|
console.log('[ConflictResolution] Tracked optimistic update:', updateId)
|
|
|
|
return updateId
|
|
}
|
|
|
|
// Confirm an optimistic update succeeded
|
|
function confirmOptimisticUpdate(updateId: string, serverData?: any) {
|
|
const update = state.value.optimisticUpdates.get(updateId)
|
|
if (update) {
|
|
update.status = 'confirmed'
|
|
if (serverData) {
|
|
update.data = serverData
|
|
}
|
|
// Keep confirmed updates for a short time for debugging
|
|
setTimeout(() => {
|
|
state.value.optimisticUpdates.delete(updateId)
|
|
}, 5000)
|
|
|
|
console.log('[ConflictResolution] Confirmed optimistic update:', updateId)
|
|
}
|
|
}
|
|
|
|
// Mark an optimistic update as failed and create conflict if needed
|
|
function failOptimisticUpdate<T>(
|
|
updateId: string,
|
|
serverData: T,
|
|
serverVersion: number
|
|
): string | null {
|
|
const update = state.value.optimisticUpdates.get(updateId)
|
|
if (!update) return null
|
|
|
|
update.status = 'failed'
|
|
|
|
// Create conflict resolution entry
|
|
const conflictId = `conflict_${updateId}`
|
|
const conflict: ConflictResolution<T> = {
|
|
id: conflictId,
|
|
entity_type: update.entity_type,
|
|
entity_id: update.entity_id,
|
|
local_version: 0, // We don't track versions in optimistic updates
|
|
server_version: serverVersion,
|
|
local_data: update.data,
|
|
server_data: serverData,
|
|
resolution_strategy: getDefaultStrategy(update.entity_type)
|
|
}
|
|
|
|
state.value.pendingConflicts.set(conflictId, conflict)
|
|
console.log('[ConflictResolution] Created conflict:', conflictId)
|
|
|
|
return conflictId
|
|
}
|
|
|
|
// Get default resolution strategy for entity type
|
|
function getDefaultStrategy(entityType: string): 'local' | 'server' | 'merge' | 'manual' {
|
|
const strategies = state.value.resolutionStrategies.get(entityType)
|
|
if (strategies) return strategies
|
|
|
|
// Default strategies by entity type
|
|
switch (entityType) {
|
|
case 'expense':
|
|
case 'settlement':
|
|
return 'manual' // Financial data requires manual review
|
|
case 'chore':
|
|
return 'server' // Prefer server state for chores
|
|
case 'list':
|
|
case 'item':
|
|
return 'merge' // Try to merge list changes
|
|
default:
|
|
return 'server'
|
|
}
|
|
}
|
|
|
|
// Set resolution strategy for entity type
|
|
function setResolutionStrategy(entityType: string, strategy: 'local' | 'server' | 'merge' | 'manual') {
|
|
state.value.resolutionStrategies.set(entityType, strategy)
|
|
}
|
|
|
|
// Resolve a conflict with chosen strategy
|
|
function resolveConflict<T>(
|
|
conflictId: string,
|
|
strategy: 'local' | 'server' | 'merge' | 'manual',
|
|
mergedData?: T
|
|
): T | null {
|
|
const conflict = state.value.pendingConflicts.get(conflictId)
|
|
if (!conflict) return null
|
|
|
|
let resolvedData: T
|
|
|
|
switch (strategy) {
|
|
case 'local':
|
|
resolvedData = conflict.local_data
|
|
break
|
|
case 'server':
|
|
resolvedData = conflict.server_data
|
|
break
|
|
case 'merge':
|
|
resolvedData = mergeData(conflict.local_data, conflict.server_data)
|
|
break
|
|
case 'manual':
|
|
if (!mergedData) {
|
|
console.error('[ConflictResolution] Manual resolution requires merged data')
|
|
return null
|
|
}
|
|
resolvedData = mergedData
|
|
break
|
|
}
|
|
|
|
// Update conflict with resolution
|
|
conflict.resolution_strategy = strategy
|
|
conflict.resolved_data = resolvedData
|
|
conflict.resolved_at = new Date().toISOString()
|
|
|
|
// Remove from pending conflicts
|
|
state.value.pendingConflicts.delete(conflictId)
|
|
|
|
console.log('[ConflictResolution] Resolved conflict:', conflictId, 'with strategy:', strategy)
|
|
return resolvedData
|
|
}
|
|
|
|
// Smart merge algorithm for common data types
|
|
function mergeData<T>(localData: T, serverData: T): T {
|
|
if (typeof localData !== 'object' || typeof serverData !== 'object') {
|
|
return serverData // Fallback to server data for primitives
|
|
}
|
|
|
|
// For objects, merge non-conflicting properties
|
|
const merged = { ...serverData } as any
|
|
const local = localData as any
|
|
const server = serverData as any
|
|
|
|
for (const key in local) {
|
|
if (local.hasOwnProperty(key)) {
|
|
// Prefer local changes for certain fields
|
|
if (isUserEditableField(key)) {
|
|
merged[key] = local[key]
|
|
}
|
|
// For arrays, try to merge intelligently
|
|
else if (Array.isArray(local[key]) && Array.isArray(server[key])) {
|
|
merged[key] = mergeArrays(local[key], server[key])
|
|
}
|
|
// For dates, prefer the more recent one
|
|
else if (isDateField(key)) {
|
|
const localDate = new Date(local[key])
|
|
const serverDate = new Date(server[key])
|
|
merged[key] = localDate > serverDate ? local[key] : server[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged as T
|
|
}
|
|
|
|
// Check if field is typically user-editable
|
|
function isUserEditableField(fieldName: string): boolean {
|
|
const editableFields = [
|
|
'name', 'description', 'notes', 'title',
|
|
'quantity', 'price', 'category',
|
|
'priority', 'status',
|
|
'custom_interval_days'
|
|
]
|
|
return editableFields.includes(fieldName)
|
|
}
|
|
|
|
// Check if field represents a date
|
|
function isDateField(fieldName: string): boolean {
|
|
return fieldName.includes('_at') || fieldName.includes('date') || fieldName.includes('time')
|
|
}
|
|
|
|
// Merge arrays intelligently (basic implementation)
|
|
function mergeArrays<T>(localArray: T[], serverArray: T[]): T[] {
|
|
// Simple merge - combine arrays and remove duplicates based on id
|
|
const merged = [...serverArray]
|
|
|
|
localArray.forEach(localItem => {
|
|
const localItemAny = localItem as any
|
|
if (localItemAny.id) {
|
|
const existingIndex = merged.findIndex((item: any) => item.id === localItemAny.id)
|
|
if (existingIndex >= 0) {
|
|
// Replace with local version
|
|
merged[existingIndex] = localItem
|
|
} else {
|
|
// Add new local item
|
|
merged.push(localItem)
|
|
}
|
|
} else {
|
|
// No ID, just add if not duplicate
|
|
if (!merged.some(item => JSON.stringify(item) === JSON.stringify(localItem))) {
|
|
merged.push(localItem)
|
|
}
|
|
}
|
|
})
|
|
|
|
return merged
|
|
}
|
|
|
|
// Get all pending conflicts
|
|
function getPendingConflicts() {
|
|
return Array.from(state.value.pendingConflicts.values())
|
|
}
|
|
|
|
// Get specific conflict
|
|
function getConflict(conflictId: string) {
|
|
return state.value.pendingConflicts.get(conflictId)
|
|
}
|
|
|
|
// Clear all conflicts (for testing or reset)
|
|
function clearConflicts() {
|
|
state.value.pendingConflicts.clear()
|
|
state.value.optimisticUpdates.clear()
|
|
}
|
|
|
|
// Get statistics
|
|
function getStats() {
|
|
return {
|
|
pendingConflicts: state.value.pendingConflicts.size,
|
|
optimisticUpdates: state.value.optimisticUpdates.size,
|
|
confirmedUpdates: Array.from(state.value.optimisticUpdates.values())
|
|
.filter(u => u.status === 'confirmed').length,
|
|
failedUpdates: Array.from(state.value.optimisticUpdates.values())
|
|
.filter(u => u.status === 'failed').length
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
hasConflicts,
|
|
conflictCount,
|
|
pendingUpdates,
|
|
|
|
// Methods
|
|
trackOptimisticUpdate,
|
|
confirmOptimisticUpdate,
|
|
failOptimisticUpdate,
|
|
resolveConflict,
|
|
setResolutionStrategy,
|
|
getPendingConflicts,
|
|
getConflict,
|
|
clearConflicts,
|
|
getStats
|
|
}
|
|
}
|