From 6c45c97ffd303161a50b9f2ce92c87e54794c8d2 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 22 Jul 2026 20:46:32 +0200 Subject: [PATCH] Extract circuit grid projection --- docs/circuit-list-editor-architecture.md | 1 + package.json | 4 +- .../components/circuit-tree-editor.tsx | 179 +---------------- src/frontend/utils/circuit-grid-projection.ts | 186 ++++++++++++++++++ tests/circuit-grid-projection.test.ts | 144 ++++++++++++++ 5 files changed, 343 insertions(+), 171 deletions(-) create mode 100644 src/frontend/utils/circuit-grid-projection.ts create mode 100644 tests/circuit-grid-projection.test.ts diff --git a/docs/circuit-list-editor-architecture.md b/docs/circuit-list-editor-architecture.md index b2038ef..95f53d1 100644 --- a/docs/circuit-list-editor-architecture.md +++ b/docs/circuit-list-editor-architecture.md @@ -8,6 +8,7 @@ The circuit-list editor is a circuit-first planning workspace for distribution-b - `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration. - `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values. +- `circuit-grid-projection.ts` owns block-preserving filtering/sorting and the normalized visible row projection. - `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions. - `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements. diff --git a/package.json b/package.json index 78c83d4..62f9e36 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 803a5c1..d31dc2e 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -17,16 +17,11 @@ import { } from "../utils/circuit-grid-safety"; import { allColumns, - circuitOnlyColumns, - compareSortValues, defaultVisibleColumnKeys, deviceFieldKeys, formatValue, - getBlockSortValue, - getCellKind, getCircuitValue, getDeviceValue, - normalizeFilterValue, parseNumeric, } from "../utils/circuit-grid-model"; import type { @@ -35,6 +30,12 @@ import type { ColumnDef, RowType, } from "../utils/circuit-grid-model"; +import { + buildVisibleGridRows, + filterAndSortCircuitSections, + getDistinctFilterValues, +} from "../utils/circuit-grid-projection"; +import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { createCircuit, createCircuitDeviceRow, @@ -82,22 +83,6 @@ interface SelectionIntent { deviceId?: string; } -interface VisibleGridCell { - cellKey: CellKey; - editable: boolean; - kind: CellKind; - value: string | number | boolean | undefined; -} - -interface VisibleGridRow { - rowKey: string; - rowType: RowType; - sectionId: string; - circuit?: CircuitTreeCircuitDto; - device?: CircuitTreeDeviceRowDto; - cells: VisibleGridCell[]; -} - interface HistoryCommand { label: string; redo: () => Promise; @@ -332,129 +317,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const distinctValuesByColumn = useMemo(() => { // Filter options are generated only for visible columns to match current header UI. // Hidden columns still exist in row model, but are intentionally not filterable from header. - const result = {} as Record; - for (const column of visibleColumns) { - const set = new Set(); - for (const section of data?.sections ?? []) { - for (const circuit of section.circuits) { - set.add(normalizeFilterValue(getCircuitValue(circuit, column.key))); - for (const row of circuit.deviceRows) { - set.add(normalizeFilterValue(getDeviceValue(row, column.key))); - } - } - } - result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - } - return result; + return getDistinctFilterValues(data?.sections ?? [], visibleColumns); }, [data, visibleColumns]); const filteredSortedSections = useMemo(() => { // Filtering and sorting are frontend view state. Backend sort order remains unchanged // until user explicitly applies sorted order. - if (!data) { - return [] as CircuitTreeResponseDto["sections"]; - } - const matchesColumnFilter = ( - circuit: CircuitTreeCircuitDto, - row: CircuitTreeDeviceRowDto | null, - key: CellKey, - selected: string[] - ) => { - if (!selected.length) { - return true; - } - const values = new Set(); - values.add(normalizeFilterValue(getCircuitValue(circuit, key))); - if (row) { - values.add(normalizeFilterValue(getDeviceValue(row, key))); - } else { - for (const device of circuit.deviceRows) { - values.add(normalizeFilterValue(getDeviceValue(device, key))); - } - } - return selected.some((entry) => values.has(entry)); - }; - - const sections = data.sections - .map((section) => { - let circuits = section.circuits - .map((circuit) => { - const selectedFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0); - const circuitMatchesAll = selectedFilters.every(([key, values]) => - matchesColumnFilter(circuit, null, key as CellKey, values ?? []) - ); - if (!circuitMatchesAll) { - return null; - } - - if (!hasActiveFilters || circuit.deviceRows.length <= 1) { - return circuit; - } - - const matchingRows = circuit.deviceRows.filter((row) => - selectedFilters.every(([key, values]) => - matchesColumnFilter(circuit, row, key as CellKey, values ?? []) - ) - ); - - if (matchingRows.length > 0) { - return { ...circuit, deviceRows: matchingRows }; - } - if (selectedFilters.some(([key]) => circuitOnlyColumns.has(key as CellKey))) { - return circuit; - } - return null; - }) - .filter(Boolean) as CircuitTreeCircuitDto[]; - - if (sortState) { - // Sort on circuit blocks so multi-device circuits keep row grouping integrity. - circuits = [...circuits].sort((a, b) => { - const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key)); - return sortState.direction === "asc" ? cmp : -cmp; - }); - } - return { ...section, circuits }; - }) - .filter((section) => section.circuits.length > 0); - - return sections; - }, [data, columnFilters, hasActiveFilters, sortState]); + return filterAndSortCircuitSections(data?.sections ?? [], columnFilters, sortState); + }, [data, columnFilters, sortState]); // visibleRows is the single normalized grid used by render + navigation + editability. // This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads. const visibleRows = useMemo(() => { - if (!filteredSortedSections.length) { - return [] as VisibleGridRow[]; - } - const rows: VisibleGridRow[] = []; - for (const section of filteredSortedSections) { - rows.push({ - rowKey: `section:${section.id}`, - rowType: "section", - sectionId: section.id, - // Section headers are structural markers only: visible, but never editable/selectable. - cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })), - }); - for (const circuit of section.circuits) { - if (circuit.deviceRows.length === 0) { - rows.push(makeRow("reserveCircuit", section.id, circuit)); - continue; - } - if (circuit.deviceRows.length === 1) { - rows.push(makeRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); - continue; - } - rows.push(makeRow("circuitSummary", section.id, circuit)); - for (const device of circuit.deviceRows) { - rows.push(makeRow("deviceRow", section.id, circuit, device)); - } - } - // Placeholder row is virtual (-frei-) but intentionally part of editable grid because - // editing/dropping here is the fast path to create a real circuit in this section. - rows.push(makeRow("placeholder", section.id)); - } - return rows; + return buildVisibleGridRows(filteredSortedSections); }, [filteredSortedSections]); const searchableProjectDevices = useMemo(() => { @@ -490,40 +365,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str [allCircuits] ); - function makeRow( - rowType: RowType, - sectionId: string, - circuit?: CircuitTreeCircuitDto, - device?: CircuitTreeDeviceRowDto - ): VisibleGridRow { - // Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh. - const rowKey = - rowType === "placeholder" - ? `placeholder:${sectionId}` - : rowType === "deviceRow" && device - ? `device:${device.id}` - : `${rowType}:${circuit?.id ?? sectionId}`; - const cells = allColumns.map((col) => { - const kind = getCellKind(rowType, col.key); - // Hidden columns remain in the normalized row data; only visibility controls rendering. - const editable = kind === "circuitField" || kind === "deviceField"; - let value: string | number | boolean | undefined; - if (rowType === "placeholder") { - value = col.key === "equipmentIdentifier" ? "-frei-" : undefined; - } else if (kind === "deviceField" && device) { - value = getDeviceValue(device, col.key); - } else if (kind === "circuitField" && circuit) { - value = getCircuitValue(circuit, col.key); - } else if (col.key === "circuitTotalPower" && circuit) { - value = circuit.circuitTotalPower; - } else if (col.key === "rowTotalPower" && device) { - value = device.rowTotalPower; - } - return { cellKey: col.key, editable, kind, value }; - }); - return { rowKey, rowType, sectionId, circuit, device, cells }; - } - const editableCells = useMemo( () => visibleRows.flatMap((row) => diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts new file mode 100644 index 0000000..a5bda32 --- /dev/null +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -0,0 +1,186 @@ +import type { + CircuitTreeCircuitDto, + CircuitTreeDeviceRowDto, + CircuitTreeSectionDto, +} from "../types.js"; +import { + allColumns, + circuitOnlyColumns, + compareSortValues, + deviceFieldKeys, + getBlockSortValue, + getCellKind, + getCircuitValue, + getDeviceValue, + normalizeFilterValue, +} from "./circuit-grid-model"; +import type { + CellKey, + CellKind, + ColumnDef, + GridValue, + RowType, +} from "./circuit-grid-model"; + +export type ColumnFilters = Partial>; + +export interface GridSortState { + key: CellKey; + direction: "asc" | "desc"; +} + +export interface VisibleGridCell { + cellKey: CellKey; + editable: boolean; + kind: CellKind; + value: GridValue; +} + +export interface VisibleGridRow { + rowKey: string; + rowType: RowType; + sectionId: string; + circuit?: CircuitTreeCircuitDto; + device?: CircuitTreeDeviceRowDto; + cells: VisibleGridCell[]; +} + +function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set { + const values = new Set(); + if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") { + values.add(normalizeFilterValue(getCircuitValue(circuit, key))); + } + if (!circuitOnlyColumns.has(key)) { + for (const device of circuit.deviceRows) { + values.add(normalizeFilterValue(getDeviceValue(device, key))); + } + } + if (values.size === 0 && deviceFieldKeys.has(key)) { + values.add(normalizeFilterValue(undefined)); + } + return values; +} + +export function getDistinctFilterValues( + sections: readonly CircuitTreeSectionDto[], + columns: readonly ColumnDef[] +): Record { + const result = {} as Record; + for (const column of columns) { + const values = new Set(); + for (const section of sections) { + for (const circuit of section.circuits) { + for (const value of getCircuitBlockFilterValues(circuit, column.key)) { + values.add(value); + } + } + } + result[column.key] = [...values].sort((left, right) => + left.localeCompare(right, undefined, { numeric: true }) + ); + } + return result; +} + +export function filterAndSortCircuitSections( + sections: readonly CircuitTreeSectionDto[], + columnFilters: ColumnFilters, + sortState: GridSortState | null +): CircuitTreeSectionDto[] { + const activeFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0) as Array< + [CellKey, string[]] + >; + + return sections + .map((section) => { + let circuits = section.circuits.filter((circuit) => + activeFilters.every(([key, selectedValues]) => { + const blockValues = getCircuitBlockFilterValues(circuit, key); + return selectedValues.some((selectedValue) => blockValues.has(selectedValue)); + }) + ); + + if (sortState) { + // Circuit blocks are sorted as units; their device row membership/order is untouched. + circuits = [...circuits].sort((left, right) => { + const comparison = compareSortValues( + getBlockSortValue(left, sortState.key), + getBlockSortValue(right, sortState.key) + ); + return sortState.direction === "asc" ? comparison : -comparison; + }); + } + + return { ...section, circuits }; + }) + .filter((section) => section.circuits.length > 0); +} + +function makeVisibleGridRow( + rowType: RowType, + sectionId: string, + circuit?: CircuitTreeCircuitDto, + device?: CircuitTreeDeviceRowDto +): VisibleGridRow { + const rowKey = + rowType === "placeholder" + ? `placeholder:${sectionId}` + : rowType === "deviceRow" && device + ? `device:${device.id}` + : `${rowType}:${circuit?.id ?? sectionId}`; + + const cells = allColumns.map((column): VisibleGridCell => { + const kind = getCellKind(rowType, column.key); + const editable = kind === "circuitField" || kind === "deviceField"; + let value: GridValue; + + if (rowType === "placeholder") { + value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; + } else if (kind === "deviceField" && device) { + value = getDeviceValue(device, column.key); + } else if (kind === "circuitField" && circuit) { + value = getCircuitValue(circuit, column.key); + } else if (column.key === "circuitTotalPower" && circuit) { + value = circuit.circuitTotalPower; + } else if (column.key === "rowTotalPower" && device) { + value = device.rowTotalPower; + } + + return { cellKey: column.key, editable, kind, value }; + }); + + return { rowKey, rowType, sectionId, circuit, device, cells }; +} + +export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] { + const rows: VisibleGridRow[] = []; + for (const section of sections) { + rows.push({ + rowKey: `section:${section.id}`, + rowType: "section", + sectionId: section.id, + cells: allColumns.map((column) => ({ + cellKey: column.key, + editable: false, + kind: "readonly", + value: undefined, + })), + }); + + for (const circuit of section.circuits) { + if (circuit.deviceRows.length === 0) { + rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); + } else if (circuit.deviceRows.length === 1) { + rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); + } else { + rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit)); + for (const device of circuit.deviceRows) { + rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device)); + } + } + } + + rows.push(makeVisibleGridRow("placeholder", section.id)); + } + return rows; +} diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts new file mode 100644 index 0000000..e5f1f9a --- /dev/null +++ b/tests/circuit-grid-projection.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildVisibleGridRows, + filterAndSortCircuitSections, + getDistinctFilterValues, +} from "../src/frontend/utils/circuit-grid-projection.js"; +import type { + CircuitTreeCircuitDto, + CircuitTreeDeviceRowDto, + CircuitTreeSectionDto, +} from "../src/frontend/types.js"; + +function makeDevice( + id: string, + displayName: string, + roomNumberSnapshot: string, + sortOrder: number +): CircuitTreeDeviceRowDto { + return { + id, + sortOrder, + name: displayName, + displayName, + roomNumberSnapshot, + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + rowTotalPower: 0.1, + }; +} + +function makeCircuit( + id: string, + equipmentIdentifier: string, + displayName: string, + deviceRows: CircuitTreeDeviceRowDto[], + sortOrder: number +): CircuitTreeCircuitDto { + return { + id, + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier, + displayName, + sortOrder, + isReserve: deviceRows.length === 0, + circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0), + deviceRows, + }; +} + +const multiDeviceCircuit = makeCircuit( + "circuit-multi", + "-1F2", + "Office lighting", + [makeDevice("device-office", "Office light", "1.01", 10), makeDevice("device-store", "Store light", "1.02", 20)], + 20 +); +const compactCircuit = makeCircuit( + "circuit-compact", + "-1F1", + "Corridor lighting", + [makeDevice("device-corridor", "Corridor light", "1.03", 10)], + 10 +); +const reserveCircuit = makeCircuit("circuit-reserve", "-1F3", "Reserve", [], 30); + +const sections: CircuitTreeSectionDto[] = [ + { + id: "section-1", + key: "lighting", + displayName: "Lighting", + prefix: "-1F", + sortOrder: 10, + circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit], + }, +]; + +describe("circuit grid projection", () => { + it("keeps every device row in a circuit block when one device matches a filter", () => { + const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null); + + assert.deepEqual(result.map((section) => section.circuits.map((circuit) => circuit.id)), [["circuit-multi"]]); + assert.deepEqual(result[0].circuits[0].deviceRows.map((row) => row.id), ["device-office", "device-store"]); + }); + + it("combines column filters across the complete circuit block", () => { + const result = filterAndSortCircuitSections( + sections, + { equipmentIdentifier: ["-1F2"], roomNumberSnapshot: ["1.02"] }, + null + ); + + assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-multi"]); + }); + + it("treats missing device values as missing without matching populated circuits", () => { + const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["-"] }, null); + + assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-reserve"]); + }); + + it("sorts complete circuit blocks without changing device row order", () => { + const result = filterAndSortCircuitSections(sections, {}, { key: "equipmentIdentifier", direction: "desc" }); + + assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), [ + "circuit-reserve", + "circuit-multi", + "circuit-compact", + ]); + assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]); + }); + + it("builds the compact, grouped, reserve and placeholder row shapes", () => { + const rows = buildVisibleGridRows(sections); + + assert.deepEqual(rows.map((row) => row.rowType), [ + "section", + "circuitCompact", + "circuitSummary", + "deviceRow", + "deviceRow", + "reserveCircuit", + "placeholder", + ]); + assert.equal(rows.at(-1)?.rowKey, "placeholder:section-1"); + assert.equal( + rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value, + "-frei-" + ); + }); + + it("collects filter options from both circuit and device values", () => { + const values = getDistinctFilterValues(sections, [ + { key: "displayName", label: "Display name" }, + { key: "roomNumberSnapshot", label: "Room number" }, + ]); + + assert.ok(values.displayName.includes("Office lighting")); + assert.ok(values.displayName.includes("Store light")); + assert.ok(values.roomNumberSnapshot.includes("1.03")); + }); +});