Compare commits
No commits in common. "7e2a69d4a55e8b42833a9319e6dbc930cb68302b" and "a6b3024a9975944c5de99cc469356365227dc4a6" have entirely different histories.
7e2a69d4a5
...
a6b3024a99
|
|
@ -1,10 +1,9 @@
|
||||||
---
|
---
|
||||||
id: task-044
|
id: task-044
|
||||||
title: Test dev branch UI redesign and Map fixes
|
title: Test dev branch UI redesign and Map fixes
|
||||||
status: Done
|
status: To Do
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2025-12-07 23:26'
|
created_date: '2025-12-07 23:26'
|
||||||
updated_date: '2025-12-08 01:19'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
|
|
@ -25,15 +24,3 @@ Test the changes pushed to dev branch in commit 8123f0f
|
||||||
- [ ] #5 Map scroll wheel zooms correctly
|
- [ ] #5 Map scroll wheel zooms correctly
|
||||||
- [ ] #6 Old boards with Map shapes load without validation errors
|
- [ ] #6 Old boards with Map shapes load without validation errors
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
Session completed. All changes pushed to dev branch:
|
|
||||||
- UI redesign: unified top-right menu with grey oval container
|
|
||||||
- Social Network graph: dark theme with directional arrows
|
|
||||||
- MI bar: responsive layout (bottom on mobile)
|
|
||||||
- Map fixes: tool clicks work, scroll zoom works
|
|
||||||
- Automerge: Map shape schema validation fix
|
|
||||||
- Network graph: graceful fallback on API errors
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
id: task-045
|
|
||||||
title: Implement offline-first loading from IndexedDB
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-08 08:47'
|
|
||||||
labels:
|
|
||||||
- bug-fix
|
|
||||||
- offline
|
|
||||||
- automerge
|
|
||||||
dependencies: []
|
|
||||||
priority: high
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Fixed a bug where the app would hang indefinitely when the server wasn't running because `await adapter.whenReady()` blocked IndexedDB loading. Now the app loads from IndexedDB first (offline-first), then syncs with server in the background with a 5-second timeout.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
@ -312,8 +312,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
|
|
||||||
const initializeHandle = async () => {
|
const initializeHandle = async () => {
|
||||||
try {
|
try {
|
||||||
// OFFLINE-FIRST: Load from IndexedDB immediately, don't wait for network
|
// CRITICAL: Wait for the network adapter to be ready before creating document
|
||||||
// Network sync happens in the background after local data is loaded
|
// This ensures the WebSocket connection is established for sync
|
||||||
|
await adapter.whenReady()
|
||||||
|
|
||||||
|
if (!mounted) return
|
||||||
|
|
||||||
let handle: DocHandle<TLStoreSnapshot>
|
let handle: DocHandle<TLStoreSnapshot>
|
||||||
let loadedFromLocal = false
|
let loadedFromLocal = false
|
||||||
|
|
@ -351,7 +354,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
|
||||||
if (localRecordCount > 0) {
|
if (localRecordCount > 0) {
|
||||||
console.log(`📦 Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
||||||
|
|
||||||
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
|
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
|
||||||
// This ensures shapes with old-format indices like "b1" are fixed
|
// This ensures shapes with old-format indices like "b1" are fixed
|
||||||
|
|
@ -391,144 +394,107 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
|
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
|
|
||||||
// OFFLINE-FIRST: Set the handle and mark as ready BEFORE network sync
|
// Sync with server to get latest data (or upload local changes if offline was edited)
|
||||||
// This allows the UI to render immediately with local data
|
// This ensures we're in sync even if we loaded from IndexedDB
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
let serverDoc = await response.json() as TLStoreSnapshot
|
||||||
|
|
||||||
|
// Migrate server data to fix any invalid indices
|
||||||
|
if (serverDoc.store) {
|
||||||
|
serverDoc = {
|
||||||
|
...serverDoc,
|
||||||
|
store: migrateStoreData(serverDoc.store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||||
|
|
||||||
|
// Get current local state
|
||||||
|
const localDoc = handle.doc()
|
||||||
|
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||||
|
|
||||||
|
// Merge server data with local data
|
||||||
|
// Strategy:
|
||||||
|
// 1. If local is EMPTY, use server data (bootstrap from R2)
|
||||||
|
// 2. If local HAS data, only add server records that don't exist locally
|
||||||
|
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
|
||||||
|
if (serverDoc.store && serverRecordCount > 0) {
|
||||||
|
handle.change((doc: any) => {
|
||||||
|
// Initialize store if it doesn't exist
|
||||||
|
if (!doc.store) {
|
||||||
|
doc.store = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localIsEmpty = Object.keys(doc.store).length === 0
|
||||||
|
let addedFromServer = 0
|
||||||
|
let skippedExisting = 0
|
||||||
|
|
||||||
|
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||||
|
if (localIsEmpty) {
|
||||||
|
// Local is empty - bootstrap everything from server
|
||||||
|
doc.store[id] = record
|
||||||
|
addedFromServer++
|
||||||
|
} else if (!doc.store[id]) {
|
||||||
|
// Local has data but missing this record - add from server
|
||||||
|
// This handles: shapes created on another device and synced to R2
|
||||||
|
doc.store[id] = record
|
||||||
|
addedFromServer++
|
||||||
|
} else {
|
||||||
|
// Record exists locally - preserve local version
|
||||||
|
// The Automerge binary sync will handle merging conflicts via CRDT
|
||||||
|
// This preserves offline edits to existing shapes
|
||||||
|
skippedExisting++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalDoc = handle.doc()
|
||||||
|
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||||
|
console.log(`Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
|
||||||
|
} else if (!loadedFromLocal) {
|
||||||
|
// Server is empty and we didn't load from local - fresh start
|
||||||
|
console.log(`Starting fresh - no data on server or locally`)
|
||||||
|
}
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
// No document found on server
|
||||||
|
if (loadedFromLocal) {
|
||||||
|
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
|
||||||
|
} else {
|
||||||
|
console.log(`No document found on server - starting fresh`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Network error - continue with local data if available
|
||||||
|
if (loadedFromLocal) {
|
||||||
|
console.log(`Offline mode: using local data from IndexedDB`)
|
||||||
|
} else {
|
||||||
|
console.error("Error loading from server (offline?):", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify final document state
|
||||||
|
const finalDoc = handle.doc() as any
|
||||||
|
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||||
|
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
||||||
|
|
||||||
|
// CRITICAL: Set the documentId on the adapter BEFORE setHandle
|
||||||
|
// This ensures the adapter can properly route incoming binary sync messages
|
||||||
|
// The server may send sync messages immediately after connection, before we send anything
|
||||||
if (handle.url) {
|
if (handle.url) {
|
||||||
adapter.setDocumentId(handle.url)
|
adapter.setDocumentId(handle.url)
|
||||||
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we loaded from local, set handle immediately so UI can render
|
setHandle(handle)
|
||||||
if (loadedFromLocal) {
|
setIsLoading(false)
|
||||||
const localDoc = handle.doc() as any
|
|
||||||
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
||||||
console.log(`📴 Offline-ready: ${localShapeCount} shapes available from IndexedDB`)
|
|
||||||
setHandle(handle)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync with server in the background (non-blocking for offline-first)
|
|
||||||
// This runs in parallel - if it fails, we still have local data
|
|
||||||
const syncWithServer = async () => {
|
|
||||||
try {
|
|
||||||
// Wait for network adapter with a timeout
|
|
||||||
const networkReadyPromise = adapter.whenReady()
|
|
||||||
const timeoutPromise = new Promise<'timeout'>((resolve) =>
|
|
||||||
setTimeout(() => resolve('timeout'), 5000)
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await Promise.race([networkReadyPromise, timeoutPromise])
|
|
||||||
|
|
||||||
if (result === 'timeout') {
|
|
||||||
console.log(`⏱️ Network adapter timeout - continuing in offline mode`)
|
|
||||||
// If we haven't set the handle yet (no local data), set it now
|
|
||||||
if (!loadedFromLocal && mounted) {
|
|
||||||
setHandle(handle)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return
|
|
||||||
|
|
||||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
|
||||||
if (response.ok) {
|
|
||||||
let serverDoc = await response.json() as TLStoreSnapshot
|
|
||||||
|
|
||||||
// Migrate server data to fix any invalid indices
|
|
||||||
if (serverDoc.store) {
|
|
||||||
serverDoc = {
|
|
||||||
...serverDoc,
|
|
||||||
store: migrateStoreData(serverDoc.store)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
||||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
|
||||||
|
|
||||||
// Get current local state
|
|
||||||
const localDoc = handle.doc()
|
|
||||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
|
||||||
|
|
||||||
// Merge server data with local data
|
|
||||||
// Strategy:
|
|
||||||
// 1. If local is EMPTY, use server data (bootstrap from R2)
|
|
||||||
// 2. If local HAS data, only add server records that don't exist locally
|
|
||||||
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
|
|
||||||
if (serverDoc.store && serverRecordCount > 0) {
|
|
||||||
handle.change((doc: any) => {
|
|
||||||
// Initialize store if it doesn't exist
|
|
||||||
if (!doc.store) {
|
|
||||||
doc.store = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localIsEmpty = Object.keys(doc.store).length === 0
|
|
||||||
let addedFromServer = 0
|
|
||||||
let skippedExisting = 0
|
|
||||||
|
|
||||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
|
||||||
if (localIsEmpty) {
|
|
||||||
// Local is empty - bootstrap everything from server
|
|
||||||
doc.store[id] = record
|
|
||||||
addedFromServer++
|
|
||||||
} else if (!doc.store[id]) {
|
|
||||||
// Local has data but missing this record - add from server
|
|
||||||
// This handles: shapes created on another device and synced to R2
|
|
||||||
doc.store[id] = record
|
|
||||||
addedFromServer++
|
|
||||||
} else {
|
|
||||||
// Record exists locally - preserve local version
|
|
||||||
// The Automerge binary sync will handle merging conflicts via CRDT
|
|
||||||
// This preserves offline edits to existing shapes
|
|
||||||
skippedExisting++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const finalDoc = handle.doc()
|
|
||||||
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
|
||||||
console.log(`🔄 Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
|
|
||||||
} else if (!loadedFromLocal) {
|
|
||||||
// Server is empty and we didn't load from local - fresh start
|
|
||||||
console.log(`Starting fresh - no data on server or locally`)
|
|
||||||
}
|
|
||||||
} else if (response.status === 404) {
|
|
||||||
// No document found on server
|
|
||||||
if (loadedFromLocal) {
|
|
||||||
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
|
|
||||||
} else {
|
|
||||||
console.log(`No document found on server - starting fresh`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Network error - continue with local data if available
|
|
||||||
if (loadedFromLocal) {
|
|
||||||
console.log(`📴 Offline mode: using local data from IndexedDB`)
|
|
||||||
} else {
|
|
||||||
console.error("Error loading from server (offline?):", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify final document state
|
|
||||||
const finalDoc = handle.doc() as any
|
|
||||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
|
||||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
||||||
console.log(`✅ Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
|
||||||
|
|
||||||
// If we haven't set the handle yet (no local data), set it now after server sync
|
|
||||||
if (!loadedFromLocal && mounted) {
|
|
||||||
setHandle(handle)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server sync in background (don't await - non-blocking)
|
|
||||||
syncWithServer()
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing Automerge handle:", error)
|
console.error("Error initializing Automerge handle:", error)
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue