feat(geo): add driverLabelFor + unassignedGlyphFor helpers

This commit is contained in:
SJCoetz 2026-07-11 23:38:00 +02:00
parent 02458909f7
commit 032c76ff19
2 changed files with 147 additions and 0 deletions

96
src/lib/geo.test.ts Normal file
View file

@ -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");
});
});

View file

@ -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";
}