e9ad92de01
feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235)
Squash-merges PR #192: uat→main PROD promotion.
Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS).
Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard).
CTO-reviewed and approved (review #4542).
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
156 lines
4.8 KiB
TypeScript
156 lines
4.8 KiB
TypeScript
// Navigation export — turn an optimized groomer route into a deep-link URL that
|
|
// opens the device's native navigation app (Google Maps / Apple Maps).
|
|
//
|
|
// A route is exported as: origin = first stop, destination = last stop, with the
|
|
// in-between stops carried as ordered intermediate waypoints. Each platform caps
|
|
// how many intermediate waypoints a deep link may carry, so callers must validate
|
|
// the route length before handing the URL to the client.
|
|
|
|
/**
|
|
* Max intermediate waypoints a Google Maps URLs API deep link supports
|
|
* (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents
|
|
* a ceiling of 9 waypoints between origin and destination.
|
|
*/
|
|
export const GOOGLE_MAPS_MAX_WAYPOINTS = 9;
|
|
|
|
/**
|
|
* Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's
|
|
* URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is
|
|
* a conservative practical limit that keeps the URL well under length limits.
|
|
*/
|
|
export const APPLE_MAPS_MAX_WAYPOINTS = 15;
|
|
|
|
export type NavigationPlatform = "google-maps" | "apple-maps";
|
|
|
|
/** A single ordered point on the route. `label` is optional, for display only. */
|
|
export interface NavigationStop {
|
|
latitude: number;
|
|
longitude: number;
|
|
label?: string | null;
|
|
}
|
|
|
|
export interface NavigationExportSuccess {
|
|
platform: NavigationPlatform;
|
|
url: string;
|
|
/** Total stops included (origin + waypoints + destination). */
|
|
stopCount: number;
|
|
/** Intermediate waypoints only (excludes origin and destination). */
|
|
waypointCount: number;
|
|
}
|
|
|
|
export interface NavigationExportError {
|
|
error: string;
|
|
status: 400;
|
|
}
|
|
|
|
export type NavigationExportResult =
|
|
| NavigationExportSuccess
|
|
| NavigationExportError;
|
|
|
|
function isError(r: NavigationExportResult): r is NavigationExportError {
|
|
return "error" in r;
|
|
}
|
|
|
|
/** Intermediate waypoints = every stop that is neither origin nor destination. */
|
|
export function intermediateWaypointCount(stopCount: number): number {
|
|
return Math.max(0, stopCount - 2);
|
|
}
|
|
|
|
function coord(stop: NavigationStop): string {
|
|
return `${stop.latitude},${stop.longitude}`;
|
|
}
|
|
|
|
/**
|
|
* Builds a Google Maps URLs API driving deep link. On mobile this opens the
|
|
* native Google Maps app; on desktop it opens maps.google.com.
|
|
*/
|
|
export function buildGoogleMapsUrl(
|
|
stops: NavigationStop[]
|
|
): NavigationExportResult {
|
|
if (stops.length === 0) {
|
|
return { error: "route has no stops to export", status: 400 };
|
|
}
|
|
const waypointCount = intermediateWaypointCount(stops.length);
|
|
if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) {
|
|
return {
|
|
error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`,
|
|
status: 400,
|
|
};
|
|
}
|
|
|
|
const origin = stops[0]!;
|
|
const destination = stops[stops.length - 1]!;
|
|
const params = new URLSearchParams();
|
|
params.set("api", "1");
|
|
params.set("travelmode", "driving");
|
|
params.set("origin", coord(origin));
|
|
params.set("destination", coord(destination));
|
|
if (stops.length > 2) {
|
|
const mids = stops
|
|
.slice(1, -1)
|
|
.map(coord)
|
|
.join("|");
|
|
params.set("waypoints", mids);
|
|
}
|
|
|
|
return {
|
|
platform: "google-maps",
|
|
url: `https://www.google.com/maps/dir/?${params.toString()}`,
|
|
stopCount: stops.length,
|
|
waypointCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds an Apple Maps `maps://` driving deep link. The first stop is the source
|
|
* (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`).
|
|
* Built by hand because the `+to:` separators are part of Apple's scheme and must
|
|
* not be percent-encoded.
|
|
*/
|
|
export function buildAppleMapsUrl(
|
|
stops: NavigationStop[]
|
|
): NavigationExportResult {
|
|
if (stops.length === 0) {
|
|
return { error: "route has no stops to export", status: 400 };
|
|
}
|
|
const waypointCount = intermediateWaypointCount(stops.length);
|
|
if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) {
|
|
return {
|
|
error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`,
|
|
status: 400,
|
|
};
|
|
}
|
|
|
|
const params: string[] = ["dirflg=d"];
|
|
if (stops.length === 1) {
|
|
// Single stop: destination only, no source.
|
|
params.unshift(`daddr=${coord(stops[0]!)}`);
|
|
} else {
|
|
const daddr = stops
|
|
.slice(1)
|
|
.map(coord)
|
|
.join("+to:");
|
|
params.unshift(`daddr=${daddr}`);
|
|
params.unshift(`saddr=${coord(stops[0]!)}`);
|
|
}
|
|
|
|
return {
|
|
platform: "apple-maps",
|
|
url: `maps://?${params.join("&")}`,
|
|
stopCount: stops.length,
|
|
waypointCount,
|
|
};
|
|
}
|
|
|
|
/** Dispatches to the correct builder for the requested platform. */
|
|
export function buildNavigationUrl(
|
|
platform: NavigationPlatform,
|
|
stops: NavigationStop[]
|
|
): NavigationExportResult {
|
|
return platform === "google-maps"
|
|
? buildGoogleMapsUrl(stops)
|
|
: buildAppleMapsUrl(stops);
|
|
}
|
|
|
|
export { isError as isNavigationExportError };
|