WFDASH-WEBAPP/src/lib/geo.ts
Dave 7ba0b9e71f feat(visits): batch pin tool — tag every customer on a shaft at once
The single-pin tool (pick one customer, click the map, save) doesn't
scale to how the customer base actually clusters: dozens of contacts
sit at Harmony Kusasalethu, at South Deep Shaft 3, and so on. This adds
a batch counterpart at /visits/pin-tool/batch: type a site name, tick
every customer there, click the map once, and all of them get pinned.

Site is a plain free-text field on customers (Customer.site in
lib/types.ts), not a separate sites collection — simpler to add and
maintain, at the cost of relying on the office typing the name the same
way each time. The batch tool's autocomplete (backed by
listDistinctSites) and its "select everyone already tagged X" shortcut
exist specifically to curb that drift without a second collection to
manage.

- jitterCoordinates (lib/geo.ts) scatters each customer 30-80m from the
  clicked point so forty pins on one shaft render as distinct dots on
  the office map instead of one marker stacked forty deep. Bounded by
  distanceKm in tests, which is the same great-circle maths the
  check-in radius match uses.
- assignCustomersToSite / listDistinctSites (customers-service.ts) —
  the batch write and its autocomplete source. Each customer is written
  independently, mirroring recordVisits: a stale id in a forty-customer
  batch shouldn't cost the other thirty-nine.
- use-customer-list.ts extracted from pin-tool-view.tsx (verbatim, no
  behaviour change) so the batch view doesn't duplicate the fetch.
- batch-pin-helpers.ts holds the pure decisions (save-readiness order,
  filter) the same way pin-tool-helpers.ts does for the single-pin tool
  — the Leaflet-dependent view is hard to unit test directly.

Applied the "use client" + dynamic(ssr:false) lesson from the single-pin
tool's earlier 500 up front in batch/page.tsx, with a comment explaining
why it's load-bearing rather than decorative.

