Files
av-planner/src/state/diagramStore.ts
T
aarbitandClaude Sonnet 5 143a96aed1 Add collapsible device nodes, cable bundling, and aggregated edges
Canvas devices can now collapse to a compact per-direction-group view; when
collapsed, cables between the same two devices of the same type merge into
one dashed "virtual" edge with a ×N marker so a busy rig doesn't turn into
a wall of parallel lines. Adds CableBundle so several real Connections
(a stereo RCA pair, a snake's channels) can be grouped and bought as one
BOM line while keeping each leg's own family-checked cable type intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:37 -05:00

481 lines
17 KiB
TypeScript

import { v4 as uuid } from 'uuid'
import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyDiagram, incrementPortName } from '../domain/diagram'
import type { CableBundle, Catalog, Connection, Device, DeviceCategory, Diagram, Port } from '../domain/types'
import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
// This is exactly the swap the repository pattern was built for: only this
// one line changed to move off localStorage-only and onto real (locally
// self-hosted, for now) Supabase persistence — nothing else in the store or
// UI layers needed to change. LocalStorageDiagramRepository is still in the
// codebase as a reference implementation / fallback, just no longer active.
const repository: DiagramRepository = new SupabaseDiagramRepository()
// Which diagram to reopen on next launch — a session/UI preference, not app
// data, so it lives in localStorage even though the diagrams themselves are
// in Supabase now. Falls back gracefully (see loadInitialDiagram) if the
// referenced diagram no longer exists.
const LAST_DIAGRAM_ID_KEY = 'av-planner:lastDiagramId'
function getLastDiagramId(): string | null {
try {
return window.localStorage.getItem(LAST_DIAGRAM_ID_KEY)
} catch {
return null
}
}
function setLastDiagramId(id: string): void {
try {
window.localStorage.setItem(LAST_DIAGRAM_ID_KEY, id)
} catch {
// Not essential — worst case, next launch falls back to the most
// recently updated diagram instead of this exact one.
}
}
async function loadMostRecent(): Promise<Diagram | null> {
const list = await repository.list()
if (list.length === 0) return null
return repository.loadById(list[0].id)
}
interface DiagramStoreState {
diagram: Diagram
isLoaded: boolean
/** Every diagram the current user can see, most recently updated first.
* Populated by refreshDiagramList — not kept live-synced on every store
* change, just refreshed at the moments that matter (initial load, and
* whenever the diagram manager UI opens or changes something). */
diagramList: DiagramSummary[]
/** Who owns the *currently open* diagram and what the current user can do
* with it — refreshed whenever the open diagram changes, not kept live
* (matches §8's turn-based, not realtime, collaboration model: a
* permission change made by the owner while you're viewing takes effect
* next time you load the diagram, not mid-session). Null only during the
* brief window before the very first diagram finishes loading. */
access: DiagramAccess | null
/** Set to a snapshot's id right after restoring it, meaning the live
* diagram data is verbatim identical to that already-existing snapshot
* row — see applyRestoredDiagram. `restoredDiagramUpdatedAt` is the
* diagram's own `updatedAt` at that moment; touch() always advances
* `updatedAt` on a genuine edit, so comparing the two later is a free
* way to tell "has anything changed since this restore" without
* hooking every mutating action. Both null once anything invalidates
* the match (an edit, or loading/switching to a diagram fresh). Used by
* DiagramSnapshotStore to skip writing a pointless duplicate safety
* snapshot when the user restores several old versions in a row without
* editing in between. */
restoredFromSnapshotId: string | null
restoredDiagramUpdatedAt: string | null
selectedDeviceId: string | null
selectedConnectionId: string | null
lastConnectionError: string | null
clearConnectionError: () => void
/** Figures out which diagram to open on launch (last one you had open, else
* most recently updated, else a fresh empty one) and loads it. */
loadInitialDiagram: () => Promise<void>
refreshDiagramList: () => Promise<void>
/** Creates a new diagram, makes it current, and persists it immediately
* (not just via the debounced autosave) so it shows up in the list right away. */
newDiagram: (name?: string) => Promise<void>
renameDiagram: (name: string) => void
/** Imports diagram data under a fresh id, so it's added as a new diagram
* rather than risking a collision with (or silently overwriting) an
* existing one that happens to share the file's id. */
importDiagram: (diagram: Diagram) => Promise<void>
/** Applies a diagram that was already persisted by something else (the
* version-history "Restore" action already wrote the restored data to
* Supabase) — just reflects it in the currently open canvas, without
* re-triggering an import-style "treat this as a brand new diagram". */
applyRestoredDiagram: (diagram: Diagram, snapshotId: string) => void
/** Switches the active diagram to another one of the user's own. */
switchToDiagram: (id: string) => Promise<void>
/** Permanently deletes a diagram. If it's the currently open one, falls
* back to another existing diagram, or a fresh empty one if none remain. */
deleteDiagram: (id: string) => Promise<void>
selectDevice: (id: string | null) => void
selectConnection: (id: string | null) => void
addDeviceFromTemplate: (catalog: Catalog, templateId: string, position: { x: number; y: number }) => void
updateDevicePosition: (id: string, position: { x: number; y: number }) => void
updateDevice: (
id: string,
patch: Partial<Pick<Device, 'name' | 'category' | 'manufacturerId' | 'model' | 'notes' | 'cost'>>,
) => void
removeDevice: (id: string) => void
/** Returns the new port's id, so the caller can move keyboard focus to it. */
addPort: (deviceId: string, port: Omit<Port, 'id'>) => string
/** Inserts a copy of the port right after the original (name auto-incremented) and
* returns the new port's id, so the caller can move keyboard focus to it. */
clonePort: (deviceId: string, portId: string) => string
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => void
removePort: (deviceId: string, portId: string) => void
addConnection: (
catalog: Catalog,
params: {
sourceDeviceId: string
sourcePortId: string
targetDeviceId: string
targetPortId: string
cableTypeId: string
},
) => boolean
updateConnection: (
id: string,
patch: Partial<Pick<Connection, 'cableTypeId' | 'distance' | 'cost' | 'label' | 'notes'>>,
) => void
removeConnection: (id: string) => void
/** Creates a new bundle containing just this one connection — the first
* leg of a stereo pair, snake, etc. Further legs join via
* addConnectionToBundle. */
startBundle: (connectionId: string, fields: Omit<CableBundle, 'id'>) => void
addConnectionToBundle: (connectionId: string, bundleId: string) => void
/** Clears this connection's bundleId; deletes the bundle itself if that
* was its last remaining member. */
removeConnectionFromBundle: (connectionId: string) => void
updateBundle: (bundleId: string, patch: Partial<Omit<CableBundle, 'id'>>) => void
}
function touch(diagram: Diagram): Diagram {
return { ...diagram, updatedAt: new Date().toISOString() }
}
/** Drops any bundle no connection references anymore — called after
* anything that could zero out a bundle's membership (removing a
* connection, or un-bundling its last remaining leg), so an empty bundle
* never lingers in the diagram for a user to stumble on later. */
function pruneEmptyBundles(diagram: Diagram): Diagram {
const referencedIds = new Set(diagram.connections.map((c) => c.bundleId).filter((id): id is string => !!id))
const bundles = diagram.bundles.filter((b) => referencedIds.has(b.id))
return bundles.length === diagram.bundles.length ? diagram : { ...diagram, bundles }
}
export const useDiagramStore = create<DiagramStoreState>((set, get) => ({
diagram: createEmptyDiagram(),
isLoaded: false,
diagramList: [],
access: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
selectedDeviceId: null,
selectedConnectionId: null,
lastConnectionError: null,
clearConnectionError: () => set({ lastConnectionError: null }),
loadInitialDiagram: async () => {
const lastId = getLastDiagramId()
const loaded = lastId ? await repository.loadById(lastId) : null
const diagram = loaded ?? (await loadMostRecent()) ?? createEmptyDiagram()
setLastDiagramId(diagram.id)
const access = await repository.getAccess(diagram.id)
set({ diagram, access, isLoaded: true, restoredFromSnapshotId: null, restoredDiagramUpdatedAt: null })
get().refreshDiagramList()
},
refreshDiagramList: async () => {
const diagramList = await repository.list()
set({ diagramList })
},
newDiagram: async (name = 'Untitled Diagram') => {
const diagram = createEmptyDiagram(name)
setLastDiagramId(diagram.id)
set({
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(diagram)
const access = await repository.getAccess(diagram.id)
set({ access })
get().refreshDiagramList()
},
renameDiagram: (name) => {
set((state) => ({ diagram: touch({ ...state.diagram, name }) }))
},
applyRestoredDiagram: (diagram, snapshotId) => {
set({
diagram,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: snapshotId,
restoredDiagramUpdatedAt: diagram.updatedAt,
})
},
importDiagram: async (diagram) => {
const fresh = touch({ ...diagram, id: uuid() })
setLastDiagramId(fresh.id)
set({
diagram: fresh,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
await repository.save(fresh)
const access = await repository.getAccess(fresh.id)
set({ access })
get().refreshDiagramList()
},
switchToDiagram: async (id) => {
if (id === get().diagram.id) return
const loaded = await repository.loadById(id)
if (!loaded) return
setLastDiagramId(loaded.id)
const access = await repository.getAccess(loaded.id)
set({
diagram: loaded,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
},
deleteDiagram: async (id) => {
await repository.deleteById(id)
if (get().diagram.id === id) {
const next = (await loadMostRecent()) ?? createEmptyDiagram()
setLastDiagramId(next.id)
const access = await repository.getAccess(next.id)
set({
diagram: next,
access,
selectedDeviceId: null,
selectedConnectionId: null,
restoredFromSnapshotId: null,
restoredDiagramUpdatedAt: null,
})
}
get().refreshDiagramList()
},
selectDevice: (id) => set({ selectedDeviceId: id, selectedConnectionId: null }),
selectConnection: (id) => set({ selectedConnectionId: id, selectedDeviceId: null }),
addDeviceFromTemplate: (catalog, templateId, position) => {
const { diagram } = get()
const template = allDeviceTemplates(catalog).find((t) => t.id === templateId)
if (!template) return
const device = createDeviceFromTemplate(template, position)
set({ diagram: touch({ ...diagram, devices: [...diagram.devices, device] }), selectedDeviceId: device.id })
},
updateDevicePosition: (id, position) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, position } : d)),
}),
}))
},
updateDevice: (id, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
}),
}))
},
removeDevice: (id) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.filter((d) => d.id !== id),
connections: state.diagram.connections.filter(
(c) => c.sourceDeviceId !== id && c.targetDeviceId !== id,
),
}),
selectedDeviceId: state.selectedDeviceId === id ? null : state.selectedDeviceId,
}))
},
addPort: (deviceId, port) => {
const newId = uuid()
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId ? { ...d, ports: [...d.ports, { ...port, id: newId }] } : d,
),
}),
}))
return newId
},
updatePort: (deviceId, portId, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId
? { ...d, ports: d.ports.map((p) => (p.id === portId ? { ...p, ...patch } : p)) }
: d,
),
}),
}))
},
clonePort: (deviceId, portId) => {
const newId = uuid()
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) => {
if (d.id !== deviceId) return d
const index = d.ports.findIndex((p) => p.id === portId)
if (index === -1) return d
const source = d.ports[index]
const clone: Port = { ...source, id: newId, name: incrementPortName(source.name) }
return { ...d, ports: [...d.ports.slice(0, index + 1), clone, ...d.ports.slice(index + 1)] }
}),
}),
}))
return newId
},
removePort: (deviceId, portId) => {
set((state) => ({
diagram: touch({
...state.diagram,
devices: state.diagram.devices.map((d) =>
d.id === deviceId ? { ...d, ports: d.ports.filter((p) => p.id !== portId) } : d,
),
connections: state.diagram.connections.filter(
(c) => c.sourcePortId !== portId && c.targetPortId !== portId,
),
}),
}))
},
addConnection: (catalog, { sourceDeviceId, sourcePortId, targetDeviceId, targetPortId, cableTypeId }) => {
const { diagram } = get()
const validation = validateConnection(diagram, catalog, {
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
})
if (!validation.valid) {
set({ lastConnectionError: validation.reason ?? 'Invalid connection.' })
return false
}
const connection: Connection = {
id: uuid(),
sourceDeviceId,
sourcePortId,
targetDeviceId,
targetPortId,
cableTypeId,
}
set({
diagram: touch({ ...diagram, connections: [...diagram.connections, connection] }),
lastConnectionError: null,
selectedConnectionId: connection.id,
selectedDeviceId: null,
})
return true
},
updateConnection: (id, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
connections: state.diagram.connections.map((c) => (c.id === id ? { ...c, ...patch } : c)),
}),
}))
},
removeConnection: (id) => {
set((state) => ({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.filter((c) => c.id !== id),
}),
),
selectedConnectionId: state.selectedConnectionId === id ? null : state.selectedConnectionId,
}))
},
startBundle: (connectionId, fields) => {
const bundle: CableBundle = { id: uuid(), ...fields }
set((state) => ({
diagram: touch({
...state.diagram,
bundles: [...state.diagram.bundles, bundle],
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: bundle.id } : c,
),
}),
}))
},
addConnectionToBundle: (connectionId, bundleId) => {
set((state) => ({
diagram: touch({
...state.diagram,
connections: state.diagram.connections.map((c) => (c.id === connectionId ? { ...c, bundleId } : c)),
}),
}))
},
removeConnectionFromBundle: (connectionId) => {
set((state) => ({
diagram: touch(
pruneEmptyBundles({
...state.diagram,
connections: state.diagram.connections.map((c) =>
c.id === connectionId ? { ...c, bundleId: undefined } : c,
),
}),
),
}))
},
updateBundle: (bundleId, patch) => {
set((state) => ({
diagram: touch({
...state.diagram,
bundles: state.diagram.bundles.map((b) => (b.id === bundleId ? { ...b, ...patch } : b)),
}),
}))
},
}))
// Debounced autosave: fires whenever `diagram` changes (skipped until the
// initial load completes, so we don't immediately overwrite storage with a
// blank diagram on first mount).
let saveTimeout: ReturnType<typeof setTimeout> | undefined
useDiagramStore.subscribe((state, prevState) => {
// Skip the isLoaded:false -> true transition itself (that's the initial
// load, not a user edit) and any tick where `diagram` didn't change.
if (!prevState.isLoaded) return
if (state.diagram === prevState.diagram) return
// Defense in depth: the UI shouldn't expose any mutating control to a
// view-only collaborator in the first place (see FlowCanvas/inspectors),
// but skip the write here too rather than relying on RLS's silent no-op.
if (state.access?.myPermission === 'view') return
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
repository.save(state.diagram).catch((err) => console.error('Autosave failed', err))
}, 400)
})
export type { DeviceCategory }