Files
TREK/client/src/store/collectionStore.ts
T
Maurice 8acc035efa fix(collections): all saved reads its lists four at a time and reloads once per burst of updates
The "All saved" view read every list in full at the same time, and every
collections:* websocket event started that again right away. An agent saving
places one by one over MCP sends one event per place, so an open collections
tab stacked up full reads of every list on the server.

The union now goes through the same batched loader the trip's saved-places
picker uses (four lists at a time), reloads asked for while it runs fold into
one more pass, and a list or union read that lands after the user switched to
something else is dropped instead of painting its places over the new view.
The page waits for a run of events to settle before it reloads and keeps the
selection on "All saved" like it already did on a single list. Desktop and
phone share useCollections, so both get it.

Refs #2559
2026-10-12 12:59:44 +02:00

524 lines
20 KiB
TypeScript

import { create } from 'zustand'
import { collectionsApi } from '../api/collections'
import { loadUnionPlaces } from './collectionUnion'
import type {
Collection,
CollectionPlace,
CollectionMember,
CollectionStatus,
CollectionRole,
CollectionListResponse,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionPlaceUpdateRequest,
CollectionLabel,
CollectionLabelUpdateRequest,
} from '@trek/shared'
/** A pending invitation the current user has received (derived server-side). */
export type IncomingCollectionInvite = CollectionListResponse['incomingInvites'][number]
/** Sentinel id for the client-side "All saved" union pseudo-list. */
export const ALL_SAVED = 'all' as const
export type ActiveCollectionId = number | typeof ALL_SAVED | null
export type CollectionView = 'list' | 'map'
export type StatusFilter = CollectionStatus | 'all'
/** Display order for a list's places: the saved order, or alphabetical by name. */
export type CollectionSortMode = 'default' | 'name_asc'
interface CollectionState {
collections: Collection[]
activeId: ActiveCollectionId
places: CollectionPlace[]
members: CollectionMember[]
labels: CollectionLabel[]
incomingInvites: IncomingCollectionInvite[]
view: CollectionView
statusFilter: StatusFilter
categoryFilter: number | 'all'
ratingFilter: number | 'all'
labelFilter: number[]
sortMode: CollectionSortMode
search: string
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
loading: boolean
placesLoading: boolean
loadAll: () => Promise<void>
loadCollection: (id: number) => Promise<void>
setActive: (id: ActiveCollectionId) => Promise<void>
refreshActive: () => Promise<void>
createCollection: (payload: CollectionCreateRequest) => Promise<Collection | null>
updateCollection: (id: number, updates: CollectionUpdateRequest) => Promise<void>
uploadCover: (id: number, file: File) => Promise<void>
deleteCollection: (id: number) => Promise<void>
reorderCollections: (orderedIds: number[]) => Promise<void>
setStatus: (placeId: number, status: CollectionStatus) => Promise<void>
updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise<void>
uploadPlaceImage: (placeId: number, file: File) => Promise<void>
ratePlace: (placeId: number, rating: number | null) => Promise<void>
deletePlace: (placeId: number) => Promise<void>
deleteMany: (ids: number[]) => Promise<void>
copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
moveToList: (placeIds: number[], targetId: number) => Promise<void>
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
createLabel: (name: string, color?: string) => Promise<void>
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
deleteLabel: (labelId: number) => Promise<void>
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
acceptInvite: (collectionId: number) => Promise<void>
declineInvite: (collectionId: number) => Promise<void>
cancelInvite: (collectionId: number, userId: number) => Promise<void>
removeMember: (collectionId: number, userId: number) => Promise<void>
leave: (collectionId: number) => Promise<void>
setView: (view: CollectionView) => void
setStatusFilter: (filter: StatusFilter) => void
setCategoryFilter: (filter: number | 'all') => void
setRatingFilter: (filter: number | 'all') => void
setLabelFilter: (labelIds: number[]) => void
setSortMode: (mode: CollectionSortMode) => void
setSearch: (search: string) => void
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
toggleSelect: (id: number) => void
setSelectedIds: (ids: number[]) => void
clearSelection: () => void
}
/**
* What the user last asked to look at, and the "All saved" union read that may
* be running for it. A list or union read that comes back after the user has
* moved on is dropped instead of painting the old places over the new ones, and
* reloads of the union asked for while one is running fold into a single further
* pass instead of stacking up full reads of every list (#2559).
*/
const activeLoad = {
/** Bumped by every setActive/refreshActive, so a read can tell it was overtaken. */
seq: 0,
target: null as ActiveCollectionId,
union: null as Promise<void> | null,
unionAgain: false,
}
/** True once a newer setActive has turned away from what a read started at `seq` was for. */
function overtaken(seq: number, id: ActiveCollectionId): boolean {
return activeLoad.seq !== seq && activeLoad.target !== id
}
/**
* Load whatever `id` points at (a list, the "All saved" union, or nothing) into
* the store. Split out of setActive() because switching lists also has to drop
* the per-list UI state, while re-reading the list that is already open must not
* (#1921: every add from the still-open add-place dialog runs a refresh).
*/
async function loadActive(
set: (partial: Partial<CollectionState>) => void,
get: () => CollectionState,
id: ActiveCollectionId,
): Promise<void> {
activeLoad.seq++
activeLoad.target = id
if (id === null) {
set({ activeId: null, places: [], members: [], labels: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
if (activeLoad.union !== null) {
activeLoad.unionAgain = true
return activeLoad.union
}
activeLoad.union = loadUnion(set, get)
return activeLoad.union
}
await get().loadCollection(id)
}
/** The "All saved" union, read again as long as reloads keep coming in while it runs. */
async function loadUnion(
set: (partial: Partial<CollectionState>) => void,
get: () => CollectionState,
): Promise<void> {
try {
do {
activeLoad.unionAgain = false
// Client-side union of every list the user owns or co-owns (no server change).
// On first load the lists may not be fetched yet (loadAll still in flight),
// which would union nothing — make sure they're loaded first.
let lists = get().collections
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
const merged = await loadUnionPlaces(lists.map(l => l.id))
if (activeLoad.target !== ALL_SAVED) return
set({ places: merged })
} while (activeLoad.unionAgain && activeLoad.target === ALL_SAVED)
} finally {
// Cleared here and not after the promise settles, so a reload asked for from
// now on starts a pass of its own instead of joining one that has ended.
activeLoad.union = null
// A list opened meanwhile clears the flag when its own read is done.
if (typeof activeLoad.target !== 'number') set({ placesLoading: false })
}
}
export const useCollectionStore = create<CollectionState>((set, get) => ({
collections: [],
activeId: null,
places: [],
members: [],
labels: [],
incomingInvites: [],
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
ratingFilter: 'all',
labelFilter: [],
sortMode: 'default',
search: '',
selectedPlaceId: null,
selectMode: false,
selectedIds: [],
loading: false,
placesLoading: false,
loadAll: async () => {
set({ loading: true })
try {
const data = await collectionsApi.list()
set({ collections: data.collections, incomingInvites: data.incomingInvites })
} finally {
set({ loading: false })
}
},
loadCollection: async (id: number) => {
const seq = activeLoad.seq
set({ placesLoading: true })
try {
const data = await collectionsApi.get(id)
if (overtaken(seq, id)) return
set({
activeId: id,
places: data.places,
members: data.collection.members ?? [],
labels: data.collection.labels ?? [],
})
} catch {
// The list may have been left / removed / deleted out from under us (a WS
// event or the URL sync can re-request an id we just lost access to). Clear
// it instead of leaving an uncaught 403/404 rejection; useCollections drops
// a route id that is no longer one of the lists.
if (!overtaken(seq, id) && get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
} finally {
// Whatever overtook this read owns the flag now; nothing does after setActive(null).
if (!overtaken(seq, id) || activeLoad.target === null) set({ placesLoading: false })
}
},
setActive: async (id: ActiveCollectionId) => {
// Labels are per-collection, so their filter can't carry across lists.
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
await loadActive(set, get, id)
},
refreshActive: async () => {
const { activeId } = get()
if (activeId === null) return
// Same list, so select mode, the selection and the label filter stay put.
await loadActive(set, get, activeId)
// Only what the reload no longer contains can't stay selected.
const ids = new Set(get().places.map(p => p.id))
const { selectedIds, selectedPlaceId } = get()
const kept = selectedIds.filter(id => ids.has(id))
const keptPlaceId = selectedPlaceId != null && ids.has(selectedPlaceId) ? selectedPlaceId : null
if (kept.length !== selectedIds.length || keptPlaceId !== selectedPlaceId) {
set({ selectedIds: kept, selectedPlaceId: keptPlaceId })
}
},
createCollection: async (payload) => {
// POST answers with the collection itself. Reading `.collection` off it
// returned undefined every time, so the caller never got the new id: a cover
// picked while creating a list was dropped and the list never opened.
const created = await collectionsApi.create(payload)
await get().loadAll()
return created ?? null
},
updateCollection: async (id, updates) => {
await collectionsApi.update(id, updates)
await get().loadAll()
if (get().activeId === id) await get().loadCollection(id)
},
uploadCover: async (id: number, file: File) => {
const fd = new FormData()
fd.append('cover', file)
await collectionsApi.uploadCover(id, fd)
await get().loadAll()
if (get().activeId === id) await get().loadCollection(id)
},
deleteCollection: async (id: number) => {
await collectionsApi.remove(id)
// Labels are per-list, so they have to go with the list they belonged to.
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [], labelFilter: [] })
await get().loadAll()
},
reorderCollections: async (orderedIds: number[]) => {
// optimistic
const byId = new Map(get().collections.map(c => [c.id, c]))
const reordered = orderedIds.map(id => byId.get(id)).filter(Boolean) as Collection[]
set({ collections: reordered })
try {
await collectionsApi.reorder(orderedIds)
} finally {
await get().loadAll()
}
},
setStatus: async (placeId: number, status: CollectionStatus) => {
// optimistic
set({ places: get().places.map(p => (p.id === placeId ? { ...p, status } : p)) })
try {
await collectionsApi.setStatus(placeId, status)
} catch {
await get().refreshActive()
}
},
updatePlace: async (placeId, body) => {
// The endpoint returns the updated place directly (not wrapped in { place }).
const updated = await collectionsApi.updatePlace(placeId, body)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
uploadPlaceImage: async (placeId: number, file: File) => {
const fd = new FormData()
fd.append('image', file)
const updated = await collectionsApi.uploadPlaceImage(placeId, fd)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
ratePlace: async (placeId: number, rating: number | null) => {
const updated = await collectionsApi.ratePlace(placeId, rating)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
deletePlace: async (placeId: number) => {
// optimistic; put the row back if the server refuses, then let the caller toast
const prev = get().places
set({ places: prev.filter(p => p.id !== placeId) })
try {
await collectionsApi.deletePlace(placeId)
} catch (err) {
set({ places: prev })
throw err
}
await get().loadAll()
},
deleteMany: async (ids: number[]) => {
const idSet = new Set(ids)
const prev = get().places
const prevSelected = get().selectedIds
const prevSelectMode = get().selectMode
set({ places: prev.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
try {
await collectionsApi.deleteMany(ids)
} catch (err) {
set({ places: prev, selectedIds: prevSelected, selectMode: prevSelectMode })
throw err
}
await get().loadAll()
},
copyToTrip: async (tripId: number, placeIds: number[], force?: boolean) => {
const res = await collectionsApi.copyToTrip({ trip_id: tripId, place_ids: placeIds, force })
return res
},
// Move the selected places into another list (re-point collection_id). They
// leave the current list, so drop them locally + refresh.
moveToList: async (placeIds: number[], targetId: number) => {
// The fan-out can fail halfway through, so drop only the places that really
// moved and refresh either way — otherwise the open list keeps showing rows
// that already belong to the target.
const moved: number[] = []
let failure: unknown = null
for (const id of placeIds) {
try {
await collectionsApi.updatePlace(id, { collection_id: targetId })
moved.push(id)
} catch (err) {
failure = err
break
}
}
const idSet = new Set(moved)
set({ places: get().places.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
await get().loadAll()
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
if (failure) throw failure
},
// Duplicate the selected places into another list (re-save each place's data).
duplicateToList: async (placeIds: number[], targetId: number) => {
const byId = new Map(get().places.map(p => [p.id, p]))
for (const id of placeIds) {
const p = byId.get(id)
if (!p) continue
await collectionsApi.savePlace({
collection_id: targetId,
name: p.name,
description: p.description ?? null,
lat: p.lat ?? null,
lng: p.lng ?? null,
address: p.address ?? null,
category_id: p.category_id ?? null,
price: p.price ?? null,
currency: p.currency ?? null,
notes: p.notes ?? null,
image_url: p.image_url ?? null,
google_place_id: p.google_place_id ?? null,
google_ftid: p.google_ftid ?? null,
osm_id: p.osm_id ?? null,
website: p.website ?? null,
phone: p.phone ?? null,
status: p.status,
links: p.links ?? [],
force: true,
})
}
set({ selectedIds: [], selectMode: false })
await get().loadAll()
},
createLabel: async (name: string, color?: string) => {
const active = get().activeId
if (typeof active !== 'number') return
await collectionsApi.createLabel(active, name, color)
await get().loadCollection(active)
},
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
// optimistic recolor/rename
const prevLabels = get().labels
set({ labels: prevLabels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
try {
await collectionsApi.updateLabel(labelId, body)
} catch (err) {
set({ labels: prevLabels })
throw err
}
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
deleteLabel: async (labelId: number) => {
// optimistic: drop the label + its assignments + any active filter on it
const prev = { labels: get().labels, labelFilter: get().labelFilter, places: get().places }
set({
labels: prev.labels.filter(l => l.id !== labelId),
labelFilter: prev.labelFilter.filter(id => id !== labelId),
places: prev.places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
})
try {
await collectionsApi.deleteLabel(labelId)
} catch (err) {
set(prev)
throw err
}
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
const idSet = new Set(placeIds)
// optimistic per-place label_ids update
const prevPlaces = get().places
set({
places: prevPlaces.map(p => {
if (!idSet.has(p.id)) return p
const current = new Set(p.label_ids ?? [])
if (remove) labelIds.forEach(id => current.delete(id))
else labelIds.forEach(id => current.add(id))
return { ...p, label_ids: [...current] }
}),
})
try {
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
else await collectionsApi.assignLabels(labelIds, placeIds)
} catch (err) {
set({ places: prevPlaces })
throw err
}
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
await collectionsApi.invite(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
setMemberRole: async (collectionId: number, userId: number, role: CollectionRole) => {
await collectionsApi.setMemberRole(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
acceptInvite: async (collectionId: number) => {
await collectionsApi.acceptInvite(collectionId)
await get().loadAll()
},
declineInvite: async (collectionId: number) => {
await collectionsApi.declineInvite(collectionId)
await get().loadAll()
},
cancelInvite: async (collectionId: number, userId: number) => {
await collectionsApi.cancelInvite(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
removeMember: async (collectionId: number, userId: number) => {
await collectionsApi.removeMember(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
leave: async (collectionId: number) => {
await collectionsApi.leave(collectionId)
// Labels are per-list, so they have to go with the list we just left.
if (get().activeId === collectionId) set({ activeId: null, places: [], members: [], labels: [], labelFilter: [] })
await get().loadAll()
},
setView: (view: CollectionView) => set({ view }),
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
setRatingFilter: (filter: number | 'all') => set({ ratingFilter: filter }),
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
setSortMode: (mode: CollectionSortMode) => set({ sortMode: mode }),
setSearch: (search: string) => set({ search }),
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
toggleSelect: (id: number) => {
const selected = get().selectedIds
set({ selectedIds: selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id] })
},
setSelectedIds: (ids: number[]) => set({ selectedIds: ids }),
clearSelection: () => set({ selectedIds: [], selectMode: false }),
}))