/** * 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(() => daysAgoISO(30)) const [appliedEnd, setAppliedEnd] = useState(() => nowISO()) // Which quick-range preset is currently active (null = custom / shifted range) const [activePreset, setActivePreset] = useState(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({ 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 ( {/* Controls bar */} {/* Time-range row */} setStartInput(e.currentTarget.value)} size="xs" style={{ minWidth: 180 }} data-testid="time-start-input" /> 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. */}