Skip to content

Commit 5a1e164

Browse files
committed
feat(routing): plan trips around per-waypoint times and dwell
A stop can now carry its own schedule: leave after a time, be there by a time, an appointment that fixes both, and how long you stay. A pure solver in @openmapx/core turns those constraints plus a leg-travel oracle into one canonical trip schedule — arrival, departure, dwell and wait at every stop, with violations naming the exact waypoint and shortfall. Ground trips are served by POST /directions/schedule. A dwell-only trip takes one provider call, mapping dwell onto Valhalla's per-location `waiting` so the engine's own clock advances across each stop and later legs are costed for the later hour. Any window constraint switches to explicit leg chaining — one two-point call per leg, pinned to the instant the solver computed — concatenated back into a single route so the map, route card and turn-by-turn consume it unchanged. Multi-stop public transport is served by POST /transit/plan/chain, which plans one connection per segment and hands each segment's realtime arrival to the next. Providers declare per-semantic support as native, emulated, approximate or unsupported. The worst level across the semantics a request uses becomes the response fidelity, so an OSRM-only deployment gets a correct schedule labelled as an estimate rather than a 503. A repo-wide gate keeps those declarations complete and internally consistent. Dwell is deliberately excluded from route duration — it belongs to the schedule, not the driving time. Stop-order optimisation is refused while a window is set, because reordering could move you past an appointment. Also moves the trip-level departure time out of DirectionsPanelContent's local state into directionsStore. The map's independent directions query never saw it there, so a timed trip made the panel and the map key different cache entries and draw different routes. Closes #308
1 parent 508bfaf commit 5a1e164

82 files changed

Lines changed: 6858 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/transit-chain-plan.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@openmapx/core": minor
3+
---
4+
5+
Add the chained transit-plan client surface: `postTransitChainPlan`,
6+
`useTransitChainPlan` and `transitChainQueryKey` against the new
7+
`POST /transit/plan/chain` endpoint, which plans a multi-stop public-transport
8+
trip around per-waypoint time windows and dwell, plus the `ChainedTripPlan`
9+
types that describe its result.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@openmapx/core": minor
3+
---
4+
5+
Add the per-waypoint schedule model and solver: `WaypointSchedule` constraints
6+
on `Waypoint`, `TemporalCapabilities` for declaring provider support,
7+
`resolveScheduleConstraints` for turning wall clocks into validated instants,
8+
`composeSchedule` for the canonical `TripSchedule`, and `planScheduledTrip` for
9+
driving a per-leg travel oracle. Also adds `RoutingOptions.dwellSeconds`, the
10+
`isoWithOffsetInZone` timezone helper, and the scheduled-directions client
11+
surface (`ScheduleDirectionsRequest`, `ScheduledDirectionsResult`,
12+
`postScheduledDirections`) against the new `POST /directions/schedule` endpoint.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@openmapx/core": minor
3+
---
4+
5+
Move the directions trip time into `directionsStore` (`timeMode` / `tripTime`)
6+
so every consumer of the directions cache builds the same request, and add
7+
per-waypoint schedule state (`setWaypointSchedule`, `applyWaypointOrder`,
8+
`hasScheduleConstraints`) plus the `useScheduledDirections` query hook.
9+
`RouteSharePayload` gains optional versioned per-waypoint schedules.

