diff --git a/src/lib/geo.test.ts b/src/lib/geo.test.ts new file mode 100644 index 0000000..7c3d2a3 --- /dev/null +++ b/src/lib/geo.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { driverLabelFor, unassignedGlyphFor } 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("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"); + }); +}); \ No newline at end of file diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 87a2dbe..f672970 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -226,4 +226,55 @@ export function colorForStop(args: { 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 alphabetically- + * sorted driver list. Position 0 → "A", 1 → "B", ..., 25 → "Z", 26 → "1", + * 27 → "2", ... 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 then loop — at >35 drivers we'll + // reuse digits, which is a graceful degradation rather than a crash). + return String((idx - 25) % 10); +} + +/** + * 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"; } \ No newline at end of file