Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions apps/api/src/planned/go.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) };
}
73 changes: 50 additions & 23 deletions apps/api/src/routes/planned-go.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -24,13 +24,13 @@ afterAll(async () => {
});

type Headers = Record<string, string>;
/** 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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/planned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/go/progress.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stepMapsUrl } from '@widoo/orchestration';
import { describe, expect, it } from 'vitest';
import {
actualMinutes,
Expand All @@ -6,7 +7,6 @@ import {
emptyGoLocal,
goProgress,
isArrivalSuggested,
walkingMapsUrl,
} from './progress';
import { plannedFixture, stepIds } from './testPlanned';

Expand Down Expand Up @@ -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');
});
});
10 changes: 0 additions & 10 deletions apps/mobile/src/go/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
}
10 changes: 6 additions & 4 deletions apps/mobile/src/go/testPlanned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 8 additions & 11 deletions apps/mobile/src/go/useGo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' });
Expand Down Expand Up @@ -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;
Expand All @@ -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);
},
Expand Down
5 changes: 3 additions & 2 deletions packages/api-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/orchestration/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/orchestration/src/maps.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading