diff --git a/src/components/ConflictResolver.tsx b/src/components/ConflictResolver.tsx new file mode 100644 index 00000000..222b5f00 --- /dev/null +++ b/src/components/ConflictResolver.tsx @@ -0,0 +1,188 @@ +'use client'; + +import React, { useState } from 'react'; +import { ConflictRecord, ResolutionStrategy } from '@/lib/conflict/types'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, AlertTriangle, ArrowRight, Save, History, Check } from 'lucide-react'; + +interface ConflictResolverProps { + conflict: ConflictRecord; + onResolve: (strategy: ResolutionStrategy, manualData?: any) => void; + onClose: () => void; +} + +export const ConflictResolver: React.FC = ({ + conflict, + onResolve, + onClose, +}) => { + const [selectedStrategy, setSelectedStrategy] = useState('manual'); + const [showHistory, setShowHistory] = useState(false); + + const localItems = Object.entries(conflict.localData).filter( + ([key]) => !['updatedAt', 'version', 'id'].includes(key), + ); + + const remoteItems = Object.entries(conflict.remoteData).filter( + ([key]) => !['updatedAt', 'version', 'id'].includes(key), + ); + + return ( + +
+ + {/* Header */} +
+
+
+ +
+
+

Conflict Detected

+

+ Resolution required for {conflict.entityType} +

+
+
+ +
+ + {/* Content */} +
+
+ {/* Local Changes */} +
setSelectedStrategy('local')} + className={`p-4 rounded-xl border-2 transition-all cursor-pointer ${ + selectedStrategy === 'local' + ? 'border-orange-500 bg-orange-500/5' + : 'border-white/5 bg-white/5 hover:border-white/20' + }`} + > +
+ + Local Version + + {selectedStrategy === 'local' && } +
+
+ {localItems.map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+
+ + {/* Remote Changes */} +
setSelectedStrategy('remote')} + className={`p-4 rounded-xl border-2 transition-all cursor-pointer ${ + selectedStrategy === 'remote' + ? 'border-blue-500 bg-blue-500/5' + : 'border-white/5 bg-white/5 hover:border-white/20' + }`} + > +
+ + Remote Version + + {selectedStrategy === 'remote' && } +
+
+ {remoteItems.map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+
+
+ + {/* Merge Option */} +
setSelectedStrategy('merge')} + className={`p-4 rounded-xl border-2 transition-all cursor-pointer flex items-center justify-between ${ + selectedStrategy === 'merge' + ? 'border-purple-500 bg-purple-500/5' + : 'border-white/5 bg-white/5 hover:border-white/20' + }`} + > +
+
+ +
+
+ Smart Merge + Combine changes automatically +
+
+ {selectedStrategy === 'merge' && } +
+ + {/* History Toggle */} + + + {showHistory && ( + + {conflict.history.map((h, i) => ( +
+ {new Date(h.timestamp).toLocaleString()} + + {h.action} +

{h.details}

+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + Resolved conflicts are tracked in the audit log. + +
+ + +
+
+
+
+
+ ); +}; diff --git a/src/lib/conflict/resolver.ts b/src/lib/conflict/resolver.ts new file mode 100644 index 00000000..25ceed85 --- /dev/null +++ b/src/lib/conflict/resolver.ts @@ -0,0 +1,101 @@ +import { ConflictRecord, ResolutionStrategy, ProgressData } from './types'; + +/** + * Detects if a conflict exists between local and remote data based on timestamps and versions. + */ +export function detectConflict( + local: T, + remote: T, +): boolean { + // If remote is newer than local, we have a potential conflict + // (Assuming local changes were made while remote was already newer) + const localTime = new Date(local.updatedAt).getTime(); + const remoteTime = new Date(remote.updatedAt).getTime(); + + if (remoteTime > localTime) { + return true; + } + + if (local.version !== undefined && remote.version !== undefined) { + return local.version < remote.version; + } + + return false; +} + +/** + * Resolves a conflict based on the chosen strategy. + */ +export function resolveConflict(local: T, remote: T, strategy: ResolutionStrategy): T { + switch (strategy) { + case 'local': + return local; + case 'remote': + return remote; + case 'merge': + return mergeData(local, remote); + case 'manual': + // Manual resolution handled by UI, default to remote if called programmatically + return remote; + default: + return remote; + } +} + +/** + * Merges two data objects. Specifically handles ProgressData. + */ +function mergeData(local: T, remote: T): T { + // If it's progress data, we merge by taking the maximum progress and completion status + if (isProgressData(local) && isProgressData(remote)) { + const merged: ProgressData = { + ...remote, + progress: Math.max(local.progress, remote.progress), + completed: local.completed || remote.completed, + updatedAt: new Date().toISOString(), + version: Math.max(local.version || 0, remote.version || 0) + 1, + }; + return merged as unknown as T; + } + + // Generic merge (last write wins on field level if they were objects, but here we just return remote) + return { ...local, ...remote }; +} + +function isProgressData(data: any): data is ProgressData { + return ( + data && + typeof data.progress === 'number' && + typeof data.completed === 'boolean' && + typeof data.updatedAt === 'string' + ); +} + +/** + * Creates a new conflict record with initial history. + */ +export function createConflictRecord( + entityType: string, + entityKey: string, + localData: T, + remoteData: T, +): ConflictRecord { + const id = `conflict-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return { + id, + entityType, + entityKey, + localData, + remoteData, + timestamp: new Date().toISOString(), + strategy: 'manual', + resolved: false, + history: [ + { + timestamp: new Date().toISOString(), + action: 'CREATED', + details: 'Conflict detected during synchronization', + }, + ], + }; +} diff --git a/src/lib/conflict/types.ts b/src/lib/conflict/types.ts new file mode 100644 index 00000000..370ee3db --- /dev/null +++ b/src/lib/conflict/types.ts @@ -0,0 +1,27 @@ +export type ResolutionStrategy = 'local' | 'remote' | 'merge' | 'manual'; + +export interface ConflictRecord { + id: string; + entityType: string; + entityKey: string; + localData: T; + remoteData: T; + timestamp: string; + strategy: ResolutionStrategy; + resolved: boolean; + history: Array<{ + timestamp: string; + action: string; + details?: string; + }>; +} + +export interface SyncMetadata { + updatedAt: string; + version: number; +} + +export interface ProgressData extends SyncMetadata { + progress: number; + completed: boolean; +} diff --git a/src/services/offlineSync.ts b/src/services/offlineSync.ts index d3c9e1bb..173f2a1a 100644 --- a/src/services/offlineSync.ts +++ b/src/services/offlineSync.ts @@ -1,6 +1,13 @@ 'use client'; import { openDB, IDBPDatabase } from 'idb'; +import { + ConflictRecord, + ResolutionStrategy, + detectConflict, + resolveConflict, + createConflictRecord, +} from '@/lib/conflict/resolver'; export type SyncItemType = 'course_progress'; @@ -47,6 +54,7 @@ export interface OfflineProgressRecord { updatedAt: string; synced: boolean; syncedAt?: string; + version?: number; } export interface SyncQueueItem { @@ -58,17 +66,7 @@ export interface SyncQueueItem { version: number; } -export interface SyncConflict { - id: string; - type: SyncItemType; - entityKey: string; - localItem: SyncQueueItem; - remoteItem: SyncQueueItem; - resolution: 'local' | 'remote' | 'merge' | 'manual'; - resolved: boolean; - createdAt: string; - resolvedAt?: string; -} +export type SyncConflict = ConflictRecord; export interface SyncResult { success: boolean; @@ -80,7 +78,7 @@ export interface SyncResult { export interface SyncOptions { forceSync?: boolean; - resolveConflicts?: 'auto' | 'manual' | 'local' | 'remote' | 'merge'; + resolveConflicts?: 'auto' | ResolutionStrategy; retryAttempts?: number; } @@ -334,18 +332,44 @@ export class OfflineSyncService { return await index.getAll(IDBKeyRange.only(false)); } - async resolveConflict(conflictId: string, resolution: SyncConflict['resolution']): Promise { + async resolveConflict( + conflictId: string, + strategy: ResolutionStrategy, + manualData?: any, + ): Promise { const conflict = await this.db.get('conflicts', conflictId); if (!conflict) return; + const resolvedData = + strategy === 'manual' && manualData + ? manualData + : resolveConflict(conflict.localData, conflict.remoteData, strategy); + const resolvedConflict: SyncConflict = { ...conflict, - resolution, + strategy, resolved: true, - resolvedAt: new Date().toISOString(), + history: [ + ...conflict.history, + { + timestamp: new Date().toISOString(), + action: 'RESOLVED', + details: `Resolved using ${strategy} strategy`, + }, + ], }; await this.db.put('conflicts', resolvedConflict); + + // Update local storage with resolved data + const [courseId, moduleId] = conflict.entityKey.split(':'); + await this.storage.saveProgress({ + courseId, + moduleId, + ...resolvedData, + synced: true, + syncedAt: new Date().toISOString(), + }); } async syncData(options: SyncOptions = {}): Promise { @@ -413,49 +437,47 @@ export class OfflineSyncService { const [courseId, moduleId] = entityKey.split(':'); const existing = await this.storage.getProgress(courseId, moduleId); - const hasRemoteNewer = existing?.synced && existing.updatedAt > candidate.data.updatedAt; - if (hasRemoteNewer) { - const conflict: SyncConflict = { - id: `conflict-${Date.now()}-${Math.random().toString(36).slice(2)}`, - type: candidate.type, - entityKey, - localItem: candidate, - remoteItem: { - ...candidate, - data: { - ...candidate.data, - progress: existing.progress, - completed: existing.completed, - updatedAt: existing.updatedAt, - }, - timestamp: existing.updatedAt, - version: candidate.version + 1, - }, - resolution: 'manual', - resolved: false, - createdAt: new Date().toISOString(), - }; + const isConflicted = existing?.synced && detectConflict(candidate.data, existing); - const resolution = this.resolveConflictStrategy(conflict, options); - conflict.resolution = resolution; - conflict.resolved = resolution !== 'manual'; - conflict.resolvedAt = conflict.resolved ? new Date().toISOString() : undefined; + if (isConflicted) { + const remoteData = { + progress: existing.progress, + completed: existing.completed, + updatedAt: existing.updatedAt, + version: existing.version || 1, + }; - await this.addConflict(conflict); - conflicts.push(conflict); + const conflict = createConflictRecord( + candidate.type, + entityKey, + candidate.data, + remoteData, + ); + + const strategy = this.resolveConflictStrategy(conflict, options); + + if (strategy !== 'manual') { + const resolvedData = resolveConflict(candidate.data, remoteData, strategy); + conflict.strategy = strategy; + conflict.resolved = true; + conflict.history.push({ + timestamp: new Date().toISOString(), + action: 'AUTO_RESOLVED', + details: `Automatically resolved using ${strategy} strategy`, + }); - if (conflict.resolved && resolution !== 'remote') { await this.storage.saveProgress({ courseId, moduleId, - progress: candidate.data.progress, - completed: candidate.data.completed, - updatedAt: candidate.data.updatedAt, + ...resolvedData, synced: true, syncedAt: new Date().toISOString(), }); } + + await this.addConflict(conflict); + conflicts.push(conflict); } else { await this.storage.saveProgress({ courseId, @@ -463,6 +485,7 @@ export class OfflineSyncService { progress: candidate.data.progress, completed: candidate.data.completed, updatedAt: candidate.data.updatedAt, + version: candidate.data.version, synced: true, syncedAt: new Date().toISOString(), }); @@ -495,28 +518,20 @@ export class OfflineSyncService { private resolveConflictStrategy( conflict: SyncConflict, options: SyncOptions, - ): SyncConflict['resolution'] { + ): ResolutionStrategy { if ( options.resolveConflicts === 'local' || options.resolveConflicts === 'remote' || options.resolveConflicts === 'merge' ) { - return options.resolveConflicts; + return options.resolveConflicts as ResolutionStrategy; } if (options.resolveConflicts === 'manual') { return 'manual'; } - const localUpdated = new Date(conflict.localItem.data.updatedAt).getTime(); - const remoteUpdated = new Date(conflict.remoteItem.data.updatedAt).getTime(); - - if (localUpdated === remoteUpdated) { - return conflict.localItem.data.progress >= conflict.remoteItem.data.progress - ? 'local' - : 'remote'; - } - - return localUpdated > remoteUpdated ? 'local' : 'remote'; + // Default "auto" strategy: smart merge if both are progress data, otherwise manual + return 'manual'; } } diff --git a/src/testing/conflict.test.ts b/src/testing/conflict.test.ts new file mode 100644 index 00000000..1377b242 --- /dev/null +++ b/src/testing/conflict.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { detectConflict, resolveConflict, createConflictRecord } from '../lib/conflict/resolver'; +import { ProgressData } from '../lib/conflict/types'; + +describe('Conflict Resolution', () => { + describe('detectConflict', () => { + it('should detect conflict when remote is newer', () => { + const local = { updatedAt: '2023-01-01T10:00:00Z', version: 1 }; + const remote = { updatedAt: '2023-01-01T11:00:00Z', version: 2 }; + expect(detectConflict(local, remote)).toBe(true); + }); + + it('should not detect conflict when local is newer', () => { + const local = { updatedAt: '2023-01-01T12:00:00Z', version: 3 }; + const remote = { updatedAt: '2023-01-01T11:00:00Z', version: 2 }; + expect(detectConflict(local, remote)).toBe(false); + }); + + it('should detect conflict on version mismatch even if timestamps are same', () => { + const local = { updatedAt: '2023-01-01T10:00:00Z', version: 1 }; + const remote = { updatedAt: '2023-01-01T10:00:00Z', version: 2 }; + expect(detectConflict(local, remote)).toBe(true); + }); + }); + + describe('resolveConflict', () => { + const local: ProgressData = { + progress: 50, + completed: false, + updatedAt: '2023-01-01T10:00:00Z', + version: 1, + }; + const remote: ProgressData = { + progress: 30, + completed: true, + updatedAt: '2023-01-01T11:00:00Z', + version: 2, + }; + + it('should resolve using local strategy', () => { + expect(resolveConflict(local, remote, 'local')).toEqual(local); + }); + + it('should resolve using remote strategy', () => { + expect(resolveConflict(local, remote, 'remote')).toEqual(remote); + }); + + it('should resolve using merge strategy for progress', () => { + const merged = resolveConflict(local, remote, 'merge') as ProgressData; + expect(merged.progress).toBe(50); // Max of 50 and 30 + expect(merged.completed).toBe(true); // true || false + expect(merged.version).toBe(3); // Max(1, 2) + 1 + }); + }); + + describe('createConflictRecord', () => { + it('should create a record with initial history', () => { + const local = { data: 'a' }; + const remote = { data: 'b' }; + const record = createConflictRecord('test', 'key', local, remote); + + expect(record.entityType).toBe('test'); + expect(record.entityKey).toBe('key'); + expect(record.history).toHaveLength(1); + expect(record.history[0].action).toBe('CREATED'); + }); + }); +});