apps/api/openapi.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4402,6 +4402,23 @@
44024402
"x-openmapx-source": "integrations/routing/index.ts"
44034403
}
44044404
},
4405+
"/api/integrations/routing/directions/schedule": {
4406+
"post": {
4407+
"operationId": "postApiIntegrationsRoutingDirectionsSchedule",
4408+
"responses": {
4409+
"default": {
4410+
"description": "Integration response"
4411+
}
4412+
},
4413+
"summary": "POST /directions/schedule (routing)",
4414+
"tags": [
4415+
"integrations"
4416+
],
4417+
"x-openmapx-auth": "public",
4418+
"x-openmapx-integration": "routing",
4419+
"x-openmapx-source": "integrations/routing/index.ts"
4420+
}
4421+
},
44054422
"/api/integrations/routing/match": {
44064423
"post": {
44074424
"operationId": "postApiIntegrationsRoutingMatch",
@@ -4851,6 +4868,23 @@
48514868
"x-openmapx-source": "integrations/transit/index.ts"
48524869
}
48534870
},
4871+
"/api/integrations/transit/plan/chain": {
4872+
"post": {
4873+
"operationId": "postApiIntegrationsTransitPlanChain",
4874+
"responses": {
4875+
"default": {
4876+
"description": "Integration response"
4877+
}
4878+
},
4879+
"summary": "POST /plan/chain (transit)",
4880+
"tags": [
4881+
"integrations"
4882+
],
4883+
"x-openmapx-auth": "public",
4884+
"x-openmapx-integration": "transit",
4885+
"x-openmapx-source": "integrations/transit/index.ts"
4886+
}
4887+
},
48544888
"/api/integrations/transit/plan/refresh": {
48554889
"post": {
48564890
"operationId": "postApiIntegrationsTransitPlanRefresh",

apps/api/src/services/__tests__/share-links.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,64 @@ describe("toOwnerShare", () => {
195195
expect(JSON.stringify(owner)).not.toMatch(/HASH|user-1|snapshot/);
196196
});
197197
});
198+
199+
describe("validateRouteShare with per-waypoint schedules", () => {
200+
it("accepts an aligned schedules array and records the version", () => {
201+
const parsed = validateRouteShare({
202+
...ROUTE,
203+
schedules: [
204+
null,
205+
{ arriveBy: "2026-09-01T14:00", dwellSeconds: 1800, timeZone: "Europe/Berlin" },
206+
],
207+
});
208+
expect(parsed?.scheduleVersion).toBe(1);
209+
expect(parsed?.schedules).toEqual([
210+
null,
211+
{ arriveBy: "2026-09-01T14:00", dwellSeconds: 1800, timeZone: "Europe/Berlin" },
212+
]);
213+
});
214+
215+
it("omits the schedules entirely when every entry is unconstrained", () => {
216+
const parsed = validateRouteShare({ ...ROUTE, schedules: [null, null] });
217+
expect(parsed?.schedules).toBeUndefined();
218+
expect(parsed?.scheduleVersion).toBeUndefined();
219+
});
220+
221+
it("rejects a schedules array that does not align with the waypoints", () => {
222+
expect(validateRouteShare({ ...ROUTE, schedules: [null] })).toBeNull();
223+
});
224+
225+
it("rejects a malformed wall clock", () => {
226+
expect(
227+
validateRouteShare({ ...ROUTE, schedules: [null, { arriveBy: "tomorrow" }] }),
228+
).toBeNull();
229+
});
230+
231+
it("rejects an unrecognized time zone", () => {
232+
expect(
233+
validateRouteShare({ ...ROUTE, schedules: [null, { timeZone: "Not/AZone" }] }),
234+
).toBeNull();
235+
});
236+
237+
it("rejects an out-of-range or fractional dwell", () => {
238+
expect(
239+
validateRouteShare({ ...ROUTE, schedules: [null, { dwellSeconds: 100_000 }] }),
240+
).toBeNull();
241+
expect(validateRouteShare({ ...ROUTE, schedules: [null, { dwellSeconds: 90.5 }] })).toBeNull();
242+
expect(validateRouteShare({ ...ROUTE, schedules: [null, { dwellSeconds: -1 }] })).toBeNull();
243+
});
244+
245+
it("rejects an unknown schedule field", () => {
246+
expect(validateRouteShare({ ...ROUTE, schedules: [null, { leaveWhenever: true }] })).toBeNull();
247+
});
248+
249+
it("rejects an unknown schedule version", () => {
250+
expect(
251+
validateRouteShare({ ...ROUTE, scheduleVersion: 2, schedules: [null, null] }),
252+
).toBeNull();
253+
});
254+
255+
it("leaves an ordinary payload untouched", () => {
256+
expect(validateRouteShare(ROUTE)).toEqual(ROUTE);
257+
});
258+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { existsSync, readdirSync } from "node:fs";
2+
import { dirname, join, resolve } from "node:path";
3+
import { fileURLToPath, pathToFileURL } from "node:url";
4+
import type { TemporalCapabilities, TemporalSupport } from "@openmapx/core";
5+
import { createMockIntegrationContext } from "@openmapx/integration-framework/testing";
6+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const INTEGRATIONS_DIR = resolve(__dirname, "../../../../../integrations");
10+
11+
const SEMANTICS: (keyof TemporalCapabilities)[] = [
12+
"tripDepartAt",
13+
"tripArriveBy",
14+
"dwell",
15+
"waypointDepartAfter",
16+
"waypointArriveBy",
17+
"timeDependentTravel",
18+
];
19+
const LEVELS: TemporalSupport[] = ["native", "emulated", "approximate", "unsupported"];
20+
21+
function backendIntegrationDirs(): string[] {
22+
if (!existsSync(INTEGRATIONS_DIR)) return [];
23+
return readdirSync(INTEGRATIONS_DIR, { withFileTypes: true })
24+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("_"))
25+
.map((entry) => entry.name)
26+
.filter((name) => existsSync(join(INTEGRATIONS_DIR, name, "index.ts")));
27+
}
28+
29+
function assertComplete(label: string, temporal: Partial<TemporalCapabilities>): void {
30+
for (const semantic of SEMANTICS) {
31+
const level = temporal[semantic];
32+
expect(
33+
LEVELS.includes(level as TemporalSupport),
34+
`${label}: temporal.${semantic} must be one of ${LEVELS.join(", ")}`,
35+
).toBe(true);
36+
}
37+
// Claiming a time-dependent semantic while admitting travel time is not
38+
// time-dependent is self-contradictory, and would label estimated wall clocks
39+
// as "exact" fidelity.
40+
if (temporal.timeDependentTravel === "unsupported") {
41+
for (const semantic of SEMANTICS) {
42+
if (semantic === "timeDependentTravel") continue;
43+
expect(
44+
temporal[semantic],
45+
`${label}: temporal.${semantic} cannot be "native" while travel time ignores the departure instant`,
46+
).not.toBe("native");
47+
}
48+
}
49+
}
50+
51+
/**
52+
* A partial `temporal` block is worse than none: the schedule planner reads
53+
* every semantic, and a missing key becomes `undefined` rather than falling back
54+
* to the documented default. This drives each integration's real `setup()`
55+
* through a capturing mock context and checks whatever it registers.
56+
*/
57+
describe("Temporal capability conformance", () => {
58+
const dirs = backendIntegrationDirs();
59+
60+
beforeAll(() => {
61+
vi.stubGlobal("fetch", () =>
62+
Promise.reject(new Error("network disabled in temporal capability conformance test")),
63+
);
64+
});
65+
afterAll(() => {
66+
vi.unstubAllGlobals();
67+
});
68+
69+
it("finds backend integrations to check", () => {
70+
expect(dirs.length).toBeGreaterThan(0);
71+
});
72+
73+
it("checks at least one real declaration", async () => {
74+
const ctx = createMockIntegrationContext({ id: "routing-valhalla" });
75+
const mod = await import(
76+
pathToFileURL(join(INTEGRATIONS_DIR, "routing-valhalla", "index.ts")).href
77+
);
78+
await mod.setup?.(ctx);
79+
const declared = ctx.registered.routing.filter(
80+
(provider) => (provider as { temporal?: TemporalCapabilities }).temporal !== undefined,
81+
);
82+
expect(declared.length).toBeGreaterThan(0);
83+
});
84+
85+
for (const dir of dirs) {
86+
it(`${dir}: declared temporal capabilities are complete and consistent`, async () => {
87+
const ctx = createMockIntegrationContext({ id: dir });
88+
const mod = await import(pathToFileURL(join(INTEGRATIONS_DIR, dir, "index.ts")).href);
89+
if (typeof mod.setup !== "function") return;
90+
try {
91+
await mod.setup(ctx);
92+
} catch {
93+
// Tolerated: setup() may require real config/services. Assert on what
94+
// registered before the throw.
95+
}
96+
97+
for (const provider of ctx.registered.routing) {
98+
const temporal = (provider as { temporal?: Partial<TemporalCapabilities> }).temporal;
99+
if (!temporal) continue;
100+
assertComplete(`${dir}/${(provider as { id?: string }).id ?? "routing"}`, temporal);
101+
}
102+
103+
for (const provider of ctx.registered.transit) {
104+
const temporal = provider.capabilities.planningFeatures?.temporal;
105+
if (!temporal) continue;
106+
assertComplete(`${dir}/${provider.id}`, temporal);
107+
}
108+
});
109+
}
110+
});

apps/api/src/services/share-links.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,26 @@ export interface RouteShareWaypoint {
2121
label?: string;
2222
}
2323

24+
/** Mirrors `WaypointSchedule` in `@openmapx/core`; kept structural so this
25+
* service does not depend on the web package's type graph. */
26+
export interface ShareWaypointSchedule {
27+
departAfter?: string;
28+
arriveBy?: string;
29+
fixedAt?: string;
30+
dwellSeconds?: number;
31+
timeZone?: string;
32+
}
33+
2434
export interface RouteSharePayload {
2535
waypoints: RouteShareWaypoint[];
2636
mode: RouteShareMode;
2737
avoidHighways?: boolean;
2838
avoidTolls?: boolean;
2939
avoidFerries?: boolean;
40+
/** Bumped when the schedule encoding changes; readers reject an unknown value. */
41+
scheduleVersion?: 1;
42+
/** Per-waypoint constraints, aligned to `waypoints`. */
43+
schedules?: (ShareWaypointSchedule | null)[];
3044
}
3145

3246
export interface StoredListSnapshot {
@@ -70,6 +84,52 @@ export function isExpired(row: { expiresAt: Date | null }, now: Date): boolean {
7084
return row.expiresAt !== null && row.expiresAt.getTime() <= now.getTime();
7185
}
7286

87+
const SHARE_WALL_CLOCK = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
88+
const MAX_SHARE_DWELL_SECONDS = 86_400;
89+
90+
/**
91+
* `undefined` means "reject the whole payload"; `null` means "this waypoint is
92+
* unconstrained". A malformed schedule is never trimmed away — a share link
93+
* that silently dropped an appointment would be worse than no link.
94+
*/
95+
function validSchedule(value: unknown): ShareWaypointSchedule | null | undefined {
96+
if (value === null || value === undefined) return null;
97+
if (typeof value !== "object" || Array.isArray(value)) return undefined;
98+
const source = value as Record<string, unknown>;
99+
const known = new Set(["departAfter", "arriveBy", "fixedAt", "dwellSeconds", "timeZone"]);
100+
if (Object.keys(source).some((key) => !known.has(key))) return undefined;
101+
102+
const schedule: ShareWaypointSchedule = {};
103+
for (const field of ["departAfter", "arriveBy", "fixedAt"] as const) {
104+
const raw = source[field];
105+
if (raw === undefined) continue;
106+
if (typeof raw !== "string" || !SHARE_WALL_CLOCK.test(raw)) return undefined;
107+
schedule[field] = raw;
108+
}
109+
if (source.timeZone !== undefined) {
110+
if (typeof source.timeZone !== "string") return undefined;
111+
try {
112+
new Intl.DateTimeFormat("en-US", { timeZone: source.timeZone });
113+
} catch {
114+
return undefined;
115+
}
116+
schedule.timeZone = source.timeZone;
117+
}
118+
if (source.dwellSeconds !== undefined) {
119+
const dwell = source.dwellSeconds;
120+
if (
121+
typeof dwell !== "number" ||
122+
!Number.isInteger(dwell) ||
123+
dwell < 0 ||
124+
dwell > MAX_SHARE_DWELL_SECONDS
125+
) {
126+
return undefined;
127+
}
128+
schedule.dwellSeconds = dwell;
129+
}
130+
return Object.keys(schedule).length > 0 ? schedule : null;
131+
}
132+
73133
function validWaypoint(value: unknown): RouteShareWaypoint | null {
74134
if (typeof value !== "object" || value === null) return null;
75135
const { lat, lng, label } = value as Record<string, unknown>;
@@ -88,10 +148,8 @@ function validWaypoint(value: unknown): RouteShareWaypoint | null {
88148
*/
89149
export function validateRouteShare(input: unknown): RouteSharePayload | null {
90150
if (typeof input !== "object" || input === null) return null;
91-
const { waypoints, mode, avoidHighways, avoidTolls, avoidFerries } = input as Record<
92-
string,
93-
unknown
94-
>;
151+
const { waypoints, mode, avoidHighways, avoidTolls, avoidFerries, scheduleVersion, schedules } =
152+
input as Record<string, unknown>;
95153
if (!ROUTE_SHARE_MODES.includes(mode as RouteShareMode)) return null;
96154
if (!Array.isArray(waypoints) || waypoints.length < 2 || waypoints.length > MAX_WAYPOINTS) {
97155
return null;
@@ -109,6 +167,21 @@ export function validateRouteShare(input: unknown): RouteSharePayload | null {
109167
if (avoidHighways !== undefined) payload.avoidHighways = avoidHighways as boolean;
110168
if (avoidTolls !== undefined) payload.avoidTolls = avoidTolls as boolean;
111169
if (avoidFerries !== undefined) payload.avoidFerries = avoidFerries as boolean;
170+
171+
if (scheduleVersion !== undefined && scheduleVersion !== 1) return null;
172+
if (schedules !== undefined) {
173+
if (!Array.isArray(schedules) || schedules.length !== parsed.length) return null;
174+
const parsedSchedules: (ShareWaypointSchedule | null)[] = [];
175+
for (const candidate of schedules) {
176+
const schedule = validSchedule(candidate);
177+
if (schedule === undefined) return null;
178+
parsedSchedules.push(schedule);
179+
}
180+
if (parsedSchedules.some((schedule) => schedule !== null)) {
181+
payload.scheduleVersion = 1;
182+
payload.schedules = parsedSchedules;
183+
}
184+
}
112185
return payload;
113186
}
114187

0 commit comments

Comments
 (0)