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..1401e284 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 { stepMapsUrl } 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,65 @@ 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 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(); 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, as the app does offline', 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. + 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(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); + } }); 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 }, diff --git a/apps/mobile/src/go/progress.test.ts b/apps/mobile/src/go/progress.test.ts index edfc437d..a9a2ee96 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 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/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..dc127843 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'; @@ -18,14 +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, - walkingMapsUrl, - type GoProgress, -} from './progress'; +import { actualMinutes, doneRatio, emptyGoLocal, goProgress, type GoProgress } from './progress'; import { startGo } from './startGo'; const storage = createMMKV({ id: 'go' }); @@ -270,7 +264,10 @@ 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; written + * here offline with the same shared `stepMapsUrl` (E-07, #510). + */ async directions() { if (!current) { return; @@ -281,8 +278,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({ 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}`; +}