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>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { driverLabelFor, unassignedGlyphFor, jitterCoordinates, distanceKm } from "./geo";
|
|
|
|
describe("driverLabelFor", () => {
|
|
it("returns A for the first driver alphabetically", () => {
|
|
const drivers = [{ id: "u-1" }, { id: "u-2" }].sort((a, b) =>
|
|
a.id.localeCompare(b.id)
|
|
);
|
|
// u-1 < u-2 lexically, so driver at index 0 is "u-1"
|
|
expect(driverLabelFor(drivers, drivers[0].id)).toBe("A");
|
|
expect(driverLabelFor(drivers, drivers[1].id)).toBe("B");
|
|
});
|
|
|
|
it("returns sequential letters A through Z for the first 26 drivers", () => {
|
|
const drivers = Array.from({ length: 26 }, (_, i) => ({
|
|
id: `driver-${String(i).padStart(2, "0")}`,
|
|
}));
|
|
const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id));
|
|
for (let i = 0; i < 26; i++) {
|
|
expect(driverLabelFor(sorted, sorted[i].id)).toBe(
|
|
String.fromCharCode("A".charCodeAt(0) + i)
|
|
);
|
|
}
|
|
});
|
|
|
|
it("falls back to digits 1..9 starting at position 27", () => {
|
|
const drivers = Array.from({ length: 30 }, (_, i) => ({
|
|
id: `driver-${String(i).padStart(2, "0")}`,
|
|
}));
|
|
const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id));
|
|
expect(driverLabelFor(sorted, sorted[26].id)).toBe("1");
|
|
expect(driverLabelFor(sorted, sorted[27].id)).toBe("2");
|
|
expect(driverLabelFor(sorted, sorted[28].id)).toBe("3");
|
|
expect(driverLabelFor(sorted, sorted[29].id)).toBe("4");
|
|
});
|
|
|
|
it("clamps at '9' for the 35th driver and beyond (no '0' wrap)", () => {
|
|
const drivers = Array.from({ length: 40 }, (_, i) => ({
|
|
id: `driver-${String(i).padStart(2, "0")}`,
|
|
}));
|
|
const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id));
|
|
// Sorted: driver-00 (A) ... driver-34 (9), then driver-35 clamps to "9".
|
|
expect(driverLabelFor(sorted, sorted[34].id)).toBe("9");
|
|
expect(driverLabelFor(sorted, sorted[35].id)).toBe("9");
|
|
expect(driverLabelFor(sorted, sorted[39].id)).toBe("9");
|
|
});
|
|
|
|
it("returns '?' for an unknown driver id", () => {
|
|
const drivers = [{ id: "u-1" }];
|
|
expect(driverLabelFor(drivers, "u-missing")).toBe("?");
|
|
});
|
|
|
|
it("is stable — same input returns the same label on repeated calls", () => {
|
|
const drivers = [{ id: "u-1" }, { id: "u-2" }, { id: "u-3" }];
|
|
const sorted = [...drivers].sort((a, b) => a.id.localeCompare(b.id));
|
|
const first = driverLabelFor(sorted, "u-2");
|
|
const second = driverLabelFor(sorted, "u-2");
|
|
const third = driverLabelFor(sorted, "u-2");
|
|
expect(first).toBe(second);
|
|
expect(second).toBe(third);
|
|
});
|
|
|
|
it("is stable when given names instead of ids (alphabetical by name)", () => {
|
|
const drivers = [
|
|
{ id: "alice" },
|
|
{ id: "bob" },
|
|
{ id: "carol" },
|
|
];
|
|
expect(driverLabelFor(drivers, "alice")).toBe("A");
|
|
expect(driverLabelFor(drivers, "bob")).toBe("B");
|
|
expect(driverLabelFor(drivers, "carol")).toBe("C");
|
|
});
|
|
});
|
|
|
|
describe("unassignedGlyphFor", () => {
|
|
it("returns 'ready' when every item is ready_for_collection=true", () => {
|
|
expect(
|
|
unassignedGlyphFor([
|
|
{ ready_for_collection: true },
|
|
{ ready_for_collection: true },
|
|
])
|
|
).toBe("ready");
|
|
});
|
|
|
|
it("returns 'unready' when at least one item is not ready", () => {
|
|
expect(
|
|
unassignedGlyphFor([
|
|
{ ready_for_collection: true },
|
|
{ ready_for_collection: false },
|
|
])
|
|
).toBe("unready");
|
|
});
|
|
|
|
it("returns 'unready' when at least one item has ready_for_collection=undefined", () => {
|
|
expect(
|
|
unassignedGlyphFor([
|
|
{ ready_for_collection: true },
|
|
{},
|
|
])
|
|
).toBe("unready");
|
|
});
|
|
|
|
it("returns 'unready' for an empty array", () => {
|
|
expect(unassignedGlyphFor([])).toBe("unready");
|
|
});
|
|
});
|
|
describe("jitterCoordinates", () => {
|
|
const CENTER: readonly [number, number] = [-26.4, 27.4];
|
|
|
|
it("offsets the point by a distance within the requested range", () => {
|
|
// distanceKm is the same great-circle maths the check-in radius
|
|
// match uses, so this asserts the jitter is bounded in the units
|
|
// that actually matter for the feature.
|
|
for (let i = 0; i < 50; i++) {
|
|
const jittered = jitterCoordinates(CENTER, 30, 80);
|
|
const metres = distanceKm(CENTER, jittered) * 1000;
|
|
expect(metres).toBeGreaterThanOrEqual(30 - 0.5);
|
|
expect(metres).toBeLessThanOrEqual(80 + 0.5);
|
|
}
|
|
});
|
|
|
|
it("never returns the exact same point", () => {
|
|
expect(jitterCoordinates(CENTER, 30, 80)).not.toEqual(CENTER);
|
|
});
|
|
|
|
it("produces different points on repeated calls", () => {
|
|
// Not a hard guarantee (it's random), but with a continuous range
|
|
// and a bearing over the full circle, two calls landing on the
|
|
// exact same float is negligible — this catches an accidental
|
|
// deterministic implementation.
|
|
const a = jitterCoordinates(CENTER, 30, 80);
|
|
const b = jitterCoordinates(CENTER, 30, 80);
|
|
expect(a).not.toEqual(b);
|
|
});
|
|
|
|
it("keeps a zero-width range at that exact distance", () => {
|
|
const jittered = jitterCoordinates(CENTER, 50, 50);
|
|
const metres = distanceKm(CENTER, jittered) * 1000;
|
|
expect(metres).toBeCloseTo(50, 0);
|
|
});
|
|
});
|