Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 15 additions & 32 deletions frontend-v2/src/app/event/activist-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,18 @@ export type ActivistRecord = {
* Reads are synchronous (from memory) for fast autocomplete/filtering.
* Writes are async and automatically persist to IndexedDB when storage is configured.
*
* @param storage - Optional IndexedDB storage for persistence.
* When provided, enables automatic write-through caching.
* When omitted, registry operates in memory-only mode.
* When storage is not configured, registry operates in memory-only mode.
*/
export class ActivistRegistry {
private activists: ActivistRecord[]
private activistsByName: Map<string, ActivistRecord>
private activistsById: Map<number, ActivistRecord>
private storage?: ActivistStorage

constructor(storage?: ActivistStorage) {
constructor() {
this.activists = []
this.activistsByName = new Map()
this.activistsById = new Map()
this.storage = storage
}

/**
Expand All @@ -48,24 +45,18 @@ export class ActivistRegistry {
/**
* Loads activists from IndexedDB storage into memory.
* Call once after construction, before first use of the registry.
* @throws Error if storage is not configured
*/
async loadFromStorage(): Promise<void> {
if (!this.storage) {
throw new Error(
'Cannot load activists from storage: storage not configured.',
)
}
async loadFromStorage(storage: ActivistStorage): Promise<void> {
this.storage = storage

const stored = await this.storage.getAllActivists()
this.activists = stored
this.activists = await this.storage.getAllActivists()
this.sortActivists()
this.activistsByName = new Map(stored.map((a) => [a.name, a]))
this.activistsById = new Map(stored.map((a) => [a.id, a]))
this.activistsByName = new Map(this.activists.map((a) => [a.name, a]))
this.activistsById = new Map(this.activists.map((a) => [a.id, a]))
}

/**
* Merges new activists with existing data, replacing duplicates by id.
* Merges new activists with existing data.
* If storage is configured, persists updates to IndexedDB.
*/
async mergeActivists(newActivists: ActivistRecord[]): Promise<void> {
Expand All @@ -79,27 +70,17 @@ export class ActivistRegistry {
const existingIndex = indexById.get(activist.id) ?? -1

if (existingIndex >= 0) {
// Update existing activist (handles renames properly)
const oldActivist = this.activists[existingIndex]
this.activists[existingIndex] = activist

// Remove old name from index if name changed
if (oldActivist.name !== activist.name) {
this.activistsByName.delete(oldActivist.name)
}
} else {
// Add new activist
this.activists.push(activist)
indexById.set(activist.id, this.activists.length - 1)
}

// Update indexes
this.activistsByName.set(activist.name, activist)
this.activistsById.set(activist.id, activist)
}

// Re-sort after batch updates to maintain sort order
this.sortActivists()
this.activistsByName = new Map(this.activists.map((a) => [a.name, a]))

// Write through to storage if configured
await this.storage?.saveActivists(newActivists)
Expand All @@ -116,14 +97,16 @@ export class ActivistRegistry {

const idsToRemove = new Set(ids)

this.activists = this.activists.filter((activist) => {
const remainingActivists: ActivistRecord[] = []
for (const activist of this.activists) {
if (idsToRemove.has(activist.id)) {
this.activistsByName.delete(activist.name)
this.activistsById.delete(activist.id)
return false
continue
}
return true
})
remainingActivists.push(activist)
}
this.activists = remainingActivists

// Write through to storage if configured
await this.storage?.deleteActivistsByIds(ids)
Expand Down
7 changes: 5 additions & 2 deletions frontend-v2/src/app/event/activist-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Provides caching and incremental sync capabilities.
*
* Why IndexedDB instead of localStorage:
* - Data size: ~3MB currently, approaching localStorage's 5-10MB limit
* - Data size: ~3MB as of January 2026, approaching localStorage's 5-10MB limit
* - Async operations: Avoid blocking main thread with large JSON parse/stringify
* - Structured storage: Store by ID with built-in indexing
*/
Expand Down Expand Up @@ -121,7 +121,10 @@ export class ActivistStorage {
const request =
timestamp === null
? store.delete('lastSync')
: store.put({ lastSyncTime: timestamp }, 'lastSync')
: store.put(
{ lastSyncTime: timestamp } satisfies SyncMetadata,
'lastSync',
)

request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
Expand Down
9 changes: 4 additions & 5 deletions frontend-v2/src/app/event/useActivistRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import toast from 'react-hot-toast'
*/
export function useActivistRegistry() {
// Single registry instance with write-through storage to IndexedDB (if available)
const registryRef = useRef(new ActivistRegistry(activistStorage))
const registry = new ActivistRegistry()
const registryRef = useRef(registry)
const [isStorageLoaded, setIsStorageLoaded] = useState(false)
const [isServerLoaded, setIsServerLoaded] = useState(false)

Expand All @@ -33,7 +34,7 @@ export function useActivistRegistry() {
}

registryRef.current
.loadFromStorage()
.loadFromStorage(activistStorage)
.then(() => {
if (mounted) setIsStorageLoaded(true)
})
Expand Down Expand Up @@ -131,9 +132,7 @@ export function useActivistRegistry() {
}

// Merge newer activists (registry handles both memory and storage)
if (activistsToUpdate.length > 0) {
await registryRef.current.mergeActivists(activistsToUpdate)
}
await registryRef.current.mergeActivists(activistsToUpdate)

// Update last sync timestamp
await registryRef.current.setLastSyncTime(new Date().toISOString())
Expand Down