New page lets a rep see their own rep_visits on a Leaflet map.
Defaults to last 7 days; toggleable to 14 / 30 / 90.
/rep/visits ── server-side fetch + re-export to client
/rep/visits map ── per-customer cluster, count badge, last-visit
label, popup with full per-visit timeline + notes
+ 'manual' badge, side-list of customers in window
+ search filter + heatmap toggle
Service + route (no pb-helpers):
visits-service.getVisitsForRep(repId, {from?, to?, limit?})
- admin PB (session-scoped at the route layer)
- expands customer relation, filters position_source != 'tracker'
server-side, PB date format gotcha (T → space), 5000-row cap
/api/v1/rep-visits/list GET, browser-session auth, scopes repId
from user.id (never a query param — same
hardening as POST /api/v1/visits)
Heatmap:
leaflet.heat wrapper at src/components/leaflet-heatmap-layer.tsx
- useMap() + useEffect lifecycle
- brand gradient (deep blue → green → amber → yellow → crimson)
- reads on both light OSM and dark CARTO tile sets
- maxZoom default 10 — leaflet.heat scales intensity by
1/2^(maxZoom-currentZoom); without this the OSM default maxZoom
of 18 makes points render at ~5% opacity at our default zoom 10
Other fixes landed mid-feature:
- KPI cards collapsed from 3 into 1 (clusters count is a
zoom-dependent implementation detail, not a glanceable stat)
- added 'View my visits on a map' nav link under /rep header
Dep: +leaflet.heat@^0.2.0, +@types/leaflet.heat@^0.2.5
(react-leaflet@5, leaflet@1.9.4 already present)
Tests: 50 + 34 = 84 pass in the touched (rep) area + visits-service
+ rep-visits API. Pre-existing failures on main (sidebar,
pocketbase-admin, quotes-service) confirmed unrelated by
stashing this branch.
114 lines
No EOL
4.5 KiB
TypeScript
114 lines
No EOL
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useMap } from "react-leaflet";
|
|
import L from "leaflet";
|
|
import "leaflet.heat";
|
|
|
|
/**
|
|
* A `react-leaflet` wrapper for the `leaflet.heat` plugin.
|
|
*
|
|
* The plugin ships as plain JS and mutates the global `L` namespace
|
|
* with a `heatLayer(points, options)` factory — there's no first-class
|
|
* React adapter, so the standard pattern is:
|
|
*
|
|
* 1. `useMap()` to get the live Leaflet map instance.
|
|
* 2. Build the heat layer in `useEffect`, add it to the map.
|
|
* 3. Return `null` so this component contributes no DOM.
|
|
* 4. Clean up on unmount.
|
|
*
|
|
* The `import "leaflet.heat"` side-effect at the top of the file
|
|
* augments `L` with the `heatLayer` factory. Without it `L.heatLayer`
|
|
* is `undefined` at runtime and the page crashes on first render.
|
|
*
|
|
* Options:
|
|
* - `radius` in pixels, default 30. Smaller radius = more localised
|
|
* blob, larger = broader area. 30 reads well on a single
|
|
* shaft; crank it down for multiple shafts in view.
|
|
* - `blur` in pixels, default 15. Trades focus against density —
|
|
* higher blur makes a busy area look smoother.
|
|
* - `max` intensity ceiling. Default 1.0 (auto). Drop to taste
|
|
* if a single very-frequent spot saturates the map.
|
|
* - `maxZoom` CRITICAL. The plugin scales every point's intensity by
|
|
* `1/2^k` where `k = max(0, min(maxZoom - mapZoom, 12))`.
|
|
* If `maxZoom` is left undefined the plugin falls back
|
|
* to the map's `getMaxZoom()` (18 for OSM) — at the page
|
|
* default zoom of 10 that's `1/256`, so every check-in
|
|
* renders at ~5% opacity and the layer looks empty. We
|
|
* pin it to `10` so the default zoom renders at FULL
|
|
* intensity; zooming in past it stays at full intensity
|
|
* (the inner `min(...)` clamps to 0 when `mapZoom >
|
|
* maxZoom`); zooming out causes decay (which is the
|
|
* desired UX — heatmaps lose meaning at country scale).
|
|
* - `minOpacity` floor per-point alpha. Default 0.05 (plugin) is
|
|
* barely visible; we default to 0.15 so a single check-in
|
|
* still leaves a readable mark.
|
|
* - `gradient` colour stops for low → high intensity.
|
|
*
|
|
* Points are `[lat, lon]` tuples by default. Each one contributes
|
|
* intensity 1; pass `[lat, lon, intensity]` tuples to weight them
|
|
* (e.g. per-visit duration). The `/rep/visits` page uses plain
|
|
* `[lat, lon]` — every check-in counts once.
|
|
*/
|
|
export function HeatmapLayer({
|
|
points,
|
|
visible,
|
|
options,
|
|
}: {
|
|
points: Array<[number, number]>;
|
|
visible: boolean;
|
|
options?: {
|
|
radius?: number;
|
|
blur?: number;
|
|
max?: number;
|
|
maxZoom?: number;
|
|
minOpacity?: number;
|
|
gradient?: Record<number, string>;
|
|
};
|
|
}) {
|
|
const map = useMap();
|
|
|
|
useEffect(() => {
|
|
if (!visible || points.length === 0) return;
|
|
|
|
// `leaflet.heat` types its input as `[lat, lng, intensity]` tuples
|
|
// only — a 2-tuple is silently dropped by the plugin, so we have
|
|
// to upgrade every point. Intensity is fixed at 1 because each
|
|
// rep visit counts the same; the page can pass weighted points
|
|
// later if duration matters.
|
|
const tuples = points.map(([lat, lon]): [number, number, number] => [lat, lon, 1]);
|
|
|
|
const layer = L.heatLayer(tuples, {
|
|
radius: options?.radius ?? 30,
|
|
blur: options?.blur ?? 15,
|
|
max: options?.max ?? 1.0,
|
|
// See the doc-block above for why this matters. Without
|
|
// `maxZoom` set, the plugin uses the map's max zoom (18 for
|
|
// OSM tiles) and divides every point's intensity by 256 at
|
|
// our default zoom of 10 — the layer renders at ~5% opacity
|
|
// and looks like nothing's there.
|
|
maxZoom: options?.maxZoom ?? 10,
|
|
minOpacity: options?.minOpacity ?? 0.15,
|
|
// Brand-driven gradient: brand yellow (PixelForge signature
|
|
// accent) on the high end, deep crimson on the low. Reads on
|
|
// both light OSM and dark CARTO tiles without screaming.
|
|
gradient: options?.gradient ?? {
|
|
0.2: "#1B76BB",
|
|
0.4: "#22C55E",
|
|
0.6: "#F4B842",
|
|
0.8: "#FFE600",
|
|
1.0: "#E64B4B",
|
|
},
|
|
});
|
|
|
|
layer.addTo(map);
|
|
return () => {
|
|
map.removeLayer(layer);
|
|
};
|
|
// The `points` and `options` deps drive a full teardown + rebuild
|
|
// on any data change — fine for a rep-scale dataset (≤5,000
|
|
// points) where rebuilding the canvas is microseconds.
|
|
}, [map, points, visible, options]);
|
|
|
|
return null;
|
|
} |