99 new/updated tests. Scoped verification only (geo, customers-service,
visits/*) — the wider suite currently shows unrelated failures in
quotes-service/quote-pdf-service from a change on main I did not make
and did not investigate further; none of it touches customers, geo, or
visits.

BLOCKED until one PB admin change: customers.site does not exist yet
(text field, optional). Nothing in this commit writes to it until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:30:23 +02:00

347 lines
No EOL
12 KiB
TypeScript

/**
* Geo helpers — shared by `SupplierMap` and `CollectionMap`.
*
* Pure functions, no React / no Leaflet, so they tree-shake cleanly and
* can be unit-tested in isolation.
*/
export type LatLng = readonly [number, number];
/**
* A PocketBase `geoPoint` column as it comes back over the wire.
*/
export type GeoPoint = { lat: number; lon: number };
/**
* Treat PB's `{ lon: 0, lat: 0 }` (Null Island) as "no coordinates".
*
* Every `geoPoint` column in this database defaults to `{0, 0}` rather
* than null, so "has the operator actually set a position?" is a
* value check, not a null check. Null Island is in the Gulf of Guinea
* — no WFDASH customer, company or supplier will ever legitimately sit
* there, so treating it as "unset" is safe.
*
* Previously duplicated inline in `customers/[id]/page.tsx` and
* `companies/[id]/page.tsx`; lives here now that the rep check-in flow
* is a third caller.
*/
export function isRealGeoPoint(
point: { lat?: number | null; lon?: number | null } | null | undefined
): point is GeoPoint {
if (!point) return false;
const { lat, lon } = point;
if (typeof lat !== "number" || typeof lon !== "number") return false;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat !== 0 || lon !== 0;
}
/**
* Parse a coordinate string in any of three common shapes:
* 1. JSON object — `{"lat": -26.2, "lng": 28.04}`
* 2. Google Maps URL fragment — `@-26.2041,28.0473`
* 3. Plain `lat, lng` pair (any separator: `,`, `;`, or whitespace)
*
* Returns `[lat, lng]` or `null` if the string isn't parseable.
*
* The regex is permissive — it accepts `"lat"` / `"latitude"` / `"lon"` /
* `"lng"` / `"longitude"` so legacy data written in any of those shapes
* round-trips without re-writing the row.
*/
export function parseCoordinates(value?: string | null): LatLng | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const jsonMatch = trimmed.match(
/"?(?:lat|latitude)"?\s*:\s*(-?\d+(?:\.\d+)?).*"?(?:lng|lon|longitude)"?\s*:\s*(-?\d+(?:\.\d+)?)/i
);
if (jsonMatch) return [Number(jsonMatch[1]), Number(jsonMatch[2])] as const;
const atMatch = trimmed.match(/@(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)/);
if (atMatch) return [Number(atMatch[1]), Number(atMatch[2])] as const;
const pairMatch = trimmed.match(/(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)/);
if (!pairMatch) return null;
const first = Number(pairMatch[1]);
const second = Number(pairMatch[2]);
if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
return [first, second] as const;
}
/**
* A [lat, lng] point randomly offset from `center` by a distance in
* `[minMetres, maxMetres]`, at a random bearing.
*
* Used by the batch pin tool: dozens of customers on the same mine
* shaft get the same clicked point, which would render as a single
* marker on the office map and make it impossible to confirm who's
* actually pinned. Scattering them by a few tens of metres keeps every
* pin individually visible without moving anyone off the site — at the
* 2 km check-in radius, an 80 m jitter is noise.
*
* Uses an equirectangular approximation (flat-earth over the offset
* distance), which is accurate to well under a metre at this scale —
* good enough for "visually distinct dots on a map", not for anything
* that needs survey-grade precision.
*/
export function jitterCoordinates(
center: LatLng,
minMetres: number,
maxMetres: number
): LatLng {
const [lat, lng] = center;
const bearing = Math.random() * 2 * Math.PI;
const distanceM = minMetres + Math.random() * (maxMetres - minMetres);
const earthRadiusM = 6_371_000;
const dLat = ((distanceM * Math.cos(bearing)) / earthRadiusM) * (180 / Math.PI);
const dLng =
((distanceM * Math.sin(bearing)) /
(earthRadiusM * Math.cos((lat * Math.PI) / 180))) *
(180 / Math.PI);
return [lat + dLat, lng + dLng] as const;
}
/**
* Great-circle distance between two [lat, lng] points in kilometres.
*
* Haversine formula — accurate enough for "is this stop within Xkm of
* that supplier?" queries at city/country scale. Not suitable for
* sub-metre navigation.
*/
export function distanceKm(a: LatLng, b: LatLng): number {
const toRad = (value: number) => (value * Math.PI) / 180;
const earthRadiusKm = 6371;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * earthRadiusKm * Math.asin(Math.sqrt(h));
}
/**
* Cluster radius in km, bucketed by Leaflet zoom level.
*
* Far-zoomed-out (≤7): 80km — a city-wide cluster.
* Mid-zoom (8-9): 35km — neighbouring suburbs.
* Mid-high (10-11): 14km — same industrial area.
* Close-up (≥12): 8km — almost individual pins.
*
* Bucketing on zoom breakpoints (not raw zoom) keeps `buildClusters`
* from re-running on every wheel tick. If you change this function,
* re-check the `useMemo([clusterDistanceKm])` deps in callers.
*/
export function clusterDistanceForZoom(zoom: number): number {
if (zoom <= 7) return 80;
if (zoom <= 9) return 35;
if (zoom <= 11) return 14;
return 8;
}
/**
* Stable colour palette for clusters. Each cluster picks its colour by
* index so the same cluster keeps the same colour across re-renders
* (no hash needed — insertion order is deterministic for our use case).
*
* Brand-driven: picks with high contrast on both light OSM and dark
* CARTO tiles. The first two (deep blue + crimson) intentionally contrast
* the supplier palette so an assigned-driver pin doesn't get lost in a
* cluster of nearby supplier pins.
*/
export const CLUSTER_PALETTE = [
"#1B76BB", // deep blue
"#E64B4B", // crimson
"#F4B842", // amber
"#169B62", // forest
"#7C3AED", // violet
"#EA580C", // burnt orange
"#0F766E", // teal
"#FFE600", // brand yellow — PixelForge signature accent
] as const;
export type Cluster<T> = {
id: string;
color: string;
center: LatLng;
items: T[];
};
/**
* Greedy single-pass clustering: each new item joins the nearest
* existing cluster within `distanceKmLimit`, otherwise spawns a new
* cluster. Cluster centres are the running mean of their members.
*
* Returns clusters sorted by insertion order — i.e. by the order
* items appear in the input. That's stable for our use case (PO
* rows are sorted by `assigned_at` ascending).
*/
export function buildClusters<T extends { lat: number; lng: number }>(
items: T[],
distanceKmLimit: number
): Cluster<T>[] {
const clusters: Cluster<T>[] = [];
for (const item of items) {
const location: LatLng = [item.lat, item.lng];
const nearby = clusters.find(
(cluster) => distanceKm(cluster.center, location) <= distanceKmLimit
);
if (!nearby) {
clusters.push({
id: `cluster-${clusters.length + 1}`,
color: CLUSTER_PALETTE[clusters.length % CLUSTER_PALETTE.length],
center: location,
items: [item],
});
continue;
}
nearby.items.push(item);
const n = nearby.items.length;
nearby.center = [
(nearby.center[0] * (n - 1) + item.lat) / n,
(nearby.center[1] * (n - 1) + item.lng) / n,
] as LatLng;
}
return clusters;
}
/**
* FNV-1a hash → palette index. Used to assign a stable colour per
* supplier/driver id so the same supplier keeps the same swatch
* across renders without needing a lookup table.
*/
export function paletteIndexForId(id: string, paletteSize = CLUSTER_PALETTE.length): number {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash % paletteSize;
}
/* ============================================================================
* Collection map — role-driven colours (separate from CLUSTER_PALETTE so the
* supplier-page clustering and the assignment-page map can use independent
* colour semantics without coupling.)
*
* Role → colour:
* driver (index 0) → Rausch crimson #E64B4B
* driver (index 1) → deep blue #1B76BB
* unassigned → brand amber #F59E0B (diamond)
* collected → emerald #22C55E
*
* Failed collects get a red ring on the green pin so the operator sees the
* sub-state without losing the colour-coded bucket.
*
* Driver index is assigned by sorting the active driver list by id (stable,
* deterministic across re-renders). Adding a third driver means adding a
* colour here — the page checks via `driverIndex + 1 < DRIVER_PALETTE.length`
* and falls back to the brand yellow if you've added more drivers than slots.
* ========================================================================= */
export const DRIVER_PALETTE = ["#E64B4B", "#1B76BB"] as const;
export const STOP_STATUS_COLOR = {
unassigned: "#F59E0B",
collected: "#22C55E",
failed: "#DC2626",
} as const;
/**
* Resolves the stable driver index (0 or 1) by sorting the supplied drivers
* by id and returning the position of the requested driver in that sorted
* list. Returns 0 as a safe fallback so a missing driver renders red (the
* same colour as driver 0) instead of crashing the page.
*/
export function driverIndexFor(sortedDrivers: { id: string }[], driverId: string | undefined): number {
if (!driverId) return 0;
const idx = sortedDrivers.findIndex((d) => d.id === driverId);
return idx === -1 ? 0 : Math.min(idx, DRIVER_PALETTE.length - 1);
}
/**
* The single colour decision for a stop pin. Combines the per-role colour
* for unassigned/collected with the driver-indexed palette for assigned.
* `failed` overlays the red ring on top of green via the failedOutlined
* parameter — the marker factory reads both.
*/
export function colorForStop(args: {
role: "unassigned" | "assigned" | "collected" | "failed";
driverIndex: number;
}): string {
switch (args.role) {
case "unassigned":
return STOP_STATUS_COLOR.unassigned;
case "collected":
case "failed":
return STOP_STATUS_COLOR.collected;
case "assigned":
default:
return DRIVER_PALETTE[args.driverIndex] ?? DRIVER_PALETTE[0];
}
}
/* ============================================================================
* Collection map — pin label + glyph helpers.
*
* Pure functions that drive the new two-pin model on the collection map.
* Driver labels are alphabetical by id (A → Z, then 1 → 9); the unassigned
* glyph collapses ready / unready into ✓ / ✕ based on the bucket contents.
* ========================================================================= */
/**
* Returns the driver label for a given driver id within an lexically-
* sorted driver list. Position 0 → "A", 1 → "B", ..., 25 → "Z", 26 → "1",
* 27 → "2", ..., 34 → "9", 35 → "9" (clamped). Returns "?" if the id
* isn't in the list.
*
* The caller MUST pass a list already sorted by `id` ascending — typically
* the `driversWithItems` array, which `page.tsx` already sorts by name via
* `localeCompare` on the matching `LookupUser` record. Because driver
* `id` and driver `name` are stable across days, the letter for a given
* driver is also stable across days.
*/
export function driverLabelFor(
sortedDrivers: { id: string }[],
driverId: string
): string {
const idx = sortedDrivers.findIndex((d) => d.id === driverId);
if (idx === -1) return "?";
if (idx < 26) return String.fromCharCode("A".charCodeAt(0) + idx);
// After Z, fall back to 1..9 and clamp at 9 — the glyph "0" would
// read as "unknown driver" next to the existing "?" fallback, and
// wrapping back to "1" would alias position 26's driver. Clamping
// makes it visually obvious you have >35 drivers without silently
// mislabeling anyone.
return String(Math.min(idx - 25, 9));
}
/**
* Returns the unassigned glyph for a supplier bucket:
* - "ready" → ✓ : every pool item has ready_for_collection === true.
* - "unready" → ✕ : at least one isn't ready, or the bucket is empty.
*
* Returning "unready" for an empty input keeps the pin from falsely
* advertising "ready, 0 items" — an empty bucket shouldn't render a pin
* at all, but if the caller asks the helper for the glyph defensively,
* "unready" is the safer answer.
*/
export function unassignedGlyphFor(
poolItems: { ready_for_collection?: boolean }[]
): "ready" | "unready" {
if (poolItems.length === 0) return "unready";
const anyUnready = poolItems.some(
(it) => it.ready_for_collection !== true
);
return anyUnready ? "unready" : "ready";
}