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
80 changes: 76 additions & 4 deletions frontend/e2e/hearth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -85,17 +103,21 @@ async function mockApi(page: Page): Promise<ApiState> {
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<string, unknown>;
const created = { id: nextPointId++, ...body };
Expand Down Expand Up @@ -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('/');
Expand Down Expand Up @@ -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);
});
138 changes: 134 additions & 4 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
20 changes: 17 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ type Tab = 'floorplan' | 'rooms' | 'panels';

function App() {
const [tab, setTab] = useState<Tab>('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 (
<div className="app">
<header>
<h1>Hearth</h1>
<nav className="tabs">
<button className={tab === 'floorplan' ? 'active' : ''} onClick={() => setTab('floorplan')}>
<button className={tab === 'floorplan' ? 'active' : ''} onClick={() => openFloorplan()}>
Floorplan
</button>
<button className={tab === 'rooms' ? 'active' : ''} onClick={() => setTab('rooms')}>
Expand All @@ -26,9 +35,14 @@ function App() {
</nav>
</header>
<main>
{tab === 'floorplan' && <FloorplanView />}
{tab === 'floorplan' && (
<FloorplanView
initialCircuitId={floorplanTarget?.circuitId}
initialFloor={floorplanTarget?.floor}
/>
)}
{tab === 'rooms' && <RoomEditor />}
{tab === 'panels' && <PanelEditor />}
{tab === 'panels' && <PanelEditor onViewCircuit={openFloorplan} />}
</main>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const api = {
remove: (id: number) => del(`/circuits/${id}`),
},
circuitPoints: {
list: () => request<CircuitPoint[]>('/circuit-points'),
create: (point: Omit<CircuitPoint, 'id'>) => post<CircuitPoint>('/circuit-points', point),
update: (id: number, point: Partial<Omit<CircuitPoint, 'id'>>) =>
patch<CircuitPoint>(`/circuit-points/${id}`, point),
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/components/FloorplanView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Room[]>([]);
const [panels, setPanels] = useState<Panel[]>([]);
const [circuits, setCircuits] = useState<Circuit[]>([]);
const [floor, setFloor] = useState<string>('');
const [floor, setFloor] = useState<string>(initialFloor ?? '');
const [plan, setPlan] = useState<Floorplan>({ rooms: [], circuit_points: [] });
const [selectedPointId, setSelectedPointId] = useState<number | null>(null);
const [selectedCircuitId, setSelectedCircuitId] = useState<number | null>(null);
const [selectedCircuitId, setSelectedCircuitId] = useState<number | null>(initialCircuitId ?? null);
const [mode, setMode] = useState<InteractionMode>('idle');
const [draftPoint, setDraftPoint] = useState<PointDraft | null>(null);
const [walkCircuitId, setWalkCircuitId] = useState<number | ''>('');
Expand Down
Loading
Loading