Files
home-automation/frontend/src/pages/HomePage.tsx
T
tliu93 da236643f2
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
M2: frontend walkthrough fixes + explicit dev compose stack
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00

407 lines
14 KiB
TypeScript

/**
* HomePage — data-visualization map view (M2-T09).
*
* Renders a heat map of location records (where you've been) and poo records
* (where the dog poops), plus a toggleable scatter layer for point-select
* edit/delete (reusing T10's modals + hooks).
*
* Data fetching and all state live here; the map itself is fully isolated in
* src/map/RecordsMap.tsx (the ONLY place that imports leaflet).
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Stack,
Group,
Switch,
TextInput,
Button,
Select,
ActionIcon,
Tooltip,
Paper,
Text,
Box,
Loader,
Alert,
Badge,
useComputedColorScheme,
} from '@mantine/core'
import { ChevronLeft, ChevronRight } from 'react-feather'
import apiClient from '../api/client'
import {
locationsToHeatPoints,
pooToHeatPoints,
locationsToMapPoints,
pooToMapPoints,
filterPooByTimeWindow,
daysAgoISO,
nowISO,
TIME_PRESETS,
presetRange,
shiftRange,
} from '../map'
import { RecordsMap } from '../map'
import {
EditLocationModal,
EditPooModal,
ConfirmDeleteModal,
useDeleteLocation,
useDeletePoo,
} from '../records'
import type { LocationRecord, PooRecord } from '../records'
// ---------------------------------------------------------------------------
// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...])
// ---------------------------------------------------------------------------
function useLocations(start: string | null, end: string | null) {
return useQuery({
queryKey: ['locations', { start, end, limit: 5000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/locations', {
params: {
query: {
limit: 5000,
offset: 0,
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data?.items ?? []
},
})
}
/**
* Poo endpoint has no server-side time filter — fetch a large page (max 1000)
* and client-filter by timestamp below.
*/
function usePoo() {
return useQuery({
queryKey: ['poo', { limit: 1000 }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', {
params: { query: { limit: 1000, offset: 0 } },
})
return res.data?.items ?? []
},
})
}
// ---------------------------------------------------------------------------
// Point-select state (which record is selected + which modal to show)
// ---------------------------------------------------------------------------
type SelectionState =
| { kind: 'none' }
| { kind: 'editLocation'; record: LocationRecord }
| { kind: 'deleteLocation'; record: LocationRecord }
| { kind: 'editPoo'; record: PooRecord }
| { kind: 'deletePoo'; record: PooRecord }
// ---------------------------------------------------------------------------
// HomePage
// ---------------------------------------------------------------------------
export function HomePage() {
// ------ Time-window state -----------------------------------------------
// Default: last 30 days → now
const [startInput, setStartInput] = useState(() => {
const d = new Date()
d.setUTCDate(d.getUTCDate() - 30)
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
})
const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16))
// Applied (committed) window — updated on Apply / preset / shift
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
// Which quick-range preset is currently active (null = custom / shifted range)
const [activePreset, setActivePreset] = useState<string | null>(null)
// Set both the committed window and the editable inputs from an ISO [start, end].
function setWindow(startISO: string, endISO: string) {
setAppliedStart(startISO)
setAppliedEnd(endISO)
setStartInput(startISO.slice(0, 16))
setEndInput(endISO.slice(0, 16))
}
// Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style).
function applyPreset(value: string | null) {
const preset = TIME_PRESETS.find((p) => p.value === value)
if (!preset) return
const { start, end } = presetRange(preset.spanMs)
setWindow(start, end)
setActivePreset(value)
}
// Shift the committed window by its own span. -1 = earlier, +1 = later.
function shiftWindow(direction: -1 | 1) {
if (!appliedStart || !appliedEnd) return
const { start, end } = shiftRange(appliedStart, appliedEnd, direction)
setWindow(start, end)
// A shifted window is an absolute range, no longer "now - X".
setActivePreset(null)
}
// ------ Layer toggle state -----------------------------------------------
const [showLocationHeat, setShowLocationHeat] = useState(true)
const [showPooHeat, setShowPooHeat] = useState(true)
const [showScatter, setShowScatter] = useState(false)
// ------ Data fetching ----------------------------------------------------
const locationsQuery = useLocations(appliedStart, appliedEnd)
const pooQuery = usePoo()
// Client-side time-filter for poo (server has no filter)
const filteredPoo = useMemo(
() => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd),
[pooQuery.data, appliedStart, appliedEnd],
)
// Derived map data
const locationHeatPoints = useMemo(
() => locationsToHeatPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooHeatPoints = useMemo(
() => pooToHeatPoints(filteredPoo),
[filteredPoo],
)
const locationScatterPoints = useMemo(
() => locationsToMapPoints(locationsQuery.data ?? []),
[locationsQuery.data],
)
const pooScatterPoints = useMemo(
() => pooToMapPoints(filteredPoo),
[filteredPoo],
)
// ------ Point-select state -----------------------------------------------
const [selection, setSelection] = useState<SelectionState>({ kind: 'none' })
const deleteLocationMut = useDeleteLocation()
const deletePooMut = useDeletePoo()
// Handlers
function handleSelectLocation(record: LocationRecord) {
setSelection({ kind: 'editLocation', record })
}
function handleSelectPoo(record: PooRecord) {
setSelection({ kind: 'editPoo', record })
}
function applyWindow() {
// Convert local datetime-local inputs (which have no TZ) to ISO8601
// by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM".
const toISO = (s: string) => (s ? s + ':00Z' : null)
setAppliedStart(toISO(startInput))
setAppliedEnd(toISO(endInput))
// Manually-applied range is custom, not a preset.
setActivePreset(null)
}
// ------ Render -----------------------------------------------------------
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
const isError = locationsQuery.isError || pooQuery.isError
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
{/* Controls bar */}
<Paper
shadow="xs"
p="xs"
style={{ zIndex: 1000, flexShrink: 0 }}
data-testid="map-controls"
>
<Stack gap="xs">
{/* Time-range row */}
<Group gap="xs" align="flex-end" wrap="wrap">
<TextInput
label="From"
type="datetime-local"
value={startInput}
onChange={(e) => setStartInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-start-input"
/>
<TextInput
label="To"
type="datetime-local"
value={endInput}
onChange={(e) => setEndInput(e.currentTarget.value)}
size="xs"
style={{ minWidth: 180 }}
data-testid="time-end-input"
/>
{/* Quick range + shift buttons (Grafana-style) — between To and Apply.
zIndex raised above Leaflet (~1000) so the dropdown/tooltips are
not painted over by the map below. */}
<Group gap={4} align="flex-end">
<Select
label="Quick range"
placeholder="Pick a range"
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
value={activePreset}
onChange={applyPreset}
size="xs"
allowDeselect={false}
style={{ width: 150 }}
comboboxProps={{ zIndex: 3000 }}
data-testid="quick-range-select"
/>
<Tooltip label="Shift earlier (one window back)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift earlier"
onClick={() => shiftWindow(-1)}
data-testid="shift-earlier"
>
<ChevronLeft size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Shift later (one window forward)" zIndex={3000}>
<ActionIcon
variant="default"
size="input-xs"
aria-label="Shift later"
onClick={() => shiftWindow(1)}
data-testid="shift-later"
>
<ChevronRight size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
Apply
</Button>
{isLoading && <Loader size="xs" />}
</Group>
{/* Layer toggles row */}
<Group gap="md" wrap="wrap">
<Switch
label={
<Group gap={4}>
<Text size="xs">Location heat</Text>
<Badge size="xs" color="blue" variant="light">
{locationsQuery.data?.length ?? 0}
</Badge>
</Group>
}
checked={showLocationHeat}
onChange={(e) => setShowLocationHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-location-heat"
/>
<Switch
label={
<Group gap={4}>
<Text size="xs">Poo heat</Text>
<Badge size="xs" color="orange" variant="light">
{filteredPoo.length}
</Badge>
</Group>
}
checked={showPooHeat}
onChange={(e) => setShowPooHeat(e.currentTarget.checked)}
size="xs"
data-testid="toggle-poo-heat"
/>
<Switch
label={<Text size="xs">Scatter (click to edit)</Text>}
checked={showScatter}
onChange={(e) => setShowScatter(e.currentTarget.checked)}
size="xs"
data-testid="toggle-scatter"
/>
</Group>
{/* Error banner */}
{isError && (
<Alert color="red" data-testid="map-error-alert">
Failed to load data. Check connection and refresh.
</Alert>
)}
</Stack>
</Paper>
{/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
z-indexes (panes/controls up to ~1000) in their own stacking context so
they can't paint over portaled popups (Quick-range dropdown, tooltips,
and the point-select edit/delete modals). */}
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
<RecordsMap
locationHeatPoints={locationHeatPoints}
pooHeatPoints={pooHeatPoints}
locationScatterPoints={locationScatterPoints}
pooScatterPoints={pooScatterPoints}
showLocationHeat={showLocationHeat}
showPooHeat={showPooHeat}
showScatter={showScatter}
onSelectLocation={handleSelectLocation}
onSelectPoo={handleSelectPoo}
height="100%"
dark={colorScheme === 'dark'}
/>
</Box>
{/* ---------- Point-select modals ---------- */}
{selection.kind === 'editLocation' && (
<EditLocationModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'deleteLocation' && (
<ConfirmDeleteModal
message={`Delete location record for ${selection.record.person} at ${selection.record.datetime}?`}
loading={deleteLocationMut.isPending}
onConfirm={async () => {
await deleteLocationMut.mutateAsync({
person: selection.record.person,
datetime: selection.record.datetime,
})
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
{selection.kind === 'editPoo' && (
<EditPooModal
record={selection.record}
onClose={() => setSelection({ kind: 'none' })}
onSaved={() => {
// After saving, optionally switch to delete prompt or just close.
setSelection({ kind: 'none' })
}}
/>
)}
{selection.kind === 'deletePoo' && (
<ConfirmDeleteModal
message={`Delete poo record at ${selection.record.timestamp}?`}
loading={deletePooMut.isPending}
onConfirm={async () => {
await deletePooMut.mutateAsync(selection.record.timestamp)
setSelection({ kind: 'none' })
}}
onCancel={() => setSelection({ kind: 'none' })}
/>
)}
</Box>
)
}