32d93bba2a
- 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
324 lines
10 KiB
TypeScript
324 lines
10 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,
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|