mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65ee472d66 | |||
| 73fa7aeb19 | |||
| fbba4fd73b | |||
| 2e1dca9fe9 | |||
| aacc7f2c8d | |||
| f833a05461 | |||
| 65fe8cd6e5 | |||
| 3fe34061fd | |||
| 3659297af1 | |||
| f57a0dbd31 | |||
| 3748bdb815 | |||
| 8fa75b3c4e | |||
| 6ec06c90f4 | |||
| 2449616ee9 | |||
| acc294dd8e | |||
| bde2ad2ca8 | |||
| ae79ab8dad | |||
| 6a0c54cb2f | |||
| a8be1c5b45 | |||
| d0cc6fc2f4 | |||
| f016972a4e | |||
| c80152f41d | |||
| f745ca13c9 | |||
| 4f68029b2c | |||
| 64445a5204 | |||
| bff9bcde92 | |||
| 3572f4b8a3 | |||
| ea9e997338 | |||
| ad2d653e9a | |||
| b177daea3c | |||
| 63a7a4d397 | |||
| f4533fc69b | |||
| 1993ed4d28 | |||
| 1969ca57d9 | |||
| b826ecd425 | |||
| 19946039ad | |||
| 6577595058 | |||
| 78366a9024 | |||
| 0331d9950f | |||
| dfe11e9c4c | |||
| fcdb7e48cd | |||
| 008aaeb408 | |||
| 5ff7e54e09 | |||
| 0169be3d79 | |||
| 68907ec811 | |||
| 51bfb5abd1 | |||
| 1d9b9bf838 | |||
| c83eb42730 | |||
| cd407cf80c | |||
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e |
@@ -16,7 +16,7 @@
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
|
||||
@@ -571,6 +571,12 @@
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
|
||||
//
|
||||
// LIB-03: every method pins its verb, its path and its body, and hands back what
|
||||
// the server answered.
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { collectionToursApi } from './collectionTours';
|
||||
|
||||
const BASE = '/api/addons/collections/3/tours';
|
||||
|
||||
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
|
||||
function record(data: JsonBodyType | null) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
const text = await request.text();
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
|
||||
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
|
||||
};
|
||||
}
|
||||
|
||||
describe('collectionToursApi', () => {
|
||||
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
|
||||
server.use(
|
||||
http.get(BASE, record({ tours: [] })),
|
||||
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
|
||||
http.post(BASE, record({ tour: { id: 5 } })),
|
||||
http.delete(`${BASE}/4`, record({ success: true }))
|
||||
);
|
||||
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
|
||||
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
|
||||
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
|
||||
tour: { id: 5 },
|
||||
});
|
||||
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', path: BASE, body: null },
|
||||
{ method: 'GET', path: `${BASE}/4`, body: null },
|
||||
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
|
||||
{ method: 'DELETE', path: `${BASE}/4`, body: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
|
||||
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
|
||||
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
|
||||
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
|
||||
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CollectionTourDetailResponse,
|
||||
CollectionTourListResponse,
|
||||
CollectionTourSaveRequest,
|
||||
CollectionTourSaveResponse,
|
||||
CollectionTourTakeOverRequest,
|
||||
TourCreateResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
|
||||
|
||||
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
|
||||
export const collectionToursApi = {
|
||||
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
|
||||
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
|
||||
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
|
||||
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
|
||||
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
|
||||
apiClient.post(base(collectionId), body).then((r) => r.data),
|
||||
remove: (collectionId: number, tourId: number): Promise<void> =>
|
||||
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
|
||||
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-API-HORSE-001 to FE-API-HORSE-002
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { horseRoutesApi } from './horseRoutes';
|
||||
|
||||
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||
const BBOX = '47.10000,11.20000,47.30000,11.50000';
|
||||
|
||||
describe('horseRoutesApi.list', () => {
|
||||
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
|
||||
let url = '';
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
|
||||
url = request.url;
|
||||
return HttpResponse.json(reply);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
|
||||
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
|
||||
});
|
||||
|
||||
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', () =>
|
||||
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
|
||||
* The server races the Overpass mirrors under its own deadline (25 s by
|
||||
* default) and answers 502 when none made it, so this sits just above it, as
|
||||
* the via ferrata layer's wait does.
|
||||
*/
|
||||
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
|
||||
* routes OpenStreetMap knows inside one map view. The answer comes back
|
||||
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
|
||||
*/
|
||||
export const horseRoutesApi = {
|
||||
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
|
||||
.then((response) => response.data as unknown),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
|
||||
export const tourActivitiesApi = {
|
||||
assign: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourActivityAssignRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
|
||||
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
|
||||
export const tourActivityStatsApi = {
|
||||
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
|
||||
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
|
||||
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
|
||||
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
return fd;
|
||||
}
|
||||
|
||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
||||
/**
|
||||
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
|
||||
* first (PAD-02), then imported as the type chosen.
|
||||
*/
|
||||
export const tourGpxApi = {
|
||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* Where a tour's packing link lives; a write queued offline replays to the
|
||||
* same path, `{id}` standing for a tour that has not synced yet.
|
||||
*/
|
||||
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
|
||||
return `/trips/${tripId}/tours/${placeId}/packing-link`;
|
||||
}
|
||||
|
||||
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
|
||||
export const tourPackingLinkApi = {
|
||||
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
|
||||
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
|
||||
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
|
||||
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
|
||||
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
|
||||
export const tourSkiApi = {
|
||||
classify: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourSkiClassificationRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
|
||||
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
|
||||
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
|
||||
}
|
||||
|
||||
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
|
||||
export const tourVisitsApi = {
|
||||
list: (tripId: number | string): Promise<TourVisitListResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
|
||||
update: (
|
||||
tripId: number | string,
|
||||
assignmentId: number,
|
||||
stationKey: string,
|
||||
body: TourVisitUpdateRequest
|
||||
): Promise<TourVisitListResponse> =>
|
||||
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||
* follow a short link a few hops within its own deadline, so the call waits
|
||||
* a little longer than that before it gives up.
|
||||
*/
|
||||
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const tourWalkImportApi = {
|
||||
preview: (
|
||||
tripId: number | string,
|
||||
body: WalkImportPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WalkImportPreviewResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AdminFeatureToggleResponse,
|
||||
TourSourceCheckResponse,
|
||||
TourSourceHintResponse,
|
||||
WandererConnection,
|
||||
WandererImportPreview,
|
||||
WandererImportRequest,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
WandererSettingsRequest,
|
||||
WandererTestRequest,
|
||||
WandererTestResponse,
|
||||
WandererWatch,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
@@ -31,6 +33,10 @@ export const wandererApi = {
|
||||
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
||||
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
||||
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
||||
/** WAN-02: the optional background watch of the connection, with the open hints. */
|
||||
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
|
||||
setWatch: (enabled: boolean): Promise<WandererWatch> =>
|
||||
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
|
||||
|
||||
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
||||
@@ -43,6 +49,9 @@ export const wandererApi = {
|
||||
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
||||
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
||||
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
|
||||
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The admin's switch for wanderer under the Tours addon. */
|
||||
|
||||
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||
'tours:changed',
|
||||
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||
'tours:visits',
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// FE-STA2-PANE-001 to FE-STA2-PANE-006
|
||||
// STA-02: the Atlas tab of the user's own tour activities: the distance large
|
||||
// with how many tours at its corner, the climb, the types, what the numbers
|
||||
// rest on, and the full list in a dialog.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
|
||||
vi.mock('./useTourActivityStats', () => ({
|
||||
useTourActivityStats: (active: boolean) => {
|
||||
hook.active.push(active);
|
||||
return hook.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 12,
|
||||
ascent_m: 800,
|
||||
descent_m: 790,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
|
||||
const stats: TourActivityStats = {
|
||||
status: data ? 'ready' : 'loading',
|
||||
data,
|
||||
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||
period: 'all',
|
||||
setPeriod: vi.fn(),
|
||||
periods: ['all', 'thisYear', 'last12'],
|
||||
type: 'all',
|
||||
setType: vi.fn(),
|
||||
types: ['all', 'hike', 'kayak'],
|
||||
pending: new Set(),
|
||||
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||
retry: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
hook.current = stats;
|
||||
return stats;
|
||||
}
|
||||
|
||||
const data: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
|
||||
entry({
|
||||
place_id: 3,
|
||||
name: 'Lake',
|
||||
tour_type: 'kayak',
|
||||
basis: 'track',
|
||||
track: { place_id: 9, name: 'Watch' },
|
||||
distance_km: 8,
|
||||
ascent_m: null,
|
||||
descent_m: null,
|
||||
}),
|
||||
],
|
||||
uncounted: { unassigned: 0, undated: 1 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
hook.active = [];
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||
});
|
||||
|
||||
describe('AtlasTourActivityPane', () => {
|
||||
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
|
||||
statsOf(null, { status: 'idle' });
|
||||
const { rerender } = render(<AtlasTourActivityPane active={false} />);
|
||||
expect(hook.active).toEqual([false]);
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
statsOf(null);
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(hook.active).toEqual([false, true]);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
|
||||
statsOf(data);
|
||||
render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
|
||||
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
|
||||
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
|
||||
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
|
||||
statsOf(data);
|
||||
render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
|
||||
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
|
||||
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
|
||||
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
|
||||
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
|
||||
|
||||
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
|
||||
statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasTourActivityPane active />);
|
||||
await user.click(screen.getByRole('button', { name: 'Show tours' }));
|
||||
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
|
||||
statsOf(null, { status: 'offline' });
|
||||
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
|
||||
|
||||
const failed = statsOf(null, { status: 'error' });
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(failed.retry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { TourActivitySummary } from '@trek/shared';
|
||||
import { Footprints, ListOrdered } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { CardAction, InfoDot } from '../../Tours/tourParts';
|
||||
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||
import { splitMeasure } from './tourActivityStatsModel';
|
||||
import {
|
||||
ActivityFilters,
|
||||
ActivityStatusLine,
|
||||
BasisBadges,
|
||||
distanceReading,
|
||||
heightReading,
|
||||
typeIcon,
|
||||
typeLabel,
|
||||
UncountedBadges,
|
||||
useDistanceUnit,
|
||||
} from './tourActivityParts';
|
||||
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
|
||||
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
|
||||
/** How many types the strip names before the dialog takes over. */
|
||||
const STRIP_TYPES = 3;
|
||||
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
|
||||
|
||||
function Divider() {
|
||||
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance of the period, large, with how many tours at its corner; the
|
||||
* tour count stands in when no tour knows its distance.
|
||||
*/
|
||||
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
const reading = distanceReading(summary.totals, unit);
|
||||
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
|
||||
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
|
||||
return (
|
||||
<div
|
||||
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
|
||||
style={{ background: HERO_TINT }}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
|
||||
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
|
||||
{shown.value}
|
||||
</span>
|
||||
{shown.unit && (
|
||||
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
|
||||
{shown.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={EYEBROW} style={fs(9)}>
|
||||
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
|
||||
</span>
|
||||
{reading && (
|
||||
<Tooltip label={countLabel}>
|
||||
<span
|
||||
aria-label={countLabel}
|
||||
data-testid="atlas-tour-activity-count"
|
||||
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
style={fs(11)}
|
||||
>
|
||||
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
|
||||
{summary.totals.count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
|
||||
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
|
||||
const { value, unit } = splitMeasure(text);
|
||||
return (
|
||||
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
|
||||
{unit}
|
||||
</span>
|
||||
</span>
|
||||
<span className={EYEBROW} style={fs(9)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
|
||||
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-4 px-2 py-3">
|
||||
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
|
||||
const Icon = typeIcon(scope.type);
|
||||
const distance = distanceReading(scope, unit);
|
||||
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
|
||||
return (
|
||||
<Tooltip key={scope.type} label={label}>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
data-testid={`atlas-tour-activity-type-${scope.type}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span
|
||||
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
|
||||
style={fs(9)}
|
||||
>
|
||||
{scope.count}
|
||||
</span>
|
||||
</span>
|
||||
{distance && (
|
||||
<span
|
||||
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{distance.text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the sums rest on, what does not count, and the way into the full list. */
|
||||
interface StripProps {
|
||||
stats: TourActivityStats;
|
||||
summary: TourActivitySummary;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function SideColumn({ stats, summary, onOpen }: StripProps) {
|
||||
const { t } = useTranslation();
|
||||
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
|
||||
return (
|
||||
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
|
||||
</div>
|
||||
{summary.done.length > 0 && (
|
||||
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
|
||||
{t('tours.stats.showTours')}
|
||||
</CardAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The numbers of the period, or why there are none, with the filters before them. */
|
||||
function Strip({ stats, summary, onOpen }: StripProps) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const ascent = heightReading(summary.totals.ascentM, unit);
|
||||
const descent = heightReading(summary.totals.descentM, unit);
|
||||
const counted = summary.totals.count > 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||
<ActivityFilters stats={stats} quiet />
|
||||
</div>
|
||||
{counted ? (
|
||||
<>
|
||||
<Hero summary={summary} unit={unit} />
|
||||
{ascent && (
|
||||
<Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />
|
||||
)}
|
||||
{descent && (
|
||||
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
|
||||
)}
|
||||
{stats.type === 'all' && summary.types.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
<TypeColumns summary={summary} unit={unit} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
|
||||
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
|
||||
</p>
|
||||
)}
|
||||
<Divider />
|
||||
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
|
||||
* the distance done with how many tours at its corner, the climb, the types,
|
||||
* and what the numbers rest on (the trip planning, or recorded tracks where
|
||||
* linked). The full list, where a tour can be left out, opens in a dialog. The
|
||||
* read waits until the tab is first shown.
|
||||
*/
|
||||
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
|
||||
const stats = useTourActivityStats(active);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
|
||||
style={{ maxWidth }}
|
||||
data-testid="atlas-tour-activity"
|
||||
>
|
||||
{stats.summary ? (
|
||||
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
|
||||
) : (
|
||||
<div className="flex items-center px-6 py-5">
|
||||
<ActivityStatusLine stats={stats} />
|
||||
</div>
|
||||
)}
|
||||
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||
import { EntryTile, MeasurePills, useDistanceUnit } from './tourActivityParts';
|
||||
|
||||
const ROW_BADGE_ICON = 'flex-none text-content-faint';
|
||||
|
||||
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
|
||||
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const track = entry.basis === 'track';
|
||||
const hint = track
|
||||
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
|
||||
: t('tours.stats.basisPlanHint');
|
||||
return (
|
||||
<Tooltip label={hint}>
|
||||
<span
|
||||
className="inline-flex"
|
||||
aria-label={hint}
|
||||
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
|
||||
>
|
||||
<SoftPill
|
||||
tone={track ? 'success' : 'neutral'}
|
||||
icon={
|
||||
track ? (
|
||||
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
|
||||
) : (
|
||||
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
|
||||
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
|
||||
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||
{formatDate(entry.date, locale) ?? entry.date}
|
||||
</WhiteBadge>
|
||||
<Tooltip label={t('tours.stats.openTrip')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/trips/${entry.trip.id}`)}
|
||||
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<WhiteBadge
|
||||
className="min-w-0 max-w-full"
|
||||
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
|
||||
>
|
||||
<span className="truncate">{entry.trip.title}</span>
|
||||
</WhiteBadge>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{entry.day_count > 1 && (
|
||||
<Tooltip label={t('tours.stats.multiDayHint')}>
|
||||
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
|
||||
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||
{entry.day_count}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<BasisPill entry={entry} />
|
||||
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
|
||||
function ExcludeToggle({
|
||||
entry,
|
||||
busy,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: TourActivityStatsEntry;
|
||||
busy: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
|
||||
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
|
||||
let icon = <Ban size={14} strokeWidth={2} />;
|
||||
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
|
||||
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
|
||||
return (
|
||||
<Tooltip label={hint}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
aria-label={label}
|
||||
aria-pressed={entry.excluded}
|
||||
data-testid={`tour-activity-toggle-${entry.place_id}`}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: one tour of the user's activities: its type, name and where it
|
||||
* counts, its distance and climb, and the switch to leave it out. A tour left
|
||||
* out stays in the list, dimmed, so it can be counted again in place.
|
||||
*/
|
||||
export function TourActivityEntryRow({
|
||||
entry,
|
||||
busy,
|
||||
onToggleExcluded,
|
||||
}: {
|
||||
entry: TourActivityStatsEntry;
|
||||
busy: boolean;
|
||||
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
|
||||
}) {
|
||||
const unit = useDistanceUnit();
|
||||
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
|
||||
return (
|
||||
<li
|
||||
data-testid={`tour-activity-row-${key}`}
|
||||
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<EntryTile entry={entry} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<EntryFacts entry={entry} />
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end gap-1">
|
||||
<MeasurePills
|
||||
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
|
||||
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
|
||||
testId={`tour-activity-${key}`}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
|
||||
// STA-02: the full list of the user's tour activities: the sums with what they
|
||||
// rest on, the split by type, each tour with its basis and a switch to leave it
|
||||
// out (dimmed, never hidden), and the way into its trip.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-router')>()),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 12,
|
||||
ascent_m: 800,
|
||||
descent_m: 790,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statsOf(
|
||||
data: TourActivityStatsResponse | null,
|
||||
overrides: Partial<TourActivityStats> = {}
|
||||
): TourActivityStats {
|
||||
return {
|
||||
status: data ? 'ready' : 'loading',
|
||||
data,
|
||||
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||
period: 'all',
|
||||
setPeriod: vi.fn(),
|
||||
periods: ['all', 'thisYear', 'last12'],
|
||||
type: 'all',
|
||||
setType: vi.fn(),
|
||||
types: ['all', 'hike', 'kayak'],
|
||||
pending: new Set(),
|
||||
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||
retry: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const data: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({
|
||||
place_id: 2,
|
||||
name: 'Lake',
|
||||
tour_type: 'kayak',
|
||||
date: '2026-08-01',
|
||||
basis: 'track',
|
||||
track: { place_id: 9, name: 'Watch export' },
|
||||
distance_km: 8.5,
|
||||
ascent_m: null,
|
||||
descent_m: null,
|
||||
}),
|
||||
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
|
||||
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
|
||||
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
|
||||
],
|
||||
uncounted: { unassigned: 2, undated: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||
});
|
||||
|
||||
describe('TourActivityStatsDialog', () => {
|
||||
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
|
||||
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
|
||||
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
const list = screen.getByTestId('tour-activity-list');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
|
||||
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
|
||||
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
|
||||
expect(
|
||||
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
|
||||
).toHaveTextContent('3');
|
||||
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
|
||||
const stats = statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
|
||||
await user.click(screen.getByRole('button', { name: 'Count again' }));
|
||||
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
|
||||
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
|
||||
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/5');
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
|
||||
const stats = statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: /Period/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'This year' }));
|
||||
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
|
||||
await user.click(screen.getByRole('button', { name: /Type/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
|
||||
expect(stats.setType).toHaveBeenCalledWith('kayak');
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||
rerender(
|
||||
<TourActivityStatsDialog
|
||||
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
|
||||
open
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
|
||||
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closeButton as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
|
||||
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
|
||||
import { Footprints } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
|
||||
import { TourActivityEntryRow } from './TourActivityEntryRow';
|
||||
import {
|
||||
ActivityFilters,
|
||||
ActivityStatusLine,
|
||||
BasisBadges,
|
||||
distanceReading,
|
||||
heightReading,
|
||||
MeasurePills,
|
||||
partialHint,
|
||||
typeIcon,
|
||||
UncountedBadges,
|
||||
useDistanceUnit,
|
||||
} from './tourActivityParts';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
|
||||
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
|
||||
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
|
||||
const sums = [
|
||||
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
|
||||
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
|
||||
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
|
||||
];
|
||||
for (const { label, sum, reading } of sums) {
|
||||
if (!reading) continue;
|
||||
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
|
||||
fields.push({ label, value: reading.text, info });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
|
||||
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const Icon = typeIcon(type);
|
||||
const distance = distanceReading(totals, unit);
|
||||
const ascent = heightReading(totals.ascentM, unit);
|
||||
return (
|
||||
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
|
||||
<span className="relative flex-none">
|
||||
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||
<Icon size={14} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
|
||||
style={fs(9)}
|
||||
>
|
||||
{totals.count}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t(`tourTypes.${type}`)}
|
||||
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
|
||||
</span>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
<MeasurePills
|
||||
distance={distance?.text ?? null}
|
||||
ascent={ascent?.text ?? null}
|
||||
testId={`tour-activity-type-${type}`}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
|
||||
function StatsBody({ stats }: { stats: TourActivityStats }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const { summary, data } = stats;
|
||||
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
|
||||
const showTypes = stats.type === 'all' && summary.types.length > 1;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
|
||||
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||
</div>
|
||||
</div>
|
||||
{showTypes && (
|
||||
<DialogSection label={t('tours.stats.byType')}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{summary.types.map(({ type, ...totals }) => (
|
||||
<TypeLine key={type} type={type} totals={totals} />
|
||||
))}
|
||||
</ul>
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection
|
||||
label={t('tours.mode.tours')}
|
||||
action={
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{summary.done.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{summary.done.length === 0 ? (
|
||||
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
|
||||
{summary.done.map((entry) => (
|
||||
<TourActivityEntryRow
|
||||
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
|
||||
entry={entry}
|
||||
busy={stats.pending.has(entry.place_id)}
|
||||
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the user's tour activities in full, opened from the Atlas: the
|
||||
* period and type on the head band, the sums with what they rest on, the
|
||||
* split by type, and every tour done in the period, each one able to be left
|
||||
* out (a tour that fell through) and counted again without touching its plan.
|
||||
*/
|
||||
export function TourActivityStatsDialog({
|
||||
stats,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
stats: TourActivityStats;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
return (
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('tours.stats.title')}
|
||||
sub={t('tours.stats.sub')}
|
||||
subWraps
|
||||
pills={<ActivityFilters stats={stats} />}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<StatsBody stats={stats} />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { TourActivityBasis, TourActivityStatsEntry, TourActivityTotals } from '@trek/shared';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
Footprints,
|
||||
Layers,
|
||||
Loader2,
|
||||
Ruler,
|
||||
WifiOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { PillSelect } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
|
||||
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
|
||||
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||
import { periodYear, sumReading, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The distance unit the user reads in. */
|
||||
export function useDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
}
|
||||
|
||||
/** What a period is called in the filter. */
|
||||
function periodLabel(period: ActivityPeriod, t: Translate): string {
|
||||
if (period === 'all') return t('tours.stats.period.all');
|
||||
if (period === 'thisYear') return t('tours.stats.period.thisYear');
|
||||
if (period === 'last12') return t('tours.stats.period.last12');
|
||||
return String(periodYear(period));
|
||||
}
|
||||
|
||||
/** What a type is called in the filter. */
|
||||
export function typeLabel(type: ActivityType, t: Translate): string {
|
||||
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
|
||||
}
|
||||
|
||||
/** The icon a type is drawn with; every type at once is a stack. */
|
||||
export function typeIcon(type: ActivityType): LucideIcon {
|
||||
return type === 'all' ? Layers : tourDayRowIcon(type);
|
||||
}
|
||||
|
||||
/** The period and type filters, as head band pills or quiet ones in a panel. */
|
||||
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const typeOption = (type: ActivityType) => ({
|
||||
value: type,
|
||||
label: typeLabel(type, t),
|
||||
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<PillSelect
|
||||
quiet={quiet}
|
||||
label={t('tours.stats.period')}
|
||||
value={stats.period}
|
||||
options={stats.periods.map((period) => ({
|
||||
value: period,
|
||||
label: periodLabel(period, t),
|
||||
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
|
||||
}))}
|
||||
fallback={{ label: periodLabel(stats.period, t) }}
|
||||
onChange={stats.setPeriod}
|
||||
/>
|
||||
<PillSelect
|
||||
quiet={quiet}
|
||||
label={t('tours.filter.type')}
|
||||
value={stats.type}
|
||||
options={stats.types.map(typeOption)}
|
||||
fallback={typeOption(stats.type)}
|
||||
onChange={stats.setType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
|
||||
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
|
||||
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
|
||||
}
|
||||
|
||||
/** A height sum as said, the same way. */
|
||||
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
|
||||
return sumReading(sum, (metres) => formatElevation(metres, unit));
|
||||
}
|
||||
|
||||
/** How a short sum explains itself: known for some of the tours only. */
|
||||
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
|
||||
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
|
||||
}
|
||||
|
||||
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
|
||||
export function MeasurePills({
|
||||
distance,
|
||||
ascent,
|
||||
testId,
|
||||
}: {
|
||||
distance: string | null;
|
||||
ascent: string | null;
|
||||
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
|
||||
testId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{distance !== null && (
|
||||
<TourDayRowPill
|
||||
icon={Ruler}
|
||||
value={distance}
|
||||
label={`${t('tours.detail.distance')}: ${distance}`}
|
||||
testId={`${testId}-distance`}
|
||||
/>
|
||||
)}
|
||||
{ascent !== null && (
|
||||
<TourDayRowPill
|
||||
icon={ArrowUp}
|
||||
value={ascent}
|
||||
label={`${t('tours.detail.ascent')}: ${ascent}`}
|
||||
testId={`${testId}-ascent`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sums rest on, each as a figure badge: how many tours count with
|
||||
* their planned values and how many with a recorded track. The planned values
|
||||
* are never passed off as measured, so they always say so.
|
||||
*/
|
||||
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{byBasis.plan > 0 && (
|
||||
<FigureBadge
|
||||
testId="tour-activity-basis-plan"
|
||||
label={t('tours.stats.basisPlan')}
|
||||
value={String(byBasis.plan)}
|
||||
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
info={t('tours.stats.basisPlanHint')}
|
||||
/>
|
||||
)}
|
||||
{byBasis.track > 0 && (
|
||||
<FigureBadge
|
||||
testId="tour-activity-basis-track"
|
||||
label={t('tours.stats.basisTrack')}
|
||||
value={String(byBasis.track)}
|
||||
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
|
||||
info={t('tours.stats.basisTrackCountHint')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
|
||||
export function UncountedBadges({
|
||||
upcoming,
|
||||
unassigned,
|
||||
undated,
|
||||
}: {
|
||||
upcoming: number;
|
||||
unassigned: number;
|
||||
undated: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const badges = [
|
||||
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
|
||||
{
|
||||
key: 'unassigned',
|
||||
value: unassigned,
|
||||
labelKey: 'tours.stats.unassigned',
|
||||
infoKey: 'tours.stats.unassignedHint',
|
||||
},
|
||||
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{badges
|
||||
.filter((badge) => badge.value > 0)
|
||||
.map((badge) => (
|
||||
<FigureBadge
|
||||
key={badge.key}
|
||||
testId={`tour-activity-${badge.key}`}
|
||||
label={t(badge.labelKey)}
|
||||
value={String(badge.value)}
|
||||
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
info={t(badge.infoKey)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's type as a round tile; a tour done with a recorded track carries
|
||||
* the footprints at its corner, the way a waypoint's number carries its kind.
|
||||
*/
|
||||
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = tourDayRowIcon(entry.tour_type);
|
||||
return (
|
||||
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
|
||||
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
|
||||
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
{entry.basis === 'track' && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="tour-activity-track-corner"
|
||||
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
|
||||
>
|
||||
<Footprints size={9} strokeWidth={2.6} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the read stands while there is nothing to show yet: on its way, refused
|
||||
* offline, or failed with a way to try again.
|
||||
*/
|
||||
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
|
||||
const { t } = useTranslation();
|
||||
// Not asked yet: the tab has not been shown, so there is nothing to say.
|
||||
if (stats.status === 'idle') return null;
|
||||
if (stats.status === 'offline')
|
||||
return (
|
||||
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<WifiOff size={13} strokeWidth={2} className="flex-none" />
|
||||
{t('common.needsConnection')}
|
||||
</p>
|
||||
);
|
||||
if (stats.status === 'error')
|
||||
return (
|
||||
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
|
||||
{t('tours.stats.unavailable')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={stats.retry}
|
||||
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{t('common.errorRetry')}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
||||
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
|
||||
// STA-02: the periods and types the Atlas offers for the user's tour
|
||||
// activities, and how a sum reads when some tours do not know the value.
|
||||
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
periodOptions,
|
||||
periodRange,
|
||||
periodYear,
|
||||
splitMeasure,
|
||||
sumReading,
|
||||
typeOptions,
|
||||
} from './tourActivityStatsModel';
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 10,
|
||||
ascent_m: 500,
|
||||
descent_m: 400,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('tourActivityStatsModel', () => {
|
||||
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
|
||||
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
|
||||
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
|
||||
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
|
||||
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
|
||||
const entries = [
|
||||
entry({ date: '2023-05-01' }),
|
||||
entry({ date: '2025-08-01' }),
|
||||
entry({ date: '2025-09-01' }),
|
||||
entry({ date: '2026-03-01' }),
|
||||
];
|
||||
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
|
||||
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
|
||||
const entries = [
|
||||
entry({ tour_type: 'kayak' }),
|
||||
entry({ tour_type: 'run' }),
|
||||
entry({ tour_type: 'hike' }),
|
||||
entry({ tour_type: 'bike', date: '2026-12-01' }),
|
||||
];
|
||||
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'kayak', 'run']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
|
||||
expect(periodYear('year:2024')).toBe(2024);
|
||||
expect(periodYear('thisYear')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
|
||||
const km = (value: number) => `${value} km`;
|
||||
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
|
||||
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
|
||||
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
|
||||
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
|
||||
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
|
||||
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
|
||||
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
|
||||
'all',
|
||||
'thisYear',
|
||||
'last12',
|
||||
'year:2025',
|
||||
]);
|
||||
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
isTourActivityDone,
|
||||
plannableTourTypeSchema,
|
||||
type TourActivityStatsEntry,
|
||||
type TourActivitySum,
|
||||
type TourTypeKey,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* STA-02: the periods and types the Atlas narrows the user's tour activities
|
||||
* by, and how a sum reads. Pure, so the hook and its views share one answer.
|
||||
*/
|
||||
|
||||
/** All time, the calendar year, the last twelve months, or one earlier year. */
|
||||
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
|
||||
|
||||
/** One type, or every type at once. */
|
||||
export type ActivityType = TourTypeKey | 'all';
|
||||
|
||||
interface PeriodRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
|
||||
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
|
||||
const year = Number(today.slice(0, 4));
|
||||
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||
// A year back to the day, today included: the same day a year ago is the first one.
|
||||
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
|
||||
if (period.startsWith('year:')) {
|
||||
const chosen = period.slice(5);
|
||||
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
|
||||
}
|
||||
return { from: null, to: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The periods worth offering: all time, this year and the last twelve months,
|
||||
* then each earlier year a done tour is in, the newest first. A year without
|
||||
* a tour would only ever show nothing.
|
||||
*/
|
||||
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
|
||||
const thisYear = today.slice(0, 4);
|
||||
const years = new Set(
|
||||
entries
|
||||
.filter((entry) => isTourActivityDone(entry, today))
|
||||
.map((entry) => entry.date.slice(0, 4))
|
||||
.filter((year) => year < thisYear)
|
||||
);
|
||||
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
|
||||
return ['all', 'thisYear', 'last12', ...earlier];
|
||||
}
|
||||
|
||||
/** The types a done tour has, in the order tours are planned in, the older types after them. */
|
||||
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
|
||||
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
|
||||
const planned: readonly string[] = plannableTourTypeSchema.options;
|
||||
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
|
||||
return ['all', ...ordered];
|
||||
}
|
||||
|
||||
function rank(order: readonly string[], value: string): number {
|
||||
const index = order.indexOf(value);
|
||||
return index === -1 ? order.length : index;
|
||||
}
|
||||
|
||||
/** The year of a `year:` period, or null for the others. */
|
||||
export function periodYear(period: ActivityPeriod): number | null {
|
||||
return period.startsWith('year:') ? Number(period.slice(5)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum as it is said: nothing when nobody knows the value, else the value,
|
||||
* marked as at least that much when some of the tours lack it.
|
||||
*/
|
||||
export function sumReading(
|
||||
sum: TourActivitySum,
|
||||
format: (value: number) => string
|
||||
): { text: string; partial: boolean } | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
|
||||
}
|
||||
|
||||
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
|
||||
export function splitMeasure(text: string): { value: string; unit: string } {
|
||||
const at = text.lastIndexOf(' ');
|
||||
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// FE-STA2-HOOK-001 to FE-STA2-HOOK-007
|
||||
// STA-02: the user's own tour activities are read when the tab is first shown,
|
||||
// narrowed by period and type, and a tour left out shows at once and comes back
|
||||
// with a message when the server refuses.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useTourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
|
||||
tourActivityStatsRepo: repo,
|
||||
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 10,
|
||||
ascent_m: 500,
|
||||
descent_m: 400,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const stats: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
|
||||
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
|
||||
],
|
||||
uncounted: { unassigned: 0, undated: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 20, 12));
|
||||
repo.get.mockResolvedValue(stats);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useTourActivityStats', () => {
|
||||
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
|
||||
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
|
||||
initialProps: { active: false },
|
||||
});
|
||||
expect(result.current.status).toBe('idle');
|
||||
expect(repo.get).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ active: true });
|
||||
expect(result.current.status).toBe('loading');
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.summary?.totals.count).toBe(2);
|
||||
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
|
||||
expect(result.current.summary?.upcoming).toBe(1);
|
||||
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
|
||||
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
act(() => result.current.setPeriod('thisYear'));
|
||||
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
|
||||
act(() => {
|
||||
result.current.setPeriod('all');
|
||||
result.current.setType('kayak');
|
||||
});
|
||||
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
|
||||
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
|
||||
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('offline'));
|
||||
|
||||
repo.get.mockRejectedValueOnce(new Error('boom'));
|
||||
act(() => result.current.retry());
|
||||
expect(result.current.status).toBe('loading');
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(repo.get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
|
||||
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
let write: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
write = result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.pending.has(1)).toBe(true);
|
||||
expect(result.current.summary?.totals.count).toBe(1);
|
||||
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
|
||||
|
||||
await act(async () => {
|
||||
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
|
||||
answer({ ...stats, entries });
|
||||
await write;
|
||||
});
|
||||
expect(result.current.pending.size).toBe(0);
|
||||
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
|
||||
repo.setExcluded.mockRejectedValue(new Error('nope'));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
await act(async () => {
|
||||
await result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.summary?.totals.count).toBe(2);
|
||||
expect(result.current.pending.size).toBe(0);
|
||||
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
|
||||
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const { result, unmount } = renderHook(() => useTourActivityStats(true));
|
||||
expect(result.current.status).toBe('loading');
|
||||
unmount();
|
||||
await act(async () => answer(stats));
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
|
||||
repo.get.mockReturnValue(new Promise(() => undefined));
|
||||
repo.setExcluded.mockRejectedValue(new Error('offline'));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await act(async () => {
|
||||
await result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
summarizeTourActivities,
|
||||
type TourActivityStatsEntry,
|
||||
type TourActivityStatsResponse,
|
||||
type TourActivitySummary,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
|
||||
import { localIsoDate } from '../../../utils/localDate';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import {
|
||||
periodOptions,
|
||||
periodRange,
|
||||
typeOptions,
|
||||
type ActivityPeriod,
|
||||
type ActivityType,
|
||||
} from './tourActivityStatsModel';
|
||||
|
||||
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
|
||||
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
|
||||
|
||||
export interface TourActivityStats {
|
||||
status: ActivityStatsStatus;
|
||||
data: TourActivityStatsResponse | null;
|
||||
/** The sums of the chosen period and type; null until the first read is there. */
|
||||
summary: TourActivitySummary | null;
|
||||
period: ActivityPeriod;
|
||||
setPeriod: (period: ActivityPeriod) => void;
|
||||
periods: ActivityPeriod[];
|
||||
type: ActivityType;
|
||||
setType: (type: ActivityType) => void;
|
||||
types: ActivityType[];
|
||||
/** The tours whose exclusion is on its way to the server. */
|
||||
pending: ReadonlySet<number>;
|
||||
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/** What the latest read came back with, and the attempt it answered. */
|
||||
interface Answered {
|
||||
attempt: number;
|
||||
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
|
||||
}
|
||||
|
||||
/** Every entry of the tour with the flag as asked, the rest as they were. */
|
||||
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
|
||||
* is first shown (and again on retry), never cached on the device; narrowed by
|
||||
* period and type here, summed by the shared summary the assistant uses too.
|
||||
* Leaving a tour out shows at once and is undone with a message when the
|
||||
* server refuses it; the plan of the tour never changes.
|
||||
*/
|
||||
export function useTourActivityStats(active: boolean): TourActivityStats {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
|
||||
const [answered, setAnswered] = useState<Answered | null>(null);
|
||||
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
|
||||
const [period, setPeriod] = useState<ActivityPeriod>('all');
|
||||
const [type, setType] = useState<ActivityType>('all');
|
||||
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const today = localIsoDate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const controller = new AbortController();
|
||||
tourActivityStatsRepo.get(controller.signal).then(
|
||||
(answer) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setData(answer);
|
||||
setAnswered({ attempt, status: 'ready' });
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
|
||||
}
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [active, attempt]);
|
||||
|
||||
let status: ActivityStatsStatus = 'loading';
|
||||
if (answered?.attempt === attempt) status = answered.status;
|
||||
else if (!active && !answered) status = 'idle';
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const range = periodRange(period, today);
|
||||
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
|
||||
}, [data, period, type, today]);
|
||||
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
|
||||
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
|
||||
|
||||
const setExcluded = useCallback(
|
||||
async (entry: TourActivityStatsEntry, excluded: boolean) => {
|
||||
const placeId = entry.place_id;
|
||||
setPending((current) => new Set(current).add(placeId));
|
||||
setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||
try {
|
||||
setData(await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded }));
|
||||
} catch {
|
||||
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
|
||||
toast.error(t('tours.stats.excludeFailed'));
|
||||
} finally {
|
||||
setPending((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(placeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), []);
|
||||
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
summary,
|
||||
period,
|
||||
setPeriod,
|
||||
periods,
|
||||
type,
|
||||
setType,
|
||||
types,
|
||||
pending,
|
||||
setExcluded,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-024
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
|
||||
describe('MapLayerSwitcher', () => {
|
||||
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
||||
@@ -235,3 +236,79 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
|
||||
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
|
||||
})
|
||||
})
|
||||
|
||||
const pisteOverlay = (overrides: Partial<ToggleableRasterOverlay> = {}): ToggleableRasterOverlay => ({
|
||||
id: 'pistes',
|
||||
url: 'https://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png',
|
||||
attribution: 'OpenSnowMap',
|
||||
minZoom: 9,
|
||||
maxZoom: 18,
|
||||
active: false,
|
||||
status: 'off',
|
||||
belowMinZoom: false,
|
||||
onToggle: vi.fn(),
|
||||
onTileError: vi.fn(),
|
||||
onTileLoad: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
||||
it('FE-COMP-MAPLAYER-021: the pistes switch sits on its icon alone, named by its tooltip, and offers no key while off', async () => {
|
||||
const overlay = pisteOverlay()
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer()} layers={{ overlays: [overlay] }} />)
|
||||
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(toggle.textContent).toBe('')
|
||||
expect(screen.queryByTestId('piste-key-button')).toBeNull()
|
||||
fireEvent.click(toggle)
|
||||
expect(overlay.onToggle).toHaveBeenCalledTimes(1)
|
||||
fireEvent.mouseEnter(toggle)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes, cross-country trails and lifts')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-022: on, the switch is pressed in the accent and the key rides beside it in the same pill', () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
|
||||
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(toggle).toHaveClass('bg-accent', 'text-accent-text')
|
||||
const key = screen.getByTestId('piste-key-button')
|
||||
expect(screen.getByRole('group')).toContainElement(key)
|
||||
expect(key).toHaveAccessibleName('Piste key')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-023: a piste source that stopped answering shows the warning badge, says so, and hides the key', async () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down' })] }} />)
|
||||
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||
expect(toggle).toHaveAttribute('data-status', 'down')
|
||||
expect(toggle).toHaveTextContent('!')
|
||||
expect(screen.queryByTestId('piste-key-button')).toBeNull()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading. The map and your route still work.')
|
||||
fireEvent.mouseEnter(toggle)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes are not loading.')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-024: on further out than the pistes reach, the switch stays pressed in the soft accent and says to zoom in', async () => {
|
||||
const { rerender } = render(
|
||||
<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on', belowMinZoom: true })] }} />
|
||||
)
|
||||
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(toggle).toHaveAttribute('data-below-min-zoom', 'true')
|
||||
expect(toggle).toHaveClass('bg-accent-soft', 'text-accent')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Zoom in to see the pistes')
|
||||
fireEvent.mouseEnter(toggle)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to see the pistes')
|
||||
// The key stays at hand and opens with the same line on top.
|
||||
fireEvent.click(screen.getByTestId('piste-key-button'))
|
||||
expect(await screen.findByTestId('piste-key-zoom-in')).toHaveTextContent('Zoom in to see the pistes')
|
||||
|
||||
// A source that stopped answering says so instead, and close enough the switch is plainly on.
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.')
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
|
||||
const plain = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||
expect(plain).not.toHaveAttribute('data-below-min-zoom')
|
||||
expect(plain).toHaveClass('bg-accent', 'text-accent-text')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||
import type { ComponentType, CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
@@ -193,17 +194,32 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How each overlay the planner can offer is named and drawn on its switch. */
|
||||
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
/** How an overlay the planner can offer is named and drawn on its switch. */
|
||||
interface OverlayLook {
|
||||
Icon: typeof Anchor
|
||||
labelKey: string
|
||||
downKey: string
|
||||
/** On its icon alone, named by its tooltip, so the row stays short beside a type's other switches. */
|
||||
iconOnly?: boolean
|
||||
/** What the switch says while the map is further out than the overlay's first tiles. */
|
||||
zoomInKey?: string
|
||||
/** The overlay's key, beside its switch while the layer is on; told when nothing of it is drawn yet. */
|
||||
Key?: ComponentType<{ belowMinZoom: boolean }>
|
||||
}
|
||||
|
||||
const OVERLAY_LOOK: Record<string, OverlayLook> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
// SKI-01: a hike's row already holds four maps, the ferratas and 3D, so the pistes go by their icon.
|
||||
pistes: {
|
||||
Icon: MountainSnow,
|
||||
labelKey: 'tours.map.pistes',
|
||||
downKey: 'tours.map.pistesDown',
|
||||
zoomInKey: 'tours.map.pistesZoomIn',
|
||||
iconOnly: true,
|
||||
Key: PisteKeyButton,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for a transparent overlay (the seamarks), the way the via
|
||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
|
||||
function DownBadge() {
|
||||
return (
|
||||
@@ -213,6 +229,15 @@ function DownBadge() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for a transparent overlay (the seamarks, the pistes), the way the via
|
||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it. Switched on further out than its first tiles, it
|
||||
* stays pressed in the soft accent and its tooltip says to zoom in, so an empty
|
||||
* map does not read as broken. An overlay with a key shows the key's button
|
||||
* beside the switch while it is on and answering.
|
||||
*/
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
@@ -220,9 +245,12 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
const label = t(look.labelKey)
|
||||
const down = overlay.status === 'down'
|
||||
const on = overlay.status !== 'off'
|
||||
const zoomIn = !down && overlay.belowMinZoom && look.zoomInKey ? t(look.zoomInKey) : null
|
||||
const notice = down ? t(look.downKey) : zoomIn
|
||||
const tone = zoomIn ? 'bg-accent-soft text-accent' : 'bg-accent text-accent-text'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
||||
<Tooltip label={notice ?? label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={overlay.onToggle}
|
||||
@@ -230,15 +258,17 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
aria-pressed={on}
|
||||
data-testid={`map-overlay-${overlay.id}`}
|
||||
data-status={overlay.status}
|
||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
data-below-min-zoom={zoomIn ? true : undefined}
|
||||
className={on ? tone : 'text-content-muted'}
|
||||
style={segmentStyle(on)}
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{!compact && !look.iconOnly && <span>{label}</span>}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
{look.Key && overlay.status === 'on' && <look.Key belowMinZoom={!!zoomIn} />}
|
||||
<span role="status" aria-live="polite" className="sr-only">{notice ?? ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1141,10 +1141,11 @@ describe('MapView plugin route vias', () => {
|
||||
})
|
||||
|
||||
describe('MapView map event wiring', () => {
|
||||
it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => {
|
||||
it('FE-COMP-MAPVIEW-043: reports the viewport bbox and its zoom once the map is ready and again on every move', () => {
|
||||
const onViewportChange = vi.fn()
|
||||
mapMock.getZoom.mockReturnValue(10)
|
||||
render(<MapView onViewportChange={onViewportChange} />)
|
||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 })
|
||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }, 10)
|
||||
|
||||
const moveend = handlerFor('moveend')
|
||||
const zoomend = handlerFor('zoomend')
|
||||
|
||||
@@ -340,14 +340,14 @@ function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onViewportChange) return
|
||||
const emit = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
|
||||
}
|
||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||
map.on('moveend', emit)
|
||||
|
||||
@@ -2215,13 +2215,15 @@ describe('MapViewGL', () => {
|
||||
expect((window as any).__trek_map).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => {
|
||||
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill, with the zoom on the raster tile scale', async () => {
|
||||
const onViewportChange = vi.fn()
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(10)
|
||||
render(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
|
||||
await act(async () => {})
|
||||
|
||||
act(() => { mapHandler('moveend')() })
|
||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 })
|
||||
// The GL engine counts on 512 px tiles; a raster tile's zoom is one step further in.
|
||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }, 11)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applyBaseLayer } from './glRasterLayers'
|
||||
import { applyBaseLayer, rasterTileZoom } from './glRasterLayers'
|
||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
@@ -1433,10 +1433,13 @@ export function MapViewGL({
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
// pill can fetch OSM places for the visible area.
|
||||
// pill can fetch OSM places for the visible area, with the zoom on the raster tile scale.
|
||||
const emitViewport = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
onViewportChangeRef.current?.(
|
||||
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
|
||||
rasterTileZoom(map.getZoom()),
|
||||
)
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
|
||||
@@ -2,7 +2,7 @@ import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
@@ -20,7 +20,7 @@ function plannerWaypointIcon(
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>${plannerPinBadge(point)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ export default function PlannerWaypointMarker({
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const { label, shaping } = point;
|
||||
const { label, shaping, stageEnd } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping]
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping, stageEnd]
|
||||
);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -23,6 +23,8 @@ function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOver
|
||||
maxNativeZoom={overlay.maxZoom}
|
||||
maxZoom={22}
|
||||
zIndex={OVERLAY_Z_INDEX}
|
||||
// The page's origin as the referer, which the overlays' operators ask for (OpenSnowMap names it).
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { projectDayItinerary, tourStageStopsByPlace } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
|
||||
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
@@ -437,6 +437,54 @@ describe('Tour day itinerary segments', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// MDT-02: a day holding one stage of a multi-day tour routes to that stage and draws its stretch.
|
||||
describe('A stage of a multi-day tour on a day', () => {
|
||||
const line: [number, number][] = [[48, 11], [48.01, 11], [48.02, 11], [48.03, 11], [48.04, 11]]
|
||||
const stops = [
|
||||
{ key: 'stop-start', lat: 48, lng: 11 },
|
||||
{ key: 'stop-hut', lat: 48.02, lng: 11 },
|
||||
{ key: 'stop-end', lat: 48.04, lng: 11 },
|
||||
]
|
||||
const hutToHut = (order: number, tour_stage_key: string | null) => {
|
||||
const assignment = at(48, 11, order)
|
||||
return { ...assignment, tour_place_id: assignment.place.id, tour_route_geometry: JSON.stringify(line), tour_stage_key }
|
||||
}
|
||||
|
||||
it('FE-MAP-DRP-017: draws and routes to the stretch of the stage, the stops telling where it runs', () => {
|
||||
const second = hutToHut(1, 'stop-hut')
|
||||
const stageStops = new Map([[second.place.id, stops]])
|
||||
expect(projectDayItinerary([second], true, [], stageStops)[0]).toMatchObject({
|
||||
kind: 'tour', valid: true, start: { lat: 48.02, lng: 11 }, end: { lat: 48.04, lng: 11 }, geometry: line.slice(2),
|
||||
})
|
||||
const first = hutToHut(1, 'stop-start')
|
||||
expect(projectDayItinerary([first], true, [], new Map([[first.place.id, stops]]))[0]).toMatchObject({
|
||||
start: { lat: 48, lng: 11 }, end: { lat: 48.02, lng: 11 },
|
||||
})
|
||||
const runs = buildDayRouteRuns(1, {
|
||||
...inputs({ assignments: { '1': [at(47.99, 11, 0), second, at(48.05, 11, 2)] } }),
|
||||
toursEnabled: true, tourStageStops: stageStops,
|
||||
})
|
||||
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[47.99, 11], [48.02, 11]], [[48.04, 11], [48.05, 11]]])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: keeps the whole tour where the stops do not tell the stage', () => {
|
||||
const whole = hutToHut(1, null)
|
||||
const unknown = hutToHut(1, 'stop-gone')
|
||||
const last = hutToHut(1, 'stop-end')
|
||||
for (const assignment of [whole, unknown, last]) {
|
||||
expect(projectDayItinerary([assignment], true, [], new Map([[assignment.place.id, stops]]))[0]).toMatchObject({ geometry: line })
|
||||
}
|
||||
const noStops = hutToHut(1, 'stop-hut')
|
||||
expect(projectDayItinerary([noStops], true)[0]).toMatchObject({ geometry: line })
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-019: reads the stage stops of the multi-day tours off the tour list, none for a day tour', () => {
|
||||
const stages = tourStageStopsByPlace([{ place_id: 4, stage_stops: stops }, { place_id: 5, stage_stops: null }, { place_id: 6 }])
|
||||
expect([...stages.keys()]).toEqual([4])
|
||||
expect(stages.get(4)).toBe(stops)
|
||||
})
|
||||
})
|
||||
|
||||
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
|
||||
// plain stop drew a second road from the track start to the next stop, alongside the
|
||||
// track itself. Any assigned place with a track now leaves that stretch to the track.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarri
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { projectDayItinerary, type TourStageStops } from './dayTourProjection'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DayRouteInputs {
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
toursEnabled?: boolean
|
||||
places?: Place[]
|
||||
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to it. */
|
||||
tourStageStops?: TourStageStops
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places, input.tourStageStops).map(item => [item.assignment.id, item]))
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
import type { TourListItem, TourStageStop } from '@trek/shared'
|
||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||
import type { Assignment, Place } from '../../types'
|
||||
import { vertexIndicesOf } from '../Tours/stages/tourStages'
|
||||
|
||||
/** MDT-02: the stops each multi-day tour's stages run between, by the tour's place id. */
|
||||
export type TourStageStops = ReadonlyMap<number, readonly TourStageStop[]>
|
||||
|
||||
/** The stage stops of the trip's multi-day tours, from the tour list; a day tour has none. */
|
||||
export function tourStageStopsByPlace(tours: readonly Pick<TourListItem, 'place_id' | 'stage_stops'>[]): TourStageStops {
|
||||
return new Map(tours.flatMap(tour => (tour.stage_stops ? [[tour.place_id, tour.stage_stops] as const] : [])))
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-02: the stretch of a tour's line one stage runs, from the stop it starts
|
||||
* at to the stop the next one starts at; null where the stops do not tell it
|
||||
* (a stage the tour no longer has, a line that has not reached them yet).
|
||||
*/
|
||||
function stageStretch(
|
||||
line: [number, number][],
|
||||
stops: readonly TourStageStop[] | undefined,
|
||||
stageKey: string | null | undefined,
|
||||
): [number, number][] | null {
|
||||
if (!stageKey || !stops || line.length < 2) return null
|
||||
const index = stops.findIndex(stop => stop.key === stageKey)
|
||||
if (index < 0 || index >= stops.length - 1) return null
|
||||
const at = vertexIndicesOf(stops, line)
|
||||
return at[index + 1] > at[index] ? line.slice(at[index], at[index + 1] + 1) : null
|
||||
}
|
||||
|
||||
export type DayItineraryItem =
|
||||
| { kind: 'stop'; assignment: Assignment }
|
||||
@@ -14,10 +41,17 @@ export type DayItineraryItem =
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's stops with each tour projected onto its line: the day's route runs
|
||||
* to where the tour starts and on from where it ends. A day holding one stage
|
||||
* of a multi-day tour (MDT-02) runs to that stage's start and on from its end,
|
||||
* and draws that stage's stretch, where `stageStops` tells them.
|
||||
*/
|
||||
export function projectDayItinerary(
|
||||
assignments: Assignment[],
|
||||
toursEnabled: boolean,
|
||||
places: readonly Place[] = [],
|
||||
stageStops?: TourStageStops,
|
||||
): DayItineraryItem[] {
|
||||
const placesById = new Map(places.map(place => [place.id, place]))
|
||||
return assignments.map(assignment => {
|
||||
@@ -37,7 +71,8 @@ export function projectDayItinerary(
|
||||
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
|
||||
}
|
||||
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
|
||||
const geometry = analysis?.routeCoordinates ?? []
|
||||
const whole = analysis?.routeCoordinates ?? []
|
||||
const geometry = stageStretch(whole, stageStops?.get(placeId), assignment.tour_stage_key) ?? whole
|
||||
const start = geometry.length >= 2
|
||||
? { lat: geometry[0][0], lng: geometry[0][1] }
|
||||
: assignment.place?.lat != null && assignment.place?.lng != null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
|
||||
import { applyBaseLayer, rasterTileZoom, tourRasterSourceId } from './glRasterLayers';
|
||||
|
||||
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
|
||||
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
|
||||
@@ -53,3 +53,10 @@ describe('applyBaseLayer (MAP-01)', () => {
|
||||
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rasterTileZoom', () => {
|
||||
it('puts a GL zoom on the 256 px tile scale one step further in, where a raster minimum is read', () => {
|
||||
expect(rasterTileZoom(8)).toBe(9);
|
||||
expect(rasterTileZoom(7.5)).toBe(8.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,16 @@ const SATELLITE_LAYER_ID = 'trip-satellite-raster';
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||
|
||||
/**
|
||||
* A GL map's zoom on the 256 px tile scale that raster tile URLs, their
|
||||
* min and max zoom, and Leaflet count in. The GL engines count on 512 px
|
||||
* tiles, so the same view is one step further in on that scale, and a 256 px
|
||||
* raster source is drawn from its tiles one level up.
|
||||
*/
|
||||
export function rasterTileZoom(glZoom: number): number {
|
||||
return glZoom + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,8 @@ type RasterOverlayStatus = 'off' | 'on' | 'down';
|
||||
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
||||
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
||||
status: RasterOverlayStatus;
|
||||
/** On, but the map is further out than its first tiles, so nothing of it is drawn yet. */
|
||||
belowMinZoom: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface MapViewProps {
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
/** MDT-01: a stage of a multi-day tour ends here; the pin carries a moon at its corner. */
|
||||
stageEnd?: boolean;
|
||||
}>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
@@ -180,7 +182,11 @@ export interface MapViewProps {
|
||||
roadtripVias?: Record<number, RoadtripVia[]>;
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void;
|
||||
onRemoveVia?: (dayId: number, id: number) => void;
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void;
|
||||
/**
|
||||
* The visible area after every move, and the zoom on the 256 px tile scale
|
||||
* raster tile URLs count in (a GL map's own zoom is one step lower there).
|
||||
*/
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }, zoom: number) => void;
|
||||
/**
|
||||
* The raster tile URL template. Only the Leaflet renderer draws raster tiles;
|
||||
* the GL renderer draws the style of the chosen provider and ignores it.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||
// FE-PLANNER-PIN-002: the stop where a stage of a multi-day tour ends carries the moon at its corner (MDT-01).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
|
||||
|
||||
describe('plannerPinShape', () => {
|
||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||
@@ -9,4 +10,13 @@ describe('plannerPinShape', () => {
|
||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PIN-002: marks a stage end with the moon at the corner, and nothing else', () => {
|
||||
const badge = plannerPinBadge({ stageEnd: true, label: '3' });
|
||||
expect(badge).toContain('data-stage-end="true"');
|
||||
expect(badge).toContain('<svg');
|
||||
expect(badge).toContain('inset-inline-end');
|
||||
expect(plannerPinBadge({ label: '3' })).toBe('');
|
||||
expect(plannerPinBadge({ stageEnd: true, shaping: true })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Moon } from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||
|
||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||
export interface PlannerPinPoint {
|
||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
/** MDT-01: a stage of a multi-day tour ends at this stop. */
|
||||
stageEnd?: boolean;
|
||||
}
|
||||
|
||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||
@@ -14,3 +20,15 @@ export function plannerPinShape(
|
||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||
}
|
||||
|
||||
const MOON = renderIconMarkup(createElement(Moon, { size: 9, strokeWidth: 2.6, 'aria-hidden': true }));
|
||||
|
||||
/**
|
||||
* MDT-01: the moon at the corner of a pin where a stage ends, the night
|
||||
* between two days of the tour, as the planner's list marks the stop's number.
|
||||
* Laid over the pin, so it needs no room of its own and moves with it.
|
||||
*/
|
||||
export function plannerPinBadge(point: PlannerPinPoint): string {
|
||||
if (!point.stageEnd || point.shaping) return '';
|
||||
return `<span data-stage-end="true" aria-hidden="true" style="position:absolute;bottom:-4px;inset-inline-end:-4px;display:grid;place-items:center;width:15px;height:15px;border-radius:9999px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);pointer-events:none">${MOON}</span>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
|
||||
import type { TourPoiCategory } from '@trek/shared'
|
||||
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
@@ -62,6 +62,12 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
|
||||
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
|
||||
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
|
||||
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
|
||||
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
|
||||
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
|
||||
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
|
||||
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
|
||||
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
|
||||
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
|
||||
}
|
||||
|
||||
// A Map rather than an object, so a category named after an Object.prototype member
|
||||
|
||||
@@ -107,6 +107,7 @@ export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey):
|
||||
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
|
||||
input.toursEnabled ?? false,
|
||||
input.places,
|
||||
input.tourStageStops,
|
||||
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// FE-PLANNER-PIN-GL-001: the GL map's planner pins carry the moon where a stage of a multi-day tour ends (MDT-01).
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL';
|
||||
|
||||
describe('usePlannerWaypointMarkersGL', () => {
|
||||
it('FE-PLANNER-PIN-GL-001: puts the moon on the pin of a stage end only, numbered as the list numbers it', () => {
|
||||
const container = document.createElement('div');
|
||||
const map = { getContainer: () => container, unproject: () => ({ lat: 0, lng: 0 }) };
|
||||
const attached: HTMLElement[] = [];
|
||||
renderHook(() =>
|
||||
usePlannerWaypointMarkersGL({
|
||||
mapRef: { current: map },
|
||||
mapReady: true,
|
||||
pinsRef: { current: [] },
|
||||
attachPin: (el) => {
|
||||
attached.push(el);
|
||||
return { remove: vi.fn(), setLngLat: vi.fn() };
|
||||
},
|
||||
pinCss: () => 'display:flex',
|
||||
plannerWaypoints: [
|
||||
{ id: 'a', lat: 47, lng: 11, label: '1' },
|
||||
{ id: 'b', lat: 47.1, lng: 11, label: '2', stageEnd: true },
|
||||
],
|
||||
selectedPlannerWaypointId: null,
|
||||
onPlannerWaypointClick: vi.fn(),
|
||||
onPlannerWaypointMove: undefined,
|
||||
})
|
||||
);
|
||||
expect(attached).toHaveLength(2);
|
||||
expect(attached[0].querySelector('[data-stage-end]')).toBeNull();
|
||||
expect(attached[1].querySelector('[data-stage-end]')).not.toBeNull();
|
||||
expect(attached[1].textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
@@ -79,6 +79,9 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
// MDT-01: the moon where a stage ends; the pin is positioned, so the badge sits at its corner.
|
||||
const badge = plannerPinBadge(point);
|
||||
if (badge) el.insertAdjacentHTML('beforeend', badge);
|
||||
const swallow = (event: Event) => event.stopPropagation();
|
||||
const select = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
import type { TourStageStops } from './dayTourProjection'
|
||||
|
||||
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
||||
|
||||
@@ -46,6 +47,8 @@ export function useTripRouteOverview(
|
||||
enabled: boolean,
|
||||
places: Place[] = EMPTY_PLACES,
|
||||
vias?: TripRouteVias,
|
||||
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one draws that stage. */
|
||||
tourStageStops?: TourStageStops,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||
@@ -66,9 +69,9 @@ export function useTripRouteOverview(
|
||||
const viasByDay = vias?.viasByDay
|
||||
const plan = useMemo(
|
||||
() => (enabled
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
|
||||
: []),
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay, tourStageStops],
|
||||
)
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
|
||||
@@ -519,6 +519,23 @@ describe('valhallaAlternates', () => {
|
||||
expect(bodies[1]).not.toHaveProperty('alternates')
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-005: a section of a hike is offered ways on foot under its own limit and preferences (RTE-02)', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
|
||||
}))
|
||||
|
||||
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'walking', [], 3, undefined, { max_hiking_difficulty: 4, use_hills: 0.2 })
|
||||
|
||||
expect(ways).toHaveLength(2)
|
||||
expect(bodies[0]).toMatchObject({
|
||||
alternates: 3,
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 4, use_hills: 0.2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
|
||||
@@ -551,6 +568,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
|
||||
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ shape: SHAPE, edges: [] })
|
||||
}))
|
||||
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run')
|
||||
expect(bodies[0]).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||
shape_match: 'map_snap',
|
||||
})
|
||||
expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale')
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
|
||||
let shape: unknown[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
@@ -574,6 +606,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ shape: SHAPE, edges: [] })
|
||||
}))
|
||||
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot')
|
||||
expect(bodies[0]).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||
shape_match: 'map_snap',
|
||||
})
|
||||
expect(JSON.stringify(bodies[0])).not.toContain('sac_scale')
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
|
||||
let asked = false
|
||||
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
|
||||
|
||||
@@ -368,14 +368,48 @@ const TRACE_MAX_POINTS = 4000
|
||||
const TRACE_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
|
||||
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
|
||||
* of every edge, with the line as matched. The edges are matched as a mountain
|
||||
* bike would ride them, the costing that refuses the fewest ways, so a road
|
||||
* bike's route and a gravel one are described alike. Null for every failure:
|
||||
* the description is an addition beside a route that already exists.
|
||||
* How a line is matched to the ways under it: as a mountain bike rides, the
|
||||
* bicycle costing that refuses the fewest ways, so a road bike's route and a
|
||||
* gravel one are described alike; or on foot with every grade of path open,
|
||||
* since a hike's line (MAP-03) may run on a T6 ridge the matcher must not
|
||||
* refuse, and steps and mountain paths a run takes (RUN-02) stay on the line
|
||||
* (matched as a bike, a trail run over steps was described as the street
|
||||
* beside them). Matching is no routing: the limit a hike was planned with
|
||||
* plays no part. A hike and a run are matched alike; they differ in whether
|
||||
* the router's own hiking grade is asked for.
|
||||
*/
|
||||
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
|
||||
const TRACE_COSTING = {
|
||||
bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } },
|
||||
foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
|
||||
run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
|
||||
} as const
|
||||
|
||||
export type TraceMatching = keyof typeof TRACE_COSTING
|
||||
|
||||
const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length',
|
||||
'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index']
|
||||
|
||||
/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */
|
||||
function traceAttributesFor(matching: TraceMatching): string[] {
|
||||
return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale']
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line
|
||||
* (`trace_attributes`): the surface, the use and road class, the cycle lane,
|
||||
* the OpenStreetMap way of every edge and, but for a hike, the router's
|
||||
* hiking grade (`sac_scale`, 0 where the way has none), with the line as
|
||||
* matched. A run's grade shares (RUN-02) read the router's grade; the grade a
|
||||
* hike shows (MAP-03) comes from OpenStreetMap itself, since the router's
|
||||
* reading may be normalised or derived, so it is not asked for there. Null for
|
||||
* every failure: the description is an addition beside a route that already
|
||||
* exists.
|
||||
*/
|
||||
export async function valhallaTraceAttributes(
|
||||
line: readonly [number, number][],
|
||||
signal?: AbortSignal,
|
||||
matching: TraceMatching = 'bike'
|
||||
): Promise<unknown> {
|
||||
const base = valhallaBase()
|
||||
if (!base || line.length < 2) return null
|
||||
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
|
||||
@@ -389,12 +423,10 @@ export async function valhallaTraceAttributes(line: readonly [number, number][],
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify({
|
||||
shape,
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
...TRACE_COSTING[matching],
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
|
||||
'edge.begin_shape_index', 'edge.end_shape_index'],
|
||||
attributes: traceAttributesFor(matching),
|
||||
action: 'include',
|
||||
},
|
||||
}),
|
||||
@@ -428,6 +460,8 @@ async function requestRun(
|
||||
* quickest road beside a day Valhalla had routed was offered as the faster way, and
|
||||
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
|
||||
* which is all one leg is, and weighs every one of them the way it weighed the day.
|
||||
* `costingOptions` carries the rest of how that day was weighed, such as a hike's limit
|
||||
* and preferences (RTE-02), so a section of a tour is offered ways under its own rules.
|
||||
*/
|
||||
export async function valhallaAlternates(
|
||||
from: Waypoint,
|
||||
@@ -436,10 +470,11 @@ export async function valhallaAlternates(
|
||||
avoid: readonly AvoidClass[],
|
||||
count: number,
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<ValhallaLeg[] | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base)) return null
|
||||
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
|
||||
const body = { ...routeBody([from, to], profile, avoid, costingOptions), ...(count > 0 ? { alternates: count } : {}) }
|
||||
return askWithRetry(base, async () => {
|
||||
const data = await postRoute(base, body, signal)
|
||||
return data === null ? null : alternatesFrom(data)
|
||||
|
||||
@@ -15,8 +15,9 @@ export const KAT_COLORS = [
|
||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||
|
||||
// A category's first item is seeded with this sentinel because the server
|
||||
// rejects empty names. Treat it as a placeholder in the UI.
|
||||
export const PACKING_PLACEHOLDER_NAME = '...'
|
||||
// rejects empty names. Treat it as a placeholder in the UI. Shared, because a
|
||||
// tour's packing link (P2D-XD-01) leaves it out the same way.
|
||||
export { PACKING_PLACEHOLDER_NAME } from '@trek/shared'
|
||||
|
||||
/** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */
|
||||
export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown'
|
||||
|
||||
@@ -7,8 +7,10 @@ import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { stageNumberOf, TourStageDayPills } from '../Tours/stages/TourStageDayPills';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { WalkProgressPill } from '../Tours/walk/WalkStations';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||
@@ -19,6 +21,10 @@ interface DayPlanPlaceSublineProps {
|
||||
tourTimes: DayRowTourTimes | undefined;
|
||||
isTourPlace: boolean;
|
||||
timeFormat: string;
|
||||
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
|
||||
assignmentId?: number;
|
||||
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; absent for the whole tour. */
|
||||
tourStageKey?: string | null;
|
||||
}
|
||||
|
||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||
@@ -57,9 +63,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-planned-duration"
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${plannedTotalText(tourTimes, t)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
||||
{t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)}
|
||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
@@ -68,6 +74,12 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The tour's own pills, or a stage's where the day holds one stage of a multi-day tour (MDT-02).
|
||||
function DayRowTourPills({ tour, stageKey }: { tour: TourListItem; stageKey?: string | null }) {
|
||||
if (stageKey && stageNumberOf(tour, stageKey) !== null) return <TourStageDayPills tour={tour} stageKey={stageKey} />;
|
||||
return <TourDayRowPills tour={tour} />;
|
||||
}
|
||||
|
||||
// Description line, one line like every stop's.
|
||||
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
||||
return (
|
||||
@@ -90,7 +102,15 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
|
||||
|
||||
// Second line of a place row in the day plan: time, tour facts or planned
|
||||
// tour durations, then the clamped description.
|
||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
||||
export function DayPlanPlaceSubline({
|
||||
place,
|
||||
cat,
|
||||
tourTimes,
|
||||
isTourPlace,
|
||||
timeFormat,
|
||||
assignmentId,
|
||||
tourStageKey,
|
||||
}: DayPlanPlaceSublineProps) {
|
||||
const { locale } = useTranslation();
|
||||
const dayRowTour = tourTimes?.tour;
|
||||
const description = place.description || place.address || cat?.name;
|
||||
@@ -105,7 +125,8 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{dayRowTour ? <DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
// CW-08: public transit between two city walks from the day plan's connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildReservation,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { tourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
legs: waypoints.slice(1).map((to, index) => ({
|
||||
from: [waypoints[index]?.lat, waypoints[index]?.lng],
|
||||
to: [to.lat, to.lng],
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
|
||||
const mockTourDetail = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
|
||||
});
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
@@ -160,7 +181,14 @@ describe('DayPlanSidebar', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
||||
const times = {
|
||||
walkingMinutes: 60,
|
||||
breakMinutes: 35,
|
||||
plannedTotalMinutes: 95,
|
||||
manuallyOverridden: false,
|
||||
walk: null,
|
||||
atLeast: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
@@ -189,6 +217,22 @@ describe('DayPlanSidebar', () => {
|
||||
);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||
|
||||
// CW-02: a city walk whose stay is not entered yet plans at least its known total.
|
||||
view.rerender(
|
||||
<DayPlanSidebar
|
||||
{...{
|
||||
...props,
|
||||
plannedTourDurations: new Map([
|
||||
[place.id, { ...times, breakMinutes: null, plannedTotalMinutes: 135, atLeast: true }],
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration');
|
||||
expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min');
|
||||
expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||
@@ -305,3 +349,214 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
|
||||
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
tour_place_id: 51,
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
tour_place_id: 52,
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
distance: 3,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: 90,
|
||||
break_additional_minutes: null,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
}),
|
||||
buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
}),
|
||||
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||
],
|
||||
};
|
||||
const tours = [walkTour(oldTown), walkTour(harbour)];
|
||||
return makeDefaultProps({
|
||||
days: [day],
|
||||
places: [oldTown, harbour, cafe],
|
||||
assignments,
|
||||
selectedDayId: 10,
|
||||
routeShown: true,
|
||||
tourPlaceIds: new Set([51, 52]),
|
||||
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
|
||||
reservations: extra.reservations ?? [],
|
||||
onPlanTransitLeg: extra.onPlanTransitLeg,
|
||||
});
|
||||
}
|
||||
|
||||
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
|
||||
function savedJourney(arrives: string): Reservation {
|
||||
return {
|
||||
...buildReservation({
|
||||
id: 300,
|
||||
type: 'transit',
|
||||
title: 'Old bridge → Fish market',
|
||||
reservation_time: '2025-06-01T12:20',
|
||||
reservation_end_time: `2025-06-01T${arrives}`,
|
||||
day_id: 10,
|
||||
}),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Old bridge',
|
||||
lat: 52.51,
|
||||
lng: 13.42,
|
||||
local_date: '2025-06-01',
|
||||
local_time: '12:20',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Fish market',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
local_date: '2025-06-01',
|
||||
local_time: arrives,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
|
||||
},
|
||||
},
|
||||
} as unknown as Reservation;
|
||||
}
|
||||
|
||||
function contextMenu() {
|
||||
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: TOURS_ON });
|
||||
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
|
||||
Promise.resolve({
|
||||
tour: walkTour(placeId === 51 ? oldTown : harbour),
|
||||
waypoints:
|
||||
placeId === 51
|
||||
? [
|
||||
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
|
||||
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
|
||||
]
|
||||
: [
|
||||
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
|
||||
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
|
||||
|
||||
await user.click(await screen.findByLabelText('Change travel mode'));
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
|
||||
await waitFor(() =>
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockTourDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
|
||||
|
||||
const connectors = await screen.findAllByLabelText('Change travel mode');
|
||||
expect(connectors).toHaveLength(2);
|
||||
await user.click(connectors[1]);
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: '', lat: 52.49, lng: 13.46 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: null,
|
||||
});
|
||||
expect(mockTourDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
|
||||
|
||||
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
|
||||
await screen.findByLabelText('Change travel mode');
|
||||
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||
@@ -68,7 +69,7 @@ import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnec
|
||||
import { resolveLegMode } from './legMode'
|
||||
import { useLegModeActions } from './useLegModeActions'
|
||||
import { dayExportStops, fillAroundLocked } from './dayRoute'
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection'
|
||||
import { projectDayItinerary, type TourStageStops } from '../Map/dayTourProjection'
|
||||
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
@@ -93,6 +94,8 @@ export interface DayPlanSidebarProps {
|
||||
places: Place[]
|
||||
tourPlaceIds?: ReadonlySet<number>
|
||||
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
|
||||
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to that stage. */
|
||||
tourStageStops?: TourStageStops
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
@@ -161,8 +164,9 @@ export interface DayPlanSidebarProps {
|
||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||
* so "public transport" becomes an option on any connector, not just the day header.
|
||||
* Between two city walks it carries what the search says of them (CW-08).
|
||||
*/
|
||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
||||
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
@@ -619,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
|
||||
const planDay = (dayId: number) => {
|
||||
const merged = mergedItemsMap[dayId] || []
|
||||
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item]))
|
||||
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places, props.tourStageStops).map(item => [item.assignment.id, item]))
|
||||
// Each run point carries the ORIGIN assignment's per-segment travel mode
|
||||
// (#1281): the leg from this point to the next is routed with `mode` (null =
|
||||
// inherit the day default). Transport endpoints carry no mode → day default.
|
||||
@@ -789,6 +793,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
|
||||
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
|
||||
const mode = resolveLegMode(from, to, dfMode)
|
||||
tasks.push(async () => {
|
||||
const seg = await legBetween(from, to, dayId, mode)
|
||||
@@ -836,7 +842,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
|
||||
// effect on the string is equivalent while staying stable across unrelated renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay])
|
||||
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay, props.tourStageStops])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
@@ -1405,26 +1411,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
// Coordinates back to names for the transit search, over every located place,
|
||||
// hotel and booking endpoint on the trip (see transitLeg.ts).
|
||||
const transitNameIndex = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
// The extra connector-menu entry (#1281 follow-up): search public transit for this
|
||||
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
|
||||
if (!onPlanTransitLeg) return undefined
|
||||
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
|
||||
if (!leg) return undefined
|
||||
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
|
||||
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||
const leg = transitLegs.legFor(dayId, seg)
|
||||
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||
}
|
||||
|
||||
// Open the mode menu at the clicked connector: every route profile, the optional
|
||||
// "public transport" entry, plus a "use day default" entry that clears the override.
|
||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
|
||||
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
|
||||
}
|
||||
|
||||
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
||||
@@ -2033,7 +2032,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} tourStageKey={assignment.tour_stage_key} />
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
@@ -2359,6 +2358,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
|
||||
</div>
|
||||
{transitMeta && (() => {
|
||||
const expanded = expandedTransitIds.has(res.id)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
|
||||
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
|
||||
it: TransitItinerary
|
||||
tzFrom: string
|
||||
tzTo: string
|
||||
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
adding: boolean
|
||||
/** CW-08: the next city walk this connection arrives too late for. */
|
||||
late: WalkLate | null
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<WalkLateLine late={late} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
|
||||
initialTo?: PickedPlace | null
|
||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||
initialTime?: string | null
|
||||
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState(initialTime || '09:00')
|
||||
const walkHere = walk?.dayId === day.id ? walk : null
|
||||
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
|
||||
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||
const canSearch = !!from && !!to && !!time && !loading
|
||||
|
||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||
<WalkTransitNote walk={walkHere} />
|
||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
disabled={!canSearch}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
onAdd={() => addItinerary(it, idx)}
|
||||
adding={addingIdx === idx}
|
||||
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// CW-08: the transit search between two city walks, opened from the day plan's
|
||||
// connector with the first walk's end and the next one's start. The mocks are the
|
||||
// ones TransitSearchPanel.test.tsx renders the panel with.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
|
||||
import TransitSearchPanel from './TransitSearchPanel';
|
||||
|
||||
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return { ...actual, transitApi: transitApiMock };
|
||||
});
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
|
||||
|
||||
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
|
||||
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
|
||||
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
|
||||
const WALK: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
|
||||
function itinerary(arrives: string) {
|
||||
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
|
||||
return {
|
||||
startTime: '2025-06-01T10:20:00Z',
|
||||
endTime: `2025-06-01T${arrives}:00Z`,
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
walkSeconds: 240,
|
||||
legs: [
|
||||
{
|
||||
mode: 'TRAM',
|
||||
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
|
||||
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
|
||||
duration: 1800,
|
||||
distance: null,
|
||||
headsign: 'Harbour',
|
||||
line: 'M1',
|
||||
lineColor: null,
|
||||
lineTextColor: null,
|
||||
agency: 'BVG',
|
||||
intermediateStops: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
|
||||
const onAdd = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<TransitSearchPanel
|
||||
day={day}
|
||||
days={[day]}
|
||||
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
|
||||
onAdd={onAdd}
|
||||
initialFrom={OLD_BRIDGE}
|
||||
initialTo={FISH_MARKET}
|
||||
initialTime={initialTime}
|
||||
walk={walk}
|
||||
/>
|
||||
);
|
||||
return { onAdd };
|
||||
}
|
||||
|
||||
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
|
||||
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
|
||||
});
|
||||
|
||||
describe('TransitSearchPanel between two city walks (CW-08)', () => {
|
||||
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel(WALK);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
|
||||
expect(from?.value).toBe('Old bridge');
|
||||
expect(to?.value).toBe('Fish market');
|
||||
expect(timeField().value).toBe('12:15');
|
||||
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: '52.51,13.42',
|
||||
to: '52.5,13.45',
|
||||
time: '2025-06-01T10:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
|
||||
renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await screen.findByText(/12:20\D+13:10/);
|
||||
const late = screen.getAllByTestId('transit-walk-late');
|
||||
expect(late).toHaveLength(1);
|
||||
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
|
||||
});
|
||||
|
||||
it('says an end that leaves a time out is the earliest', () => {
|
||||
renderPanel({ ...WALK, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
|
||||
);
|
||||
});
|
||||
|
||||
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
|
||||
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town only ends after midnight, so choose a departure time.'
|
||||
);
|
||||
expect(timeField().value).toBe('');
|
||||
});
|
||||
|
||||
it('makes up no time without an end, and waits for one to be chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel({ ...WALK, ends: null, nextStarts: null });
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'When Old town ends is not known yet, so choose a departure time.'
|
||||
);
|
||||
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('');
|
||||
expect(searchButton()).toBeDisabled();
|
||||
|
||||
await user.type(timeField(), '1230');
|
||||
await user.tab();
|
||||
expect(searchButton()).toBeEnabled();
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
|
||||
});
|
||||
|
||||
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
|
||||
renderPanel({ ...WALK, dayId: 11 }, null);
|
||||
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('09:00');
|
||||
});
|
||||
|
||||
it('adds a connection only once it is opened and taken, between the two stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
|
||||
const { onAdd } = renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await user.click(await screen.findByText(/12:20\D+12:50/));
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
|
||||
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
|
||||
const payload = onAdd.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
|
||||
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
|
||||
});
|
||||
});
|
||||
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
||||
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
||||
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01' }),
|
||||
|
||||
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||||
import TransitSearchPanel from './TransitSearchPanel'
|
||||
import type { TransitPrefill } from './transitLeg'
|
||||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||||
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
||||
@@ -70,7 +71,7 @@ interface TransportModalProps {
|
||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||
tripHasDates?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||||
transitPrefill?: TransitPrefill | null
|
||||
/** Trip members + guests, for the traveler picker (#1517). */
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
walk={transitPrefill?.walk ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,14 +83,12 @@ export function stayFormFrom<P>(
|
||||
}
|
||||
|
||||
/**
|
||||
* The places a stay can be at (no GPX tracks, but always the one already picked),
|
||||
* The places a stay can be at (no GPX tracks or tours, but always the one already picked),
|
||||
* narrowed to a category when one is chosen.
|
||||
*/
|
||||
export function stayPlaceChoices<P extends { id: number; route_geometry?: string | null; category_id?: number | null }>(
|
||||
places: P[],
|
||||
placeId: number | string | null | undefined,
|
||||
categoryId: number | null
|
||||
): P[] {
|
||||
export function stayPlaceChoices<
|
||||
P extends { id: number; route_geometry?: string | null; tour_place_id?: number | null; category_id?: number | null },
|
||||
>(places: P[], placeId: number | string | null | undefined, categoryId: number | null): P[] {
|
||||
const offered = stayPlaces<P>(places, placeId);
|
||||
return categoryId != null ? offered.filter((p) => p.category_id === categoryId) : offered;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-007
|
||||
|
||||
const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) =>
|
||||
({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment
|
||||
@@ -69,4 +69,11 @@ describe('buildTransitLeg', () => {
|
||||
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
|
||||
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITLEG-007: a leg leaving a loop tour does not depart when the tour starts (CW-08)', () => {
|
||||
// A tour's place sits at its start; a loop ends there too, but its time is when it starts.
|
||||
const loop = { ...visit(31, 3, 'Old town loop', 52.52, 13.4, '10:00'), tour_place_id: 31 } as Assignment
|
||||
const tours = { '3': [loop, visit(32, 3, 'Harbour', 52.5, 13.45)] }
|
||||
expect(buildTransitLeg(seg([52.52, 13.4], [52.5, 13.45]), 3, buildTransitNameIndex(tours, [], []), tours, [])?.time).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
import type { PickedPlace } from './TransitSearchPanel'
|
||||
|
||||
/** What the transit search is opened with for one leg of a day. */
|
||||
@@ -8,6 +9,20 @@ export interface TransitLeg {
|
||||
time: string | null
|
||||
}
|
||||
|
||||
/** A leg as a view hands it on to open the search: its day and, between two city walks, what it says of them (CW-08). */
|
||||
export interface PlannedTransitLeg extends TransitLeg {
|
||||
dayId: number
|
||||
walk?: WalkTransitContext
|
||||
}
|
||||
|
||||
/** What the search is seeded with: a leg, or the ends of a journey whose route is changed. */
|
||||
export interface TransitPrefill {
|
||||
from?: PickedPlace | null
|
||||
to?: PickedPlace | null
|
||||
time?: string | null
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A connector's endpoints carry only coordinates (RouteSegment.from/to, both
|
||||
* [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
|
||||
@@ -48,7 +63,9 @@ function originDepartureTime(
|
||||
assignments: Record<string, Assignment[]>,
|
||||
reservations: Reservation[],
|
||||
): string | null {
|
||||
for (const a of assignments[String(dayId)] ?? []) {
|
||||
// A tour's place is where it starts and its time when it starts: a leg leaving a loop is no departure then.
|
||||
const stops = (assignments[String(dayId)] ?? []).filter(a => a.tour_place_id == null || a.tour_place_id !== a.place?.id)
|
||||
for (const a of stops) {
|
||||
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
|
||||
}
|
||||
for (const r of reservations) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import type { RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { useTransitLegPlanner, type TransitLegPlannerDeps } from './useTransitLegPlanner';
|
||||
|
||||
// CW-08: the transit leg behind the desktop day plan and the phone timeline.
|
||||
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } };
|
||||
});
|
||||
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const museum = buildPlace({ id: 53, name: 'Museum', lat: 52.47, lng: 13.48, place_time: '9:05' });
|
||||
const cafe = buildPlace({ id: 54, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
const walkA = buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
});
|
||||
const walkB = buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
});
|
||||
const assignments = {
|
||||
'10': [walkB, walkA],
|
||||
'11': [
|
||||
buildAssignment({ id: 301, day_id: 11, order_index: 0, place: museum }),
|
||||
buildAssignment({ id: 302, day_id: 11, order_index: 1, place: cafe }),
|
||||
],
|
||||
};
|
||||
|
||||
function walk(place: { id: number; name: string }): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
} as TourListItem;
|
||||
}
|
||||
const tours = new Map([walk(oldTown), walk(harbour)].map((tour) => [tour.place_id, tour]));
|
||||
|
||||
const BETWEEN_WALKS = { from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment;
|
||||
const MUSEUM_TO_CAFE = { from: [52.47, 13.48], to: [52.48, 13.47] } as unknown as RouteSegment;
|
||||
|
||||
function setup(overrides: Partial<TransitLegPlannerDeps> = {}) {
|
||||
const onPlan = vi.fn();
|
||||
const deps: TransitLegPlannerDeps = {
|
||||
tripId: 1,
|
||||
assignments,
|
||||
accommodations: [],
|
||||
reservations: [],
|
||||
places: [oldTown, harbour, museum, cafe],
|
||||
toursEnabled: true,
|
||||
tourOf: (placeId) => tours.get(placeId),
|
||||
onPlan,
|
||||
...overrides,
|
||||
};
|
||||
const { result, unmount } = renderHook(() => useTransitLegPlanner(deps), { wrapper: TranslationProvider });
|
||||
return { onPlan, result, unmount };
|
||||
}
|
||||
|
||||
const detail = (placeId: number, names: [string, string]) => ({
|
||||
tour: tours.get(placeId),
|
||||
waypoints: names.map((name, sequence) => ({
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
sequence,
|
||||
key: `${placeId}-${sequence}`,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
/** Walk reads that answer only once let go, and what each was asked with. */
|
||||
function heldReads() {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
gate.then(() => detail(placeId, ['First', 'Last']) as never)
|
||||
);
|
||||
const signals = () => vi.mocked(tourRepo.detail).mock.calls.map((call) => call[2]?.aborted);
|
||||
// A turn of the event loop lets every read and what follows it settle.
|
||||
const settle = async () => {
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
return { signals, settle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tourRepo.detail).mockReset();
|
||||
});
|
||||
|
||||
describe('useTransitLegPlanner', () => {
|
||||
it('offers no leg without a way to open the search, and the leg as for any connector with one', () => {
|
||||
expect(setup({ onPlan: undefined }).result.current.legFor(11, MUSEUM_TO_CAFE)).toBeNull();
|
||||
expect(setup().result.current.legFor(11, MUSEUM_TO_CAFE)).toEqual({
|
||||
from: { name: 'Museum', lat: 52.47, lng: 13.48 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: '09:05',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the search for an ordinary leg at once, with nothing said of walks', () => {
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(11, leg, 301);
|
||||
expect(onPlan.mock.calls[0]?.[0]).toStrictEqual({ dayId: 11, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('between two walks, opens it from the last stop to the first once both are read, when the first one ends', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
Promise.resolve(
|
||||
detail(placeId, placeId === 51 ? ['Town hall', 'Old bridge'] : ['Fish market', 'Lighthouse']) as never
|
||||
)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(onPlan).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(tourRepo.detail).mock.calls.map((call) => call[1])).toEqual([51, 52]);
|
||||
});
|
||||
|
||||
it('still opens it where a walk cannot be read, naming that end after its walk', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
placeId === 51
|
||||
? Promise.reject(new Error('Tour not available offline'))
|
||||
: // Route ends without keys stand in for stops this device does not hold.
|
||||
Promise.resolve({
|
||||
tour: tours.get(52),
|
||||
waypoints: [{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0 }],
|
||||
} as never)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
|
||||
expect(onPlan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: { name: 'End of Old town' },
|
||||
to: { name: 'Start of Harbour' },
|
||||
time: '12:15',
|
||||
});
|
||||
});
|
||||
|
||||
it('with tours off, treats the leg between two walks as any other', () => {
|
||||
const { onPlan, result } = setup({ toursEnabled: false });
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 10, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing without a way to open the search', () => {
|
||||
const { result } = setup({ onPlan: undefined });
|
||||
const leg = { from: { name: '', lat: 52.51, lng: 13.42 }, to: { name: '', lat: 52.5, lng: 13.45 }, time: null };
|
||||
expect(() => result.current.plan(10, leg, walkA.id)).not.toThrow();
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens only the leg asked for last, and drops the walks still being read for an earlier one', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result } = setup();
|
||||
const between = result.current.legFor(10, BETWEEN_WALKS);
|
||||
const ordinary = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!between || !ordinary) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(11, ordinary, 301);
|
||||
expect(reads.signals()).toEqual([true, true, true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).toHaveBeenCalledTimes(1);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 11, ...ordinary });
|
||||
});
|
||||
|
||||
it('opens nothing once the view is gone before the walks are read', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result, unmount } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(reads.signals()).toEqual([false, false]);
|
||||
unmount();
|
||||
expect(reads.signals()).toEqual([true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says when a saved journey now arrives after the walk it leads to starts, while tours are on', () => {
|
||||
const journey = buildReservation({ id: 300, type: 'transit', reservation_end_time: '2025-06-01T13:10' });
|
||||
const following = [{ type: 'place', sortKey: 1, data: walkB }] as MergedItem[];
|
||||
const day = { date: '2025-06-01' };
|
||||
expect(setup().result.current.lateBefore(day, journey, following)).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(setup({ toursEnabled: false }).result.current.lateBefore(day, journey, following)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation, RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection';
|
||||
import { readWalkStations } from '../Tours/walk/walkStationsRead';
|
||||
import { journeyLate, walkPairAt, walkTransitLeg, type TourOf, type WalkLate } from '../Tours/walk/walkTransit';
|
||||
import { buildTransitLeg, buildTransitNameIndex, type PlannedTransitLeg, type TransitLeg } from './transitLeg';
|
||||
|
||||
export interface TransitLegPlannerDeps {
|
||||
tripId: number | string;
|
||||
assignments: AssignmentsMap;
|
||||
accommodations: Accommodation[];
|
||||
reservations: Reservation[];
|
||||
places: Place[];
|
||||
toursEnabled: boolean;
|
||||
tourOf: TourOf;
|
||||
/** Opens the search for a leg; without it no leg offers one, as on a trip without dates. */
|
||||
onPlan: ((leg: PlannedTransitLeg) => void) | undefined;
|
||||
}
|
||||
|
||||
/** A walk's stops, or null where this device cannot read them. */
|
||||
async function stopsOrNull(tripId: number | string, placeId: number, signal: AbortSignal) {
|
||||
try {
|
||||
return await readWalkStations(tripId, placeId, signal);
|
||||
} catch {
|
||||
// They only name the leg's two ends: without them it opens named after the walks.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public transit for one leg of a day (#2398), behind the desktop day plan and
|
||||
* the phone timeline alike: the leg's two ends and the time its origin is
|
||||
* planned for. Between two city walks (CW-08) it runs from the first walk's
|
||||
* last stop to the next one's first, departing when the first one ends, and a
|
||||
* saved journey that now arrives after the next walk starts says so. Nothing
|
||||
* is saved here: the search opens, and only the connection picked there is
|
||||
* added to the day. Only the last leg asked for opens it, and none after the
|
||||
* view is gone.
|
||||
*/
|
||||
export function useTransitLegPlanner({
|
||||
tripId,
|
||||
assignments,
|
||||
accommodations,
|
||||
reservations,
|
||||
places,
|
||||
toursEnabled,
|
||||
tourOf,
|
||||
onPlan,
|
||||
}: TransitLegPlannerDeps) {
|
||||
const { t } = useTranslation();
|
||||
// Coordinates back to names, over every located place, hotel and booking endpoint of the trip.
|
||||
const names = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations]
|
||||
);
|
||||
|
||||
const legFor = useCallback(
|
||||
(dayId: number, seg?: RouteSegment): TransitLeg | null =>
|
||||
onPlan ? buildTransitLeg(seg, dayId, names, assignments, reservations) : null,
|
||||
[onPlan, names, assignments, reservations]
|
||||
);
|
||||
|
||||
// The walks' stops being read for the leg asked for last; a later leg, or leaving the view, drops them.
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
|
||||
/** Opens the search for a leg, told by the entry it leaves where the connector carries one. */
|
||||
const plan = useCallback(
|
||||
(dayId: number, leg: TransitLeg, originId?: number) => {
|
||||
if (!onPlan) return;
|
||||
pending.current?.abort();
|
||||
const day = [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index);
|
||||
const pair = toursEnabled ? walkPairAt(projectDayItinerary(day, true, places), leg, tourOf, originId) : null;
|
||||
if (!pair) {
|
||||
onPlan({ dayId, ...leg });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
pending.current = controller;
|
||||
// The stops name the ends; read from this device's copy when offline, so the search waits only for them.
|
||||
void Promise.all([
|
||||
stopsOrNull(tripId, pair.first.item.placeId, controller.signal),
|
||||
stopsOrNull(tripId, pair.next.item.placeId, controller.signal),
|
||||
]).then(([first, next]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
onPlan({ dayId, ...walkTransitLeg({ leg, dayId, pair, stops: { first, next }, places, t }) });
|
||||
});
|
||||
},
|
||||
[onPlan, assignments, toursEnabled, places, tourOf, tripId, t]
|
||||
);
|
||||
|
||||
/** A saved journey on a day that arrives after the walk it leads to starts; null otherwise. */
|
||||
const lateBefore = useCallback(
|
||||
(day: Pick<Day, 'date'>, reservation: Reservation, following: readonly MergedItem[]): WalkLate | null =>
|
||||
toursEnabled ? journeyLate({ reservation, following, date: day.date ?? null, tourOf }) : null,
|
||||
[toursEnabled, tourOf]
|
||||
);
|
||||
|
||||
return { legFor, plan, lateBefore };
|
||||
}
|
||||
@@ -64,4 +64,26 @@ describe('ToggleSwitch', () => {
|
||||
rerender(<ToggleSwitch on={true} onToggle={() => {}} />);
|
||||
expect(button.querySelector('span')!.style.left).toBe('22px');
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-009: a disabled switch is out of reach and says so', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<ToggleSwitch on={false} onToggle={onToggle} disabled />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(button.style.cursor).toBe('not-allowed');
|
||||
await user.click(button);
|
||||
await user.tab();
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
expect(button).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-COMP-TOGGLESWITCH-010: an enabled switch keeps the pointer and no aria-disabled', () => {
|
||||
render(<ToggleSwitch on={false} onToggle={() => {}} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).not.toHaveAttribute('aria-disabled');
|
||||
expect(button.style.cursor).toBe('pointer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,18 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
|
||||
/**
|
||||
* `describedBy` names the element that explains the switch, for a switch whose label alone
|
||||
* does not say what flipping it changes.
|
||||
* does not say what flipping it changes. `disabled` takes it out of the tab order and tells
|
||||
* assistive tech it cannot act; the caller dims it and says why.
|
||||
*/
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
disabled={disabled} aria-disabled={disabled || undefined}
|
||||
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
|
||||
transition: `background 0.2s ${EASE}`,
|
||||
|
||||
@@ -16,11 +16,14 @@ import {
|
||||
} from './settingsKit';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import { useWandererConnection } from './useWandererConnection';
|
||||
import WandererWatchRow from './WandererWatchRow';
|
||||
|
||||
/**
|
||||
* Settings → Integrations → wanderer: the reader's own trail database, from
|
||||
* which the Tours workspace imports trails. Laid out like the Dawarich and
|
||||
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
|
||||
* Under the form, the optional background check of the imported trails
|
||||
* (WAN-02), which switches on its own and needs no save.
|
||||
*/
|
||||
export default function WandererConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
@@ -136,6 +139,8 @@ export default function WandererConnectionSection(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WandererWatchRow refreshKey={S.checkKey} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import WandererWatchRow from './WandererWatchRow';
|
||||
|
||||
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('react-router', async () => ({
|
||||
...(await vi.importActual<typeof import('react-router')>('react-router')),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
const off: WandererWatch = {
|
||||
enabled: false,
|
||||
connected: true,
|
||||
lastRunAt: null,
|
||||
pausedUntil: null,
|
||||
blocked: false,
|
||||
hints: [],
|
||||
};
|
||||
const hints: WandererSourceHintItem[] = [
|
||||
{
|
||||
placeId: 7,
|
||||
tripId: 3,
|
||||
tripTitle: 'Alps',
|
||||
tourName: 'Ridge',
|
||||
state: 'changed',
|
||||
changes: ['name'],
|
||||
reason: null,
|
||||
externalUpdated: '2026-10-14',
|
||||
checkedAt: '2026-10-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
placeId: 8,
|
||||
tripId: 3,
|
||||
tripTitle: 'Alps',
|
||||
tourName: 'Gorge',
|
||||
state: 'unverifiable',
|
||||
changes: [],
|
||||
reason: 'unavailable',
|
||||
externalUpdated: null,
|
||||
checkedAt: '2026-10-19T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WandererWatchRow (WAN-02)', () => {
|
||||
it('is off by default and dimmed, not hidden, without a connection', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, connected: false });
|
||||
render(<WandererWatchRow refreshKey="false||" />);
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
|
||||
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(toggle).toHaveAttribute('aria-disabled', 'true');
|
||||
await userEvent.tab();
|
||||
expect(toggle).not.toHaveFocus();
|
||||
expect(screen.queryByTestId('wanderer-watch-status')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches on at once and then says when it last looked', async () => {
|
||||
repo.getWatch.mockResolvedValue(off);
|
||||
repo.setWatch.mockResolvedValue({ ...off, enabled: true });
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
|
||||
await waitFor(() => expect(toggle).toBeEnabled());
|
||||
await userEvent.click(toggle);
|
||||
expect(repo.setWatch).toHaveBeenCalledWith(true);
|
||||
expect(await screen.findByText('Not looked yet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Hints/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('counts the open hints on the button and opens each tour from the dialog', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z', hints });
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Hints/ }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Hints on your trails')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('2 open hints')).toBeInTheDocument();
|
||||
const ridge = within(dialog).getByTestId('wanderer-hint-7');
|
||||
expect(within(ridge).getByText('Changed')).toBeInTheDocument();
|
||||
expect(within(ridge).getByText('Name')).toBeInTheDocument();
|
||||
expect(within(within(dialog).getByTestId('wanderer-hint-8')).getByText('Trail out of reach')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(ridge);
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('names a refused token instead of a last look', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true });
|
||||
render(<WandererWatchRow refreshKey="true||" />);
|
||||
expect(await screen.findByText('Token refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BellRing } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { InfoDot } from '../Tours/tourParts';
|
||||
import WandererHintsDialog from '../Tours/wanderer/WandererHintsDialog';
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import { useWandererWatch, type WandererWatchController } from './useWandererWatch';
|
||||
|
||||
/** The switch, dimmed with a word on why while there is no connection to watch with. */
|
||||
function WatchSwitch({ W }: { W: WandererWatchController }) {
|
||||
const { t } = useTranslation();
|
||||
const toggle = (
|
||||
<ToggleSwitch
|
||||
on={W.enabled}
|
||||
onToggle={() => void W.toggle()}
|
||||
label={t('wanderer.watch.label')}
|
||||
disabled={!W.available}
|
||||
/>
|
||||
);
|
||||
if (W.available) return toggle;
|
||||
// A disabled button takes no focus and, in some browsers, no hover either, so
|
||||
// the reason sits on a focusable wrapper the tooltip listens to.
|
||||
return (
|
||||
<Tooltip label={t('wanderer.watch.notConnected')}>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex cursor-not-allowed rounded-full opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
>
|
||||
<span className="pointer-events-none inline-flex">{toggle}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The open hints behind a button, their number as a badge on its corner; dimmed while there are none. */
|
||||
function HintsButton({ W }: { W: WandererWatchController }) {
|
||||
const { t } = useTranslation();
|
||||
const count = W.hints.length;
|
||||
return (
|
||||
<Tooltip label={count ? t('wanderer.watch.hintsCount', { count }) : t('wanderer.watch.empty')}>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={W.openHints}
|
||||
disabled={count === 0}
|
||||
className={`${SETTINGS_BUTTON} relative`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<BellRing size={13} strokeWidth={2.2} />
|
||||
{t('wanderer.watch.hints')}
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-label={t('wanderer.watch.hintsCount', { count })}
|
||||
data-testid="wanderer-hint-count"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-[color:var(--bg-card)]"
|
||||
style={fs(10)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WAN-02 in the wanderer card: the optional background check of the user's
|
||||
* own imported trails. Off until switched on; while on, what it last did and
|
||||
* its open hints, which open in a dialog that leads to each tour.
|
||||
*/
|
||||
export default function WandererWatchRow({ refreshKey }: { refreshKey: string }): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const W = useWandererWatch(refreshKey);
|
||||
return (
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('wanderer.watch.label')}
|
||||
<InfoDot label={t('wanderer.watch.label')} description={t('wanderer.watch.info')} />
|
||||
</span>
|
||||
}
|
||||
hint={t('wanderer.watch.hint')}
|
||||
dimmed={W.saving}
|
||||
control={<WatchSwitch W={W} />}
|
||||
>
|
||||
{W.status && (
|
||||
<div className="flex flex-wrap items-center gap-2" data-testid="wanderer-watch-status">
|
||||
<Tooltip label={W.status.why ?? ''} disabled={!W.status.why}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={W.status.tone}>{W.status.text}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="ms-auto">
|
||||
<HintsButton W={W} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</SettingRow>
|
||||
<WandererHintsDialog open={W.hintsOpen} hints={W.hints} onClose={W.closeHints} onOpenTour={W.openTour} />
|
||||
</SettingRows>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
packing_tagged: 'settings.notifyPackingTagged',
|
||||
wanderer_source_hint: 'settings.notifyWandererSourceHint',
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,13 @@ export function useWandererConnection() {
|
||||
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
|
||||
? errorText(saved.lastCheckError)
|
||||
: null;
|
||||
/**
|
||||
* Changes whenever the server's view of the connection does: connecting,
|
||||
* disconnecting, a saved token (which clears the last check) or a test that
|
||||
* stored a new result. The watch row re-reads itself on it, since each of
|
||||
* those can lift or set its "token refused" state.
|
||||
*/
|
||||
const checkKey = `${connected}|${saved?.lastCheckedAt ?? ''}|${saved?.lastCheckOk ?? ''}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
@@ -145,6 +152,7 @@ export function useWandererConnection() {
|
||||
tokenInvalid,
|
||||
canSave,
|
||||
lastCheck,
|
||||
checkKey,
|
||||
save,
|
||||
test,
|
||||
disconnect,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useWandererWatch, watchStatusOf } from './useWandererWatch';
|
||||
|
||||
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
|
||||
locale: 'en',
|
||||
}),
|
||||
}));
|
||||
vi.mock('react-router', async () => ({
|
||||
...(await vi.importActual<typeof import('react-router')>('react-router')),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
const t = (key: string) => key;
|
||||
const off: WandererWatch = {
|
||||
enabled: false,
|
||||
connected: true,
|
||||
lastRunAt: null,
|
||||
pausedUntil: null,
|
||||
blocked: false,
|
||||
hints: [],
|
||||
};
|
||||
const hint: WandererSourceHintItem = {
|
||||
placeId: 7,
|
||||
tripId: 3,
|
||||
tripTitle: 'Alps',
|
||||
tourName: 'Ridge',
|
||||
state: 'possible_change',
|
||||
changes: [],
|
||||
reason: null,
|
||||
externalUpdated: '2026-10-14',
|
||||
checkedAt: '2026-10-20T10:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.getWatch.mockResolvedValue(off);
|
||||
repo.setWatch.mockImplementation(async (enabled: boolean) => ({ ...off, enabled }));
|
||||
});
|
||||
|
||||
describe('watchStatusOf (WAN-02)', () => {
|
||||
const now = Date.parse('2026-10-20T12:00:00Z');
|
||||
|
||||
it('says nothing while the watch is off', () => {
|
||||
expect(watchStatusOf(null, t, 'en', now)).toBeNull();
|
||||
expect(watchStatusOf(off, t, 'en', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('names a refused token first, then a pause still running, then the last look', () => {
|
||||
const on = { ...off, enabled: true };
|
||||
expect(watchStatusOf({ ...on, blocked: true, pausedUntil: '2026-10-21T00:00:00Z' }, t, 'en', now)).toEqual({
|
||||
tone: 'danger',
|
||||
text: 'wanderer.watch.blocked',
|
||||
why: 'wanderer.watch.blockedHint',
|
||||
});
|
||||
expect(watchStatusOf({ ...on, pausedUntil: '2026-10-20T14:00:00Z' }, t, 'en', now)).toMatchObject({
|
||||
tone: 'warning',
|
||||
text: 'wanderer.watch.paused',
|
||||
why: 'wanderer.watch.pausedHint',
|
||||
});
|
||||
expect(
|
||||
watchStatusOf({ ...on, pausedUntil: '2026-10-20T11:00:00Z', lastRunAt: '2026-10-20T10:00:00Z' }, t, 'en', now)
|
||||
).toEqual({ tone: 'neutral', text: 'wanderer.watch.lastRun', why: null });
|
||||
expect(watchStatusOf(on, t, 'en', now)).toEqual({ tone: 'neutral', text: 'wanderer.watch.neverRun', why: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWandererWatch (WAN-02)', () => {
|
||||
it('loads with the card and again when the connection comes or goes', async () => {
|
||||
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
|
||||
initialProps: { refreshKey: 'true||' },
|
||||
});
|
||||
await waitFor(() => expect(result.current.available).toBe(true));
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.status).toBeNull();
|
||||
rerender({ refreshKey: 'true||' });
|
||||
expect(repo.getWatch).toHaveBeenCalledTimes(1);
|
||||
rerender({ refreshKey: 'false||' });
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('drops a refused token once a connection test passes, without a reload', async () => {
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, blocked: true, pausedUntil: '2999-01-01T00:00:00Z' });
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z' });
|
||||
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
|
||||
initialProps: { refreshKey: 'true|2026-10-20T09:00:00Z|false' },
|
||||
});
|
||||
await waitFor(() => expect(result.current.status?.text).toBe('wanderer.watch.blocked'));
|
||||
rerender({ refreshKey: 'true|2026-10-20T11:00:00Z|true' });
|
||||
await waitFor(() => expect(result.current.status?.tone).toBe('neutral'));
|
||||
expect(result.current.status?.text).toMatch(/^wanderer\.watch\.lastRun/);
|
||||
});
|
||||
|
||||
it('switches at once, and says so either way', async () => {
|
||||
const { result } = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(result.current.available).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.toggle();
|
||||
});
|
||||
expect(repo.setWatch).toHaveBeenCalledWith(true);
|
||||
expect(result.current.enabled).toBe(true);
|
||||
expect(toast.success).toHaveBeenCalledWith('wanderer.watch.on');
|
||||
|
||||
repo.setWatch.mockRejectedValueOnce(new Error('down'));
|
||||
await act(async () => {
|
||||
await result.current.toggle();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('wanderer.watch.failed');
|
||||
expect(result.current.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the switch alone without a connection, or when the watch could not be read', async () => {
|
||||
repo.getWatch.mockResolvedValueOnce({ ...off, connected: false });
|
||||
const { result } = renderHook(() => useWandererWatch('false||'));
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await result.current.toggle();
|
||||
});
|
||||
expect(repo.setWatch).not.toHaveBeenCalled();
|
||||
|
||||
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
|
||||
const failed = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
|
||||
expect(failed.result.current.available).toBe(false);
|
||||
});
|
||||
|
||||
it('lists the hints and opens the tour one is about', async () => {
|
||||
repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] });
|
||||
const { result } = renderHook(() => useWandererWatch('true||'));
|
||||
await waitFor(() => expect(result.current.hints).toEqual([hint]));
|
||||
act(() => result.current.openHints());
|
||||
expect(result.current.hintsOpen).toBe(true);
|
||||
act(() => result.current.openTour(hint));
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
|
||||
expect(result.current.hintsOpen).toBe(false);
|
||||
act(() => result.current.openHints());
|
||||
act(() => result.current.closeHints());
|
||||
expect(result.current.hintsOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { wandererRepo } from '../../repo/wandererRepo';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { hintWhen } from '../Tours/wanderer/sourceHintParts';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** What the watch last did, as one pill: refused, waiting after errors, last looked, or not yet. */
|
||||
interface WandererWatchStatus {
|
||||
tone: 'neutral' | 'warning' | 'danger';
|
||||
text: string;
|
||||
/** Why, for the tooltip; null where the text says it all. */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
/** The pill for a switched-on watch; null while it is off, since then nothing runs to report on. */
|
||||
export function watchStatusOf(
|
||||
watch: WandererWatch | null,
|
||||
t: Translate,
|
||||
locale: string,
|
||||
now = Date.now()
|
||||
): WandererWatchStatus | null {
|
||||
if (!watch?.enabled) return null;
|
||||
if (watch.blocked) return { tone: 'danger', text: t('wanderer.watch.blocked'), why: t('wanderer.watch.blockedHint') };
|
||||
const pausedUntil = watch.pausedUntil ? new Date(watch.pausedUntil).getTime() : Number.NaN;
|
||||
if (pausedUntil > now) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
text: t('wanderer.watch.paused', { time: hintWhen(watch.pausedUntil, locale) }),
|
||||
why: t('wanderer.watch.pausedHint'),
|
||||
};
|
||||
}
|
||||
const lastRun = hintWhen(watch.lastRunAt, locale);
|
||||
return {
|
||||
tone: 'neutral',
|
||||
text: lastRun ? t('wanderer.watch.lastRun', { date: lastRun }) : t('wanderer.watch.neverRun'),
|
||||
why: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WAN-02: the background watch in the wanderer card. It loads with the card
|
||||
* and again whenever `refreshKey` changes, which the connection card bumps on
|
||||
* connecting, disconnecting, saving a token and a stored test result, so a
|
||||
* refused token clears once it is fixed. It switches on and off at
|
||||
* once (there is nothing to save with the form), and opens the tour a hint is
|
||||
* about. Online-only like the rest of the connection: the watch runs on the
|
||||
* server and its hints are read from there.
|
||||
*/
|
||||
export function useWandererWatch(refreshKey: string) {
|
||||
const { t, locale } = useTranslation();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [watch, setWatch] = useState<WandererWatch | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hintsOpen, setHintsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
wandererRepo.getWatch().then(
|
||||
(next) => {
|
||||
if (!cancelled) setWatch(next);
|
||||
},
|
||||
() => {
|
||||
// The card above already says when the connection cannot be read; without
|
||||
// an answer the switch simply stays dimmed.
|
||||
if (!cancelled) setWatch(null);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!watch?.connected || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await wandererRepo.setWatch(!watch.enabled);
|
||||
setWatch(next);
|
||||
toast.success(next.enabled ? t('wanderer.watch.on') : t('wanderer.watch.off'));
|
||||
} catch {
|
||||
toast.error(t('wanderer.watch.failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saving, t, toast, watch]);
|
||||
|
||||
const openTour = useCallback(
|
||||
(hint: WandererSourceHintItem) => {
|
||||
setHintsOpen(false);
|
||||
navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
return {
|
||||
/** The switch can act: the server knows a connection to watch with. */
|
||||
available: !!watch?.connected,
|
||||
enabled: !!watch?.enabled,
|
||||
saving,
|
||||
status: watchStatusOf(watch, t, locale),
|
||||
hints: watch?.hints ?? [],
|
||||
hintsOpen,
|
||||
openHints: useCallback(() => setHintsOpen(true), []),
|
||||
closeHints: useCallback(() => setHintsOpen(false), []),
|
||||
openTour,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
||||
export type WandererWatchController = ReturnType<typeof useWandererWatch>;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
importableTourTypeSchema,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
} from '@trek/shared';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { LABEL, Segmented } from '../shared/dialogParts';
|
||||
import { DialogSection } from '../shared/DialogShell';
|
||||
import type { GpxImportChoicesState } from './gpxImportChoices';
|
||||
import { RecordedRideFacts } from './horse/RecordedRideFacts';
|
||||
import { TourTypeSelect } from './planner/TourTypeSelect';
|
||||
import { RunProfileField } from './run/RunFields';
|
||||
import { SkiClassificationFields } from './ski/SkiClassificationFields';
|
||||
import { TourNotice } from './tourParts';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
/** Every type a GPX file can come in as: the planner's, and ski, which is only kept (SKI-02). */
|
||||
const IMPORT_TYPES = importableTourTypeSchema.options;
|
||||
|
||||
/** A paddle import's boat and water, side by side, each chosen on purpose. */
|
||||
function PaddleImportFields({ choices }: { choices: GpxImportChoicesState }) {
|
||||
const { t } = useTranslation();
|
||||
const { chosen } = choices;
|
||||
return (
|
||||
<>
|
||||
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(chosen.boat ?? '') as PaddleBoatType}
|
||||
onChange={choices.setBoat}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(chosen.water ?? '') as PaddleWaterContext}
|
||||
onChange={choices.setWater}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(chosen.boat === null || chosen.water === null) && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoatImport')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview's "import as" section: the type, and below it what that type
|
||||
* names: a bike or run profile, a paddle tour's boat and water, a ski track's
|
||||
* discipline and technique (SKI-02), and for a ride (HOR-02) what the import
|
||||
* keeps and does not work out.
|
||||
*/
|
||||
export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesState }) {
|
||||
const { t } = useTranslation();
|
||||
const typeLabelId = useId();
|
||||
const { chosen } = choices;
|
||||
return (
|
||||
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<TourTypeSelect value={chosen.type} onChange={choices.setType} labelId={typeLabelId} types={IMPORT_TYPES} />
|
||||
{chosen.type === 'bike' && (
|
||||
<div data-testid="gpx-preview-bike" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.bike.profile')}</span>
|
||||
<Segmented<BikeProfile>
|
||||
value={chosen.bikeProfile}
|
||||
onChange={choices.setBikeProfile}
|
||||
label={t('tours.bike.profile')}
|
||||
fill
|
||||
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{chosen.type === 'run' && <RunProfileField value={chosen.runProfile} onChange={choices.setRunProfile} />}
|
||||
{chosen.type === 'kayak' && <PaddleImportFields choices={choices} />}
|
||||
{chosen.type === 'ski' && <SkiClassificationFields choice={chosen.ski} onChange={choices.setSki} />}
|
||||
{chosen.type === 'horse' && (
|
||||
<div data-testid="gpx-preview-horse">
|
||||
<RecordedRideFacts />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AlertTriangle, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { CardAction, TourNotice } from './tourParts';
|
||||
|
||||
/**
|
||||
* How a card's online read stands while it has no answer to show (a bike
|
||||
* tour's ways, a hike's SAC grades): still asking, offline, or failed with a
|
||||
* way to ask again. Nothing while the answer is there, so the card shows it.
|
||||
*/
|
||||
export function ReadStatusNotice({
|
||||
status,
|
||||
messages,
|
||||
onRetry,
|
||||
}: {
|
||||
status: string;
|
||||
messages: { loading: string; offline: string; failed: string };
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'loading')
|
||||
return (
|
||||
<span role="status" className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
{messages.loading}
|
||||
</span>
|
||||
);
|
||||
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{messages.offline}</TourNotice>;
|
||||
if (status !== 'failed') return null;
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{messages.failed}</span>
|
||||
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
|
||||
{t('common.errorRetry')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* A frosted badge on the top corner of a tour's round tile, carrying one
|
||||
* icon: a hike's winter label (HIK-01), a ski track's discipline (SKI-02).
|
||||
* Its tooltip and accessible name say what the icon stands for.
|
||||
*/
|
||||
export function TourCornerMark({
|
||||
icon: Icon,
|
||||
label,
|
||||
size = 15,
|
||||
testId,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
/** The badge's diameter in pixels, about half the tile's. */
|
||||
size?: number;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="absolute -end-1 -top-1 flex">
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
data-testid={testId}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="grid place-items-center rounded-full bg-surface-card text-info shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.64)} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useTourVisitStore } from '../../store/tourVisitStore';
|
||||
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
@@ -34,6 +35,14 @@ describe('RC-01 Tour day-row facts', () => {
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('HOR-01: a ride with a recorded time shows the horseshoe beside its riding time', () => {
|
||||
render(<TourDayRowFacts tour={{ ...tour, tour_type: 'horse', max_hiking_difficulty: null }} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-walking-time');
|
||||
expect(time.getAttribute('aria-label')).toMatch(/^Riding time/);
|
||||
expect(time.querySelector('.lucide-horseshoe')).toBeTruthy();
|
||||
expect(time.querySelector('.lucide-footprints')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
|
||||
render(<TourDayRowFacts tour={tour} mobile={mobile} />);
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
|
||||
@@ -117,6 +126,78 @@ describe('PAD-09 the check bench on a tour row', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-02 a city walk's time on its day row", () => {
|
||||
const walk: TourListItem = {
|
||||
...tour,
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
max_hiking_difficulty: null,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 120,
|
||||
station_stays_unknown: 0,
|
||||
};
|
||||
|
||||
it('shows the walking time and the stays as the planned total, its breaks left out', () => {
|
||||
render(<TourDayRowPills tour={walk} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('2 h 45 min');
|
||||
expect(time).toHaveAccessibleName('Walking: 45 min, Stays: 2 h, Planned total duration: 2 h 45 min');
|
||||
});
|
||||
|
||||
it('says the total is at least what is known while a stay is not entered', () => {
|
||||
render(<TourDayRowPills tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(time).toHaveAccessibleName(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned total duration: at least 2 h 15 min'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the same total among the facts of a phone row, and no breaks', () => {
|
||||
render(<TourDayRowFacts tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} mobile />);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking: 45 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-03 a city walk's progress among the facts of a day row", () => {
|
||||
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
|
||||
|
||||
beforeEach(() =>
|
||||
useTourVisitStore.setState({
|
||||
tripId: '4',
|
||||
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } },
|
||||
})
|
||||
);
|
||||
|
||||
it.each([false, true])('shows the stops visited on that day, a tap naming them whole (mobile=%s)', (mobile) => {
|
||||
const openRow = vi.fn();
|
||||
render(
|
||||
<div onClick={openRow}>
|
||||
<TourDayRowFacts tour={walk} assignmentId={9} mobile={mobile} />
|
||||
</div>
|
||||
);
|
||||
const progress = screen.getByTestId('plan-walk-progress');
|
||||
expect(progress).toHaveTextContent('1/5');
|
||||
expect(progress).toHaveAccessibleName('1 of 5 visited');
|
||||
fireEvent.click(progress);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('1 of 5 visited');
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows none without a day, or before a stop of that day is marked', () => {
|
||||
render(
|
||||
<>
|
||||
<TourDayRowFacts tour={walk} mobile />
|
||||
<TourDayRowFacts tour={walk} assignmentId={10} mobile />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compact facts of a tour list row', () => {
|
||||
const testIds = () =>
|
||||
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { importableTourTypeSchema, type TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, CheckCheck, Clock, Gauge, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -8,13 +8,28 @@ import { WhiteBadge } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { paddleCheckFact } from './paddle/paddleCheckModel';
|
||||
import { runPaceText } from './run/runPaceText';
|
||||
import { SkiCornerMark } from './ski/skiMarks';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
|
||||
import { useWalkProgress } from './walk/walkProgress';
|
||||
import { walkStaysText } from './walk/walkTimeFields';
|
||||
import { WinterCornerMark } from './winter/winterMarks';
|
||||
|
||||
/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */
|
||||
function knownType(type: string): boolean {
|
||||
return importableTourTypeSchema.safeParse(type).success;
|
||||
}
|
||||
|
||||
/** The icon the time on the move goes by: a bike tour, a ride on horseback, a run or a paddle by its type's own, any walk by footprints. */
|
||||
function movingIconType(type: string): string {
|
||||
return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' ? type : 'walk';
|
||||
}
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
|
||||
const known = knownType(type);
|
||||
return (
|
||||
<span
|
||||
data-testid="plan-tour-icon"
|
||||
@@ -80,18 +95,41 @@ function Fact({
|
||||
/**
|
||||
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
|
||||
* tile, the same tile the tour lists use, so a tour reads alike wherever it
|
||||
* shows. A GPX import has no grade and shows its type's icon instead.
|
||||
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
|
||||
* winter label (HIK-01) or a ski track's discipline (SKI-02) sits on the
|
||||
* tile's corner, as in the tour lists.
|
||||
*/
|
||||
export function TourDayRowAvatar({
|
||||
tour,
|
||||
size = 32,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> &
|
||||
Partial<Pick<TourListItem, 'winter_activity' | 'ski_discipline' | 'ski_technique'>>;
|
||||
size?: number;
|
||||
}) {
|
||||
const avatar = <TourGradeAvatar tour={tour} size={size} />;
|
||||
const ski = tour.tour_type === 'ski' && !!tour.ski_discipline;
|
||||
if (!tour.winter_activity && !ski) return avatar;
|
||||
const markSize = Math.round(size * 0.44);
|
||||
return (
|
||||
<span className="relative flex flex-none">
|
||||
{avatar}
|
||||
<WinterCornerMark activity={tour.winter_activity} size={markSize} />
|
||||
<SkiCornerMark tour={tour} size={markSize} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TourGradeAvatar({
|
||||
tour,
|
||||
size,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
|
||||
size: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const level = tour.max_hiking_difficulty;
|
||||
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
|
||||
const known = knownType(tour.tour_type);
|
||||
const label =
|
||||
level != null
|
||||
? t(`tours.planner.difficulty.t${level}`)
|
||||
@@ -163,27 +201,27 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
|
||||
const total = plannedTotalText(times, t);
|
||||
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
|
||||
const pace = runPaceText(tour, t);
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
|
||||
// RUN-01: the pace the running time is planned at.
|
||||
pace ? `${t('tours.run.pace')}: ${pace}` : null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
: null,
|
||||
times.plannedTotalMinutes != null
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
// CW-02: a city walk's stays, which its total adds to the walking time.
|
||||
staysText ? `${t('tours.walk.stays')}: ${staysText}` : null,
|
||||
total
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
const time = shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
key="time"
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
const shown = plannedTotalText(times, t, true) ?? walking;
|
||||
const time = shown != null && (
|
||||
<TourDayRowPill key="time" icon={Clock} value={shown} label={timeParts.join(', ')} testId="trip-plan-tour-time" />
|
||||
);
|
||||
const distance = tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
@@ -209,9 +247,19 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
export default function TourDayRowFacts({
|
||||
tour,
|
||||
assignmentId,
|
||||
mobile = false,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
/** The row's day assignment, for a city walk's progress on that day (CW-03). */
|
||||
assignmentId?: number;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const progress = useWalkProgress(tour, assignmentId);
|
||||
const times = tourPlannedTimes(tour);
|
||||
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
|
||||
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
|
||||
@@ -222,11 +270,13 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
|
||||
if (times.walkingMinutes != null)
|
||||
add(
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
|
||||
tourDayRowIcon(movingIconType(tour.tour_type)),
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
'trip-plan-tour-walking-time'
|
||||
);
|
||||
const pace = runPaceText(tour, t);
|
||||
if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace');
|
||||
if (times.breakMinutes != null && times.breakMinutes > 0)
|
||||
add(
|
||||
Pause,
|
||||
@@ -234,10 +284,11 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
t('tours.planner.breaksAdditional'),
|
||||
'trip-plan-tour-breaks'
|
||||
);
|
||||
if (times.plannedTotalMinutes != null)
|
||||
const total = plannedTotalText(times, t, true);
|
||||
if (total)
|
||||
add(
|
||||
Clock,
|
||||
formatPlannedTourDuration(times.plannedTotalMinutes),
|
||||
total,
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
@@ -248,6 +299,15 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
{facts.map((fact) => (
|
||||
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
))}
|
||||
{progress && (
|
||||
<Fact
|
||||
icon={CheckCheck}
|
||||
value={`${progress.visited}/${progress.total}`}
|
||||
label={t('tours.walk.progress', progress)}
|
||||
testId="plan-walk-progress"
|
||||
mobile={mobile}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TourDetailDialog from './TourDetailDialog'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { tourRepo } from '../../repo/tourRepo'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -60,6 +61,11 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
// A repository answer one test stubs never reaches the tests after it.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TourDetailDialog', () => {
|
||||
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
|
||||
const opener = document.createElement('button')
|
||||
@@ -142,6 +148,85 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-02: shows a city walk its walking time, its stays and their total, and no breaks left from another type', async () => {
|
||||
const walk = {
|
||||
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 45, break_additional_minutes: 15,
|
||||
station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0,
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [] })
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} />)
|
||||
|
||||
// 45 min of walking, a museum of 90 min and a cafe of 30 min, as the day row says it too.
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: 2 h, Planned: 2 h 45 min')).toHaveTextContent('2 h 45 min')
|
||||
expect(screen.getByText('Stays')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />)
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min'))
|
||||
.toHaveTextContent('≥ 2 h 15 min')
|
||||
// On a phone's three narrow columns the longer values wrap rather than being cut.
|
||||
const stays = screen.getByText('Stays').closest('dt')?.nextElementSibling
|
||||
expect(stays).toHaveTextContent('at least 1 h 30 min')
|
||||
expect(stays).toHaveClass('break-words')
|
||||
expect(stays).not.toHaveClass('truncate')
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps}
|
||||
tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1, planned_duration_minutes: 240 }} />)
|
||||
expect(screen.getByLabelText(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned (Manual override): 4 h, Computed: at least 2 h 15 min'
|
||||
)).toHaveTextContent('4 h')
|
||||
expect(screen.getByText('Computed')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-04: a walk opened from a day it is on keeps that day for its times; opened otherwise, a day can be picked', async () => {
|
||||
const walk = { ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 20, station_count: 1 }
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum', stay_minutes: 30, opening_hours: 'Mo-Fr 10:00-18:00' },
|
||||
] })
|
||||
// 2026-10-19 is a Monday: the museum opens at 10:00.
|
||||
const days = [buildDay({ id: 7, date: '2026-10-19' }), buildDay({ id: 8, date: '2026-10-20' })]
|
||||
const assignments = {
|
||||
'7': [buildAssignment({ id: 9, day_id: 7, place_id: 42, assignment_time: '09:00', place })],
|
||||
'8': [buildAssignment({ id: 10, day_id: 8, place_id: 42, assignment_time: '11:00', place })],
|
||||
}
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} selectedDayId={7} />)
|
||||
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
|
||||
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('walk-station-conflict'))
|
||||
.toHaveTextContent('Museum is closed at 09:00, the stay would last until 09:30; open 10:00 to 18:00')
|
||||
expect(screen.getByText('Mark visited')).toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} />)
|
||||
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('walk-day-picker'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Day 2, .*start 11:00$/ }))
|
||||
expect(await screen.findByText('Arrival about 11:00, stay until 11:30')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-11: a walk saved with its stops only says once that its way is planned in the planner, and shows no measures yet', async () => {
|
||||
const pending = {
|
||||
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, distance: null, elevation_gain: null, elevation_loss: null,
|
||||
station_count: 2, route_pending: true,
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: pending, waypoints: [] })
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={pending} place={{ ...place, route_geometry: null }} />)
|
||||
|
||||
expect(screen.getByText(
|
||||
'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Distance')).not.toBeInTheDocument()
|
||||
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...pending, route_pending: false }} />)
|
||||
expect(screen.queryByText(/Saved with its stops only/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
|
||||
|
||||
@@ -219,6 +304,61 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('HIK-01: names the winter label of a hike in its head and on its tile, and nothing for a hike without one', () => {
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} />)
|
||||
expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tour-winter-mark')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, winter_activity: 'snowshoe' }} />)
|
||||
expect(screen.getByTestId('tour-detail-winter')).toHaveTextContent('Snowshoe hike')
|
||||
expect(screen.getByRole('img', { name: 'Snowshoe hike' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('T2').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ACT-01: a recording that is the actual track of a tour names that tour in its head', () => {
|
||||
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Lake loop' }), place] })
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false, origin: 'gpx', activity_of: 7 }} />)
|
||||
|
||||
expect(screen.getByTestId('tour-detail-track-of')).toHaveTextContent('Actual track of Lake loop')
|
||||
})
|
||||
|
||||
it('ACT-02: an actual track stands beside the plan the head shows, and only the map detail offers to compare it there', async () => {
|
||||
const activity = {
|
||||
id: 4,
|
||||
assignment_id: null,
|
||||
day_id: null,
|
||||
assigned_at: null,
|
||||
track: { place_id: 20, name: 'Watch export', origin: 'gpx' as const, file_name: 'morning.gpx', imported_at: null },
|
||||
metrics: {
|
||||
distance_km: 3,
|
||||
elevation_gain_m: null,
|
||||
elevation_loss_m: null,
|
||||
started_at: null,
|
||||
ended_at: null,
|
||||
elapsed_minutes: null,
|
||||
point_count: 2,
|
||||
},
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48, 11.03],
|
||||
] as [number, number][],
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour, waypoints: [], activities: [activity] })
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} desktopNonModal />)
|
||||
|
||||
const distance = await screen.findByTestId('tour-activity-compare-distance')
|
||||
// The plan as the head measures it from the line, not the list's stale 999 km.
|
||||
expect(distance).toHaveTextContent('2.2 km')
|
||||
expect(distance).toHaveTextContent('3 km')
|
||||
expect(screen.getByRole('button', { name: /Compare on map/ })).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} />)
|
||||
await screen.findByTestId('tour-activity-compare-distance')
|
||||
expect(screen.queryByRole('button', { name: /Compare on map/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grade on the tile and no second difficulty scale', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
Footprints,
|
||||
Loader2,
|
||||
Minus,
|
||||
Navigation,
|
||||
@@ -48,9 +49,15 @@ import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourGpxExportButton } from './TourGpxExportButton';
|
||||
import TourActivitySection from './activity/TourActivitySection';
|
||||
import type { PlanValues } from './activity/tourActivityCompare';
|
||||
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { TourPackingSection } from './packing/TourPackingSection';
|
||||
import { PaddleWindSection } from './paddle/PaddleWindCard';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
import { RunHeadPills } from './run/RunHeadPills';
|
||||
import { SkiHeadPill } from './ski/skiMarks';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { tourDaysOf } from './tourDays';
|
||||
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
|
||||
@@ -58,12 +65,18 @@ import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField }
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
hikeSourceBadgeLabel,
|
||||
plannedTotalText,
|
||||
tourPlannedTimes,
|
||||
tourSource,
|
||||
tourWebsitePresentation,
|
||||
type TourPlannedTimes,
|
||||
} from './tourPresentation';
|
||||
import { useTourDayAssignment } from './useTourDayAssignment';
|
||||
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
|
||||
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
|
||||
import { WalkStationsCard } from './walk/WalkStations';
|
||||
import { walkTimeFields } from './walk/walkTimeFields';
|
||||
import { WinterActivityPill } from './winter/winterMarks';
|
||||
|
||||
interface TourDetailDialogProps extends TourPermissionProps {
|
||||
tour: TourListItem;
|
||||
@@ -80,6 +93,10 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
onSourceReimported?: (tour: TourListItem) => Promise<void> | void;
|
||||
/** P2C-07: save this tour again as a new, independent one. */
|
||||
onSaveAsNew?: (tour: TourListItem) => Promise<void> | void;
|
||||
/** ACT-01: a recorded track was assigned, taken off or imported; the lists take in these tours. */
|
||||
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
|
||||
/** P2D-XD-01: opens the trip's packing list the tour points at. */
|
||||
onOpenPacking?: () => void;
|
||||
onAssignToDay?: (placeId: number, dayId?: number) => void;
|
||||
onRemoveAssignment?: (dayId: number, assignmentId: number) => void;
|
||||
onDelete?: () => void;
|
||||
@@ -166,19 +183,28 @@ function placeStart(place: Place): { lat: number; lng: number } | null {
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
|
||||
/**
|
||||
* The parts of the time a tour takes, as fields: walking, breaks, the planned
|
||||
* total; a city walk its walking time, its stays and their total, in the
|
||||
* planner's wording (CW-02).
|
||||
*/
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
times: TourPlannedTimes,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
const walking = { label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t) };
|
||||
if (times.walk) {
|
||||
return walkTimeFields({
|
||||
walking,
|
||||
walkingMinutes: times.walkingMinutes,
|
||||
stays: times.walk,
|
||||
manualMinutes: times.manuallyOverridden ? times.plannedTotalMinutes : null,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.walkingMinutes != null ? { ...walking, value: formatPlannedTourDuration(times.walkingMinutes) } : null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
: null,
|
||||
@@ -194,26 +220,31 @@ function tourTimeFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* The head's facts as pills: the type, where it came from, the time (its parts
|
||||
* The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's
|
||||
* discipline, where it came from, the time (its parts
|
||||
* in the tooltip), distance, ascent, descent, and a caution.
|
||||
*/
|
||||
function TourHeadPills({
|
||||
tour,
|
||||
trackColor,
|
||||
timeFields,
|
||||
shownMinutes,
|
||||
times,
|
||||
metrics,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
trackColor: string;
|
||||
timeFields: TourMetricField[];
|
||||
shownMinutes: number | null;
|
||||
/** The planned total where there is one, as the day row shows it, otherwise the walking time. */
|
||||
times: TourPlannedTimes;
|
||||
/** Measured from the line as it is now, so the head says what the fields below say. */
|
||||
metrics: { distanceKm: number | null; gain: number | null; loss: number | null };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', ');
|
||||
const shownTime =
|
||||
plannedTotalText(times, t, true) ??
|
||||
(times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes));
|
||||
const source = tourSource(tour);
|
||||
// The import this tour was planned from, named while it is still in the trip.
|
||||
const derivedFromName = useTripStore((state) =>
|
||||
@@ -221,6 +252,10 @@ function TourHeadPills({
|
||||
? null
|
||||
: (state.places.find((p) => p.id === tour.derived_from_place_id)?.name ?? null)
|
||||
);
|
||||
// ACT-01: the tour this one is the recorded track of.
|
||||
const documentsName = useTripStore((state) =>
|
||||
tour.activity_of == null ? null : (state.places.find((p) => p.id === tour.activity_of)?.name ?? null)
|
||||
);
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
@@ -232,6 +267,12 @@ function TourHeadPills({
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
|
||||
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
|
||||
{/* RUN-01: a run's profile and the pace its time is planned at. */}
|
||||
<RunHeadPills tour={tour} className={HEAD_PILL} />
|
||||
{/* SKI-02: a ski track's discipline, a cross-country technique inside it. */}
|
||||
<SkiHeadPill tour={tour} className={HEAD_PILL} />
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
|
||||
@@ -257,11 +298,19 @@ function TourHeadPills({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shownMinutes != null && (
|
||||
{documentsName && (
|
||||
<Tooltip label={t('tours.activity.isTrackHint')}>
|
||||
<span data-testid="tour-detail-track-of" className={`${HEAD_PILL} max-w-[220px]`} style={fs(10.5)}>
|
||||
<Footprints size={11} strokeWidth={2} className="flex-none text-success" />
|
||||
<span className="min-w-0 truncate">{t('tours.activity.ofTour', { name: documentsName })}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shownTime != null && (
|
||||
<Tooltip label={timeTooltip}>
|
||||
<span aria-label={timeTooltip} className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Clock size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
{formatPlannedTourDuration(shownMinutes)}
|
||||
{shownTime}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -369,6 +418,79 @@ function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps['
|
||||
return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName };
|
||||
}
|
||||
|
||||
/** The tour's name in the head; `onUpdatePlace` is given only to someone who may rename it, in place. */
|
||||
function TourNameHeading({
|
||||
place,
|
||||
onUpdatePlace,
|
||||
}: {
|
||||
place: Place;
|
||||
onUpdatePlace?: TourDetailDialogProps['onUpdatePlace'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
onUpdatePlace
|
||||
);
|
||||
if (onUpdatePlace && editingName) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourDetailDialog({
|
||||
tour,
|
||||
place,
|
||||
@@ -382,6 +504,8 @@ export default function TourDetailDialog({
|
||||
onFileUpload,
|
||||
onSourceReimported,
|
||||
onSaveAsNew,
|
||||
onToursChanged,
|
||||
onOpenPacking,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onDelete,
|
||||
@@ -405,10 +529,6 @@ export default function TourDetailDialog({
|
||||
const [colorOpen, setColorOpen] = useState(false);
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const navigationAnchorRef = useRef<HTMLSpanElement>(null);
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
canEdit ? onUpdatePlace : undefined
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
@@ -429,6 +549,21 @@ export default function TourDetailDialog({
|
||||
const times = tourPlannedTimes(tour);
|
||||
|
||||
const timeFields = tourTimeFields(times, t, tour);
|
||||
// The plan as the line measures it now: the head says it, and an actual track is compared with it (ACT-02).
|
||||
const planDistanceKm = analysis?.distanceKm ?? tour.distance;
|
||||
const planGain = analysis ? analysis.gain : tour.elevation_gain;
|
||||
const planLoss = analysis ? analysis.loss : tour.elevation_loss;
|
||||
const plan = useMemo<PlanValues>(
|
||||
() => ({
|
||||
distanceKm: planDistanceKm,
|
||||
gain: planGain,
|
||||
loss: planLoss,
|
||||
minutes: times.plannedTotalMinutes,
|
||||
minutesAtLeast: times.atLeast,
|
||||
line: analysis?.routeCoordinates ?? null,
|
||||
}),
|
||||
[analysis, planDistanceKm, planGain, planLoss, times.atLeast, times.plannedTotalMinutes]
|
||||
);
|
||||
|
||||
const header = (
|
||||
<>
|
||||
@@ -453,71 +588,13 @@ export default function TourDetailDialog({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{canEdit && editingName ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{canEdit && onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TourNameHeading place={place} onUpdatePlace={canEdit ? onUpdatePlace : undefined} />
|
||||
<TourHeadPills
|
||||
tour={tour}
|
||||
trackColor={trackColor}
|
||||
timeFields={timeFields}
|
||||
shownMinutes={times.plannedTotalMinutes ?? times.walkingMinutes}
|
||||
metrics={{
|
||||
distanceKm: analysis?.distanceKm ?? tour.distance,
|
||||
gain: analysis ? analysis.gain : tour.elevation_gain,
|
||||
loss: analysis ? analysis.loss : tour.elevation_loss,
|
||||
}}
|
||||
times={times}
|
||||
metrics={{ distanceKm: plan.distanceKm, gain: plan.gain, loss: plan.loss }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -593,6 +670,9 @@ export default function TourDetailDialog({
|
||||
{analysis && place.id > 0 && (
|
||||
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
|
||||
)}
|
||||
{place.id > 0 && (
|
||||
<TourSaveToCollectionButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
|
||||
)}
|
||||
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
|
||||
<FooterSpacer />
|
||||
{canEdit && onDelete && (
|
||||
@@ -643,6 +723,7 @@ export default function TourDetailDialog({
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
{tour.route_pending && <WalkRoutePendingHint />}
|
||||
{analysis && (
|
||||
<TourDetailMeasures
|
||||
analysis={analysis}
|
||||
@@ -652,8 +733,40 @@ export default function TourDetailDialog({
|
||||
placeId={place.id}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'walk' && (
|
||||
<WalkStationsCard
|
||||
tour={tour}
|
||||
tripId={place.trip_id}
|
||||
assignment={assignment}
|
||||
canMark={canAssign}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
route={analysis?.routeCoordinates ?? null}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
|
||||
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
|
||||
{/* A tour written here offline has no server copy to assign a recording to until it syncs. */}
|
||||
{place.id > 0 && (
|
||||
<TourActivitySection
|
||||
tripId={place.trip_id}
|
||||
tour={tour}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
canEdit={canEdit}
|
||||
plan={plan}
|
||||
planColor={trackColor}
|
||||
mapCompare={desktopNonModal}
|
||||
onToursChanged={onToursChanged}
|
||||
/>
|
||||
)}
|
||||
<TourPackingSection
|
||||
tripId={place.trip_id}
|
||||
tour={tour}
|
||||
tourName={place.name}
|
||||
canEdit={canEdit}
|
||||
onOpenPacking={onOpenPacking}
|
||||
/>
|
||||
|
||||
{description && (
|
||||
<DialogSection className="flex-none" label={t('places.formDescription')}>
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('TourGpxDropZone', () => {
|
||||
const zone = screen.getByText('workspace').parentElement as HTMLElement;
|
||||
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX, KML or KMZ files to import them');
|
||||
|
||||
fireEvent.drop(zone, filesDrag);
|
||||
expect(importer.importFiles).toHaveBeenCalledWith([file]);
|
||||
@@ -89,7 +89,7 @@ describe('TourGpxDropZone', () => {
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing…');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
|
||||
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
||||
// imported as one, and when a track was recorded shown where it carried times.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TourGpxPreviewTrack } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }));
|
||||
|
||||
function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrack {
|
||||
return {
|
||||
name: 'Morning',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
],
|
||||
point_count: 2,
|
||||
distance_km: 1.1,
|
||||
elevation_gain_m: null,
|
||||
elevation_loss_m: null,
|
||||
started_at: '2026-07-02T06:10:00.000Z',
|
||||
ended_at: '2026-07-02T07:00:00.000Z',
|
||||
duplicate: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
|
||||
return {
|
||||
importing: false,
|
||||
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
importFiles: vi.fn(),
|
||||
} as unknown as TourGpxImport;
|
||||
}
|
||||
|
||||
describe('TourGpxPreviewDialog (ACT-01)', () => {
|
||||
it('FE-GPX-PREVIEW-001: shows when a track was recorded and how long it took, and nothing for one without times', () => {
|
||||
const tracks = [track(), track({ name: 'Drawn', started_at: null, ended_at: null })];
|
||||
render(<TourGpxPreviewDialog gpxImport={gpxImport(tracks)} />);
|
||||
const recorded = screen.getAllByTestId('gpx-preview-recorded');
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0]).toHaveTextContent('50 min');
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
|
||||
const current = gpxImport([track()]);
|
||||
render(
|
||||
<TourGpxPreviewDialog
|
||||
gpxImport={current}
|
||||
initial={{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
|
||||
const walk = gpxImport([track()]);
|
||||
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
|
||||
unmount();
|
||||
|
||||
const hike = gpxImport([track()]);
|
||||
render(<TourGpxPreviewDialog gpxImport={hike} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type PlannableTourType,
|
||||
type TourGpxImportFields,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -15,7 +8,6 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { MapViewAuto } from '../Map/MapViewAuto';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { LABEL, Segmented } from '../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
@@ -27,17 +19,45 @@ import {
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TourTypeSelect } from './planner/TourTypeSelect';
|
||||
import { TourNotice } from './tourParts';
|
||||
import { recordedDay, recordedMinutes } from './activity/tourActivityModel';
|
||||
import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices';
|
||||
import { GpxImportTypeSection } from './GpxImportTypeSection';
|
||||
import { InfoDot, TourNotice } from './tourParts';
|
||||
import { formatPlannedTourDuration } from './tourPresentation';
|
||||
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
const BADGE =
|
||||
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
|
||||
|
||||
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
|
||||
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
|
||||
/** ACT-01: when a track was recorded and how long it took, where every point carried a time. */
|
||||
function RecordedBadge({ track }: { track: GpxPreviewFile['tracks'][number] }) {
|
||||
const { locale } = useTranslation();
|
||||
const minutes = recordedMinutes(track.started_at, track.ended_at);
|
||||
const day = recordedDay(track.started_at, locale);
|
||||
if (minutes === null || !day) return null;
|
||||
return (
|
||||
<span className={`${BADGE} gap-1`} data-testid="gpx-preview-recorded">
|
||||
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
{day}
|
||||
<span className="text-content-faint">{formatPlannedTourDuration(minutes)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One track of the file as a row: its number, its name, and what it measures
|
||||
* as badges; a track the trip holds says so. The number keeps the lines of a
|
||||
* file apart, they are imported one tour each and never joined.
|
||||
*/
|
||||
function PreviewTrackRow({
|
||||
name,
|
||||
number,
|
||||
track,
|
||||
}: {
|
||||
name: string;
|
||||
number: number;
|
||||
track: GpxPreviewFile['tracks'][number];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
|
||||
@@ -48,12 +68,19 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
|
||||
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content ring-2 ring-surface-card"
|
||||
style={fs(11)}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{track.name || name}
|
||||
</span>
|
||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
|
||||
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
|
||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||
{heights ? (
|
||||
<>
|
||||
@@ -64,32 +91,97 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
|
||||
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
|
||||
)}
|
||||
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
|
||||
<RecordedBadge track={track} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
|
||||
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
|
||||
const sum = { points: 0, areas: 0, other: 0 };
|
||||
for (const { leftOut } of files) {
|
||||
if (!leftOut) continue;
|
||||
sum.points += leftOut.points;
|
||||
sum.areas += leftOut.areas;
|
||||
sum.other += leftOut.other;
|
||||
}
|
||||
return sum.points + sum.areas + sum.other > 0 ? sum : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IO-02: what a KML or KMZ file held besides its lines, named before the
|
||||
* import so nothing goes missing unseen: a dashed card with each kind as a
|
||||
* counted badge, and why in the info dot.
|
||||
*/
|
||||
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
|
||||
const { t } = useTranslation();
|
||||
const leftOut = leftOutOf(files);
|
||||
if (!leftOut) return null;
|
||||
const kinds = [
|
||||
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
|
||||
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
|
||||
{ key: 'other', count: leftOut.other, icon: Layers, labelKey: 'tours.import.leftOutOther' },
|
||||
].filter(({ count }) => count > 0);
|
||||
return (
|
||||
<div
|
||||
data-testid="gpx-preview-left-out"
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2"
|
||||
>
|
||||
<Ban size={13} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
|
||||
<span className="me-0.5 font-semibold text-content-muted" style={fs(12, 'body')}>
|
||||
{t('tours.import.previewLeftOut')}
|
||||
</span>
|
||||
{kinds.map(({ key, count, icon: Icon, labelKey }) => (
|
||||
<SoftPill key={key} icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{t(labelKey, { count })}
|
||||
</SoftPill>
|
||||
))}
|
||||
<span className="ms-auto">
|
||||
<InfoDot label={t('tours.import.previewLeftOut')} description={t('tours.import.previewLeftOutHint')} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: a GPX file shown before anything is stored. The whole course on a
|
||||
* map, every track with its measures, the tracks the trip already holds named
|
||||
* as skipped, and the type to import them as; a paddle import names its boat
|
||||
* and water, as a planned paddle tour does. Closing it stores nothing.
|
||||
* and water, as a planned paddle tour does, a ski import its discipline
|
||||
* (SKI-02); a ride (HOR-02) says before anything is stored that its line stays
|
||||
* as recorded, gets no riding time and is not checked for access. Closing it
|
||||
* stores nothing. `initial` presets the type, as a recording imported for a
|
||||
* tour takes that tour's (ACT-01); left out, it is a hike. A KML or KMZ file
|
||||
* is shown the same way, with what it holds besides (IO-02).
|
||||
*/
|
||||
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
|
||||
export default function TourGpxPreviewDialog({
|
||||
gpxImport,
|
||||
initial,
|
||||
}: {
|
||||
gpxImport: TourGpxImport;
|
||||
initial?: TourGpxImportFields;
|
||||
}) {
|
||||
const { preview } = gpxImport;
|
||||
if (!preview) return null;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} initial={initial ?? HIKE} />;
|
||||
}
|
||||
|
||||
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
|
||||
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
|
||||
|
||||
function PreviewDialog({
|
||||
gpxImport,
|
||||
files,
|
||||
initial,
|
||||
}: {
|
||||
gpxImport: TourGpxImport;
|
||||
files: GpxPreviewFile[];
|
||||
initial: TourGpxImportFields;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const typeLabelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const [type, setType] = useState<PlannableTourType>('hike');
|
||||
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
|
||||
const [water, setWater] = useState<PaddleWaterContext | null>(null);
|
||||
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
|
||||
const choices = useGpxImportChoices(initial);
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
@@ -101,14 +193,8 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
|
||||
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
|
||||
const fields: TourGpxImportFields =
|
||||
type === 'kayak' && boat && water
|
||||
? { tour_type: 'kayak', boat_type: boat, water_context: water }
|
||||
: type === 'bike'
|
||||
? { tour_type: 'bike', bike_profile: bikeProfile }
|
||||
: { tour_type: 'hike' };
|
||||
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
|
||||
const fields = importFields(choices.chosen);
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
return (
|
||||
@@ -168,9 +254,10 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
|
||||
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{tracks.map(({ file, track }, index) => (
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
<PreviewLeftOut files={files} />
|
||||
{fresh === 0 && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
@@ -180,52 +267,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
|
||||
{type === 'bike' && (
|
||||
<div data-testid="gpx-preview-bike" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.bike.profile')}</span>
|
||||
<Segmented<BikeProfile>
|
||||
value={bikeProfile}
|
||||
onChange={setBikeProfile}
|
||||
label={t('tours.bike.profile')}
|
||||
fill
|
||||
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'kayak' && (
|
||||
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(boat ?? '') as PaddleBoatType}
|
||||
onChange={setBoat}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(water ?? '') as PaddleWaterContext}
|
||||
onChange={setWater}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paddleIncomplete && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoatImport')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
<GpxImportTypeSection choices={choices} />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useId, type ReactNode } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
|
||||
import { FoldButton, TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
|
||||
import type { Day } from '../../types'
|
||||
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
|
||||
import { TourDayRowPills } from './TourDayRowFacts'
|
||||
import { TourStagesBadge } from './stages/StageParts'
|
||||
import { useTourDays } from './tourDays'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -27,6 +28,8 @@ interface TourListRowProps {
|
||||
trackColor?: string
|
||||
/** PR1-07: the person's switch for this tour's line on the trip map. */
|
||||
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
|
||||
/** MDT-02: a multi-day tour unfolds its stages below the row; `controls` is the id of what unfolds. */
|
||||
fold?: { expanded: boolean; onToggle: () => void; label: string; controls: string }
|
||||
}
|
||||
|
||||
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
|
||||
@@ -76,6 +79,8 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
|
||||
>
|
||||
<TourCautionBadge tour={tour} />
|
||||
<TourActivityBadge tour={tour} />
|
||||
<TourStagesBadge tour={tour} />
|
||||
<TourDayRowPills tour={tour} compact />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
</div>
|
||||
@@ -115,7 +120,7 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
|
||||
export default function TourListRow({ tour,
|
||||
days = NO_DAYS,
|
||||
selected = false, disabled = false,
|
||||
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
|
||||
draggableToDay = false, onSelect, action, children, trackColor, mapToggle, fold }: TourListRowProps) {
|
||||
const nameId = useId()
|
||||
const tourDays = useTourDays(tour.place_id, days)
|
||||
const draggable = draggableToDay && !disabled
|
||||
@@ -149,6 +154,12 @@ export default function TourListRow({ tour,
|
||||
)}
|
||||
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
|
||||
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
|
||||
{fold && (
|
||||
// Always in sight, unlike the hover actions: the stages are part of what the row is.
|
||||
<span role="presentation" className="flex-none" onClick={event => event.stopPropagation()}>
|
||||
<FoldButton expanded={fold.expanded} onToggle={fold.onToggle} controls={fold.controls} label={fold.label} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
|
||||
@@ -65,11 +65,17 @@ function sumBadge(
|
||||
): ScopeBadge | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
const label = partial
|
||||
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
|
||||
: `${name}: ${value}`;
|
||||
return { key, icon, value, label };
|
||||
const value = partial || sum.atLeast ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
if (partial)
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
value,
|
||||
label: `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`,
|
||||
};
|
||||
// CW-02: known for every tour, but a city walk's stay is not entered yet.
|
||||
const said = sum.atLeast ? t('tours.walk.totalAtLeast', { time: format(sum.value) }) : value;
|
||||
return { key, icon, value, label: `${name}: ${said}` };
|
||||
}
|
||||
|
||||
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
|
||||
@@ -92,7 +98,12 @@ function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
|
||||
// A city walk plans its stays, not breaks (CW-02).
|
||||
{
|
||||
key: 'planned',
|
||||
icon: CalendarClock,
|
||||
name: t(scope.type === 'walk' ? 'tours.scope.plannedWithStays' : 'tours.scope.planned'),
|
||||
},
|
||||
scope.plannedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { SkiClassification } from './ski/useSkiClassification';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
|
||||
@@ -63,4 +64,12 @@ describe('TourWorkspaceRail', () => {
|
||||
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
|
||||
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
|
||||
});
|
||||
|
||||
it('SKI-02: hands the classification of the ski track in view on to the editor rail', () => {
|
||||
const skiClassification = { tour: null } as unknown as SkiClassification;
|
||||
const parts = { ...workspace(wanderer({})), skiClassification };
|
||||
seen.rail.length = 0;
|
||||
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.skiClassification).toBe(skiClassification);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import { TourPlannerRail } from './planner/TourPlannerPanels';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { SkiClassification } from './ski/useSkiClassification';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import WandererBrowserRail from './wanderer/WandererBrowserRail';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
@@ -17,6 +18,8 @@ export interface TourWorkspaceRailParts {
|
||||
trackPaddlePlan?: TrackPaddlePlan;
|
||||
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
|
||||
paddleChecklist?: PaddleChecklist;
|
||||
/** The classification of the ski track in view (SKI-02). */
|
||||
skiClassification?: SkiClassification;
|
||||
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
|
||||
tourPlaceIds?: ReadonlySet<number>;
|
||||
}
|
||||
@@ -38,6 +41,7 @@ export default function TourWorkspaceRail({
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
paddleChecklist,
|
||||
skiClassification,
|
||||
tourPlaceIds,
|
||||
} = workspace;
|
||||
return wanderer.open ? (
|
||||
@@ -51,6 +55,7 @@ export default function TourWorkspaceRail({
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
paddleChecklist={paddleChecklist}
|
||||
skiClassification={skiClassification}
|
||||
tourPlaceIds={tourPlaceIds}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
/>
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('ToursSidebar', () => {
|
||||
/>)
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])).toHaveAttribute('accept', '.gpx,.kml,.kmz')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
// Only the places are read again, not the whole trip: that would rebuild the workspace.
|
||||
@@ -269,7 +269,7 @@ describe('ToursSidebar', () => {
|
||||
onSelectTour={vi.fn()}
|
||||
/>)
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('ToursSidebar', () => {
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
|
||||
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
|
||||
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
@@ -296,7 +296,7 @@ describe('ToursSidebar', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
|
||||
vi.restoreAllMocks()
|
||||
@@ -308,10 +308,10 @@ describe('ToursSidebar', () => {
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('GPX preview')
|
||||
expect(dialog).toHaveTextContent('Import preview')
|
||||
expect(dialog).toHaveTextContent('ridge.gpx')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
|
||||
@@ -330,7 +330,7 @@ describe('ToursSidebar', () => {
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
|
||||
vi.restoreAllMocks()
|
||||
@@ -344,7 +344,7 @@ describe('ToursSidebar', () => {
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
// A file without heights says so instead of showing zero.
|
||||
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
|
||||
@@ -363,21 +363,165 @@ describe('ToursSidebar', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('SKI-02: a ski import waits for its discipline, and for a cross-country technique, then sends them along', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'loipe.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'))
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-ski'))
|
||||
const confirm = screen.getByTestId('gpx-preview-import')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(screen.getByText('Choose a discipline to import the track as ski.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cross-country' }))
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(screen.getByText('Choose the technique, or Unknown.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unknown' }))
|
||||
expect(confirm).toBeEnabled()
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(importCall).toHaveBeenCalledWith(1, file, {
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: 'unknown',
|
||||
})
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RUN-01: a run import names its profile, on purpose, and sends no pace', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'laps.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'))
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-run'))
|
||||
expect(screen.getByRole('button', { name: 'Running' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'))
|
||||
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'run', run_profile: 'running' }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('HOR-01: a recorded ride imports as a ride, with nothing of another type', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'ride.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'))
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-horse'))
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'))
|
||||
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'horse' }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
|
||||
new File(['x'], 'notes.txt', { type: 'text/plain' }),
|
||||
])
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX, KML and KMZ files can be imported as tours.'))
|
||||
expect(importCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('IO-02: previews a KML file like a GPX file, numbers its lines and names what it leaves out, then imports it', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({
|
||||
tracks: [previewTrack({ name: 'Coast (1/2)' }), previewTrack({ name: 'Coast (2/2)', elevation_gain_m: null, elevation_loss_m: null })],
|
||||
left_out: { points: 3, areas: 1, other: 0 },
|
||||
})
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<kml/>'], 'coast.kml', { type: 'application/vnd.google-earth.kml+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('Import preview')
|
||||
const rows = screen.getAllByTestId('gpx-preview-track')
|
||||
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
|
||||
expect(rows[1]).toHaveTextContent('No heights in the file')
|
||||
const leftOut = screen.getByTestId('gpx-preview-left-out')
|
||||
expect(leftOut).toHaveTextContent('Not imported')
|
||||
expect(leftOut).toHaveTextContent('3 pins')
|
||||
expect(leftOut).toHaveTextContent('1 area')
|
||||
expect(leftOut).not.toHaveTextContent('other item')
|
||||
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('IO-02: a GPX preview counts nothing it leaves out, so it shows no such card', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
await screen.findByRole('dialog')
|
||||
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('IO-02: names each file the server could not read by the reason it gave, and opens no preview for them', async () => {
|
||||
const refusal = (code: string) => ({ response: { data: { error: 'refused', code } } })
|
||||
vi.spyOn(tourGpxApi, 'preview')
|
||||
.mockRejectedValueOnce(refusal('malformed'))
|
||||
.mockRejectedValueOnce(refusal('archive_without_kml'))
|
||||
.mockRejectedValueOnce(refusal('archive_too_large'))
|
||||
.mockRejectedValueOnce(refusal('no_track'))
|
||||
.mockRejectedValueOnce(refusal('something_else'))
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
|
||||
new File(['x'], 'broken.kml'),
|
||||
new File(['x'], 'photos.kmz'),
|
||||
new File(['x'], 'huge.KMZ'),
|
||||
new File(['x'], 'pins.kml'),
|
||||
new File(['x'], 'odd.kml'),
|
||||
])
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(5))
|
||||
expect(toast.error.mock.calls.map(([message]) => message)).toEqual([
|
||||
'Could not read broken.kml: the file is damaged or not GPX, KML or KMZ',
|
||||
'No KML file found in photos.kmz',
|
||||
'huge.KMZ is too large once unpacked',
|
||||
'No track or route found in pins.kml',
|
||||
'Could not import odd.kml',
|
||||
])
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
|
||||
const days = [
|
||||
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// FE-ACT-COMPARE-LEGEND-001
|
||||
// ACT-02 over the plan map, rendered without the translation provider so
|
||||
// every text reads as its key: the legend names both lines, and closing it
|
||||
// ends the comparison.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
|
||||
import ActivityCompareLegend from './ActivityCompareLegend';
|
||||
|
||||
beforeEach(() => {
|
||||
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
|
||||
});
|
||||
|
||||
describe('ActivityCompareLegend (ACT-02)', () => {
|
||||
it('FE-ACT-COMPARE-LEGEND-001: names the plan and the actual track, and ends the comparison when closed', () => {
|
||||
const legend = { planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' };
|
||||
render(<ActivityCompareLegend legend={legend} />);
|
||||
const card = screen.getByTestId('activity-compare-legend');
|
||||
expect(card).toHaveTextContent('tours.activity.compare.plan');
|
||||
expect(card).toHaveTextContent('Ridge');
|
||||
expect(card).toHaveTextContent('tours.activity.badge');
|
||||
expect(card).toHaveTextContent('Watch export');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tours.activity.compare.hide' }));
|
||||
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { GitCompareArrows, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
|
||||
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
|
||||
import { Eyebrow } from '../../Planner/bookings/bookingParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
|
||||
import type { ActivityCompareLegend as Legend } from './useActivityCompareMap';
|
||||
|
||||
/** One line of the legend: how it is drawn, what it is, and whose it is. */
|
||||
function LegendRow({ swatch, label, name }: { swatch: ReactNode; label: string; name: string }) {
|
||||
return (
|
||||
<li className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-6 w-8 flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
{swatch}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ACT-02 over the plan map: what its two lines are while a tour is compared
|
||||
* with its actual track, in the frosted look of the map's cards. Closing it
|
||||
* ends the comparison; it changes nothing else.
|
||||
*/
|
||||
export default function ActivityCompareLegend({ legend }: { legend: Legend }) {
|
||||
const { t } = useTranslation();
|
||||
const close = t('tours.activity.compare.hide');
|
||||
return (
|
||||
<aside
|
||||
data-testid="activity-compare-legend"
|
||||
aria-label={t('tours.activity.compare.title')}
|
||||
className="pointer-events-auto w-[240px] rounded-2xl p-3"
|
||||
style={MAP_CARD_GLASS}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<GitCompareArrows size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.activity.compare.title')}
|
||||
</span>
|
||||
<Tooltip label={close}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useTourActivityCompareStore.getState().clear()}
|
||||
aria-label={close}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<X size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<LegendRow
|
||||
swatch={<LineSwatch color={legend.planColor} />}
|
||||
label={t('tours.activity.compare.plan')}
|
||||
name={legend.planName}
|
||||
/>
|
||||
<LegendRow
|
||||
swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />}
|
||||
label={t('tours.activity.badge')}
|
||||
name={legend.trackName}
|
||||
/>
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// FE-ACT-BADGE-001
|
||||
// ACT-01 on a tour's row: a tour with an actual track and a recording that is
|
||||
// the actual track of a tour each say so with an icon, the tooltip naming which.
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TourActivityBadge } from '../tourParts';
|
||||
|
||||
describe('TourActivityBadge (ACT-01)', () => {
|
||||
it('FE-ACT-BADGE-001: marks a tour with an actual track and a recording that is one, nothing else', () => {
|
||||
const { rerender } = render(
|
||||
<TourActivityBadge tour={{ activity_tracks: [{ id: 1, track_place_id: 9, assignment_id: null }] }} />
|
||||
);
|
||||
expect(screen.getByTestId('tour-activity-badge')).toHaveAttribute('aria-label', 'tours.activity.badgeHint');
|
||||
|
||||
rerender(<TourActivityBadge tour={{ activity_tracks: [], activity_of: 4 }} />);
|
||||
expect(screen.getByTestId('tour-activity-badge')).toHaveAttribute('aria-label', 'tours.activity.isTrackHint');
|
||||
|
||||
rerender(<TourActivityBadge tour={{ activity_tracks: [], activity_of: null }} />);
|
||||
expect(screen.queryByTestId('tour-activity-badge')).toBeNull();
|
||||
rerender(<TourActivityBadge tour={{}} />);
|
||||
expect(screen.queryByTestId('tour-activity-badge')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// FE-ACT-COMPARE-VIEW-001 to FE-ACT-COMPARE-VIEW-006
|
||||
// ACT-02 in an actual track's card, rendered without the translation
|
||||
// provider so every text reads as its key: plan and track side by side under
|
||||
// their own names, the difference only where both are known, a missing value
|
||||
// named as missing, the deviation from the planned line, and the map switch.
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import type { TourActivity } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
|
||||
const plan: PlanValues = {
|
||||
distanceKm: 2,
|
||||
gain: 120,
|
||||
loss: null,
|
||||
minutes: 180,
|
||||
minutesAtLeast: false,
|
||||
line: [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
[47.02, 11],
|
||||
],
|
||||
};
|
||||
|
||||
function activity(overrides: Partial<TourActivity['metrics']> = {}): TourActivity {
|
||||
return {
|
||||
id: 4,
|
||||
assignment_id: 11,
|
||||
day_id: 1,
|
||||
assigned_at: null,
|
||||
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
|
||||
metrics: {
|
||||
distance_km: 2.5,
|
||||
elevation_gain_m: 120,
|
||||
elevation_loss_m: 90,
|
||||
started_at: '2026-07-02T06:00:00Z',
|
||||
ended_at: '2026-07-02T09:15:00Z',
|
||||
elapsed_minutes: 195,
|
||||
point_count: 3,
|
||||
...overrides,
|
||||
},
|
||||
geometry: [
|
||||
[47, 11, 600],
|
||||
[47.012, 11.01, 650],
|
||||
[47.02, 11, 700],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const row = (key: string) => screen.getByTestId(`tour-activity-compare-${key}`);
|
||||
|
||||
function show(props: Partial<Parameters<typeof TourActivityCompare>[0]> = {}) {
|
||||
return render(
|
||||
<TourActivityCompare activity={activity()} plan={plan} planColor="plan-paint" unit="metric" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
describe('TourActivityCompare (ACT-02)', () => {
|
||||
it('FE-ACT-COMPARE-VIEW-001: names plan, actual and difference, and sets each measure of both side by side', () => {
|
||||
show();
|
||||
const table = screen.getByTestId('tour-activity-compare');
|
||||
expect(within(table).getByText('tours.activity.compare.plan')).toBeInTheDocument();
|
||||
expect(within(table).getByText('tours.activity.badge')).toBeInTheDocument();
|
||||
expect(within(table).getByText('tours.activity.compare.difference')).toBeInTheDocument();
|
||||
expect(row('distance')).toHaveTextContent('2 km');
|
||||
expect(row('distance')).toHaveTextContent('2.5 km');
|
||||
expect(row('distance')).toHaveTextContent('+500 m');
|
||||
expect(row('ascent')).toHaveTextContent('±0 m');
|
||||
expect(row('time')).toHaveTextContent('3 h');
|
||||
expect(row('time')).toHaveTextContent('3 h 15 min');
|
||||
expect(row('time')).toHaveTextContent('+15 min');
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-VIEW-002: a value a side does not carry is named missing, and nothing is set between them', () => {
|
||||
show({ activity: activity({ elapsed_minutes: null }), plan: { ...plan, gain: null } });
|
||||
expect(row('ascent')).toHaveTextContent('tours.activity.compare.notInPlan');
|
||||
expect(row('descent')).toHaveTextContent('tours.activity.compare.notInPlan');
|
||||
expect(row('time')).toHaveTextContent('tours.activity.noTime');
|
||||
expect(within(row('time')).queryByText(/min$/)).toBeNull();
|
||||
|
||||
show({ activity: activity({ elevation_loss_m: null }), plan: { ...plan, loss: 80 } });
|
||||
expect(screen.getAllByTestId('tour-activity-compare-descent')[1]).toHaveTextContent(
|
||||
'tours.activity.compare.notInTrack'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-VIEW-003: a planned total that is only a floor reads "at least" and is not subtracted from', () => {
|
||||
show({ plan: { ...plan, minutesAtLeast: true } });
|
||||
expect(row('time')).toHaveTextContent('≥ 3 h');
|
||||
expect(row('time')).not.toHaveTextContent('+15 min');
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-VIEW-004: measures how far the track left the planned line, or says there is no line', () => {
|
||||
const { unmount } = show();
|
||||
expect(screen.getByText('tours.activity.compare.farthest')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.activity.compare.offPlan')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.activity.compare.offPlanValue')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
show({ plan: { ...plan, line: null } });
|
||||
expect(screen.queryByText('tours.activity.compare.farthest')).toBeNull();
|
||||
expect(screen.getByText('tours.activity.compare.noLine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-VIEW-005: the map switch draws and ends the comparison, and says which it is', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = show({ map: { shown: false, onToggle } });
|
||||
const button = screen.getByRole('button', { name: /tours.activity.compare.show/ });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(button);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<TourActivityCompare
|
||||
activity={activity()}
|
||||
plan={plan}
|
||||
planColor="plan-paint"
|
||||
unit="metric"
|
||||
map={{ shown: true, onToggle }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-VIEW-006: no map switch without a plan map beside the detail, or without a line to draw', () => {
|
||||
const { unmount } = show();
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
|
||||
unmount();
|
||||
|
||||
show({ activity: { ...activity(), geometry: [] }, map: { shown: false, onToggle: vi.fn() } });
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDifference (ACT-02)', () => {
|
||||
it('signs a difference, calls one that rounds to nothing even, and keeps the unit shown', () => {
|
||||
expect(formatDifference('distance', -1.25, 'metric')).toBe('-1.3 km');
|
||||
expect(formatDifference('descent', 0.2, 'metric')).toBe('±0 m');
|
||||
expect(formatDifference('ascent', 30, 'imperial')).toBe('+98 ft');
|
||||
expect(formatDifference('time', -45, 'metric')).toBe('-45 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { TourActivity } from '@trek/shared';
|
||||
import { Equal, Map as MapIcon, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { BOX, Eyebrow } from '../../Planner/bookings/bookingParts';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { CardAction, TourFactFields, type TourMetricField } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
|
||||
import {
|
||||
compareRows,
|
||||
OFF_PLAN_M,
|
||||
planDeviation,
|
||||
type CompareKey,
|
||||
type CompareRow,
|
||||
type PlanValues,
|
||||
} from './tourActivityCompare';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
const ROW_LABELS: Record<CompareKey, string> = {
|
||||
distance: 'tours.detail.distance',
|
||||
ascent: 'tours.detail.ascent',
|
||||
descent: 'tours.detail.descent',
|
||||
time: 'tours.activity.compare.time',
|
||||
};
|
||||
|
||||
function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
|
||||
if (key === 'distance') return formatDistance(value, unit);
|
||||
if (key === 'time') return formatPlannedTourDuration(value);
|
||||
return formatElevation(value, unit);
|
||||
}
|
||||
|
||||
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
|
||||
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
|
||||
const size = formatValue(key, Math.abs(difference), unit);
|
||||
if (size === formatValue(key, 0, unit)) return `±${size}`;
|
||||
return `${difference > 0 ? '+' : '-'}${size}`;
|
||||
}
|
||||
|
||||
/** One value of one side: the number, or a word saying that side does not carry it. */
|
||||
function ValueCell({
|
||||
text,
|
||||
missing,
|
||||
hint,
|
||||
strong = false,
|
||||
}: {
|
||||
text: string;
|
||||
missing: boolean;
|
||||
hint?: string;
|
||||
strong?: boolean;
|
||||
}) {
|
||||
const look = missing
|
||||
? 'italic text-content-faint'
|
||||
: strong
|
||||
? 'font-semibold text-content'
|
||||
: 'font-medium text-content-secondary';
|
||||
return (
|
||||
<td className="px-2 py-1.5 text-end tabular-nums">
|
||||
<Tooltip label={hint ?? ''} disabled={!hint}>
|
||||
<span className={`inline-flex ${look}`}>{text}</span>
|
||||
</Tooltip>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** The plan's value; a total that leaves a stay out reads "at least", as the head says it. */
|
||||
function planText(row: CompareRow, plan: PlanValues, unit: DistanceUnit, t: Translate): string {
|
||||
if (row.plan === null) return t('tours.activity.compare.notInPlan');
|
||||
const text = formatValue(row.key, row.plan, unit);
|
||||
return row.key === 'time' && plan.minutesAtLeast ? `≥ ${text}` : text;
|
||||
}
|
||||
|
||||
/** The track's value; a time is said to be measured, or the time stamps to be missing. */
|
||||
function actualCell(row: CompareRow, unit: DistanceUnit, t: Translate): { text: string; hint?: string } {
|
||||
if (row.key === 'time') {
|
||||
return row.actual === null
|
||||
? { text: t('tours.activity.noTime'), hint: t('tours.activity.noTimeHint') }
|
||||
: { text: formatValue('time', row.actual, unit), hint: t('tours.activity.timeHint') };
|
||||
}
|
||||
return {
|
||||
text: row.actual === null ? t('tours.activity.compare.notInTrack') : formatValue(row.key, row.actual, unit),
|
||||
};
|
||||
}
|
||||
|
||||
/** The difference as a small badge whose arrow says which way; empty where the two cannot be compared. */
|
||||
function DifferenceCell({ row, unit }: { row: CompareRow; unit: DistanceUnit }) {
|
||||
if (row.difference === null) return <td className="px-2 py-1.5" />;
|
||||
const text = formatDifference(row.key, row.difference, unit);
|
||||
const Icon = text.startsWith('±') ? Equal : row.difference > 0 ? TrendingUp : TrendingDown;
|
||||
return (
|
||||
<td className="px-2 py-1.5 text-end">
|
||||
<SoftPill
|
||||
className="tabular-nums"
|
||||
icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
>
|
||||
{text}
|
||||
</SoftPill>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** A column head: its name with the line it is drawn as on the map. */
|
||||
function ColumnHead({ label, swatch }: { label: string; swatch?: ReactNode }) {
|
||||
return (
|
||||
<th scope="col" className="px-2 py-1.5 text-end font-normal">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{swatch}
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
/** The four measures of plan and track, one row each, with what lies between them. */
|
||||
function CompareTable({
|
||||
rows,
|
||||
plan,
|
||||
planColor,
|
||||
unit,
|
||||
}: {
|
||||
rows: readonly CompareRow[];
|
||||
plan: PlanValues;
|
||||
planColor: string;
|
||||
unit: DistanceUnit;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`${BOX} overflow-hidden`}>
|
||||
<table data-testid="tour-activity-compare" className="w-full border-collapse" style={fs(12, 'body')}>
|
||||
<thead className="bg-surface-secondary">
|
||||
<tr>
|
||||
<td />
|
||||
<ColumnHead label={t('tours.activity.compare.plan')} swatch={<LineSwatch color={planColor} />} />
|
||||
<ColumnHead label={t('tours.activity.badge')} swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />} />
|
||||
<ColumnHead label={t('tours.activity.compare.difference')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const actual = actualCell(row, unit, t);
|
||||
return (
|
||||
<tr key={row.key} data-testid={`tour-activity-compare-${row.key}`} className="border-t border-edge-faint">
|
||||
<th scope="row" className="px-2.5 py-1.5 text-start font-medium text-content-muted">
|
||||
{t(ROW_LABELS[row.key])}
|
||||
</th>
|
||||
<ValueCell text={planText(row, plan, unit, t)} missing={row.plan === null} />
|
||||
<ValueCell text={actual.text} hint={actual.hint} missing={row.actual === null} strong />
|
||||
<DifferenceCell row={row} unit={unit} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How far the track left the planned line, or that there is no line to measure against. */
|
||||
function deviationFields(
|
||||
activity: TourActivity,
|
||||
plan: PlanValues,
|
||||
unit: DistanceUnit,
|
||||
t: Translate
|
||||
): TourMetricField[] {
|
||||
const deviation = planDeviation(activity.geometry, plan.line);
|
||||
const offLabel = t('tours.activity.compare.offPlan');
|
||||
if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }];
|
||||
const offInfo = {
|
||||
label: offLabel,
|
||||
description: t('tours.activity.compare.offPlanHint', { distance: formatDistance(OFF_PLAN_M / 1000, unit) }),
|
||||
};
|
||||
return [
|
||||
{ label: t('tours.activity.compare.farthest'), value: formatDistance(deviation.farthestM / 1000, unit) },
|
||||
{
|
||||
label: offLabel,
|
||||
value: t('tours.activity.compare.offPlanValue', {
|
||||
distance: formatDistance(deviation.offPlanKm, unit),
|
||||
percent: Math.round(deviation.offPlanShare * 100),
|
||||
}),
|
||||
info: offInfo,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface TourActivityCompareProps {
|
||||
activity: TourActivity;
|
||||
plan: PlanValues;
|
||||
/** The plan's track colour, as the map draws it. */
|
||||
planColor: string;
|
||||
unit: DistanceUnit;
|
||||
/** The switch for the plan map; left out where no plan map is beside the detail (the phone). */
|
||||
map?: { shown: boolean; onToggle: () => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* ACT-02 in an actual track's card: its values beside the plan's, each side
|
||||
* named and drawn as it is on the map, the difference only where both are
|
||||
* known, and how far the track strayed from the planned line. It reads both
|
||||
* and changes neither; the map switch only draws.
|
||||
*/
|
||||
export default function TourActivityCompare({ activity, plan, planColor, unit, map }: TourActivityCompareProps) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => compareRows(plan, activity.metrics), [activity.metrics, plan]);
|
||||
const deviation = useMemo(() => deviationFields(activity, plan, unit, t), [activity, plan, unit, t]);
|
||||
const mapLabel = t(map?.shown ? 'tours.activity.compare.hide' : 'tours.activity.compare.show');
|
||||
return (
|
||||
<section aria-label={t('tours.activity.compare.title')} className="flex flex-col gap-2">
|
||||
<div className="flex min-h-[28px] items-center gap-2">
|
||||
<Eyebrow>{t('tours.activity.compare.title')}</Eyebrow>
|
||||
{map && activity.geometry.length >= 2 && (
|
||||
<span className="ms-auto inline-flex">
|
||||
<Tooltip label={t('tours.activity.compare.showHint')} disabled={map.shown}>
|
||||
<span className="inline-flex">
|
||||
<CardAction icon={<MapIcon size={12} strokeWidth={2.2} />} onClick={map.onToggle} pressed={map.shown}>
|
||||
{mapLabel}
|
||||
</CardAction>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CompareTable rows={rows} plan={plan} planColor={planColor} unit={unit} />
|
||||
<TourFactFields fields={deviation} columns={deviation.length === 2 ? 2 : undefined} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-ACT-DIALOG-001 to FE-ACT-DIALOG-004
|
||||
// ACT-01, the link dialog: the day, the recordings with one taken by another
|
||||
// tour dimmed, what the choice measures, the GPX import as the second way in,
|
||||
// and only "Link" writing, once a free recording is chosen.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import TourActivityDialog from './TourActivityDialog';
|
||||
import type { TourActivities } from './useTourActivities';
|
||||
import type { TrackAssignment } from './useTrackAssignment';
|
||||
|
||||
const hook = vi.hoisted(() => ({ assignment: null as unknown }));
|
||||
vi.mock('./useTrackAssignment', () => ({ useTrackAssignment: () => hook.assignment }));
|
||||
vi.mock('../TourGpxPreviewDialog', () => ({ default: () => null }));
|
||||
|
||||
const tour = { place_id: 7, name: 'Ridge', tour_type: 'hike' } as TourListItem;
|
||||
const recording = (placeId: number, name: string) =>
|
||||
({
|
||||
place_id: placeId,
|
||||
name,
|
||||
tour_type: 'hike',
|
||||
origin: 'gpx',
|
||||
has_waypoints: false,
|
||||
distance: 12.4,
|
||||
track_started_at: '2026-07-02T06:10:00Z',
|
||||
track_ended_at: '2026-07-02T09:25:00Z',
|
||||
}) as TourListItem;
|
||||
|
||||
function assignment(overrides: Partial<TrackAssignment> = {}): TrackAssignment {
|
||||
const watch = { tour: recording(20, 'Watch export'), takenBy: null };
|
||||
return {
|
||||
executions: [{ assignmentId: 11, day: { id: 1, date: '2026-07-02', title: null }, index: 0 }],
|
||||
assignmentId: 11,
|
||||
execution: { assignmentId: 11, day: null, index: 0 },
|
||||
chooseExecution: vi.fn(),
|
||||
candidates: [watch, { tour: recording(21, 'Phone export'), takenBy: 'Lake loop' }],
|
||||
selected: 20,
|
||||
choose: vi.fn(),
|
||||
choice: watch,
|
||||
replaces: [],
|
||||
gpxImport: {
|
||||
importing: false,
|
||||
preview: null,
|
||||
pick: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
importFiles: vi.fn(),
|
||||
},
|
||||
importFields: { tour_type: 'hike' },
|
||||
ready: true,
|
||||
...overrides,
|
||||
} as unknown as TrackAssignment;
|
||||
}
|
||||
|
||||
const state = (overrides: Partial<TourActivities> = {}) =>
|
||||
({
|
||||
busy: false,
|
||||
canEdit: true,
|
||||
assigning: {},
|
||||
assign: vi.fn(),
|
||||
closeAssign: vi.fn(),
|
||||
...overrides,
|
||||
}) as unknown as TourActivities;
|
||||
|
||||
const show = (current: TourActivities = state()) =>
|
||||
render(<TourActivityDialog tripId={1} tour={tour} executions={[]} state={current} activities={[]} />);
|
||||
|
||||
beforeEach(() => {
|
||||
hook.assignment = assignment();
|
||||
});
|
||||
|
||||
describe('TourActivityDialog (ACT-01)', () => {
|
||||
it('FE-ACT-DIALOG-001: offers the recordings, the taken one dimmed with its tour, and what the choice measures', () => {
|
||||
show();
|
||||
expect(screen.getByText('tours.activity.dialogEyebrow')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.activity.dialogHint')).toBeInTheDocument();
|
||||
const [watch, phone] = screen.getAllByTestId('tour-activity-candidate');
|
||||
expect(watch).toHaveAttribute('aria-checked', 'true');
|
||||
expect(phone).toBeDisabled();
|
||||
expect(phone).toHaveTextContent('tours.activity.ofTour');
|
||||
expect(watch).toHaveTextContent('3 h 15 min');
|
||||
expect(screen.getByText('tours.activity.time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ACT-DIALOG-002: only Link writes, with the chosen recording and day', () => {
|
||||
const current = state();
|
||||
show(current);
|
||||
fireEvent.click(screen.getAllByTestId('tour-activity-candidate')[0] as HTMLElement);
|
||||
expect((hook.assignment as TrackAssignment).choose).toHaveBeenCalledWith(20);
|
||||
expect(current.assign).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByTestId('tour-activity-confirm'));
|
||||
expect(current.assign).toHaveBeenCalledWith(20, 11);
|
||||
});
|
||||
|
||||
it('FE-ACT-DIALOG-003: nothing to link until a free recording is chosen; what it replaces is said', () => {
|
||||
hook.assignment = assignment({ ready: false, choice: null, selected: null, replaces: ['Old export'] });
|
||||
const current = state();
|
||||
show(current);
|
||||
expect(screen.getByTestId('tour-activity-confirm')).toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId('tour-activity-confirm'));
|
||||
expect(current.assign).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('tours.activity.replaces')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tours.activity.time')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-DIALOG-004: the GPX import is the way in when the trip has none; a list still loading waits', () => {
|
||||
const empty = assignment({ candidates: [], choice: null, selected: null, ready: false });
|
||||
hook.assignment = empty;
|
||||
const { unmount } = show();
|
||||
expect(screen.getByText('tours.activity.noCandidates')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.importGpx/ }));
|
||||
expect(empty.gpxImport.pick).toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
hook.assignment = assignment({ candidates: null, choice: null, selected: null, ready: false });
|
||||
show();
|
||||
expect(screen.getByLabelText('common.loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { TourActivity, TourListItem } from '@trek/shared';
|
||||
import { AlertTriangle, Check, Clock, FileDown, Footprints, Link2, Loader2, Ruler, Upload } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import { PillSelect } from '../../shared/dialogParts';
|
||||
import TourGpxPreviewDialog from '../TourGpxPreviewDialog';
|
||||
import { CardAction, TourMetricFields, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel } from '../tourPresentation';
|
||||
import { recordedDay, recordedMinutes, type TourExecution, type TrackCandidate } from './tourActivityModel';
|
||||
import type { TourActivities } from './useTourActivities';
|
||||
import { useTrackAssignment, type TrackAssignment } from './useTrackAssignment';
|
||||
|
||||
/** The value of the execution picker for "no day". */
|
||||
const NO_DAY = 'none';
|
||||
|
||||
/** One recording of the trip to choose: its name and what it is as badges; one taken by another tour is dimmed. */
|
||||
function CandidateRow({
|
||||
candidate,
|
||||
selected,
|
||||
onChoose,
|
||||
}: {
|
||||
candidate: TrackCandidate;
|
||||
selected: boolean;
|
||||
onChoose: () => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
|
||||
const { tour, takenBy } = candidate;
|
||||
const minutes = recordedMinutes(tour.track_started_at, tour.track_ended_at);
|
||||
const recordedOn = recordedDay(tour.track_started_at, locale);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={takenBy !== null}
|
||||
onClick={onChoose}
|
||||
data-testid="tour-activity-candidate"
|
||||
className={`flex w-full items-center gap-2.5 rounded-[12px] border px-3 py-2.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-55 ${selected ? 'border-accent bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-card enabled:hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full ring-1 ring-inset ${selected ? 'bg-accent text-accent-text ring-accent' : 'ring-edge'}`}
|
||||
>
|
||||
{selected && <Check size={12} strokeWidth={2.6} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{tour.name}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<SoftPill icon={<FileDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{hikeSourceBadgeLabel(tour, t)}
|
||||
</SoftPill>
|
||||
{tour.distance != null && (
|
||||
<SoftPill icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{formatDistance(tour.distance, unit)}
|
||||
</SoftPill>
|
||||
)}
|
||||
{recordedOn && (
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{minutes === null ? recordedOn : `${recordedOn} ${formatPlannedTourDuration(minutes)}`}
|
||||
</SoftPill>
|
||||
)}
|
||||
{takenBy !== null && <SoftPill tone="warning">{t('tours.activity.ofTour', { name: takenBy })}</SoftPill>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The recordings to choose from, or what to do while there is none. */
|
||||
function CandidateList({ assignment }: { assignment: TrackAssignment }) {
|
||||
const { t } = useTranslation();
|
||||
const { candidates } = assignment;
|
||||
if (candidates === null) {
|
||||
return (
|
||||
<div className="flex justify-center py-3 text-content-faint">
|
||||
<Loader2 size={16} className="animate-spin" aria-label={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return (
|
||||
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
|
||||
{t('tours.activity.noCandidates')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t('tours.activity.track')} className="flex flex-col gap-1.5">
|
||||
{candidates.map((candidate) => (
|
||||
<CandidateRow
|
||||
key={candidate.tour.place_id}
|
||||
candidate={candidate}
|
||||
selected={candidate.tour.place_id === assignment.selected}
|
||||
onChoose={() => assignment.choose(candidate.tour.place_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the chosen recording measures, read the way the detail will show it, before anything is written. */
|
||||
function ChoicePreview({ choice }: { choice: TourListItem }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
|
||||
const line = useTripStore((store) => store.places.find((place) => place.id === choice.place_id)?.route_geometry);
|
||||
const analysis = useMemo(() => analyzeRouteGeometry(line), [line]);
|
||||
const minutes = recordedMinutes(choice.track_started_at, choice.track_ended_at);
|
||||
return (
|
||||
<DialogSection label={t('tours.activity.badge')}>
|
||||
<TourMetricFields
|
||||
analysis={analysis}
|
||||
unit={unit}
|
||||
extra={[
|
||||
{
|
||||
label: t('tours.activity.time'),
|
||||
value: minutes === null ? t('tours.activity.noTime') : formatPlannedTourDuration(minutes),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DialogSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** The day picker: every day the tour is on, and no day at all. */
|
||||
function ExecutionPicker({ assignment }: { assignment: TrackAssignment }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const options = [
|
||||
...assignment.executions.map((execution: TourExecution) => ({
|
||||
value: String(execution.assignmentId),
|
||||
label: t('dayplan.dayN', { n: execution.index + 1 }),
|
||||
hint: (execution.day && formatDate(execution.day.date, locale)) || undefined,
|
||||
})),
|
||||
{ value: NO_DAY, label: t('tours.activity.noDay') },
|
||||
];
|
||||
return (
|
||||
<PillSelect
|
||||
quiet
|
||||
value={assignment.assignmentId === null ? NO_DAY : String(assignment.assignmentId)}
|
||||
options={options}
|
||||
onChange={(value) => assignment.chooseExecution(value === NO_DAY ? null : Number(value))}
|
||||
label={t('tours.activity.execution')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TourActivityDialogProps {
|
||||
tripId: number;
|
||||
tour: TourListItem;
|
||||
executions: readonly TourExecution[];
|
||||
state: TourActivities;
|
||||
activities: readonly TourActivity[];
|
||||
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACT-01: assigning a recorded track, on purpose and confirmed. The user picks
|
||||
* the day it was done on and a recording of the trip, or imports one through
|
||||
* the GPX import as always, sees what it measures, and only "Assign" writes.
|
||||
* The plan is not touched, and the head says so.
|
||||
*/
|
||||
export default function TourActivityDialog({
|
||||
tripId,
|
||||
tour,
|
||||
executions,
|
||||
state,
|
||||
activities,
|
||||
onToursChanged,
|
||||
}: TourActivityDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const assignment = useTrackAssignment({
|
||||
tripId,
|
||||
tour,
|
||||
executions,
|
||||
activities,
|
||||
target: state.assigning ?? {},
|
||||
canEdit: state.canEdit,
|
||||
onToursChanged,
|
||||
});
|
||||
const { gpxImport, choice, selected } = assignment;
|
||||
const importing = gpxImport.importing || gpxImport.preview !== null;
|
||||
const confirm = () => {
|
||||
if (selected !== null && assignment.ready) void state.assign(selected, assignment.assignmentId);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<DialogShell
|
||||
onClose={state.closeAssign}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
align="top"
|
||||
blocked={state.busy || importing}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={state.closeAssign}
|
||||
eyebrow={t('tours.activity.dialogEyebrow')}
|
||||
title={tour.name}
|
||||
sub={t('tours.activity.dialogHint')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={state.closeAssign} disabled={state.busy}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="tour-activity-confirm"
|
||||
disabled={!assignment.ready || state.busy || importing}
|
||||
icon={
|
||||
state.busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} strokeWidth={2} />
|
||||
}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('tours.activity.confirm')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('tours.activity.execution')}>
|
||||
<ExecutionPicker assignment={assignment} />
|
||||
</DialogSection>
|
||||
<DialogSection
|
||||
label={t('tours.activity.track')}
|
||||
action={
|
||||
<CardAction
|
||||
icon={
|
||||
gpxImport.importing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={12} strokeWidth={2.2} />
|
||||
)
|
||||
}
|
||||
onClick={gpxImport.pick}
|
||||
disabled={importing}
|
||||
>
|
||||
{t('tours.activity.importGpx')}
|
||||
</CardAction>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CandidateList assignment={assignment} />
|
||||
{assignment.replaces.length > 0 && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.activity.replaces', { name: assignment.replaces.join(', ') })}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
{choice && choice.takenBy === null && <ChoicePreview choice={choice.tour} />}
|
||||
</DialogShell>
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} initial={assignment.importFields} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// FE-ACT-SECTION-001 to FE-ACT-SECTION-008
|
||||
// ACT-01 in the tour's detail, rendered without the translation provider so
|
||||
// every text reads as its key: the actual tracks with their own values marked
|
||||
// as such beside the plan's (ACT-02), a time only where the track measured
|
||||
// one, the actions only for someone who may change the tour, and the map
|
||||
// comparison only where a plan map is beside the detail and can draw it.
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TourActivity, TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
|
||||
import type { Day } from '../../../types';
|
||||
import TourActivitySection from './TourActivitySection';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
import type { TourActivities } from './useTourActivities';
|
||||
|
||||
const hook = vi.hoisted(() => ({ state: null as unknown }));
|
||||
vi.mock('./useTourActivities', () => ({ useTourActivities: () => hook.state }));
|
||||
vi.mock('./TourActivityDialog', () => ({ default: () => <div data-testid="tour-activity-dialog" /> }));
|
||||
|
||||
const tour = { place_id: 7, name: 'Ridge', tour_type: 'hike' } as TourListItem;
|
||||
const days = [{ id: 1, date: '2026-07-02', title: null }] as unknown as Day[];
|
||||
const assignments = { '1': [{ id: 11, place_id: 7 }] } as never;
|
||||
|
||||
function activity(overrides: Partial<TourActivity['metrics']> = {}, assignmentId: number | null = 11): TourActivity {
|
||||
return {
|
||||
id: 4,
|
||||
assignment_id: assignmentId,
|
||||
day_id: 1,
|
||||
assigned_at: null,
|
||||
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
|
||||
metrics: {
|
||||
distance_km: 0.1,
|
||||
elevation_gain_m: 100,
|
||||
elevation_loss_m: 0,
|
||||
started_at: '2026-07-02T06:10:00Z',
|
||||
ended_at: '2026-07-02T09:25:00Z',
|
||||
elapsed_minutes: 195,
|
||||
point_count: 2,
|
||||
...overrides,
|
||||
},
|
||||
geometry: [
|
||||
[47, 11, 600],
|
||||
[47.001, 11, 700],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function state(overrides: Partial<TourActivities> = {}): TourActivities {
|
||||
return {
|
||||
activities: [activity()],
|
||||
unavailable: false,
|
||||
canEdit: true,
|
||||
busy: false,
|
||||
assigning: null,
|
||||
openAssign: vi.fn(),
|
||||
closeAssign: vi.fn(),
|
||||
assign: vi.fn(),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
...overrides,
|
||||
} as TourActivities;
|
||||
}
|
||||
|
||||
const plan: PlanValues = { distanceKm: 0.1, gain: 80, loss: 0, minutes: 180, minutesAtLeast: false, line: null };
|
||||
|
||||
const show = (canEdit = true, item: TourListItem = tour, mapCompare = false) =>
|
||||
render(
|
||||
<TourActivitySection
|
||||
tripId={1}
|
||||
tour={item}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
canEdit={canEdit}
|
||||
plan={plan}
|
||||
planColor="plan-paint"
|
||||
mapCompare={mapCompare}
|
||||
/>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
hook.state = state();
|
||||
useTourActivityCompareStore.setState({ comparison: null, mapAvailable: true });
|
||||
});
|
||||
|
||||
describe('TourActivitySection (ACT-01)', () => {
|
||||
it("FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values beside the plan's, and its profile", () => {
|
||||
show();
|
||||
expect(screen.getByText('tours.activity.label')).toBeInTheDocument();
|
||||
expect(screen.getByText('Watch export')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('dayplan.dayN');
|
||||
expect(screen.getByText('morning.gpx')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('tours.activity.badge').length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('3 h 15 min');
|
||||
expect(screen.getByTestId('tour-activity-compare-ascent')).toHaveTextContent('+20 m');
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-002: a track without times measures no time, and a track without a day says so', () => {
|
||||
hook.state = state({ activities: [activity({ elapsed_minutes: null, started_at: null, ended_at: null }, null)] });
|
||||
show();
|
||||
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('tours.activity.noTime');
|
||||
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('tours.activity.noDay');
|
||||
expect(screen.queryByText('tours.activity.recordedOn')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-003: someone who may change the tour links, changes and unlinks; the dialog opens when asked', () => {
|
||||
const current = state();
|
||||
hook.state = current;
|
||||
const { rerender } = show();
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.assign$/ }));
|
||||
expect(current.openAssign).toHaveBeenCalledWith();
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.change/ }));
|
||||
expect(current.openAssign).toHaveBeenCalledWith({ assignmentId: 11, activityId: 4 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.remove/ }));
|
||||
expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]);
|
||||
|
||||
hook.state = state({ assigning: {}, removing: activity() });
|
||||
rerender(<TourActivitySection tripId={1} tour={tour} days={days} assignments={assignments} canEdit />);
|
||||
expect(screen.getByTestId('tour-activity-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.activity.removeTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-004: without the right to change it, the values show and no action does', () => {
|
||||
hook.state = state({ canEdit: false });
|
||||
show(false);
|
||||
expect(screen.getByText('Watch export')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.assign$/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.remove/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-005: an empty place for one when it may be linked; nothing while loading, when it cannot, or for a track of another tour', () => {
|
||||
hook.state = state({ activities: [] });
|
||||
const { unmount } = show();
|
||||
expect(screen.getByTestId('tour-activity-empty')).toHaveTextContent('tours.activity.empty');
|
||||
unmount();
|
||||
|
||||
hook.state = state({ activities: [], canEdit: false });
|
||||
const readOnly = show(false);
|
||||
expect(readOnly.container).toBeEmptyDOMElement();
|
||||
readOnly.unmount();
|
||||
|
||||
hook.state = state({ activities: null });
|
||||
const loading = show();
|
||||
expect(loading.container).toBeEmptyDOMElement();
|
||||
loading.unmount();
|
||||
|
||||
hook.state = state();
|
||||
const track = show(true, { ...tour, activity_of: 3 });
|
||||
expect(track.container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-006: tracks that could not be read are said to be unavailable, never shown as none, and nothing is linked over them', () => {
|
||||
hook.state = state({ activities: [], unavailable: true });
|
||||
const { unmount } = show();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('tours.activity.unavailable');
|
||||
expect(screen.queryByTestId('tour-activity-empty')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /tours.activity.assign$/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
hook.state = state({ activities: [], unavailable: true, canEdit: false });
|
||||
show(false);
|
||||
expect(screen.getByText('tours.activity.unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-007: beside a plan map a track is compared there and back, and on the phone it is not offered', () => {
|
||||
const { unmount } = show(false, tour, true);
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
|
||||
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
|
||||
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.hide/ }));
|
||||
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
|
||||
|
||||
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
|
||||
unmount();
|
||||
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
|
||||
|
||||
show(false);
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-SECTION-008: in road trip mode or with the overview open the map draws other lines, so no switch is offered and a running comparison ends', () => {
|
||||
show(false, tour, true);
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
|
||||
expect(useTourActivityCompareStore.getState().comparison).not.toBeNull();
|
||||
|
||||
act(() => useTourActivityCompareStore.getState().setMapAvailable(false));
|
||||
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /tours.activity.compare.(show|hide)/ })).toBeNull();
|
||||
|
||||
act(() => useTourActivityCompareStore.getState().setMapAvailable(true));
|
||||
expect(screen.getByRole('button', { name: /tours.activity.compare.show/ })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { TourActivity, TourListItem } from '@trek/shared';
|
||||
import { CalendarDays, Clock, CloudOff, FileDown, Footprints, Plus, RefreshCw, Unlink } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
|
||||
import type { AssignmentsMap, Day } from '../../../types';
|
||||
import { BOX } from '../../Planner/bookings/bookingParts';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import ElevationProfile from '../../shared/ElevationProfile';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { dayMenuLabel } from '../tourDays';
|
||||
import { CardAction, TourNotice } from '../tourParts';
|
||||
import TourActivityCompare from './TourActivityCompare';
|
||||
import TourActivityDialog from './TourActivityDialog';
|
||||
import { ACTUAL_TRACK_COLOR } from './activityCompareParts';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
|
||||
import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison';
|
||||
import { useTourActivities, type TourActivities } from './useTourActivities';
|
||||
|
||||
/** What a card compares its track with: the plan's values and colour, and the map switch where there is a plan map. */
|
||||
interface PlanSide {
|
||||
plan: PlanValues;
|
||||
planColor: string;
|
||||
/** Null on the phone, whose detail has no plan map beside it. */
|
||||
comparison: ActivityComparisonState | null;
|
||||
}
|
||||
|
||||
/** The tile of a recording: footprints, with a small dot at its foot that says it was recorded. */
|
||||
function RecordedTile() {
|
||||
return (
|
||||
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-secondary">
|
||||
<Footprints size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full bg-success ring-2 ring-surface-card"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where and when: the execution, the file it came from, the day it was recorded, each a badge of its own. */
|
||||
function ActivityBadges({ activity, execution }: { activity: TourActivity; execution: TourExecution }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const dayLabel = execution.day ? t('dayplan.dayN', { n: execution.index + 1 }) : t('tours.activity.noDay');
|
||||
const dayTitle = execution.day ? dayMenuLabel(execution.day, execution.index, t, locale) : dayLabel;
|
||||
const recordedOn = recordedDay(activity.metrics.started_at, locale);
|
||||
const source =
|
||||
activity.track.file_name ?? t(activity.track.origin === 'wanderer' ? 'tours.badge.wanderer' : 'tours.badge.gpx');
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<Tooltip label={dayTitle}>
|
||||
<span className="inline-flex" data-testid="tour-activity-day">
|
||||
<SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{dayLabel}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SoftPill
|
||||
className="max-w-[180px]"
|
||||
icon={<FileDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
>
|
||||
<span className="min-w-0 truncate">{source}</span>
|
||||
</SoftPill>
|
||||
{recordedOn && (
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{t('tours.activity.recordedOn', { date: recordedOn })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One recording: who it is, its values beside the plan's (ACT-02), its
|
||||
* profile, and what the user can do with it.
|
||||
*/
|
||||
function ActivityCard({
|
||||
activity,
|
||||
execution,
|
||||
state,
|
||||
side,
|
||||
}: {
|
||||
activity: TourActivity;
|
||||
execution: TourExecution;
|
||||
state: TourActivities;
|
||||
side: PlanSide;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
|
||||
const analysis = useMemo(() => analysisOfLine(activity.geometry), [activity.geometry]);
|
||||
const trackPlaceId = activity.track.place_id;
|
||||
const { comparison } = side;
|
||||
return (
|
||||
<article data-testid="tour-activity" className={`${BOX} overflow-hidden`}>
|
||||
<header className="flex items-start gap-2.5 px-3 py-2.5">
|
||||
<RecordedTile />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{activity.track.name}
|
||||
</div>
|
||||
<ActivityBadges activity={activity} execution={execution} />
|
||||
</div>
|
||||
<Tooltip label={t('tours.activity.badgeHint')}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill tone="success" caps>
|
||||
{t('tours.activity.badge')}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</header>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint p-2.5">
|
||||
<TourActivityCompare
|
||||
activity={activity}
|
||||
plan={side.plan}
|
||||
planColor={side.planColor}
|
||||
unit={unit}
|
||||
map={
|
||||
comparison
|
||||
? { shown: comparison.comparedTrack === trackPlaceId, onToggle: () => comparison.toggle(trackPlaceId) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{analysis && analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<ElevationProfile
|
||||
samples={analysis.distanceIndexedProfileSamples}
|
||||
color={ACTUAL_TRACK_COLOR}
|
||||
gradientId={`tour-activity-${activity.id}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{state.canEdit && (
|
||||
<footer className="flex flex-wrap gap-1.5 border-t border-edge-faint px-2.5 py-2">
|
||||
<CardAction
|
||||
icon={<RefreshCw size={12} strokeWidth={2.2} />}
|
||||
onClick={() => state.openAssign({ assignmentId: activity.assignment_id, activityId: activity.id })}
|
||||
disabled={state.busy}
|
||||
>
|
||||
{t('tours.activity.change')}
|
||||
</CardAction>
|
||||
<CardAction
|
||||
icon={<Unlink size={12} strokeWidth={2.2} />}
|
||||
onClick={() => state.askRemove(activity)}
|
||||
disabled={state.busy}
|
||||
>
|
||||
{t('tours.activity.remove')}
|
||||
</CardAction>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** No recording yet: a quiet line, dimmed rather than hidden, so the place for one is known. */
|
||||
function NoActivity() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="tour-activity-empty"
|
||||
className={`${BOX} flex items-center gap-2.5 px-3 py-2.5 text-content-faint`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-full border border-dashed border-edge">
|
||||
<Footprints size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
{t('tours.activity.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the section holds: the tracks, the empty place for one, or word that the tour's tracks could not be read. */
|
||||
function ActivityList({
|
||||
state,
|
||||
executions,
|
||||
side,
|
||||
}: {
|
||||
state: TourActivities;
|
||||
executions: readonly TourExecution[];
|
||||
side: PlanSide;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const activities = state.activities ?? [];
|
||||
if (state.unavailable) {
|
||||
return (
|
||||
<TourNotice role="status" icon={<CloudOff size={13} strokeWidth={2} />}>
|
||||
{t('tours.activity.unavailable')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
if (activities.length === 0) return <NoActivity />;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{activities.map((activity) => (
|
||||
<ActivityCard
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
execution={executionOf(executions, activity.assignment_id)}
|
||||
state={state}
|
||||
side={side}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TourActivitySectionProps {
|
||||
tripId: number;
|
||||
tour: TourListItem;
|
||||
days: Day[];
|
||||
assignments: AssignmentsMap;
|
||||
canEdit: boolean;
|
||||
/** ACT-02: what the plan says, for each track's values to stand beside. */
|
||||
plan: PlanValues;
|
||||
/** The plan's track colour, as the map and the table draw it. */
|
||||
planColor: string;
|
||||
/** Offers to compare a track on the plan map; only where one is beside the detail and the map can draw it. */
|
||||
mapCompare?: boolean;
|
||||
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACT-01 in the tour's detail: the tracks the tour was actually done with,
|
||||
* each with the values its own recording measures and its profile, apart from
|
||||
* the planned values above; and for someone who may change the tour, the way
|
||||
* to assign one, change it or take it off. Each track's values stand beside
|
||||
* the plan's, and one track at a time can be compared with the plan on the map
|
||||
* (ACT-02). A tour that is itself the recording of another shows none: it says
|
||||
* so in its head instead.
|
||||
*/
|
||||
export default function TourActivitySection({
|
||||
tripId,
|
||||
tour,
|
||||
days,
|
||||
assignments,
|
||||
canEdit,
|
||||
plan,
|
||||
planColor,
|
||||
mapCompare = false,
|
||||
onToursChanged,
|
||||
}: TourActivitySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const executions = useMemo(() => executionsOf(tour.place_id, days, assignments), [assignments, days, tour.place_id]);
|
||||
const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged });
|
||||
const { activities, unavailable } = state;
|
||||
const comparison = useActivityComparison(tour.place_id, activities);
|
||||
// The plan map says whether it can draw a comparison; where it cannot, no switch is offered.
|
||||
const mapAvailable = useTourActivityCompareStore((store) => store.mapAvailable);
|
||||
const side: PlanSide = { plan, planColor, comparison: mapCompare && mapAvailable ? comparison : null };
|
||||
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DialogSection
|
||||
className="flex-none"
|
||||
label={t('tours.activity.label')}
|
||||
action={
|
||||
canEdit && (
|
||||
<Tooltip label={t('tours.activity.assignHint')}>
|
||||
<span className="inline-flex">
|
||||
<CardAction
|
||||
icon={<Plus size={12} strokeWidth={2.2} />}
|
||||
onClick={() => state.openAssign()}
|
||||
disabled={state.busy || unavailable}
|
||||
>
|
||||
{t('tours.activity.assign')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ActivityList state={state} executions={executions} side={side} />
|
||||
{state.assigning && (
|
||||
<TourActivityDialog
|
||||
tripId={tripId}
|
||||
tour={tour}
|
||||
executions={executions}
|
||||
state={state}
|
||||
activities={activities}
|
||||
onToursChanged={onToursChanged}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={state.removing !== null}
|
||||
onClose={state.cancelRemove}
|
||||
onConfirm={() => void state.remove()}
|
||||
title={t('tours.activity.removeTitle')}
|
||||
message={t('tours.activity.removeBody')}
|
||||
confirmLabel={t('tours.activity.remove')}
|
||||
/>
|
||||
</DialogSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* ACT-02: how plan and actual track look wherever they are named side by
|
||||
* side, so the detail's table and the map's legend show the same two lines:
|
||||
* the plan solid in its track colour, the actual track dotted in the colour
|
||||
* of done. Dotted as well as coloured, so the two stay apart for someone who
|
||||
* cannot tell the hues apart, and on a screenshot in black and white.
|
||||
*/
|
||||
|
||||
/** The colour of what was recorded, as the actual track's card and profile already use it. */
|
||||
export const ACTUAL_TRACK_COLOR = 'var(--success)';
|
||||
|
||||
/** A short stretch of line: solid for the plan, dotted for the actual track. */
|
||||
export function LineSwatch({ color, dotted = false }: { color: string; dotted?: boolean }) {
|
||||
return (
|
||||
<svg width="22" height="8" viewBox="0 0 22 8" aria-hidden="true" className="flex-none overflow-visible">
|
||||
<line
|
||||
x1="2"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="4"
|
||||
strokeWidth={dotted ? 3.2 : 3.5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={dotted ? '0.1 5.4' : undefined}
|
||||
style={{ stroke: color }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// FE-ACT-COMPARE-MODEL-001 to FE-ACT-COMPARE-MODEL-006
|
||||
// ACT-02, the pure half of comparing a plan with its actual track: the values
|
||||
// side by side, a difference only where both are known and comparable, and
|
||||
// how far the recorded line strayed from the planned one.
|
||||
import type { TourActivityMetrics } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare';
|
||||
|
||||
const plan: PlanValues = {
|
||||
distanceKm: 8,
|
||||
gain: 600,
|
||||
loss: 550,
|
||||
minutes: 240,
|
||||
minutesAtLeast: false,
|
||||
line: null,
|
||||
};
|
||||
|
||||
const metrics: TourActivityMetrics = {
|
||||
distance_km: 8.6,
|
||||
elevation_gain_m: 640,
|
||||
elevation_loss_m: null,
|
||||
started_at: '2026-07-02T06:00:00Z',
|
||||
ended_at: '2026-07-02T10:30:00Z',
|
||||
elapsed_minutes: 270,
|
||||
point_count: 400,
|
||||
};
|
||||
|
||||
// A planned line running north along 11° E for about 2.2 km.
|
||||
const planned = [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
[47.02, 11],
|
||||
];
|
||||
|
||||
describe('compareRows (ACT-02)', () => {
|
||||
it('FE-ACT-COMPARE-MODEL-001: names both values of every measure and what lies between them', () => {
|
||||
const rows = compareRows(plan, metrics);
|
||||
expect(rows.map((row) => row.key)).toEqual(['distance', 'ascent', 'descent', 'time']);
|
||||
expect(rows[0]).toEqual({ key: 'distance', plan: 8, actual: 8.6, difference: expect.closeTo(0.6, 6) });
|
||||
expect(rows[1]).toEqual({ key: 'ascent', plan: 600, actual: 640, difference: 40 });
|
||||
expect(rows[3]).toEqual({ key: 'time', plan: 240, actual: 270, difference: 30 });
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-MODEL-002: a value one side does not carry stays missing, and so does the difference', () => {
|
||||
const rows = compareRows({ ...plan, gain: null, minutes: null }, metrics);
|
||||
expect(rows[1]).toEqual({ key: 'ascent', plan: null, actual: 640, difference: null });
|
||||
expect(rows[2]).toEqual({ key: 'descent', plan: 550, actual: null, difference: null });
|
||||
expect(rows[3]).toEqual({ key: 'time', plan: null, actual: 270, difference: null });
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-MODEL-003: a planned total that is only a floor is shown but never subtracted from', () => {
|
||||
const time = compareRows({ ...plan, minutesAtLeast: true }, metrics)[3];
|
||||
expect(time).toEqual({ key: 'time', plan: 240, actual: 270, difference: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planDeviation (ACT-02)', () => {
|
||||
it('FE-ACT-COMPARE-MODEL-004: a track along the plan stays on it', () => {
|
||||
const deviation = planDeviation(
|
||||
[
|
||||
[47, 11.0001],
|
||||
[47.01, 11.0001],
|
||||
[47.02, 11.0001],
|
||||
],
|
||||
planned
|
||||
);
|
||||
expect(deviation?.farthestM).toBeLessThan(OFF_PLAN_M);
|
||||
expect(deviation?.offPlanKm).toBe(0);
|
||||
expect(deviation?.offPlanShare).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-MODEL-005: a detour away from the plan is measured by its farthest point and its length', () => {
|
||||
// Out along the plan, then about 760 m east and back to the plan's end.
|
||||
const deviation = planDeviation(
|
||||
[
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
[47.012, 11.01],
|
||||
[47.018, 11.01],
|
||||
[47.02, 11],
|
||||
],
|
||||
planned
|
||||
);
|
||||
expect(deviation?.farthestM).toBeGreaterThan(740);
|
||||
expect(deviation?.farthestM).toBeLessThan(780);
|
||||
// Only the leg between the two eastern points has both ends off the plan: about 670 m.
|
||||
expect(deviation?.offPlanKm).toBeGreaterThan(0.6);
|
||||
expect(deviation?.offPlanKm).toBeLessThan(0.7);
|
||||
// Of about 3.4 km recorded in all.
|
||||
expect(deviation?.offPlanShare).toBeGreaterThan(0.15);
|
||||
expect(deviation?.offPlanShare).toBeLessThan(0.25);
|
||||
});
|
||||
|
||||
it('FE-ACT-COMPARE-MODEL-006: nothing without two points on either line, and a long track measured in samples to its end', () => {
|
||||
expect(planDeviation([[47, 11]], planned)).toBeNull();
|
||||
expect(planDeviation(planned, null)).toBeNull();
|
||||
expect(planDeviation(planned, [[47, 11]])).toBeNull();
|
||||
|
||||
// Twice as many points as are measured along the plan, then on past its end; the last point is kept.
|
||||
const count = MEASURED_POINTS * 2;
|
||||
const long = Array.from({ length: count }, (_, index) => [47 + (0.02 * index) / count, 11]);
|
||||
long.push([47.03, 11], [47.04, 11]);
|
||||
const deviation = planDeviation(long, planned);
|
||||
expect(deviation?.farthestM).toBeGreaterThan(2200);
|
||||
expect(deviation?.offPlanKm).toBeGreaterThan(1.1);
|
||||
expect(deviation?.offPlanKm).toBeLessThan(1.12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { distancesToLine, haversineMetres, type TourActivityMetrics } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* ACT-02, the pure half of comparing a tour's plan with a track it was
|
||||
* actually done with: the values both carry side by side, the difference only
|
||||
* where both are known and mean the same thing, and how far the recorded line
|
||||
* strayed from the planned one. Nothing is estimated: a value one side does
|
||||
* not carry stays missing and is said to be.
|
||||
*/
|
||||
|
||||
type Line = readonly (readonly number[])[];
|
||||
|
||||
/** What the plan says, as the tour's detail shows it in its head. */
|
||||
export interface PlanValues {
|
||||
distanceKm: number | null;
|
||||
gain: number | null;
|
||||
loss: number | null;
|
||||
/** The planned total, breaks included, as a recording's time includes its pauses; null without one. */
|
||||
minutes: number | null;
|
||||
/** The total leaves out a stay nobody entered yet (CW-02): a floor, not a value to subtract from. */
|
||||
minutesAtLeast: boolean;
|
||||
/** The planned line, `[lat, lng]` points; null while the tour has none. */
|
||||
line: Line | null;
|
||||
}
|
||||
|
||||
export type CompareKey = 'distance' | 'ascent' | 'descent' | 'time';
|
||||
|
||||
/** One measure of both: kilometres, metres or minutes, as the key says. */
|
||||
export interface CompareRow {
|
||||
key: CompareKey;
|
||||
plan: number | null;
|
||||
actual: number | null;
|
||||
/** Actual less planned; null unless both are known and comparable. */
|
||||
difference: number | null;
|
||||
}
|
||||
|
||||
function row(key: CompareKey, plan: number | null, actual: number | null, comparable = true): CompareRow {
|
||||
return { key, plan, actual, difference: comparable && plan !== null && actual !== null ? actual - plan : null };
|
||||
}
|
||||
|
||||
/** The four measures both sides can carry, in the order the detail names them. */
|
||||
export function compareRows(plan: PlanValues, metrics: TourActivityMetrics): CompareRow[] {
|
||||
return [
|
||||
row('distance', plan.distanceKm, metrics.distance_km),
|
||||
row('ascent', plan.gain, metrics.elevation_gain_m),
|
||||
row('descent', plan.loss, metrics.elevation_loss_m),
|
||||
row('time', plan.minutes, metrics.elapsed_minutes, !plan.minutesAtLeast),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How far from the planned line a recorded point may lie and still count as
|
||||
* on it: a phone's position wanders by tens of metres, and a planned line runs
|
||||
* down the middle of a road the track walked beside.
|
||||
*/
|
||||
export const OFF_PLAN_M = 50;
|
||||
|
||||
/**
|
||||
* At most this many recorded points are measured. A track logged every second
|
||||
* holds tens of thousands, each measured against every leg of the plan; every
|
||||
* n-th point keeps the measuring quick and the answer within a few metres.
|
||||
*/
|
||||
export const MEASURED_POINTS = 1500;
|
||||
|
||||
interface PlanDeviation {
|
||||
/** The largest distance of a recorded point from the planned line, in metres. */
|
||||
farthestM: number;
|
||||
/** The recorded kilometres farther than OFF_PLAN_M from the plan. */
|
||||
offPlanKm: number;
|
||||
/** That share of the recorded distance, 0 to 1. */
|
||||
offPlanShare: number;
|
||||
}
|
||||
|
||||
/** Every n-th point, the last always among them, so the measured line still ends where the track did. */
|
||||
function sampled(points: Line): Line {
|
||||
if (points.length <= MEASURED_POINTS) return points;
|
||||
const step = Math.ceil(points.length / MEASURED_POINTS);
|
||||
const kept = points.filter((_, index) => index % step === 0);
|
||||
return kept[kept.length - 1] === points[points.length - 1] ? kept : [...kept, points[points.length - 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the recorded line left the planned one: its farthest point and the
|
||||
* stretches away from it. A stretch counts as away when both its ends lie
|
||||
* farther than OFF_PLAN_M. Null without two points on either line.
|
||||
*/
|
||||
export function planDeviation(recorded: Line, planned: Line | null): PlanDeviation | null {
|
||||
if (!planned || planned.length < 2 || recorded.length < 2) return null;
|
||||
const points = sampled(recorded);
|
||||
const distances = distancesToLine(points, planned);
|
||||
if (!distances) return null;
|
||||
let total = 0;
|
||||
let off = 0;
|
||||
let farthestM = distances[0] ?? 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const from = points[index - 1];
|
||||
const to = points[index];
|
||||
const step = haversineMetres(Number(from[0]), Number(from[1]), Number(to[0]), Number(to[1]));
|
||||
total += step;
|
||||
if ((distances[index - 1] ?? 0) > OFF_PLAN_M && (distances[index] ?? 0) > OFF_PLAN_M) off += step;
|
||||
farthestM = Math.max(farthestM, distances[index] ?? 0);
|
||||
}
|
||||
return { farthestM, offPlanKm: off / 1000, offPlanShare: total > 0 ? off / total : 0 };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// FE-ACT-MODEL-001 to FE-ACT-MODEL-009
|
||||
// ACT-01, the pure half of a tour's actual tracks: its executions, the
|
||||
// recordings that can be linked, the GPX preset, the time and the refusals.
|
||||
import type { TourActivity, TourListItem } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../../types';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import {
|
||||
analysisOfLine,
|
||||
executionOf,
|
||||
executionsOf,
|
||||
importFieldsFor,
|
||||
inDayOrder,
|
||||
isRecording,
|
||||
recordedDay,
|
||||
recordedMinutes,
|
||||
refusalOf,
|
||||
trackCandidates,
|
||||
} from './tourActivityModel';
|
||||
|
||||
const day = (id: number, date: string | null = null) => ({ id, date, title: null }) as unknown as Day;
|
||||
const days = [day(1, '2026-07-01'), day(2, '2026-07-02'), day(3, '2026-07-03')];
|
||||
const assignments: AssignmentsMap = {
|
||||
'1': [{ id: 11, place_id: 7 } as Assignment, { id: 12, place_id: 8 } as Assignment],
|
||||
'3': [{ id: 31, place_id: 7 } as Assignment],
|
||||
};
|
||||
|
||||
function tour(overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: 7,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 600,
|
||||
elevation_loss: 600,
|
||||
duration: 180,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const activity = (id: number, assignmentId: number | null) =>
|
||||
({ id, assignment_id: assignmentId }) as unknown as TourActivity;
|
||||
|
||||
describe('executions', () => {
|
||||
it('FE-ACT-MODEL-001: lists the days the tour is on with their assignment, in trip order', () => {
|
||||
const executions = executionsOf(7, days, assignments);
|
||||
expect(executions.map((execution) => [execution.assignmentId, execution.day?.id, execution.index])).toEqual([
|
||||
[11, 1, 0],
|
||||
[31, 3, 2],
|
||||
]);
|
||||
expect(executionOf(executions, 31).index).toBe(2);
|
||||
expect(executionOf(executions, null)).toEqual({ assignmentId: null, day: null, index: -1 });
|
||||
expect(executionOf(executions, 99).day).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-002: orders the actual tracks by their days, the ones without a day last', () => {
|
||||
const executions = executionsOf(7, days, assignments);
|
||||
const ordered = inDayOrder([activity(1, null), activity(2, 31), activity(3, 11), activity(4, null)], executions);
|
||||
expect(ordered.map((item) => item.id)).toEqual([3, 2, 1, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidates', () => {
|
||||
it('FE-ACT-MODEL-003: a recording is an import nobody planned over', () => {
|
||||
expect(isRecording(tour({ origin: 'gpx', has_waypoints: false }))).toBe(true);
|
||||
expect(isRecording(tour({ origin: 'wanderer', has_waypoints: false }))).toBe(true);
|
||||
expect(isRecording(tour({ origin: 'gpx', has_waypoints: true }))).toBe(false);
|
||||
expect(isRecording(tour({ origin: 'trek', has_waypoints: false }))).toBe(false);
|
||||
expect(isRecording(tour({ origin: undefined, has_waypoints: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-004: offers the recordings, dims one taken by another tour and leaves out the rest', () => {
|
||||
const tours = [
|
||||
tour(),
|
||||
tour({ place_id: 20, name: 'Watch', origin: 'gpx', has_waypoints: false }),
|
||||
tour({ place_id: 21, name: 'Phone', origin: 'gpx', has_waypoints: false, activity_of: 30 }),
|
||||
tour({ place_id: 22, name: 'Mine', origin: 'gpx', has_waypoints: false, activity_of: 7 }),
|
||||
tour({
|
||||
place_id: 23,
|
||||
name: 'Plan',
|
||||
origin: 'gpx',
|
||||
has_waypoints: false,
|
||||
activity_tracks: [{ id: 1, track_place_id: 24, assignment_id: null }],
|
||||
}),
|
||||
tour({ place_id: 30, name: 'Lake' }),
|
||||
];
|
||||
expect(trackCandidates(tours, 7).map(({ tour: item, takenBy }) => [item.place_id, takenBy])).toEqual([
|
||||
[20, null],
|
||||
[21, 'Lake'],
|
||||
[22, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the GPX preset', () => {
|
||||
it('FE-ACT-MODEL-005: imports a recording as the tour’s own type', () => {
|
||||
expect(importFieldsFor(tour({ tour_type: 'bike', bike_profile: 'gravel' }))).toEqual({
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'gravel',
|
||||
});
|
||||
expect(importFieldsFor(tour({ tour_type: 'bike', bike_profile: null }))).toEqual({
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'touring',
|
||||
});
|
||||
expect(importFieldsFor(tour({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))).toEqual({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'sup',
|
||||
water_context: 'lake',
|
||||
});
|
||||
expect(importFieldsFor(tour({ tour_type: 'kayak', boat_type: null, water_context: null }))).toEqual({
|
||||
tour_type: 'kayak',
|
||||
});
|
||||
expect(importFieldsFor(tour({ tour_type: 'walk' }))).toEqual({ tour_type: 'walk' });
|
||||
expect(importFieldsFor(tour({ tour_type: 'ski' }))).toEqual({ tour_type: 'hike' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('measures', () => {
|
||||
it('FE-ACT-MODEL-006: the time is only what the track’s times say', () => {
|
||||
expect(recordedMinutes('2026-07-02T06:10:00Z', '2026-07-02T09:25:00Z')).toBe(195);
|
||||
expect(recordedMinutes(null, '2026-07-02T09:25:00Z')).toBeNull();
|
||||
expect(recordedMinutes('2026-07-02T09:25:00Z', '2026-07-02T06:10:00Z')).toBeNull();
|
||||
expect(recordedMinutes('nonsense', undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-007: analyses a line like the plan’s, and nothing of fewer than two points', () => {
|
||||
const analysis = analysisOfLine([
|
||||
[47, 11, 600],
|
||||
[47.01, 11, 700],
|
||||
]);
|
||||
expect(analysis?.gain).toBe(100);
|
||||
expect(analysisOfLine([[47, 11]])).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-008: reads the server’s reason for a refusal, and none from anything else', () => {
|
||||
expect(refusalOf({ response: { data: { code: 'activity_track_in_use' } } })).toBe('activity_track_in_use');
|
||||
expect(refusalOf({ response: { data: { code: 'tour_revision_conflict' } } })).toBeNull();
|
||||
expect(refusalOf(new Error('offline'))).toBeNull();
|
||||
expect(refusalOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-009: the day recorded is the viewer’s calendar day of the first time, not the UTC one', () => {
|
||||
// 22:30 UTC on the 1st is the morning of the 2nd in Tokyo and still the afternoon of the 1st in Los Angeles.
|
||||
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'Asia/Tokyo')).toBe(formatDate('2026-07-02', 'en'));
|
||||
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'America/Los_Angeles')).toBe(formatDate('2026-07-01', 'en'));
|
||||
// 01:30 UTC on the 2nd is still the evening of the 1st in New York.
|
||||
expect(recordedDay('2026-07-02T01:30:00Z', 'en', 'America/New_York')).toBe(formatDate('2026-07-01', 'en'));
|
||||
expect(recordedDay(null, 'en')).toBeNull();
|
||||
expect(recordedDay('nonsense', 'en')).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user