From 0c5f1ef5253653ef427024f00b003c91a612220d Mon Sep 17 00:00:00 2001 From: Ross Tomsic Date: Tue, 11 Aug 2026 15:20:36 -0400 Subject: [PATCH] Add panel visualization --- frontend/e2e/hearth.spec.ts | 80 +++++++++- frontend/src/App.css | 138 +++++++++++++++- frontend/src/App.tsx | 20 ++- frontend/src/api.ts | 1 + frontend/src/components/FloorplanView.tsx | 12 +- frontend/src/components/PanelEditor.tsx | 182 +++++++++++++++------- 6 files changed, 367 insertions(+), 66 deletions(-) diff --git a/frontend/e2e/hearth.spec.ts b/frontend/e2e/hearth.spec.ts index 93ad791..9df3413 100644 --- a/frontend/e2e/hearth.spec.ts +++ b/frontend/e2e/hearth.spec.ts @@ -30,6 +30,14 @@ const panel = { fed_from_panel_id: null, }; +const subpanel = { + id: 2, + name: 'Workshop subpanel', + room_id: 1, + amperage: 100, + fed_from_panel_id: 1, +}; + const circuit = { id: 1, panel_id: 1, @@ -44,8 +52,18 @@ const secondCircuit = { ...circuit, id: 2, breaker_label: '2', + poles: 2, panel_sticker_text: 'Garage lights', - verified_description: 'Garage lights and switches', + verified_description: null, +}; + +const subpanelCircuit = { + ...circuit, + id: 3, + panel_id: 2, + amperage: 15, + panel_sticker_text: 'Workshop bench', + verified_description: 'Workshop bench and task lights', }; const point = { @@ -85,17 +103,21 @@ async function mockApi(page: Page): Promise { return; } if (path === '/api/panels' && method === 'GET') { - await route.fulfill({ json: [panel] }); + await route.fulfill({ json: [panel, subpanel] }); return; } if (path === '/api/circuits' && method === 'GET') { - await route.fulfill({ json: [circuit, secondCircuit] }); + await route.fulfill({ json: [circuit, secondCircuit, subpanelCircuit] }); return; } if (path === '/api/floorplan/main' && method === 'GET') { await route.fulfill({ json: { rooms: [room], circuit_points: storedPoints } }); return; } + if (path === '/api/circuit-points' && method === 'GET') { + await route.fulfill({ json: storedPoints }); + return; + } if (path === '/api/circuit-points' && method === 'POST') { const body = request.postDataJSON() as Record; const created = { id: nextPointId++, ...body }; @@ -264,12 +286,46 @@ test('makes the floorplan controls keyboard-operable and named', async ({ page } await page.keyboard.press('Enter'); await expect(page.getByRole('heading', { level: 3, name: 'outlet' })).toBeVisible(); - const circuitButton = page.getByRole('button', { name: /Breaker 1/ }); + const circuitButton = page.getByRole('button', { name: /Breaker 1 — Garage/ }); await circuitButton.focus(); await page.keyboard.press('Enter'); await expect(circuitButton).toHaveClass(/selected/); }); +test('shows panel status and opens mapped breakers on the floorplan', async ({ page }) => { + await mockApi(page); + await page.goto('/'); + await page.getByRole('button', { name: 'Panels & circuits' }).click(); + + const mainPanel = page.getByRole('region', { name: 'Main panel breaker directory' }); + const mappedBreaker = mainPanel.locator('.breaker-slot').filter({ + hasText: 'Garage north and east walls', + }); + const unmappedBreaker = mainPanel.locator('.breaker-slot').filter({ hasText: 'Garage lights' }); + + await expect(page.getByText('Feeds Workshop subpanel.')).toBeVisible(); + await expect(page.getByText('Fed from Main panel.')).toBeVisible(); + await expect(mappedBreaker.getByText('1 mapped point', { exact: true })).toBeVisible(); + await expect(mappedBreaker.getByText('Verified', { exact: true })).toBeVisible(); + await expect(unmappedBreaker.getByText('Unmapped', { exact: true })).toBeVisible(); + await expect(unmappedBreaker.getByText('Needs verification', { exact: true })).toBeVisible(); + await expect( + unmappedBreaker.getByRole('button', { name: 'View breaker 2 on floorplan' }), + ).toBeDisabled(); + + const mappedBox = await mappedBreaker.boundingBox(); + const unmappedBox = await unmappedBreaker.boundingBox(); + expect(unmappedBox?.height).toBeGreaterThan(mappedBox?.height ?? 0); + + await mappedBreaker.getByRole('button', { name: 'View breaker 1 on floorplan' }).click(); + await expect(page.getByRole('heading', { level: 2, name: 'Floorplan' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Breaker 1 — Garage/ })).toHaveClass(/selected/); + await expect(page.getByRole('button', { name: 'outlet: North wall outlet' })).toHaveAttribute( + 'stroke', + '#f97316', + ); +}); + test('requires confirmation before deleting a point', async ({ page }) => { const state = await mockApi(page); await page.goto('/'); @@ -304,3 +360,19 @@ test('keeps circuit-walk controls reachable over the phone floorplan', async ({ expect((walkBox?.x ?? 0) + (walkBox?.width ?? 0)).toBeLessThanOrEqual(390); await expect(page.getByRole('button', { name: 'Finish walk' })).toBeVisible(); }); + +test('keeps panel controls contained at phone width', async ({ page }) => { + await mockApi(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.getByRole('button', { name: 'Panels & circuits' }).click(); + + const mainPanel = page.locator('.panel-card').first(); + const deletePanel = mainPanel.getByRole('button', { name: 'Delete panel' }); + const deleteBox = await deletePanel.boundingBox(); + expect(deleteBox?.height).toBeLessThan(50); + await expect( + page.getByLabel('Workshop subpanel mapping coverage').locator('span').first(), + ).toHaveText('1 circuit'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); diff --git a/frontend/src/App.css b/frontend/src/App.css index 6795c7d..a2a183b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -135,16 +135,127 @@ .panel-card { border: 1px solid #999; - border-radius: 4px; - padding: 0.75rem; - margin-bottom: 1rem; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; } .panel-card-header { display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.panel-card-header > button { + flex-shrink: 0; +} + +.panel-card-header h3, +.panel-card-header p, +.breaker-details h4, +.breaker-details p { + margin-top: 0; +} + +.panel-card-header h3, +.panel-location, +.panel-feed { + margin-bottom: 0.25rem; +} + +.panel-amperage { + font-size: 0.85em; + font-weight: normal; +} + +.panel-coverage { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + padding: 0.5rem 0; + margin: 0.75rem 0; + border-top: 1px solid #999; + border-bottom: 1px solid #999; +} + +.breaker-directory { + display: grid; gap: 0.5rem; +} + +.breaker-slot { + display: grid; + grid-template-columns: minmax(5.5rem, 7rem) minmax(0, 1fr) minmax(10rem, auto); + border: 1px solid #777; + border-radius: 6px; + overflow: hidden; +} + +.breaker-slot.unmapped { + border-style: dashed; +} + +.breaker-handle { + display: flex; + flex-direction: column; align-items: center; - margin-bottom: 0.5rem; + justify-content: center; + min-height: 5rem; + padding: 0.5rem; + border-right: 1px solid #777; + background: color-mix(in srgb, CanvasText 8%, Canvas); + text-align: center; +} + +.breaker-handle strong { + font-size: 1.5rem; +} + +.breaker-details { + align-self: center; + padding: 0.75rem; +} + +.breaker-details h4, +.breaker-details p { + margin-bottom: 0.25rem; +} + +.status-badges { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.5rem; +} + +.status-badge { + padding: 0.1rem 0.4rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.8rem; +} + +.status-badge.mapped, +.status-badge.verified { + color: #047857; +} + +.status-badge.warning { + color: #b45309; +} + +.breaker-actions { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; +} + +.circuit-form { + padding-top: 0.75rem; + border-top: 1px solid #999; } .error { @@ -240,4 +351,23 @@ .walk-sidebar .info-card:last-child { margin-bottom: 0; } + + .panel-card { + padding: 0.75rem; + } + + .breaker-actions { + align-items: stretch; + } + + .breaker-slot { + grid-template-columns: 5rem minmax(0, 1fr); + } + + .breaker-actions { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + border-top: 1px solid #777; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca87e72..bc33c67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,13 +8,22 @@ type Tab = 'floorplan' | 'rooms' | 'panels'; function App() { const [tab, setTab] = useState('floorplan'); + const [floorplanTarget, setFloorplanTarget] = useState<{ + circuitId: number; + floor: string; + } | null>(null); + + function openFloorplan(circuitId?: number, floor?: string) { + setFloorplanTarget(circuitId != null && floor ? { circuitId, floor } : null); + setTab('floorplan'); + } return (

Hearth

- {tab === 'floorplan' && } + {tab === 'floorplan' && ( + + )} {tab === 'rooms' && } - {tab === 'panels' && } + {tab === 'panels' && }
); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 710b59e..93e51e0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -47,6 +47,7 @@ export const api = { remove: (id: number) => del(`/circuits/${id}`), }, circuitPoints: { + list: () => request('/circuit-points'), create: (point: Omit) => post('/circuit-points', point), update: (id: number, point: Partial>) => patch(`/circuit-points/${id}`, point), diff --git a/frontend/src/components/FloorplanView.tsx b/frontend/src/components/FloorplanView.tsx index 25dd1ee..015b57a 100644 --- a/frontend/src/components/FloorplanView.tsx +++ b/frontend/src/components/FloorplanView.tsx @@ -38,14 +38,20 @@ function pointAccessibleLabel(point: CircuitPoint): string { return `${point.kind}: ${point.label ?? `point ${point.id}`}`; } -export function FloorplanView() { +export function FloorplanView({ + initialCircuitId, + initialFloor, +}: { + initialCircuitId?: number; + initialFloor?: string; +}) { const [allRooms, setAllRooms] = useState([]); const [panels, setPanels] = useState([]); const [circuits, setCircuits] = useState([]); - const [floor, setFloor] = useState(''); + const [floor, setFloor] = useState(initialFloor ?? ''); const [plan, setPlan] = useState({ rooms: [], circuit_points: [] }); const [selectedPointId, setSelectedPointId] = useState(null); - const [selectedCircuitId, setSelectedCircuitId] = useState(null); + const [selectedCircuitId, setSelectedCircuitId] = useState(initialCircuitId ?? null); const [mode, setMode] = useState('idle'); const [draftPoint, setDraftPoint] = useState(null); const [walkCircuitId, setWalkCircuitId] = useState(''); diff --git a/frontend/src/components/PanelEditor.tsx b/frontend/src/components/PanelEditor.tsx index 452c894..ad44c9a 100644 --- a/frontend/src/components/PanelEditor.tsx +++ b/frontend/src/components/PanelEditor.tsx @@ -1,11 +1,16 @@ import { useEffect, useState } from 'react'; import { api } from '../api'; -import type { Circuit, Panel, Room } from '../types'; +import type { Circuit, CircuitPoint, Panel, Room } from '../types'; -export function PanelEditor() { +export function PanelEditor({ + onViewCircuit, +}: { + onViewCircuit: (circuitId: number, floor: string) => void; +}) { const [panels, setPanels] = useState([]); const [rooms, setRooms] = useState([]); const [circuits, setCircuits] = useState([]); + const [circuitPoints, setCircuitPoints] = useState([]); const [name, setName] = useState(''); const [roomId, setRoomId] = useState(''); @@ -14,17 +19,23 @@ export function PanelEditor() { const [error, setError] = useState(null); function refresh() { - Promise.all([api.panels.list(), api.circuits.list()]) - .then(([panelList, circuitList]) => { + Promise.all([ + api.panels.list(), + api.rooms.list(), + api.circuits.list(), + api.circuitPoints.list(), + ]) + .then(([panelList, roomList, circuitList, pointList]) => { setPanels(panelList); + setRooms(roomList); setCircuits(circuitList); + setCircuitPoints(pointList); setError(null); }) .catch((err) => setError(String(err))); } useEffect(() => { - api.rooms.list().then(setRooms); refresh(); }, []); @@ -57,17 +68,26 @@ export function PanelEditor() { } } + function viewCircuit(circuitId: number) { + const point = circuitPoints.find((candidate) => candidate.circuit_id === circuitId); + const floor = rooms.find((room) => room.id === point?.room_id)?.floor; + if (floor) onViewCircuit(circuitId, floor); + } + return ( -
-

Panels & circuits

+
+

Panels & circuits

{error &&

{error}

} {panels.map((panel) => ( r.id === panel.room_id)} - fedFromName={panels.find((p) => p.id === panel.fed_from_panel_id)?.name} - circuits={circuits.filter((c) => c.panel_id === panel.id)} + room={rooms.find((room) => room.id === panel.room_id)} + fedFromName={panels.find((candidate) => candidate.id === panel.fed_from_panel_id)?.name} + feedsPanels={panels.filter((candidate) => candidate.fed_from_panel_id === panel.id)} + circuits={circuits.filter((circuit) => circuit.panel_id === panel.id)} + circuitPoints={circuitPoints} + onViewCircuit={viewCircuit} onDeletePanel={() => deletePanel(panel.id)} onChange={refresh} onError={setError} @@ -83,9 +103,9 @@ export function PanelEditor() { Location:{' '} @@ -97,16 +117,16 @@ export function PanelEditor() { Fed from (subpanel of):{' '} -
+ ); } @@ -114,7 +134,10 @@ function PanelCard({ panel, room, fedFromName, + feedsPanels, circuits, + circuitPoints, + onViewCircuit, onDeletePanel, onChange, onError, @@ -122,7 +145,10 @@ function PanelCard({ panel: Panel; room?: Room; fedFromName?: string; + feedsPanels: Panel[]; circuits: Circuit[]; + circuitPoints: CircuitPoint[]; + onViewCircuit: (circuitId: number) => void; onDeletePanel: () => void; onChange: () => void; onError: (message: string | null) => void; @@ -133,6 +159,15 @@ function PanelCard({ const [stickerText, setStickerText] = useState(''); const [verifiedDescription, setVerifiedDescription] = useState(''); + const sortedCircuits = [...circuits].sort((left, right) => + left.breaker_label.localeCompare(right.breaker_label, undefined, { numeric: true }), + ); + const pointCount = (circuitId: number) => + circuitPoints.filter((point) => point.circuit_id === circuitId).length; + const mappedCircuitCount = circuits.filter((circuit) => pointCount(circuit.id) > 0).length; + const mappedPointCount = circuits.reduce((total, circuit) => total + pointCount(circuit.id), 0); + const unverifiedCount = circuits.filter((circuit) => !circuit.verified_description).length; + async function createCircuit(e: React.FormEvent) { e.preventDefault(); try { @@ -167,41 +202,84 @@ function PanelCard({ } return ( -
-
- {panel.name} - {panel.amperage && ({panel.amperage}A)} - {room && — {room.name}} - {fedFromName && — fed from {fedFromName}} - +
+
+
+

+ {panel.name} + {panel.amperage && {panel.amperage}A} +

+

Location: {room?.name ?? 'not recorded'}

+

+ {fedFromName ? `Fed from ${fedFromName}.` : 'No parent panel recorded.'} + {feedsPanels.length > 0 && ` Feeds ${feedsPanels.map((child) => child.name).join(', ')}.`} +

+
+ +
+ +

+ {circuits.length} {circuits.length === 1 ? 'circuit' : 'circuits'} + {mappedCircuitCount} with mapped points + {mappedPointCount} total points + {unverifiedCount} need verification +

+ +
+ {sortedCircuits.length === 0 ? ( +

No circuits in this panel yet.

+ ) : ( + sortedCircuits.map((circuit) => { + const mappedPoints = pointCount(circuit.id); + const verified = Boolean(circuit.verified_description); + const title = + circuit.verified_description ?? circuit.panel_sticker_text ?? 'Unlabeled circuit'; + return ( +
+
+ Breaker + {circuit.breaker_label} + {circuit.poles} pole{circuit.poles === 1 ? '' : 's'} +
+
+

{title}

+ {circuit.amperage &&

{circuit.amperage}A

} + {circuit.verified_description && circuit.panel_sticker_text && ( +

Panel says: {circuit.panel_sticker_text}

+ )} +
+ 0 ? 'mapped' : 'warning'}`}> + {mappedPoints > 0 + ? `${mappedPoints} mapped point${mappedPoints === 1 ? '' : 's'}` + : 'Unmapped'} + + + {verified ? 'Verified' : 'Needs verification'} + +
+
+
+ + +
+
+ ); + }) + )}
- - - - - - - - - - - - {circuits.map((circuit) => ( - - - - - - - - - ))} - -
BreakerAmpsPolesPanel saysConfirmed -
{circuit.breaker_label}{circuit.amperage ?? ''}{circuit.poles}{circuit.panel_sticker_text}{circuit.verified_description} - -
-
+ +
-
+ ); }