M2-T09: build data visualization UI (heatmap map as home page)
- self-contained RecordsMap (only module importing leaflet/react-leaflet/ leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props - heatmap layers for location + poo (primary); time-range selector fetches only the window (locations server-filtered; poo client-filtered) - toggleable scatter layer with marker clustering; point-select reuses T10's edit/delete modals + hooks; query-key prefixes refresh map on mutation - pure map logic isolated + unit-tested; leaflet mocked in component tests - responsive layout; typed client only
This commit is contained in:
+314
-10
@@ -1,19 +1,323 @@
|
||||
/**
|
||||
* HomePage — placeholder for M2-T09.
|
||||
* HomePage — data-visualization map view (M2-T09).
|
||||
*
|
||||
* T09 replaces this with the real home view: Leaflet map, heatmap layer,
|
||||
* time-range selector, scatter-point layer, and poo overlay.
|
||||
* 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 { Container, Title, Text } from '@mantine/core'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Switch,
|
||||
TextInput,
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Box,
|
||||
Loader,
|
||||
Alert,
|
||||
Badge,
|
||||
} from '@mantine/core'
|
||||
|
||||
import apiClient from '../api/client'
|
||||
import {
|
||||
locationsToHeatPoints,
|
||||
pooToHeatPoints,
|
||||
locationsToMapPoints,
|
||||
pooToMapPoints,
|
||||
filterPooByTimeWindow,
|
||||
daysAgoISO,
|
||||
nowISO,
|
||||
} 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 button click
|
||||
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
|
||||
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
|
||||
|
||||
// ------ 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))
|
||||
}
|
||||
|
||||
// ------ Render -----------------------------------------------------------
|
||||
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
|
||||
const isError = locationsQuery.isError || pooQuery.isError
|
||||
|
||||
return (
|
||||
<Container pt="xl">
|
||||
<Title order={2}>Home</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Map / heatmap visualisation — implemented in M2-T09.
|
||||
</Text>
|
||||
</Container>
|
||||
<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"
|
||||
/>
|
||||
<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 */}
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<RecordsMap
|
||||
locationHeatPoints={locationHeatPoints}
|
||||
pooHeatPoints={pooHeatPoints}
|
||||
locationScatterPoints={locationScatterPoints}
|
||||
pooScatterPoints={pooScatterPoints}
|
||||
showLocationHeat={showLocationHeat}
|
||||
showPooHeat={showPooHeat}
|
||||
showScatter={showScatter}
|
||||
onSelectLocation={handleSelectLocation}
|
||||
onSelectPoo={handleSelectPoo}
|
||||
height="100%"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user