/** * 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(() => daysAgoISO(30)) const [appliedEnd, setAppliedEnd] = useState(() => 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({ 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 ( {/* 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" /> {isLoading && } {/* Layer toggles row */} Location heat {locationsQuery.data?.length ?? 0} } checked={showLocationHeat} onChange={(e) => setShowLocationHeat(e.currentTarget.checked)} size="xs" data-testid="toggle-location-heat" /> Poo heat {filteredPoo.length} } checked={showPooHeat} onChange={(e) => setShowPooHeat(e.currentTarget.checked)} size="xs" data-testid="toggle-poo-heat" /> Scatter (click to edit)} checked={showScatter} onChange={(e) => setShowScatter(e.currentTarget.checked)} size="xs" data-testid="toggle-scatter" /> {/* Error banner */} {isError && ( Failed to load data. Check connection and refresh. )} {/* Map fills remaining height */} {/* ---------- Point-select modals ---------- */} {selection.kind === 'editLocation' && ( setSelection({ kind: 'none' })} onSaved={() => setSelection({ kind: 'none' })} /> )} {selection.kind === 'deleteLocation' && ( { await deleteLocationMut.mutateAsync({ person: selection.record.person, datetime: selection.record.datetime, }) setSelection({ kind: 'none' }) }} onCancel={() => setSelection({ kind: 'none' })} /> )} {selection.kind === 'editPoo' && ( setSelection({ kind: 'none' })} onSaved={() => { // After saving, optionally switch to delete prompt or just close. setSelection({ kind: 'none' }) }} /> )} {selection.kind === 'deletePoo' && ( { await deletePooMut.mutateAsync(selection.record.timestamp) setSelection({ kind: 'none' }) }} onCancel={() => setSelection({ kind: 'none' })} /> )} ) }