From 21f1d22748771f5d95413e23f07c1f59b737fcd2 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Mon, 19 Oct 2026 16:31:39 +0200 Subject: [PATCH 1/4] feat(orchestration): build the maps URL of a step in the mode of the trip to it The Google Maps URL of a step previously opened on foot only. `travelModeOf` carries the transitModes table of the tokens package, and `stepMapsUrl` opens the step at an index in the mode of the trip that leads to it, on foot for the first step and when the trip carries no mode. Co-Authored-By: Claude Fable 5.1 --- packages/orchestration/src/index.ts | 1 + packages/orchestration/src/maps.test.ts | 46 +++++++++++++++++++++++ packages/orchestration/src/maps.ts | 50 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 packages/orchestration/src/maps.test.ts create mode 100644 packages/orchestration/src/maps.ts diff --git a/packages/orchestration/src/index.ts b/packages/orchestration/src/index.ts index 15983588..d4b0fafc 100644 --- a/packages/orchestration/src/index.ts +++ b/packages/orchestration/src/index.ts @@ -9,6 +9,7 @@ export { type DirectionsProvider, } from './directions'; export { COORDINATE_DECIMALS, roundedPoint, straightLineM } from './geo'; +export { stepMapsUrl, travelModeOf, type MapsStep, type MapsTravelMode } from './maps'; export { computeTransitions, estimateOfMode, diff --git a/packages/orchestration/src/maps.test.ts b/packages/orchestration/src/maps.test.ts new file mode 100644 index 00000000..d83f3b7b --- /dev/null +++ b/packages/orchestration/src/maps.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { stepMapsUrl, travelModeOf, type MapsStep } from './maps'; + +/** Four steps near République, carrying the mode of each trip to the next one. */ +const steps = [ + { place: { location: { lat: 48.8674, lng: 2.3636 } }, transition: { mode: 'bike' } }, + { place: { location: { lat: 48.8714, lng: 2.3676 } }, transition: { mode: 'metro' } }, + { place: { location: { lat: 48.8754, lng: 2.3716 } }, transition: { mode: 'car' } }, + { place: { location: { lat: 48.8794, lng: 2.3756 } }, transition: null }, +] as const satisfies readonly MapsStep[]; + +describe('travelModeOf', () => { + it('maps each transport to the travel mode of Google Maps', () => { + expect(travelModeOf('walk')).toBe('walking'); + expect(travelModeOf('bike')).toBe('bicycling'); + expect(travelModeOf('metro')).toBe('transit'); + expect(travelModeOf('car')).toBe('driving'); + }); +}); + +describe('stepMapsUrl', () => { + it('opens the first step on foot, no trip of the route leads to it', () => { + expect(stepMapsUrl(steps, 0)).toBe( + 'https://www.google.com/maps/dir/?api=1&destination=48.8674%2C2.3636&travelmode=walking', + ); + }); + + it('opens the next steps in the mode of the trip that leads to them', () => { + expect(stepMapsUrl(steps, 1)).toContain('travelmode=bicycling'); + expect(stepMapsUrl(steps, 2)).toContain('travelmode=transit'); + expect(stepMapsUrl(steps, 3)).toContain('travelmode=driving'); + }); + + it('opens on foot a step whose trip carries no mode', () => { + const modeless = [ + { place: { location: { lat: 48.8674, lng: 2.3636 } }, transition: null }, + { place: { location: { lat: 48.8714, lng: 2.3676 } }, transition: null }, + ] as const satisfies readonly MapsStep[]; + expect(stepMapsUrl(modeless, 1)).toContain('travelmode=walking'); + }); + + it('always takes one destination, never the whole route', () => { + expect(stepMapsUrl(steps, 2)).toContain('destination=48.8754%2C2.3716&'); + expect(stepMapsUrl(steps, 2)).not.toContain('waypoints'); + }); +}); diff --git a/packages/orchestration/src/maps.ts b/packages/orchestration/src/maps.ts new file mode 100644 index 00000000..40e4feb4 --- /dev/null +++ b/packages/orchestration/src/maps.ts @@ -0,0 +1,50 @@ +/** + * Google Maps of « Itinéraire » (Ecrans › E-07): the command opens Google Maps to the step to go + * to, in the mode of the trip that leads to it (#510, D-052). One definition, shared by + * `GET /v1/me/planned/:id/maps-url` and by the app when the network is down. + */ +import type { LatLng, Transport } from '@widoo/shared'; + +/** The travel mode of Google Maps: the value of `travelmode=`, never a display label. */ +export type MapsTravelMode = 'walking' | 'bicycling' | 'transit' | 'driving'; + +/** + * The travel mode of Google Maps for a trip in each transport: the `mappings.transitModes` table + * of `packages/tokens/generated/tokens.json` (`walk` → `walking`, `bike` → `bicycling`, `metro` → + * `transit`, `car` → `driving`), defined here because the tokens package does not export it. + */ +const travelModeOfTransport: Record = { + walk: 'walking', + bike: 'bicycling', + metro: 'transit', + car: 'driving', +}; + +/** The travel mode of Google Maps for a trip in this transport. */ +export function travelModeOf(transport: Transport): MapsTravelMode { + return travelModeOfTransport[transport]; +} + +/** A step as the URL needs it: where to go, and the trip from it to the next one. */ +export interface MapsStep { + place: { location: LatLng }; + /** The trip to the next step; null for the last one (`Step.transition`). */ + transition: { mode: Transport } | null; +} + +/** + * Google Maps to the step at this index of the snapshot, in the mode of the trip to it: no trip + * leads to the first step, so Google Maps opens it on foot, and so again when the trip carries + * no mode. One URL for one step, never for the whole route (D-032). + */ +export function stepMapsUrl(steps: readonly MapsStep[], index: number): string { + const step = steps[index]; + if (!step) { + throw new Error(`No step at index ${index}`); + } + const transition = index > 0 ? steps[index - 1]?.transition : null; + const travelmode = transition ? travelModeOf(transition.mode) : 'walking'; + return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent( + `${step.place.location.lat},${step.place.location.lng}`, + )}&travelmode=${travelmode}`; +} From 81e61c0320eec3d3a70808d75156cb98df533c50 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Mon, 19 Oct 2026 16:36:37 +0200 Subject: [PATCH 2/4] fix(api): open the maps URL in the mode of the trip to the next step The first step neither done nor skipped keeps its step, but maps-url now builds the URL with the shared stepMapsUrl of the orchestration: the mode of the trip to it (the transition of the previous step), on foot for the first step and when the trip carries no mode. Co-Authored-By: Claude Fable 5.1 --- apps/api/src/planned/go.ts | 21 ++++----- apps/api/src/routes/planned-go.test.ts | 64 +++++++++++++++++--------- apps/api/src/routes/planned.ts | 3 +- 3 files changed, 51 insertions(+), 37 deletions(-) diff --git a/apps/api/src/planned/go.ts b/apps/api/src/planned/go.ts index 56d97c91..008666d1 100644 --- a/apps/api/src/planned/go.ts +++ b/apps/api/src/planned/go.ts @@ -1,23 +1,16 @@ /** * The Go of a planning (Ecrans E-07): « Étape suivante » marks a step done, and « Itinéraire » - * opens Google Maps on foot to the next step only, never the whole route (D-029, D-032). + * opens Google Maps to the next step, in the mode of the trip that leads to it, never the whole + * route (D-029, D-032, #510). */ -import type { LatLng, PlannedMapsUrl, PlannedRoute } from '@widoo/shared'; +import { stepMapsUrl } from '@widoo/orchestration'; +import type { PlannedMapsUrl, PlannedRoute } from '@widoo/shared'; import { and, asc, eq, isNull } from 'drizzle-orm'; import type { Db } from '../db/client'; import { plannedSteps } from '../db/schema'; import { httpError } from '../errors'; import { ownedPlanned, readPlanned, snapshotOfRow } from './repository'; -/** Google Maps URLs, on foot from where the phone is to `destination`. */ -export function walkingMapsUrl(destination: LatLng): string { - const url = new URL('https://www.google.com/maps/dir/'); - url.searchParams.set('api', '1'); - url.searchParams.set('destination', `${destination.lat},${destination.lng}`); - url.searchParams.set('travelmode', 'walking'); - return url.href; -} - /** Marks a step of the caller's Go done, once: a skipped step becomes done. 409 outside a Go. */ export async function markStepDone( db: Db, @@ -58,9 +51,11 @@ export async function nextStepMapsUrl(db: Db, userId: string, id: string): Promi ) .orderBy(asc(plannedSteps.position)) .limit(1); - const step = snapshotOfRow(row).steps.find((candidate) => candidate.id === next?.stepId); + const steps = snapshotOfRow(row).steps; + const index = steps.findIndex((candidate) => candidate.id === next?.stepId); + const step = index === -1 ? undefined : steps[index]; if (!step) { throw httpError(409, 'No step left to go to'); } - return { stepId: step.id, url: walkingMapsUrl(step.place.location) }; + return { stepId: step.id, url: stepMapsUrl(steps, index) }; } diff --git a/apps/api/src/routes/planned-go.test.ts b/apps/api/src/routes/planned-go.test.ts index 38efa457..ed773690 100644 --- a/apps/api/src/routes/planned-go.test.ts +++ b/apps/api/src/routes/planned-go.test.ts @@ -1,11 +1,11 @@ import { ApiError, PlannedMapsUrl, PlannedRoute } from '@widoo/shared'; +import { travelModeOf } from '@widoo/orchestration'; import { and, eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; -import { plannedSteps } from '../db/schema'; +import { plannedSteps, steps } from '../db/schema'; import { uuidv7 } from '../db/uuid'; -import { walkingMapsUrl } from '../planned/go'; import { plannedFixtures, saturdayTen } from '../planned/test-fixtures'; import { testConfig } from '../test-config'; @@ -24,13 +24,13 @@ afterAll(async () => { }); type Headers = Record; -/** A planning of the caller, its Go started unless `isStarted` is false. */ -async function go(headers: Headers, isStarted = true) { +/** A planning of the caller, its Go started unless `isStarted` is false; `route`'s plannable. */ +async function go(headers: Headers, isStarted = true, route = routeId) { const response = await app.inject({ method: 'POST', url: '/v1/me/planned', headers, - payload: { routeId, startsAt: saturdayTen }, + payload: { routeId: route, startsAt: saturdayTen }, }); const planned = PlannedRoute.parse(response.json()); if (isStarted) { @@ -49,18 +49,6 @@ const done = (headers: Headers, id: string, stepId: string | undefined, payload? const mapsUrl = (headers: Headers, id: string) => app.inject({ url: `/v1/me/planned/${id}/maps-url`, headers }); -describe('walkingMapsUrl', () => { - it('builds a Google Maps URL on foot to one point', () => { - const url = new URL(walkingMapsUrl({ lat: 48.85661, lng: 2.35222 })); - expect(url.origin + url.pathname).toBe('https://www.google.com/maps/dir/'); - expect(Object.fromEntries(url.searchParams)).toEqual({ - api: '1', - destination: '48.85661,2.35222', - travelmode: 'walking', - }); - }); -}); - describe('POST /v1/me/planned/:id/steps/:stepId/done', () => { it('marks a step done once, and a skipped step done', async () => { const headers = await fixtures.signIn(); @@ -104,26 +92,56 @@ describe('POST /v1/me/planned/:id/steps/:stepId/done', () => { }); describe('GET /v1/me/planned/:id/maps-url', () => { + /** The destination of a Google Maps URL, decoded as the test compares it. */ + const destinationOf = (url: string) => new URL(url).searchParams.get('destination'); + /** The mode of the trip to a step of the next one, as a creator keeps it. */ + const trips = ['bike', 'metro', 'car'] as const; + it('opens Google Maps on foot to the next step only', async () => { const headers = await fixtures.signIn(); const { id, route } = await go(headers); const [first, second] = route.steps; + const location = first?.place.location ?? { lat: 0, lng: 0 }; const toFirst = PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()); expect(toFirst).toEqual({ stepId: first?.id, - url: walkingMapsUrl(first?.place.location ?? { lat: 0, lng: 0 }), + url: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent( + `${location.lat},${location.lng}`, + )}&travelmode=walking`, }); await done(headers, id, first?.id); const toSecond = PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()); expect(toSecond.stepId).toBe(second?.id); - const url = new URL(toSecond.url); - expect(url.searchParams.get('destination')).toBe( + expect(destinationOf(toSecond.url)).toBe( `${second?.place.location.lat},${second?.place.location.lng}`, ); - expect(url.searchParams.get('travelmode')).toBe('walking'); - // One destination, no waypoints: never the whole route. - expect(url.searchParams.has('waypoints')).toBe(false); + expect(toSecond.url).toContain('travelmode=walking'); + }); + + it('opens Google Maps in the mode of the trip to the next steps', async () => { + const headers = await fixtures.signIn(); + const modeRoute = await fixtures.copyDemoRoute(); + for (const [position, mode] of trips.entries()) { + await app.db + .update(steps) + .set({ transition: { distanceM: 900, durationMin: 10, mode, estimated: true } }) + .where(and(eq(steps.routeId, modeRoute), eq(steps.position, position))); + } + const { id, route } = await go(headers, true, modeRoute); + // No trip of the route leads to the first step: on foot, even with a bike trip after it. + expect( + PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()).url, + ).toContain('travelmode=walking'); + for (const [stepIndex, mode] of trips.entries()) { + await done(headers, id, route.steps[stepIndex]?.id); + const answer = PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()); + expect(answer.stepId).toBe(route.steps[stepIndex + 1]?.id); + expect(answer.url).toContain(`travelmode=${travelModeOf(mode)}`); + // One destination, no waypoints: never the whole route. + expect(destinationOf(answer.url)).not.toBeNull(); + expect(answer.url).not.toContain('waypoints'); + } }); it("answers 409 once every step is done, and 404 for another user's planning", async () => { diff --git a/apps/api/src/routes/planned.ts b/apps/api/src/routes/planned.ts index f43fe63b..36c4b783 100644 --- a/apps/api/src/routes/planned.ts +++ b/apps/api/src/routes/planned.ts @@ -139,7 +139,8 @@ export const plannedRoutesPlugin: FastifyPluginAsyncZod = async (app) => { { schema: { tags, - summary: 'Google Maps on foot to the next step of my planning, never the whole route', + summary: + 'Google Maps to the next step of my planning, in the mode of the trip to it, never the whole route', security, params: PlannedParams, response: { 200: PlannedMapsUrl }, From d243398fa234fc869bf2f7152eb2cf8c2197b71a Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Mon, 19 Oct 2026 16:42:44 +0200 Subject: [PATCH 3/4] fix(mobile): build the offline maps URL in the mode of the trip, like the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline fallback of « Itinéraire » now builds the URL with the shared stepMapsUrl of the orchestration, in the mode of the trip to the step, exactly like maps-url; the local copy on foot is gone. The fixture carries a trip of each mode, and the test asserts the URL the API writes. The comment of the client notes what maps-url answers now. Fixes #510 Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/go/progress.test.ts | 19 +++++++++++++++---- apps/mobile/src/go/progress.ts | 10 ---------- apps/mobile/src/go/testPlanned.ts | 10 ++++++---- apps/mobile/src/go/useGo.ts | 9 +++++---- packages/api-client/src/client.ts | 5 +++-- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/go/progress.test.ts b/apps/mobile/src/go/progress.test.ts index edfc437d..1326dd48 100644 --- a/apps/mobile/src/go/progress.test.ts +++ b/apps/mobile/src/go/progress.test.ts @@ -1,3 +1,4 @@ +import { stepMapsUrl } from '@widoo/orchestration'; import { describe, expect, it } from 'vitest'; import { actualMinutes, @@ -6,7 +7,6 @@ import { emptyGoLocal, goProgress, isArrivalSuggested, - walkingMapsUrl, } from './progress'; import { plannedFixture, stepIds } from './testPlanned'; @@ -104,10 +104,21 @@ describe('actualMinutes', () => { }); }); -describe('walkingMapsUrl', () => { - it('writes Google Maps on foot to the step, as the API does', () => { - expect(walkingMapsUrl({ lat: 48.8674, lng: 2.3636 })).toBe( +describe('maps URL', () => { + const planned = plannedFixture(); + + it('writes offline the URL the API writes, in the mode of the trip to the step', () => { + // « Itinéraire » offline builds the URL of `maps-url` with the same shared `stepMapsUrl` + // (#510): on foot to the first step, then in the mode of the trip that leads to it. + expect(stepMapsUrl(planned.route.steps, 0)).toBe( 'https://www.google.com/maps/dir/?api=1&destination=48.8674%2C2.3636&travelmode=walking', ); + expect(stepMapsUrl(planned.route.steps, 1)).toBe( + 'https://www.google.com/maps/dir/?api=1&destination=48.8714%2C2.3676&travelmode=bicycling', + ); + // The trip to the third step: the metro, even when the straight line carries a float error. + expect(stepMapsUrl(planned.route.steps, 2)).toContain('travelmode=transit'); + // One destination, no waypoints: never the whole route. + expect(stepMapsUrl(planned.route.steps, 2)).not.toContain('waypoints'); }); }); diff --git a/apps/mobile/src/go/progress.ts b/apps/mobile/src/go/progress.ts index a4b1ebcb..18e9ed04 100644 --- a/apps/mobile/src/go/progress.ts +++ b/apps/mobile/src/go/progress.ts @@ -101,13 +101,3 @@ export function distanceToCurrent(progress: GoProgress, position: LatLng | null) /** The pill « Vous semblez être sur place »: en route, under 100 m of the step, app open. */ export const isArrivalSuggested = (progress: GoProgress, distanceM: number | null) => progress.phase === 'en_route' && distanceM !== null && distanceM < ARRIVAL_SUGGESTION_M; - -/** Google Maps on foot to `destination`, as the API writes it: « Itinéraire » offline (E-07). */ -export function walkingMapsUrl(destination: LatLng): string { - const query = new URLSearchParams({ - api: '1', - destination: `${destination.lat},${destination.lng}`, - travelmode: 'walking', - }); - return `https://www.google.com/maps/dir/?${query.toString()}`; -} diff --git a/apps/mobile/src/go/testPlanned.ts b/apps/mobile/src/go/testPlanned.ts index 35b52d1c..244f73b7 100644 --- a/apps/mobile/src/go/testPlanned.ts +++ b/apps/mobile/src/go/testPlanned.ts @@ -15,6 +15,11 @@ const times = [ ['09:00', '10:45'], ['11:00', '11:45'], ] as const; +/** The trip of each step to the next one, in a mode of its own (#510); null for the last one. */ +const trips = [ + { distanceM: 900, durationMin: 12, mode: 'bike', estimated: true }, + { distanceM: 900, durationMin: 12, mode: 'metro', estimated: true }, +] as const; /** * A fictitious Go in progress of three steps near République, started at 10:00 in Paris; the @@ -48,10 +53,7 @@ export function plannedFixture( bookingUrl: null, bookingProvider: null, transitionNote: null, - transition: - index < stepIds.length - 1 - ? { distanceM: 900, durationMin: 12, mode: 'walk', estimated: true } - : null, + transition: trips[index] ?? null, place: { id, name: names[index] ?? 'Lieu fictif', diff --git a/apps/mobile/src/go/useGo.ts b/apps/mobile/src/go/useGo.ts index 4a84c840..badc5398 100644 --- a/apps/mobile/src/go/useGo.ts +++ b/apps/mobile/src/go/useGo.ts @@ -1,5 +1,6 @@ import { onlineManager, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import { ApiRequestError } from '@widoo/api-client'; +import { stepMapsUrl } from '@widoo/orchestration'; import type { PlannedRoute } from '@widoo/shared'; import { router } from 'expo-router'; import type { TFunction } from 'i18next'; @@ -23,7 +24,6 @@ import { doneRatio, emptyGoLocal, goProgress, - walkingMapsUrl, type GoProgress, } from './progress'; import { startGo } from './startGo'; @@ -270,7 +270,8 @@ export function useGoActions( analytics.track('go_step_done', { route_id: routeId, step_index: current.index }); } }, - /** « Itinéraire »: Google Maps on foot to the step, by the API; written here offline. */ + /** « Itinéraire »: Google Maps to the step, in the mode of the trip to it, by the API or + * written here offline with the same shared rule (E-07, #510). */ async directions() { if (!current) { return; @@ -281,8 +282,8 @@ export function useGoActions( ? await api .getPlannedMapsUrl(planned.id) .then((answer) => answer.url) - .catch(() => walkingMapsUrl(current.step.place.location)) - : walkingMapsUrl(current.step.place.location); + .catch(() => stepMapsUrl(planned.route.steps, current.index)) + : stepMapsUrl(planned.route.steps, current.index); analytics.track('maps_opened', { route_id: routeId, mode: 'next_step' }); await Linking.openURL(url).catch(failed); }, diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 7f0c6cb5..e84ff526 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -241,8 +241,9 @@ export function createApiClient(options: ApiClientOptions) { }), /** - * `GET /v1/me/planned/:id/maps-url`: « Itinéraire » of the Go, Google Maps on foot to the - * first step neither done nor skipped (D-032); 409 when none is left. + * `GET /v1/me/planned/:id/maps-url`: « Itinéraire » of the Go, Google Maps to the first step + * neither done nor skipped, in the mode of the trip that leads to it (D-032, #510); 409 when + * none is left. */ getPlannedMapsUrl: (id: string, signal?: AbortSignal) => request({ From 846ff080b71780aae2ab6f3084cf7c301771271b Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Mon, 19 Oct 2026 17:18:36 +0200 Subject: [PATCH 4/4] test(api): compare the maps URL of each trip with the one the app writes offline The test of the trip modes took its expected value from travelModeOf, the function under test: it now names walking, bicycling, transit and driving, checks the destination of the step, and compares each URL of maps-url with stepMapsUrl on the steps of the same planning, as the app writes it offline. Prettier formatting of the files of the branch, and the comment of the directions action as a block. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/planned-go.test.ts | 39 ++++++++++++++++---------- apps/mobile/src/go/progress.test.ts | 2 +- apps/mobile/src/go/useGo.ts | 14 ++++----- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/apps/api/src/routes/planned-go.test.ts b/apps/api/src/routes/planned-go.test.ts index ed773690..1401e284 100644 --- a/apps/api/src/routes/planned-go.test.ts +++ b/apps/api/src/routes/planned-go.test.ts @@ -1,5 +1,5 @@ import { ApiError, PlannedMapsUrl, PlannedRoute } from '@widoo/shared'; -import { travelModeOf } from '@widoo/orchestration'; +import { stepMapsUrl } from '@widoo/orchestration'; import { and, eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -94,8 +94,12 @@ describe('POST /v1/me/planned/:id/steps/:stepId/done', () => { describe('GET /v1/me/planned/:id/maps-url', () => { /** The destination of a Google Maps URL, decoded as the test compares it. */ const destinationOf = (url: string) => new URL(url).searchParams.get('destination'); - /** The mode of the trip to a step of the next one, as a creator keeps it. */ - const trips = ['bike', 'metro', 'car'] as const; + /** The trip of each of the first three steps to the next one, and its Google Maps mode. */ + const trips = [ + ['bike', 'bicycling'], + ['metro', 'transit'], + ['car', 'driving'], + ] as const; it('opens Google Maps on foot to the next step only', async () => { const headers = await fixtures.signIn(); @@ -119,10 +123,10 @@ describe('GET /v1/me/planned/:id/maps-url', () => { expect(toSecond.url).toContain('travelmode=walking'); }); - it('opens Google Maps in the mode of the trip to the next steps', async () => { + it('opens Google Maps in the mode of the trip to the next steps, as the app does offline', async () => { const headers = await fixtures.signIn(); const modeRoute = await fixtures.copyDemoRoute(); - for (const [position, mode] of trips.entries()) { + for (const [position, [mode]] of trips.entries()) { await app.db .update(steps) .set({ transition: { distanceM: 900, durationMin: 10, mode, estimated: true } }) @@ -130,17 +134,22 @@ describe('GET /v1/me/planned/:id/maps-url', () => { } const { id, route } = await go(headers, true, modeRoute); // No trip of the route leads to the first step: on foot, even with a bike trip after it. - expect( - PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()).url, - ).toContain('travelmode=walking'); - for (const [stepIndex, mode] of trips.entries()) { - await done(headers, id, route.steps[stepIndex]?.id); + const toFirst = PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()); + expect(toFirst.url).toContain('travelmode=walking'); + // The app, offline, writes the URL of the same step from the same planning (#510). + expect(toFirst.url).toBe(stepMapsUrl(route.steps, 0)); + for (const [index, [, travelmode]] of trips.entries()) { + await done(headers, id, route.steps[index]?.id); + const next = route.steps[index + 1]; const answer = PlannedMapsUrl.parse((await mapsUrl(headers, id)).json()); - expect(answer.stepId).toBe(route.steps[stepIndex + 1]?.id); - expect(answer.url).toContain(`travelmode=${travelModeOf(mode)}`); - // One destination, no waypoints: never the whole route. - expect(destinationOf(answer.url)).not.toBeNull(); - expect(answer.url).not.toContain('waypoints'); + expect(answer.stepId).toBe(next?.id); + expect(new URL(answer.url).searchParams.get('travelmode')).toBe(travelmode); + expect(answer.url).toBe(stepMapsUrl(route.steps, index + 1)); + // One destination, the step's, and no waypoints: never the whole route (D-032). + expect(destinationOf(answer.url)).toBe( + `${next?.place.location.lat},${next?.place.location.lng}`, + ); + expect(new URL(answer.url).searchParams.has('waypoints')).toBe(false); } }); diff --git a/apps/mobile/src/go/progress.test.ts b/apps/mobile/src/go/progress.test.ts index 1326dd48..a9a2ee96 100644 --- a/apps/mobile/src/go/progress.test.ts +++ b/apps/mobile/src/go/progress.test.ts @@ -116,7 +116,7 @@ describe('maps URL', () => { expect(stepMapsUrl(planned.route.steps, 1)).toBe( 'https://www.google.com/maps/dir/?api=1&destination=48.8714%2C2.3676&travelmode=bicycling', ); - // The trip to the third step: the metro, even when the straight line carries a float error. + // The trip to the third step is by metro (its latitude, computed, is not a round literal). expect(stepMapsUrl(planned.route.steps, 2)).toContain('travelmode=transit'); // One destination, no waypoints: never the whole route. expect(stepMapsUrl(planned.route.steps, 2)).not.toContain('waypoints'); diff --git a/apps/mobile/src/go/useGo.ts b/apps/mobile/src/go/useGo.ts index badc5398..dc127843 100644 --- a/apps/mobile/src/go/useGo.ts +++ b/apps/mobile/src/go/useGo.ts @@ -19,13 +19,7 @@ import { plannedQueryKey, usePlannedLookup } from '../plan/planned'; import { haptic } from '../ui/haptics'; import { showToast } from '../ui/toastStore'; import { createGoLocalStore } from './localStore'; -import { - actualMinutes, - doneRatio, - emptyGoLocal, - goProgress, - type GoProgress, -} from './progress'; +import { actualMinutes, doneRatio, emptyGoLocal, goProgress, type GoProgress } from './progress'; import { startGo } from './startGo'; const storage = createMMKV({ id: 'go' }); @@ -270,8 +264,10 @@ export function useGoActions( analytics.track('go_step_done', { route_id: routeId, step_index: current.index }); } }, - /** « Itinéraire »: Google Maps to the step, in the mode of the trip to it, by the API or - * written here offline with the same shared rule (E-07, #510). */ + /** + * « Itinéraire »: Google Maps to the step, in the mode of the trip to it, by the API; written + * here offline with the same shared `stepMapsUrl` (E-07, #510). + */ async directions() { if (!current) { return;