da236643f2
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
407 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|