mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
Compare commits
195 Commits
ci/speed-shards
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3c0151d5 | |||
| 3101b36f3d | |||
| ef4eac34c4 | |||
| a518b8dfc2 | |||
| 8523bbb724 | |||
| 464d4e006c | |||
| 1797e1c70d | |||
| 5dc945145f | |||
| a1180b2b19 | |||
| d62fc284e1 | |||
| 8f53d98d45 | |||
| 9b71948497 | |||
| bb10b94046 | |||
| 94c369b913 | |||
| 3f1d1a57cc | |||
| 7c1de6ed82 | |||
| fbf6cd2840 | |||
| 5116f7be2c | |||
| 4f60cc5c98 | |||
| 65bb2d95eb | |||
| cbca3b2851 | |||
| 8a75712041 | |||
| 6e13f7e655 | |||
| 41f3267503 | |||
| 2a4a5617dc | |||
| 43353b9394 | |||
| 4eaf5e6a11 | |||
| 1a722e63fb | |||
| 924e38472e | |||
| bf2efca8f0 | |||
| 2377a63a05 | |||
| 377619abd1 | |||
| 988890866a | |||
| 0dda5bc3e9 | |||
| 7fa8b3fe35 | |||
| 112017a9e8 | |||
| cb5c5ab4a1 | |||
| bed9f53615 | |||
| a8e2486cd3 | |||
| 5d5015066f | |||
| 3e8d192305 | |||
| bafb74f872 | |||
| b71e32cf12 | |||
| 786df612fd | |||
| e6002444a1 | |||
| e453816ded | |||
| b9ca822d51 | |||
| 20537193b6 | |||
| 60611aee65 | |||
| 6c66cff365 | |||
| d3f574f225 | |||
| e5c5ffbc00 | |||
| 562176bd83 | |||
| ae699187f7 | |||
| 1d72fbd25f | |||
| 2d0c0597b3 | |||
| a85a50e186 | |||
| c8eb510bd4 | |||
| beea4c762b | |||
| d924dcf99d | |||
| 371083ed47 | |||
| eaeaf68352 | |||
| 5156fa2c0a | |||
| 6c6e285c3d | |||
| 93e1156aa4 | |||
| dc19807126 | |||
| 5580ff71a9 | |||
| e42622d71d | |||
| 94674e7572 | |||
| 7f14dde362 | |||
| 9765cf3fee | |||
| c38eb7ebf0 | |||
| e653733390 | |||
| 2655e510fc | |||
| 904eb31e62 | |||
| 76ec302526 | |||
| 810feba413 | |||
| 4009588078 | |||
| ef583ede18 | |||
| 9bc543a387 | |||
| 7291555e95 | |||
| 57f0e2f263 | |||
| de043cfb58 | |||
| 69df85bfa2 | |||
| be6702f3ab | |||
| 164a3633ac | |||
| 204ee2ef86 | |||
| baba2de344 | |||
| 49dd2db040 | |||
| 49e306ea48 | |||
| 1d7c8a63e2 | |||
| fb208a88e2 | |||
| 5ae8ac8e70 | |||
| 20bd384feb | |||
| 58302dc9e4 | |||
| b460cfd9a7 | |||
| 2b8d2e790f | |||
| 2fc9b1991a | |||
| 4d81180fc1 | |||
| 0f600d04f5 | |||
| b53257deaf | |||
| b284095ee0 | |||
| e40e576e39 | |||
| 4d9b84f681 | |||
| 223b056de2 | |||
| 981c372640 | |||
| 839d736905 | |||
| b9391a9921 | |||
| 5e0f216b0c | |||
| a249c861e6 | |||
| 2a40b884a1 | |||
| f5be556c27 | |||
| 6b1488994f | |||
| 2ee4ea1e65 | |||
| a1d683d7bc | |||
| ae726ed370 | |||
| f4750352e6 | |||
| 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 |
@@ -26,5 +26,8 @@
|
|||||||
*.eot binary
|
*.eot binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
*.zip binary
|
*.zip binary
|
||||||
|
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
|
||||||
|
*.sgr binary
|
||||||
|
*.gz binary
|
||||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||||
|
|||||||
@@ -261,8 +261,11 @@ jobs:
|
|||||||
# constructor parameter does not break its hand-wired call sites at
|
# constructor parameter does not break its hand-wired call sites at
|
||||||
# compile time: the argument arrives as undefined and throws at
|
# compile time: the argument arrives as undefined and throws at
|
||||||
# runtime, if anything reads it at all. This is the step that
|
# runtime, if anything reads it at all. This is the step that
|
||||||
# catches it.
|
# catches it. src and tests together are past 570,000 lines and need
|
||||||
|
# about 4.3 GB, over Node's default heap.
|
||||||
run: cd server && npm run typecheck:tests
|
run: cd server && npm run typecheck:tests
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
|
||||||
- name: Typecheck entity generator
|
- name: Typecheck entity generator
|
||||||
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ env:
|
|||||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||||
# Defaults to 25000.
|
# Defaults to 25000.
|
||||||
# TREK_PLACES_ENABLED: "false"
|
# TREK_PLACES_ENABLED: "false"
|
||||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
|
||||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||||
# here. Defaults to on.
|
# here. Defaults to on.
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
"trek/no-swallowed-catch": 133,
|
"trek/no-swallowed-catch": 133,
|
||||||
"max-params": 35,
|
"max-params": 35,
|
||||||
"trek/store-without-selector": 69,
|
"trek/store-without-selector": 69,
|
||||||
"react-hooks/refs": 559,
|
"react-hooks/refs": 557,
|
||||||
"react-hooks/set-state-in-effect": 197,
|
"react-hooks/set-state-in-effect": 196,
|
||||||
"@typescript-eslint/no-floating-promises": 167,
|
"@typescript-eslint/no-floating-promises": 167,
|
||||||
"react-hooks/exhaustive-deps": 121,
|
"react-hooks/exhaustive-deps": 121,
|
||||||
"@typescript-eslint/no-unused-vars": 129,
|
"@typescript-eslint/no-unused-vars": 127,
|
||||||
"react-hooks/immutability": 28,
|
"react-hooks/immutability": 28,
|
||||||
"max-lines-per-function": 133,
|
"max-lines-per-function": 133,
|
||||||
"react-hooks/static-components": 78,
|
"react-hooks/static-components": 78,
|
||||||
"react-hooks/preserve-manual-memoization": 15,
|
"react-hooks/preserve-manual-memoization": 12,
|
||||||
"no-useless-assignment": 7,
|
"no-useless-assignment": 7,
|
||||||
"no-empty": 19,
|
"no-empty": 19,
|
||||||
"trek/no-window-globals": 41,
|
"trek/no-window-globals": 41,
|
||||||
|
|||||||
@@ -223,6 +223,15 @@
|
|||||||
"desktop": ["src/pages/CollectionsPage.tsx"],
|
"desktop": ["src/pages/CollectionsPage.tsx"],
|
||||||
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
|
||||||
|
"hooks": [
|
||||||
|
"src/components/Tours/collection/useCollectionTours.ts",
|
||||||
|
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
|
||||||
|
],
|
||||||
|
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Copy collection places to a trip",
|
"feature": "Copy collection places to a trip",
|
||||||
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
||||||
@@ -390,6 +399,18 @@
|
|||||||
"src/mobile/screens/journey/mobileJourneyMeta.ts"
|
"src/mobile/screens/journey/mobileJourneyMeta.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Journey day take-over: useJourneyDayImport",
|
||||||
|
"hooks": ["src/components/Journey/useJourneyDayImport.ts"],
|
||||||
|
"desktop": ["src/components/Journey/JourneyDayImportDialog.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/journey/MJourneyDayImportSheet.tsx"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "Journey day recording: useJourneyRecordingDay",
|
||||||
|
"hooks": ["src/components/Journey/useJourneyRecordingDay.ts"],
|
||||||
|
"desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Journey detail page: useJourneyDetail",
|
"feature": "Journey detail page: useJourneyDetail",
|
||||||
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
|
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
|
||||||
@@ -571,6 +592,24 @@
|
|||||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.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": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
|
||||||
|
"hooks": ["src/components/Tours/tourDayTime.ts"],
|
||||||
|
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "A multi-day tour put on a day: the whole tour there, or a stage a day from that day on",
|
||||||
|
"hooks": ["src/components/Tours/stages/stageSpread.ts"],
|
||||||
|
"desktop": ["src/components/Tours/stages/TourDayChoiceDialog.tsx", "src/components/Tours/stages/TourDayAddButton.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/sheets/MTourDayChoiceSheet.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Reservation form incl. hotel accommodation creation",
|
"feature": "Reservation form incl. hotel accommodation creation",
|
||||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||||
@@ -874,6 +913,7 @@
|
|||||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||||
|
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||||
"src/components/Map/MapView.tsx": 1401,
|
"src/components/Map/MapView.tsx": 1401,
|
||||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||||
"src/components/Map/MapViewGL.tsx": 2296,
|
"src/components/Map/MapViewGL.tsx": 2293,
|
||||||
"src/components/Map/RouteCalculator.ts": 1195,
|
"src/components/Map/RouteCalculator.ts": 1195,
|
||||||
"src/components/PDF/TripPDF.tsx": 1082,
|
"src/components/PDF/TripPDF.tsx": 1082,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
"src/components/Planner/DayPlanSidebar.tsx": 2963,
|
||||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||||
"src/components/Studio/TravelElements.tsx": 1738,
|
"src/components/Studio/TravelElements.tsx": 1738,
|
||||||
"src/components/Studio/autoLayout.ts": 1691,
|
"src/components/Studio/autoLayout.ts": 1691,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||||
"src/pages/LoginPage.tsx": 1348,
|
"src/pages/LoginPage.tsx": 1348,
|
||||||
"src/pages/TripPlannerPage.tsx": 1142,
|
"src/pages/TripPlannerPage.tsx": 1140,
|
||||||
"src/pages/atlas/useAtlas.ts": 1287,
|
"src/pages/atlas/useAtlas.ts": 1287,
|
||||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
"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,27 @@
|
|||||||
|
// FE-API-JOURNEY-MAP-001
|
||||||
|
//
|
||||||
|
// JRN-04: both reads pin their path and hand back what the server answered.
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { journeyMapApi } from './journeyMap';
|
||||||
|
|
||||||
|
describe('journeyMapApi', () => {
|
||||||
|
it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => {
|
||||||
|
const layers = { places: { status: 'withheld' } };
|
||||||
|
const paths: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/journeys/7/map-layers', ({ request }) => {
|
||||||
|
paths.push(new URL(request.url).pathname);
|
||||||
|
return HttpResponse.json(layers);
|
||||||
|
}),
|
||||||
|
http.get('/api/public/journey/:token/map-layers', ({ request }) => {
|
||||||
|
paths.push(new URL(request.url).pathname);
|
||||||
|
return HttpResponse.json(layers);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await journeyMapApi.layers(7)).toEqual(layers);
|
||||||
|
expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers);
|
||||||
|
expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */
|
||||||
|
export const journeyMapApi = {
|
||||||
|
layers: (journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> =>
|
||||||
|
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
|
||||||
|
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
|
||||||
|
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// FE-API-JOURNEY-RECORDINGS-001
|
||||||
|
//
|
||||||
|
// JRN-01: 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 { journeyRecordingsApi } from './journeyRecordings';
|
||||||
|
|
||||||
|
const BASE = '/api/journeys/7/recordings';
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
|
||||||
|
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function record(data: JsonBodyType) {
|
||||||
|
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 HttpResponse.json(data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('journeyRecordingsApi', () => {
|
||||||
|
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(BASE, record({ days: [] })),
|
||||||
|
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
|
||||||
|
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
|
||||||
|
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
|
||||||
|
);
|
||||||
|
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
|
||||||
|
expect(
|
||||||
|
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
|
||||||
|
).toEqual({ date: '2026-05-14' });
|
||||||
|
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
|
||||||
|
outcome: 'created',
|
||||||
|
});
|
||||||
|
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ method: 'GET', path: BASE, body: null },
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: `${BASE}/preview`,
|
||||||
|
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
|
||||||
|
},
|
||||||
|
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
|
||||||
|
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-API-JOURNEY-RECORDINGS-002: reviews the stays of a day and takes them over (JRN-02)', async () => {
|
||||||
|
const body = { groups: [{ kind: 'single' as const, stays: [{ suggestionId: 3 }] }], skipSuggestionIds: [4] };
|
||||||
|
server.use(
|
||||||
|
http.post(`${BASE}/2026-05-14/stays/review`, record({ date: '2026-05-14', stays: [] })),
|
||||||
|
http.post(`${BASE}/2026-05-14/stays/import`, record({ entries: [], alreadyTaken: [3], skipped: [] }))
|
||||||
|
);
|
||||||
|
expect(await journeyRecordingsApi.reviewDay(7, '2026-05-14')).toEqual({ date: '2026-05-14', stays: [] });
|
||||||
|
expect(await journeyRecordingsApi.takeOverDay(7, '2026-05-14', body)).toEqual({
|
||||||
|
entries: [],
|
||||||
|
alreadyTaken: [3],
|
||||||
|
skipped: [],
|
||||||
|
});
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ method: 'POST', path: `${BASE}/2026-05-14/stays/review`, body: {} },
|
||||||
|
{ method: 'POST', path: `${BASE}/2026-05-14/stays/import`, body },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-API-JOURNEY-RECORDINGS-003: reads how the journey prepares its days (JRN-03)', async () => {
|
||||||
|
const answer = {
|
||||||
|
enabled: false,
|
||||||
|
connection: 'not_connected',
|
||||||
|
lastAttemptAt: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
server.use(http.get(`${BASE}/prep`, record(answer)));
|
||||||
|
expect(await journeyRecordingsApi.prep(7)).toEqual(answer);
|
||||||
|
expect(calls).toEqual([{ method: 'GET', path: `${BASE}/prep`, body: null }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type {
|
||||||
|
JourneyDayImportRequest,
|
||||||
|
JourneyDayImportResult,
|
||||||
|
JourneyDayStays,
|
||||||
|
JourneyRecordedDaysResponse,
|
||||||
|
JourneyRecordingPrep,
|
||||||
|
JourneyRecordingPreview,
|
||||||
|
JourneyRecordingPreviewRequest,
|
||||||
|
JourneyRecordingSaveRequest,
|
||||||
|
JourneyRecordingSaveResult,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
|
||||||
|
|
||||||
|
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
|
||||||
|
|
||||||
|
/** The preview and the save ask Dawarich first, so they get its longer wait. */
|
||||||
|
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it.
|
||||||
|
* JRN-02: the stays of a day reviewed and taken over as drafts.
|
||||||
|
* JRN-03: how the journey prepares its days for the caller.
|
||||||
|
*/
|
||||||
|
export const journeyRecordingsApi = {
|
||||||
|
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
|
||||||
|
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
|
||||||
|
prep: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordingPrep> =>
|
||||||
|
apiClient.get(`${base(journeyId)}/prep`, { signal }).then((r) => r.data),
|
||||||
|
preview: (
|
||||||
|
journeyId: number,
|
||||||
|
body: JourneyRecordingPreviewRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<JourneyRecordingPreview> =>
|
||||||
|
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
|
||||||
|
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
|
||||||
|
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
|
||||||
|
remove: (journeyId: number, date: string): Promise<void> =>
|
||||||
|
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
|
||||||
|
reviewDay: (journeyId: number, date: string, signal?: AbortSignal): Promise<JourneyDayStays> =>
|
||||||
|
apiClient.post(`${base(journeyId)}/${date}/stays/review`, {}, { ...upstream, signal }).then((r) => r.data),
|
||||||
|
takeOverDay: (journeyId: number, date: string, body: JourneyDayImportRequest): Promise<JourneyDayImportResult> =>
|
||||||
|
apiClient.post(`${base(journeyId)}/${date}/stays/import`, body).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
|
||||||
|
export const skiRouteApi = {
|
||||||
|
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
|
||||||
|
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
|
||||||
|
};
|
||||||
@@ -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 { AdminFeatureToggleResponse } from '@trek/shared';
|
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
|
/** An admin's switch for a map or a service the Tours planner offers on its operator's terms. */
|
||||||
function tourBaseMapAdminApi(path: string) {
|
function tourBaseMapAdminApi(path: string) {
|
||||||
return {
|
return {
|
||||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
|
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
|
||||||
@@ -14,3 +14,5 @@ function tourBaseMapAdminApi(path: string) {
|
|||||||
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
|
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
|
||||||
/** BIK-06: CyclOSM for a bike tour. */
|
/** BIK-06: CyclOSM for a bike tour. */
|
||||||
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
|
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
|
||||||
|
/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */
|
||||||
|
export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||||
import { postMultipart } from './client';
|
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 {
|
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
|||||||
return fd;
|
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 = {
|
export const tourGpxApi = {
|
||||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
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),
|
||||||
|
};
|
||||||
@@ -4,9 +4,10 @@ import apiClient from './client';
|
|||||||
/**
|
/**
|
||||||
* PLN-02: places along a tour. A search runs several stretches against the map
|
* PLN-02: places along a tour. A search runs several stretches against the map
|
||||||
* search on the server, each allowed its own timeout there, so it may take
|
* search on the server, each allowed its own timeout there, so it may take
|
||||||
* longer than the shared default.
|
* longer than the shared default: up to three rounds of stretches, each asking
|
||||||
|
* the Overpass mirrors one after another for about twenty seconds at most.
|
||||||
*/
|
*/
|
||||||
const POI_SEARCH_TIMEOUT_MS = 60_000;
|
const POI_SEARCH_TIMEOUT_MS = 75_000;
|
||||||
|
|
||||||
export const tourPoiSearchApi = {
|
export const tourPoiSearchApi = {
|
||||||
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 {
|
import type {
|
||||||
AdminFeatureToggleResponse,
|
AdminFeatureToggleResponse,
|
||||||
TourSourceCheckResponse,
|
TourSourceCheckResponse,
|
||||||
|
TourSourceHintResponse,
|
||||||
WandererConnection,
|
WandererConnection,
|
||||||
WandererImportPreview,
|
WandererImportPreview,
|
||||||
WandererImportRequest,
|
WandererImportRequest,
|
||||||
@@ -11,6 +12,7 @@ import type {
|
|||||||
WandererSettingsRequest,
|
WandererSettingsRequest,
|
||||||
WandererTestRequest,
|
WandererTestRequest,
|
||||||
WandererTestResponse,
|
WandererTestResponse,
|
||||||
|
WandererWatch,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ export const wandererApi = {
|
|||||||
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
||||||
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
||||||
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
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> =>
|
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
||||||
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
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. */
|
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
||||||
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
||||||
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
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. */
|
/** 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
|
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||||
'tours:changed',
|
'tours:changed',
|
||||||
|
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||||
|
'tours:visits',
|
||||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||||
'collab:note:created',
|
'collab:note:created',
|
||||||
'collab:note:updated',
|
'collab:note:updated',
|
||||||
@@ -79,6 +81,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
|||||||
// Capture times landed after a photo import (#1587); the same listener reloads
|
// Capture times landed after a photo import (#1587); the same listener reloads
|
||||||
// the journey so the gallery re-sorts.
|
// the journey so the gallery re-sorts.
|
||||||
'journey:photos:updated',
|
'journey:photos:updated',
|
||||||
|
// A day's recorded route was taken over or removed (JRN-01):
|
||||||
|
// components/Journey/useJourneyRecordings reads the kept days again.
|
||||||
|
'journey:recording:updated',
|
||||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||||
// with nothing outstanding takes the new version, one with unsaved edits
|
// with nothing outstanding takes the new version, one with unsaved edits
|
||||||
// deliberately does not and conflicts on its next save instead)
|
// deliberately does not and conflicts on its next save instead)
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ beforeEach(() => {
|
|||||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
|
||||||
|
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
|
||||||
|
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
|
||||||
|
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
|
||||||
|
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { adminApi } from '../../../api/client';
|
import { adminApi } from '../../../api/client';
|
||||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||||
import { wandererAdminApi } from '../../../api/wanderer';
|
import { wandererAdminApi } from '../../../api/wanderer';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
import { useAddonStore } from '../../../store/addonStore';
|
||||||
import type { Addon } from './addonModel';
|
import type { Addon } from './addonModel';
|
||||||
@@ -40,6 +40,7 @@ beforeEach(() => {
|
|||||||
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
|
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
|
||||||
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||||
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||||
|
vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||||
});
|
});
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ async function loaded() {
|
|||||||
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
||||||
|
|
||||||
/** One of the map switches under Tours, by its key. */
|
/** One of the map switches under Tours, by its key. */
|
||||||
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
|
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle' | 'ski') {
|
||||||
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
||||||
if (!found) throw new Error(`no ${key} map switch`);
|
if (!found) throw new Error(`no ${key} map switch`);
|
||||||
return found;
|
return found;
|
||||||
@@ -149,6 +150,16 @@ describe('useAddonManager', () => {
|
|||||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
|
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-ADDONMGR-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => {
|
||||||
|
const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||||
|
const { result } = await loaded();
|
||||||
|
await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true));
|
||||||
|
await act(() => mapSwitch(result.current, 'ski').toggle());
|
||||||
|
expect(update).toHaveBeenCalledWith(false);
|
||||||
|
expect(mapSwitch(result.current, 'ski').enabled).toBe(false);
|
||||||
|
expect(mapSwitch(result.current, 'cycle').enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
|
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
|
||||||
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
|
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
|
||||||
const { result } = await loaded();
|
const { result } = await loaded();
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { Bike, Footprints } from 'lucide-react';
|
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||||
|
import { Bike, Footprints, MountainSnow } from 'lucide-react';
|
||||||
import { useEffect, useEffectEvent, useState } from 'react';
|
import { useEffect, useEffectEvent, useState } from 'react';
|
||||||
|
|
||||||
import { adminApi } from '../../../api/client';
|
import { adminApi } from '../../../api/client';
|
||||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||||
import { wandererAdminApi } from '../../../api/wanderer';
|
import { wandererAdminApi } from '../../../api/wanderer';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
import { useAddonStore } from '../../../store/addonStore';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||||
|
|
||||||
|
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||||
|
function loadToursFlag(
|
||||||
|
api: { get: () => Promise<AdminFeatureToggleResponse> },
|
||||||
|
set: (enabled: boolean) => void,
|
||||||
|
onError: () => void
|
||||||
|
) {
|
||||||
|
api
|
||||||
|
.get()
|
||||||
|
.then((flag) => set(flag.enabled))
|
||||||
|
.catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
||||||
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles
|
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles
|
||||||
@@ -24,6 +37,7 @@ export function useAddonManager() {
|
|||||||
const [toursWanderer, setToursWanderer] = useState(false);
|
const [toursWanderer, setToursWanderer] = useState(false);
|
||||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||||
|
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||||
|
|
||||||
const loadAddons = async () => {
|
const loadAddons = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -36,18 +50,11 @@ export function useAddonManager() {
|
|||||||
|
|
||||||
const loadOnMount = useEffectEvent(() => {
|
const loadOnMount = useEffectEvent(() => {
|
||||||
void loadAddons().finally(() => setLoading(false));
|
void loadAddons().finally(() => setLoading(false));
|
||||||
wandererAdminApi
|
const unreadable = () => toast.error(t('admin.addons.toast.error'));
|
||||||
.get()
|
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
|
||||||
.then((flag) => setToursWanderer(flag.enabled))
|
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
|
||||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
|
||||||
hikingMapAdminApi
|
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
|
||||||
.get()
|
|
||||||
.then((flag) => setToursHikingMap(flag.enabled))
|
|
||||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
|
||||||
cycleMapAdminApi
|
|
||||||
.get()
|
|
||||||
.then((flag) => setToursCycleMap(flag.enabled))
|
|
||||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOnMount();
|
loadOnMount();
|
||||||
@@ -94,7 +101,7 @@ export function useAddonManager() {
|
|||||||
toast.success(t('admin.addons.toast.updated'));
|
toast.success(t('admin.addons.toast.updated'));
|
||||||
};
|
};
|
||||||
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
||||||
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
|
/** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */
|
||||||
const mapSwitches = [
|
const mapSwitches = [
|
||||||
{
|
{
|
||||||
key: 'hiking',
|
key: 'hiking',
|
||||||
@@ -112,6 +119,14 @@ export function useAddonManager() {
|
|||||||
enabled: toursCycleMap,
|
enabled: toursCycleMap,
|
||||||
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
|
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'ski',
|
||||||
|
Icon: MountainSnow,
|
||||||
|
titleKey: 'admin.toursSkiRouting.title',
|
||||||
|
subtitleKey: 'admin.toursSkiRouting.subtitle',
|
||||||
|
enabled: toursSkiRouting,
|
||||||
|
toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update),
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const asOption = (provider: Addon): ProviderOption => ({
|
const asOption = (provider: Addon): ProviderOption => ({
|
||||||
|
|||||||
@@ -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 { buildSettings } from '../../../../tests/helpers/factories';
|
||||||
|
import { render, screen } from '../../../../tests/helpers/render';
|
||||||
|
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,223 @@
|
|||||||
|
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 { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
|
||||||
|
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
|
||||||
|
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||||
|
import { splitMeasure } from './tourActivityStatsModel';
|
||||||
|
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,159 @@
|
|||||||
|
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 { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { EntryTile, MeasurePills } from './tourActivityParts';
|
||||||
|
import { useDistanceUnit } from './tourActivityReading';
|
||||||
|
|
||||||
|
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={() => void 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 { buildSettings } from '../../../../tests/helpers/factories';
|
||||||
|
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||||
|
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,180 @@
|
|||||||
|
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
|
||||||
|
import { Footprints } from 'lucide-react';
|
||||||
|
import { createElement, 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, MeasurePills, UncountedBadges } from './tourActivityParts';
|
||||||
|
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
|
||||||
|
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 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">
|
||||||
|
{createElement(typeIcon(type), { 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"
|
||||||
|
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,232 @@
|
|||||||
|
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUp,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarRange,
|
||||||
|
Footprints,
|
||||||
|
Loader2,
|
||||||
|
Ruler,
|
||||||
|
WifiOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
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 { typeIcon, typeLabel, type Translate } from './tourActivityReading';
|
||||||
|
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
|
||||||
|
import type { TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 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();
|
||||||
|
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">
|
||||||
|
{createElement(tourDayRowIcon(entry.tour_type), { 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,39 @@
|
|||||||
|
import type { TourActivityTotals } from '@trek/shared';
|
||||||
|
import { Layers, type LucideIcon } from 'lucide-react';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import type { DistanceUnit } from '../../../types';
|
||||||
|
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||||
|
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||||
|
import { sumReading, type ActivityType } from './tourActivityStatsModel';
|
||||||
|
|
||||||
|
export 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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', 'run', 'kayak']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
|
||||||
|
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
|
||||||
|
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,226 @@
|
|||||||
|
// FE-STA2-HOOK-001 to FE-STA2-HOOK-009
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-008: an older exclusion answered last never puts a newer one back', async () => {
|
||||||
|
const answers: Array<(value: TourActivityStatsResponse) => void> = [];
|
||||||
|
repo.setExcluded.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
const excludedOf = (placeId: number) =>
|
||||||
|
result.current.data?.entries.find((item) => item.place_id === placeId)?.excluded;
|
||||||
|
const withExcluded = (...ids: number[]) => ({
|
||||||
|
...stats,
|
||||||
|
entries: stats.entries.map((item) => ({ ...item, excluded: ids.includes(item.place_id) })),
|
||||||
|
});
|
||||||
|
|
||||||
|
let first: Promise<void> = Promise.resolve();
|
||||||
|
let second: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
first = result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, true);
|
||||||
|
});
|
||||||
|
// The second answer comes first and carries both; the first, from before the second, only its own.
|
||||||
|
await act(async () => {
|
||||||
|
answers[1]?.(withExcluded(1, 2));
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
answers[0]?.(withExcluded(1));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
expect(excludedOf(1)).toBe(true);
|
||||||
|
expect(excludedOf(2)).toBe(true);
|
||||||
|
|
||||||
|
// The other way round: the first comes first, without the second still on its way, which stays left out.
|
||||||
|
act(() => {
|
||||||
|
first = result.current.setExcluded(entry(), false);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, false);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
answers[2]?.(withExcluded(2));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
expect(excludedOf(1)).toBe(false);
|
||||||
|
expect(excludedOf(2)).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
answers[3]?.(withExcluded());
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
expect(excludedOf(2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-009: a read asked before an exclusion and answered after it does not undo it', async () => {
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
let read: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||||
|
repo.get.mockReturnValue(new Promise((resolve) => (read = resolve)));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
repo.setExcluded.mockResolvedValue({
|
||||||
|
...stats,
|
||||||
|
entries: stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item)),
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
await act(async () => read(stats));
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
expect(result.current.data?.entries.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
summarizeTourActivities,
|
||||||
|
type TourActivityStatsEntry,
|
||||||
|
type TourActivityStatsResponse,
|
||||||
|
type TourActivitySummary,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every entry with the flags of the exclusions still on their way laid over it, the way they show already. */
|
||||||
|
function withFlags(data: TourActivityStatsResponse, flags: ReadonlyMap<number, boolean>): TourActivityStatsResponse {
|
||||||
|
if (flags.size === 0) return data;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
entries: data.entries.map((entry) =>
|
||||||
|
flags.has(entry.place_id) ? { ...entry, excluded: flags.get(entry.place_id) === true } : 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. Answers can come
|
||||||
|
* back in any order: only the answer to the latest read or write is taken
|
||||||
|
* whole, with the exclusions still on their way laid over it; an older one
|
||||||
|
* only confirms its own tour's flag, so it never puts back a state from
|
||||||
|
* before a newer change.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
// Raised by every read and every write, so an answer knows whether a newer request went out after it.
|
||||||
|
const latest = useRef(0);
|
||||||
|
// The exclusions on their way to the server, by tour: the flag the user asked for.
|
||||||
|
const inFlight = useRef(new Map<number, boolean>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const asked = ++latest.current;
|
||||||
|
tourActivityStatsRepo.get(controller.signal).then(
|
||||||
|
(answer) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||||
|
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;
|
||||||
|
const asked = ++latest.current;
|
||||||
|
inFlight.current.set(placeId, excluded);
|
||||||
|
setPending((current) => new Set(current).add(placeId));
|
||||||
|
setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||||
|
try {
|
||||||
|
const answer = await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded });
|
||||||
|
inFlight.current.delete(placeId);
|
||||||
|
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||||
|
else setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||||
|
} catch {
|
||||||
|
inFlight.current.delete(placeId);
|
||||||
|
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,4 +1,4 @@
|
|||||||
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014
|
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-019
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import type { DawarichSuggestion } from '@trek/shared'
|
import type { DawarichSuggestion } from '@trek/shared'
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
formatDayOption,
|
formatDayOption,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
groupByDay,
|
groupByDay,
|
||||||
|
openStayDatesWithin,
|
||||||
openStaysByDate,
|
openStaysByDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
} from './dawarichSuggestionModel'
|
} from './dawarichSuggestionModel'
|
||||||
@@ -110,6 +111,11 @@ describe('clockOf / timeRange', () => {
|
|||||||
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense', false)).toBe('10:15')
|
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense', false)).toBe('10:15')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-SUGMODEL-020: a stay into the next day carries the days it ran over', () => {
|
||||||
|
expect(timeRange('2026-10-24T18:30:00+02:00', '2026-10-25T08:30:00+02:00', false)).toBe('18:30 – 08:30 +1')
|
||||||
|
expect(timeRange('2026-10-24T18:30:00+02:00', '2026-10-26T08:30:00+02:00', false)).toBe('18:30 – 08:30 +2')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
||||||
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00', false)).toBe('')
|
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00', false)).toBe('')
|
||||||
})
|
})
|
||||||
@@ -212,4 +218,20 @@ describe('openStaysByDate', () => {
|
|||||||
|
|
||||||
expect(byDate.size).toBe(0)
|
expect(byDate.size).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-SUGMODEL-019: the waiting days inside a journal span, oldest first, and none without a span', () => {
|
||||||
|
// The stays are the whole account's; a day outside the journal's entries and trips is
|
||||||
|
// not one it can take over.
|
||||||
|
const byDate = openStaysByDate([
|
||||||
|
stay({ id: 1, localDate: '2026-09-12' }),
|
||||||
|
stay({ id: 2, localDate: '2026-09-10' }),
|
||||||
|
stay({ id: 3, localDate: '2026-12-24' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(openStayDatesWithin(byDate, ['2026-09-11', null, '2026-09-09', undefined, '2026-09-14'])).toEqual([
|
||||||
|
'2026-09-10',
|
||||||
|
'2026-09-12',
|
||||||
|
])
|
||||||
|
expect(openStayDatesWithin(byDate, [null, undefined])).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ export function openStaysByDate(suggestions: DawarichSuggestion[]): Map<string,
|
|||||||
return byDate;
|
return byDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The days with waiting stays that fall inside a journal's span, oldest first. The span
|
||||||
|
* is whatever dates bound it (its entries, its trips' first and last days); without any
|
||||||
|
* there is nothing to place a stay against, because the stays are the whole account's.
|
||||||
|
*/
|
||||||
|
export function openStayDatesWithin(
|
||||||
|
byDate: Map<string, DawarichSuggestion[]>,
|
||||||
|
span: Array<string | null | undefined>
|
||||||
|
): string[] {
|
||||||
|
const bounds = span.filter((date): date is string => !!date).sort((a, b) => a.localeCompare(b));
|
||||||
|
const first = bounds[0];
|
||||||
|
const last = bounds[bounds.length - 1];
|
||||||
|
if (!first || !last) return [];
|
||||||
|
return [...byDate.keys()].filter((date) => date >= first && date <= last).sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||||
export function clockOf(iso: string): string {
|
export function clockOf(iso: string): string {
|
||||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||||
@@ -86,12 +102,17 @@ export function clockOf(iso: string): string {
|
|||||||
* timestamp and printed, so a traveller on a 12-hour clock read every Dawarich time in
|
* timestamp and printed, so a traveller on a 12-hour clock read every Dawarich time in
|
||||||
* 24-hour while the rest of the app obeyed the setting. A required argument makes the
|
* 24-hour while the rest of the app obeyed the setting. A required argument makes the
|
||||||
* next caller answer the question rather than inherit the bug.
|
* next caller answer the question rather than inherit the bug.
|
||||||
|
*
|
||||||
|
* A stay that ends on a later day says so, `08:30 +1` after an evening arrival, as a timetable
|
||||||
|
* does, or a night at the hotel reads as fourteen hours that never happened.
|
||||||
*/
|
*/
|
||||||
export function timeRange(startedAt: string, endedAt: string, is12h: boolean): string {
|
export function timeRange(startedAt: string, endedAt: string, is12h: boolean): string {
|
||||||
const from = formatClockTime(clockOf(startedAt), is12h)
|
const from = formatClockTime(clockOf(startedAt), is12h)
|
||||||
const to = formatClockTime(clockOf(endedAt), is12h)
|
const to = formatClockTime(clockOf(endedAt), is12h)
|
||||||
if (!from) return ''
|
if (!from) return ''
|
||||||
return to ? `${from} – ${to}` : from
|
if (!to) return from
|
||||||
|
const days = Math.round((Date.parse(endedAt.slice(0, 10)) - Date.parse(startedAt.slice(0, 10))) / 86_400_000)
|
||||||
|
return days > 0 ? `${from} – ${to} +${days}` : `${from} – ${to}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useId, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import DawarichIcon from '../shared/DawarichIcon';
|
||||||
|
import { DialogFooter, DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desktop frame of a journey day read from Dawarich (JRN-01, JRN-02): the
|
||||||
|
* Dawarich mark, the journey and the day in the head band, what the dialog
|
||||||
|
* does under them, the body and the buttons.
|
||||||
|
*/
|
||||||
|
export default function DawarichDayDialog({
|
||||||
|
journeyTitle,
|
||||||
|
date,
|
||||||
|
sub,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
journeyTitle: string;
|
||||||
|
date: string;
|
||||||
|
sub: string;
|
||||||
|
onClose: () => void;
|
||||||
|
footer: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const labelId = useId();
|
||||||
|
const title = new Date(`${date}T00:00:00`).toLocaleDateString(locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy={labelId}
|
||||||
|
width="detail"
|
||||||
|
align="top"
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
tile={
|
||||||
|
<DialogTile>
|
||||||
|
<DawarichIcon size={26} />
|
||||||
|
</DialogTile>
|
||||||
|
}
|
||||||
|
tint={NEUTRAL_TINT}
|
||||||
|
labelId={labelId}
|
||||||
|
onClose={onClose}
|
||||||
|
eyebrow={journeyTitle}
|
||||||
|
title={title}
|
||||||
|
sub={sub}
|
||||||
|
subWraps
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={<DialogFooter>{footer}</DialogFooter>}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-008
|
// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-010
|
||||||
/**
|
/**
|
||||||
* The fold a journal day carries for the stays Dawarich recorded on it.
|
* The fold a journal day carries for the stays Dawarich recorded on it.
|
||||||
*
|
*
|
||||||
@@ -144,4 +144,27 @@ describe('JourneyDayDawarich', () => {
|
|||||||
expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument()
|
expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument()
|
||||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } }))
|
useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-DAYDAW-009: beside the shut row, the whole day can be taken over at once (JRN-02)', () => {
|
||||||
|
const onTakeOverDay = vi.fn()
|
||||||
|
render(
|
||||||
|
<JourneyDayDawarich
|
||||||
|
suggestions={[stay({ id: 1 })]}
|
||||||
|
busyId={null}
|
||||||
|
onAccept={vi.fn()}
|
||||||
|
onDismiss={vi.fn()}
|
||||||
|
onTakeOverDay={onTakeOverDay}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Take over day' }))
|
||||||
|
|
||||||
|
expect(onTakeOverDay).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-DAYDAW-010: without the right to take a day over there is no such button', () => {
|
||||||
|
fold([stay({ id: 1 })])
|
||||||
|
expect(screen.queryByRole('button', { name: 'Take over day' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ListChecks } from 'lucide-react'
|
||||||
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import DawarichIcon from '../shared/DawarichIcon'
|
import DawarichIcon from '../shared/DawarichIcon'
|
||||||
|
import { fs } from '../shared/DialogShell'
|
||||||
import { SuggestionRow } from '../Dawarich/DawarichSuggestionsPanel'
|
import { SuggestionRow } from '../Dawarich/DawarichSuggestionsPanel'
|
||||||
import { timeRange } from '../Dawarich/dawarichSuggestionModel'
|
import { timeRange } from '../Dawarich/dawarichSuggestionModel'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -28,6 +29,7 @@ export default function JourneyDayDawarich({
|
|||||||
busyId,
|
busyId,
|
||||||
onAccept,
|
onAccept,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
onTakeOverDay,
|
||||||
}: {
|
}: {
|
||||||
/** The open stays of this day, in the order they were lived. */
|
/** The open stays of this day, in the order they were lived. */
|
||||||
suggestions: DawarichSuggestion[]
|
suggestions: DawarichSuggestion[]
|
||||||
@@ -35,6 +37,8 @@ export default function JourneyDayDawarich({
|
|||||||
busyId: number | null
|
busyId: number | null
|
||||||
onAccept: (suggestion: DawarichSuggestion, target: DawarichSuggestionTarget) => void
|
onAccept: (suggestion: DawarichSuggestion, target: DawarichSuggestionTarget) => void
|
||||||
onDismiss: (suggestion: DawarichSuggestion) => void
|
onDismiss: (suggestion: DawarichSuggestion) => void
|
||||||
|
/** Review the whole day with its stays and route instead of one stay at a time (JRN-02). */
|
||||||
|
onTakeOverDay?: () => void
|
||||||
}): React.ReactElement | null {
|
}): React.ReactElement | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
@@ -49,11 +53,12 @@ export default function JourneyDayDawarich({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-edge bg-surface-secondary overflow-hidden">
|
<div className="rounded-2xl border border-edge bg-surface-secondary overflow-hidden">
|
||||||
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => setOpen(v => !v)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover"
|
className="flex min-w-0 flex-1 items-center gap-2.5 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover"
|
||||||
>
|
>
|
||||||
{/* The brand mark in its own colours, which is what tells this row from an entry
|
{/* The brand mark in its own colours, which is what tells this row from an entry
|
||||||
at a glance. No second label saying "Dawarich": the mark is the label. */}
|
at a glance. No second label saying "Dawarich": the mark is the label. */}
|
||||||
@@ -68,6 +73,20 @@ export default function JourneyDayDawarich({
|
|||||||
? <ChevronDown size={16} className="shrink-0 text-content-faint" aria-hidden />
|
? <ChevronDown size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||||
: <ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />}
|
: <ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
|
{/* The whole day at once, beside the row: choosing, joining and naming
|
||||||
|
its stays together beats accepting them one by one. */}
|
||||||
|
{onTakeOverDay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTakeOverDay}
|
||||||
|
className="me-2 inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text transition-opacity hover:opacity-90"
|
||||||
|
style={fs(12, 'body')}
|
||||||
|
>
|
||||||
|
<ListChecks size={14} aria-hidden />
|
||||||
|
{t('journey.dayImport.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="flex flex-col gap-1 pb-2">
|
<div className="flex flex-col gap-1 pb-2">
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// FE-JRN-DAY-IMPORT-BTN-001 to FE-JRN-DAY-IMPORT-BTN-005
|
||||||
|
//
|
||||||
|
// JRN-02: the day header's way into taking a day over with its stays.
|
||||||
|
// JRN-03: the mark of a prepared day that is not final yet.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JourneyDayImportButton from './JourneyDayImportButton';
|
||||||
|
|
||||||
|
function renderButton(props: Partial<Parameters<typeof JourneyDayImportButton>[0]> = {}) {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<JourneyDayImportButton pending={0} canTakeOver needsConnection={false} onOpen={onOpen} {...props} />
|
||||||
|
);
|
||||||
|
return { onOpen, ...view };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JourneyDayImportButton', () => {
|
||||||
|
it('FE-JRN-DAY-IMPORT-BTN-001: counts the stays still waiting on the day and opens the take-over', async () => {
|
||||||
|
const { onOpen } = renderButton({ pending: 6 });
|
||||||
|
const button = screen.getByTestId('journey-day-import');
|
||||||
|
expect(button).toHaveTextContent('6');
|
||||||
|
expect(button).toHaveAccessibleName('6 stays to review');
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-IMPORT-BTN-002: a day with nothing waiting still offers the take-over, without a number', () => {
|
||||||
|
renderButton();
|
||||||
|
const button = screen.getByTestId('journey-day-import');
|
||||||
|
expect(button).toHaveAccessibleName('Take over this day with its stays');
|
||||||
|
expect(button.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-IMPORT-BTN-003: offline it stays dimmed and says why; without Dawarich or the right to edit it is not there', async () => {
|
||||||
|
const { onOpen, unmount } = renderButton({ canTakeOver: false, needsConnection: true, pending: 2 });
|
||||||
|
const button = screen.getByTestId('journey-day-import');
|
||||||
|
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderButton({ canTakeOver: false, needsConnection: false });
|
||||||
|
expect(screen.queryByTestId('journey-day-import')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-IMPORT-BTN-004: a prepared day still running carries a dot and says so after the count', () => {
|
||||||
|
renderButton({ pending: 6, prep: { state: 'running', failed: false, lastSuccessAt: null } });
|
||||||
|
expect(screen.getByTestId('journey-day-prep-unsettled')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('journey-day-import')).toHaveAccessibleName(
|
||||||
|
/^6 stays to review\s+The day is still running, more stays may follow\.$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-IMPORT-BTN-005: a day fetched whole carries no dot, one whose refresh failed does', () => {
|
||||||
|
const { unmount } = renderButton({
|
||||||
|
prep: { state: 'complete', failed: false, lastSuccessAt: '2026-05-15T08:00:00Z' },
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('journey-day-prep-unsettled')).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderButton({ prep: { state: 'complete', failed: true, lastSuccessAt: '2026-05-15T08:00:00Z' } });
|
||||||
|
expect(screen.getByTestId('journey-day-prep-unsettled')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('journey-day-import')).toHaveAccessibleName(
|
||||||
|
/The last refresh failed, this is the older state\./
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ListChecks } from 'lucide-react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { dayPrepText, dayPrepUnsettled, type JourneyDayPrep } from './journeyRecordingPrepModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking a day over with its stays (JRN-02), in the day header beside the
|
||||||
|
* route: for an editor whose Dawarich answers, with the number of stays still
|
||||||
|
* waiting on the day; offline it stays, dimmed, and says why. While the
|
||||||
|
* journey prepares its days (JRN-03), a dot marks a day that is not final yet
|
||||||
|
* (still running, fetched before it ended, not fetched, or stale after a
|
||||||
|
* failed refresh) and the tooltip says which.
|
||||||
|
*/
|
||||||
|
export default function JourneyDayImportButton({
|
||||||
|
pending,
|
||||||
|
canTakeOver,
|
||||||
|
needsConnection,
|
||||||
|
prep,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
/** Stays of the day nobody decided on yet. */
|
||||||
|
pending: number;
|
||||||
|
canTakeOver: boolean;
|
||||||
|
needsConnection: boolean;
|
||||||
|
/** Where preparing the day stands; absent while the journey does not prepare it. */
|
||||||
|
prep?: JourneyDayPrep | null;
|
||||||
|
onOpen: () => void;
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
if (!canTakeOver && !needsConnection) return null;
|
||||||
|
const base = !canTakeOver
|
||||||
|
? t('dawarich.error.offline')
|
||||||
|
: pending > 0
|
||||||
|
? t('journey.dayImport.pending', { count: pending })
|
||||||
|
: t('journey.dayImport.openHint');
|
||||||
|
const unsettled = canTakeOver && dayPrepUnsettled(prep);
|
||||||
|
// The tooltip breaks before the second line; a screen reader reads both as one.
|
||||||
|
const label = unsettled && prep ? `${base}\n${dayPrepText(prep, t, locale)}` : base;
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={canTakeOver ? onOpen : undefined}
|
||||||
|
aria-disabled={!canTakeOver}
|
||||||
|
aria-label={label}
|
||||||
|
data-testid="journey-day-import"
|
||||||
|
className="inline-flex h-7 items-center gap-1 rounded-full px-2 text-content-secondary transition-colors aria-disabled:cursor-default aria-disabled:opacity-40 [&:not([aria-disabled=true])]:hover:bg-surface-hover"
|
||||||
|
style={{ background: 'var(--vg-surf2)' }}
|
||||||
|
>
|
||||||
|
<ListChecks size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{pending > 0 && (
|
||||||
|
<span className="font-bold tabular-nums" style={fs(10.5)}>
|
||||||
|
{pending}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{unsettled && (
|
||||||
|
<span
|
||||||
|
data-testid="journey-day-prep-unsettled"
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-warning"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// FE-JRN-DAYIMPORT-DIALOG-001 to FE-JRN-DAYIMPORT-DIALOG-008
|
||||||
|
//
|
||||||
|
// JRN-02: the desktop dialog that takes a recorded day over with its stays. It
|
||||||
|
// names the journey and the day, shows the stays on the map and in a list to
|
||||||
|
// choose, rename and join, what the journal holds on the day already and who
|
||||||
|
// sees the drafts, and writes only on its button.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildDayStay, buildDayStays, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||||
|
import JourneyDayImportDialog from './JourneyDayImportDialog';
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({
|
||||||
|
reviewDay: vi.fn(),
|
||||||
|
takeOverDay: vi.fn(),
|
||||||
|
preview: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const map = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
|
||||||
|
vi.mock('./JourneyMapAuto', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
map.props = props;
|
||||||
|
return <div data-testid="day-map" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderDialog(prep?: JourneyRecordingPrepState) {
|
||||||
|
const props = { onTakenOver: vi.fn(), onKept: vi.fn(), onClose: vi.fn() };
|
||||||
|
render(
|
||||||
|
<JourneyDayImportDialog
|
||||||
|
journeyId={7}
|
||||||
|
journeyTitle="Venice"
|
||||||
|
date="2026-05-14"
|
||||||
|
dayColor="#ff0055"
|
||||||
|
storedRoute={undefined}
|
||||||
|
prep={prep}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepState(over: Partial<JourneyRecordingPrepState> = {}): JourneyRecordingPrepState {
|
||||||
|
return {
|
||||||
|
prep: {
|
||||||
|
enabled: true,
|
||||||
|
connection: 'ready',
|
||||||
|
lastAttemptAt: '2026-05-16T08:00:00.000Z',
|
||||||
|
lastSuccessAt: '2026-05-14T18:00:00.000Z',
|
||||||
|
lastError: 'unreachable',
|
||||||
|
},
|
||||||
|
saving: false,
|
||||||
|
fetching: false,
|
||||||
|
statusLine: { text: 'The last refresh failed: Dawarich did not answer.', tone: 'warning' },
|
||||||
|
canFetch: true,
|
||||||
|
setEnabled: vi.fn(),
|
||||||
|
fetchNow: vi.fn(async () => {}),
|
||||||
|
dayPrep: () => ({ state: 'provisional', failed: true, lastSuccessAt: '2026-05-14T18:00:00.000Z' }),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOf = (name: string) =>
|
||||||
|
screen.getByRole('checkbox', { name: `Take over ${name}` }).closest('[data-stay-id]') as HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
|
||||||
|
repo.reviewDay.mockResolvedValue(
|
||||||
|
buildDayStays({
|
||||||
|
entries: [{ id: 3, title: 'Breakfast at the hotel', locationName: null, entryTime: '08:00', isDraft: false }],
|
||||||
|
audience: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
repo.takeOverDay.mockResolvedValue({
|
||||||
|
entries: [{ id: 5, title: 'Piazza San Marco', stayCount: 2 }],
|
||||||
|
alreadyTaken: [],
|
||||||
|
skipped: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyDayImportDialog', () => {
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-001: names the journey and the day, and shows the stays, the route and the day so far', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('checkbox', { name: 'Take over Parcheggio' })).toBeChecked();
|
||||||
|
expect(screen.getByText('Venice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/May 14, 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('day-map')).toBeInTheDocument();
|
||||||
|
expect(map.props.entries).toHaveLength(4);
|
||||||
|
expect(screen.getByText('Breakfast at the hotel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 other people on this journey see them/)).toBeInTheDocument();
|
||||||
|
expect(within(rowOf('Parcheggio')).getByText('Unconfirmed')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("Keep the day's route too")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Take over 3 drafts' })).toBeEnabled();
|
||||||
|
expect(repo.takeOverDay).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-002: the same place twice starts joined under one title, and can be kept apart', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Title of the entry' })).toHaveValue('Piazza San Marco');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Keep apart' }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Title of the entry' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Take over 4 drafts' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-003: a stay left out is dimmed and counted, and the drafts are written on the button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const props = renderDialog();
|
||||||
|
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'Take over Parcheggio' }));
|
||||||
|
expect(rowOf('Parcheggio').className).toContain('opacity-60');
|
||||||
|
expect(screen.getByText('1 left out')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Take over 2 drafts' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||||
|
expect(repo.takeOverDay).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
'2026-05-14',
|
||||||
|
expect.objectContaining({ skipSuggestionIds: [10] })
|
||||||
|
);
|
||||||
|
expect(props.onTakenOver).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-004: a day report gathers every chosen stay under one title', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Day report' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Title of the entry' })).toHaveValue('Day of May 14');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Keep apart' })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Take over 1 draft' }));
|
||||||
|
await waitFor(() => expect(repo.takeOverDay).toHaveBeenCalled());
|
||||||
|
expect(repo.takeOverDay.mock.calls[0]?.[2]).toMatchObject({
|
||||||
|
groups: [{ kind: 'report', title: 'Day of May 14', anchorSuggestionId: 12 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-005: a stay the journal holds is shown with its entry and cannot be chosen again', async () => {
|
||||||
|
repo.reviewDay.mockResolvedValue(
|
||||||
|
buildDayStays({
|
||||||
|
stays: [buildDayStay({ suggestionId: 11, status: 'here', entry: { id: 4, title: 'Morning in San Marco' } })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
const box = await screen.findByRole('checkbox', { name: 'Take over Piazza San Marco' });
|
||||||
|
expect(box).toBeDisabled();
|
||||||
|
expect(box).not.toBeChecked();
|
||||||
|
expect(screen.getByText('In Morning in San Marco')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Take over 0 drafts' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-006: when Dawarich does not answer, the stays of the last sync are shown with the reason', async () => {
|
||||||
|
repo.reviewDay.mockResolvedValue(
|
||||||
|
buildDayStays({ fresh: false, error: 'unreachable', syncedAt: '2026-05-14T20:00:00Z', truncated: true })
|
||||||
|
);
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Dawarich did not answer/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Some stays may be missing/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Take over Rialto' })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-007: an unsettled prepared day says why, with the caller status and a fetch by hand', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const prep = prepState();
|
||||||
|
renderDialog(prep);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/before the day ended, so stays may be missing/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/this is the older state/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('journey-prep-status')).toHaveTextContent(
|
||||||
|
'The last refresh failed: Dawarich did not answer.'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Fetch now' }));
|
||||||
|
expect(prep.fetchNow).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-DIALOG-008: without the journey preparing its days, no status and no day notice', async () => {
|
||||||
|
renderDialog(prepState({ statusLine: null, canFetch: false, dayPrep: () => null }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('checkbox', { name: 'Take over Parcheggio' })).toBeChecked();
|
||||||
|
expect(screen.queryByTestId('journey-prep-status')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/this is the older state/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import DawarichDayDialog from './DawarichDayDialog';
|
||||||
|
import { JourneyDayImportActions, JourneyDayImportReview } from './JourneyDayImportReview';
|
||||||
|
import { useJourneyDayImport, type JourneyDayImportViewProps } from './useJourneyDayImport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking a recorded day with its stays into the journal (JRN-02), on the
|
||||||
|
* desktop: the route, the stays and the day's entries in the body, the drafts
|
||||||
|
* written only on the button that says how many.
|
||||||
|
*/
|
||||||
|
export default function JourneyDayImportDialog({
|
||||||
|
journeyTitle,
|
||||||
|
date,
|
||||||
|
dayColor,
|
||||||
|
onClose,
|
||||||
|
prep,
|
||||||
|
...rest
|
||||||
|
}: JourneyDayImportViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const state = useJourneyDayImport({ date, dayColor, onClose, ...rest });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DawarichDayDialog
|
||||||
|
journeyTitle={state.review?.journey.title ?? journeyTitle}
|
||||||
|
date={date}
|
||||||
|
sub={t('journey.dayImport.sub')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<JourneyDayImportActions state={state} onCancel={onClose} />}
|
||||||
|
>
|
||||||
|
<JourneyDayImportReview state={state} dayColor={dayColor} prep={prep} />
|
||||||
|
</DawarichDayDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { Clock, FilePen, Loader2, RefreshCw, Route, Users } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { formatClockTime } from '../../utils/formatters';
|
||||||
|
import { formatDistance } from '../../utils/units';
|
||||||
|
import { SoftPill } from '../Planner/planParts';
|
||||||
|
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||||
|
import { Segmented } from '../shared/dialogParts';
|
||||||
|
import { DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { JourneyDayImportStays } from './JourneyDayImportStays';
|
||||||
|
import JourneyMap from './JourneyMapAuto';
|
||||||
|
import { reasonText } from './journeyRecordingModel';
|
||||||
|
import { dayPrepNotice } from './journeyRecordingPrepModel';
|
||||||
|
import { JourneyRecordingPrepStatus } from './JourneyRecordingPrepSetting';
|
||||||
|
import { Notice } from './JourneyRecordingReview';
|
||||||
|
import { stayMarkerId, type JourneyDayImportState } from './useJourneyDayImport';
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
|
||||||
|
|
||||||
|
type Props = { state: JourneyDayImportState; dayColor: string; prep?: JourneyRecordingPrepState };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the list may not be the whole day, and what went wrong on the way. With
|
||||||
|
* the journey preparing its days, why this day is not final yet and how the
|
||||||
|
* caller's own preparation stands, so an editor sees it without the settings.
|
||||||
|
*/
|
||||||
|
function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: JourneyRecordingPrepState }) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const { review, outcome } = state;
|
||||||
|
const reason = review ? reasonText(review.error, t) : '';
|
||||||
|
const day = prep?.dayPrep(state.date);
|
||||||
|
const dayNotice = day ? dayPrepNotice(day, t, locale) : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{outcome?.kind === 'failed' && (
|
||||||
|
<Notice tone="danger">{t('journey.dayImport.failed', { reason: outcome.reason })}</Notice>
|
||||||
|
)}
|
||||||
|
{outcome?.kind === 'route' && (
|
||||||
|
<Notice tone="warning">{t('journey.dayImport.routeFailed', { reason: outcome.reason })}</Notice>
|
||||||
|
)}
|
||||||
|
{review && !review.fresh && (
|
||||||
|
<Notice tone="warning">
|
||||||
|
{review.syncedAt
|
||||||
|
? t('journey.dayImport.stale', {
|
||||||
|
reason,
|
||||||
|
date: new Date(review.syncedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||||
|
})
|
||||||
|
: t('journey.dayImport.staleNever', { reason })}
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
{review?.truncated && <Notice tone="warning">{t('journey.dayImport.truncated')}</Notice>}
|
||||||
|
{state.running && <Notice tone="info">{t('journey.dayImport.running')}</Notice>}
|
||||||
|
{dayNotice && <Notice tone="warning">{dayNotice}</Notice>}
|
||||||
|
{prep && <JourneyRecordingPrepStatus prep={prep} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day's route under the pins: kept already, to keep along, kept in another version, or none recorded. */
|
||||||
|
function RouteLine({ state }: { state: JourneyDayImportState }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||||
|
const { route } = state;
|
||||||
|
const distance = route.distanceMeters === null ? null : formatDistance(route.distanceMeters / 1000, unit);
|
||||||
|
if (route.offer === 'loading') {
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex items-center gap-1.5 text-content-faint" style={fs(11.5)}>
|
||||||
|
<Loader2 size={12} className="animate-spin" aria-hidden="true" />
|
||||||
|
{t('journey.recording.loading')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (route.offer === 'keep') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-content" style={fs(12.5, 'body')}>
|
||||||
|
{t('journey.dayImport.keepRoute')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.dayImport.keepRouteHint', { distance: distance ?? '' })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch on={route.keep} onToggle={route.toggle} label={t('journey.dayImport.keepRoute')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (route.offer === 'none') {
|
||||||
|
return (
|
||||||
|
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.dayImport.routeNone')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{distance && <SoftPill icon={<Route size={11} aria-hidden="true" />}>{distance}</SoftPill>}
|
||||||
|
<SoftPill tone="success">{t('journey.dayImport.routeKept')}</SoftPill>
|
||||||
|
{route.offer === 'differs' && (
|
||||||
|
<span className="text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.dayImport.routeDiffers')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the journal has on the day already, so nothing is taken over twice by hand. */
|
||||||
|
function DayEntries({ state }: { state: JourneyDayImportState }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||||
|
const entries = state.review?.entries ?? [];
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<DialogSection label={t('journey.dayImport.existing')}>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex min-w-0 items-center gap-1.5 text-content-secondary"
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<span className="truncate">{entry.title || entry.locationName || t('journey.dayImport.untitled')}</span>
|
||||||
|
{entry.entryTime && (
|
||||||
|
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
|
||||||
|
{formatClockTime(entry.entryTime, is12h)}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
{entry.isDraft && (
|
||||||
|
<SoftPill icon={<FilePen size={11} aria-hidden="true" />}>{t('journey.dayImport.draft')}</SoftPill>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToStay(id: string): void {
|
||||||
|
document
|
||||||
|
.querySelector(`[data-stay-id="${id.replace('stay-', '')}"]`)
|
||||||
|
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewing a recorded day with its stays (JRN-02): the route and the stays on
|
||||||
|
* one map, the stays as a list to choose, rename and join, what the journal
|
||||||
|
* holds on the day already, and who will see the drafts. The body of the
|
||||||
|
* desktop dialog and of the phone sheet alike.
|
||||||
|
*/
|
||||||
|
export function JourneyDayImportReview({ state, dayColor, prep }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { review, choice } = state;
|
||||||
|
if (!review) {
|
||||||
|
if (state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Notice tone="danger">{state.error}</Notice>
|
||||||
|
<span>
|
||||||
|
<DialogButton onClick={state.retry} icon={<RefreshCw size={14} aria-hidden="true" />}>
|
||||||
|
{t('journey.recording.retry')}
|
||||||
|
</DialogButton>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex items-center gap-2 py-6 text-content-muted" role="status" style={fs(12.5, 'body')}>
|
||||||
|
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||||
|
{t('journey.dayImport.loading')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DayNotices state={state} prep={prep} />
|
||||||
|
<DialogSection label={t('journey.dayImport.map')}>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="overflow-hidden rounded-[12px] border border-edge-faint">
|
||||||
|
<JourneyMap
|
||||||
|
checkins={[]}
|
||||||
|
entries={state.mapEntries}
|
||||||
|
tracks={state.mapTracks}
|
||||||
|
height={200}
|
||||||
|
activeMarkerId={state.activeStay === null ? null : stayMarkerId(state.activeStay)}
|
||||||
|
onMarkerClick={scrollToStay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RouteLine state={state} />
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
<DialogSection
|
||||||
|
label={t('journey.dayImport.stays')}
|
||||||
|
action={
|
||||||
|
choice && review.stays.length > 1 ? (
|
||||||
|
<Segmented
|
||||||
|
label={t('journey.dayImport.stays')}
|
||||||
|
value={choice.mode}
|
||||||
|
onChange={state.setMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'entries', label: t('journey.dayImport.modeEntries') },
|
||||||
|
{ value: 'report', label: t('journey.dayImport.modeReport') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{review.stays.length === 0 ? (
|
||||||
|
<Notice tone="info">{t('journey.dayImport.empty')}</Notice>
|
||||||
|
) : (
|
||||||
|
<JourneyDayImportStays state={state} dayColor={dayColor} />
|
||||||
|
)}
|
||||||
|
</DialogSection>
|
||||||
|
<DayEntries state={state} />
|
||||||
|
<p className="m-0 flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
<Users size={14} className="mt-px flex-none" aria-hidden="true" />
|
||||||
|
{review.audience > 0
|
||||||
|
? t('journey.dayImport.audience', { count: review.audience })
|
||||||
|
: t('journey.dayImport.draftsNote')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buttons under the review: how many stays are left out, close, and the
|
||||||
|
* one that takes the chosen stays over as drafts, saying why when it cannot
|
||||||
|
* yet. After a route that was not kept, it offers the route alone again.
|
||||||
|
*/
|
||||||
|
export function JourneyDayImportActions({ state, onCancel }: { state: JourneyDayImportState; onCancel: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { problem, busy } = state;
|
||||||
|
const working = busy !== null;
|
||||||
|
if (state.outcome?.kind === 'route') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton onClick={onCancel}>{t('common.close')}</DialogButton>
|
||||||
|
<DialogButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={state.retryRoute}
|
||||||
|
disabled={working}
|
||||||
|
icon={busy === 'route' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('journey.dayImport.routeRetry')}
|
||||||
|
</DialogButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const submit = (
|
||||||
|
<DialogButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={state.submit}
|
||||||
|
disabled={problem !== null || working || state.loading}
|
||||||
|
icon={busy === 'submit' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('journey.dayImport.submit', { count: state.groups.length })}
|
||||||
|
</DialogButton>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.skipCount > 0 && (
|
||||||
|
<Tooltip label={t('journey.dayImport.skipHint')} placement="top">
|
||||||
|
<button type="button" className="inline-flex cursor-default">
|
||||||
|
<SoftPill>{t('journey.dayImport.skipped', { count: state.skipCount })}</SoftPill>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
|
||||||
|
{problem !== null && state.review && state.review.stays.length > 0 ? (
|
||||||
|
<Tooltip label={t(`journey.dayImport.problem.${problem}`)} placement="top">
|
||||||
|
<span className="inline-flex">{submit}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
submit
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import type { JourneyDayStay } from '@trek/shared';
|
||||||
|
import { Check, Clock, Gauge, HelpCircle, Hourglass, Link2, MapPinOff, Unlink2 } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { formatDuration, timeRange } from '../Dawarich/dawarichSuggestionModel';
|
||||||
|
import { SoftPill } from '../Planner/planParts';
|
||||||
|
import { INPUT, PANEL, PillSelect } from '../shared/dialogParts';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { canJoin, isTakeable, nameOf, REPORT_KEY, type DayImportGroup } from './journeyDayImportModel';
|
||||||
|
import type { JourneyDayImportState } from './useJourneyDayImport';
|
||||||
|
|
||||||
|
type Props = { state: JourneyDayImportState; dayColor: string };
|
||||||
|
|
||||||
|
/** A fact about a stay with what it means on hover. */
|
||||||
|
function Hinted({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="top">
|
||||||
|
<button type="button" className="inline-flex cursor-default">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a stay stands, when it is not simply open: in this journal already, taken elsewhere, or left out before. */
|
||||||
|
function StatusPill({ stay }: { stay: JourneyDayStay }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (stay.status === 'here') {
|
||||||
|
return (
|
||||||
|
<SoftPill tone="success" icon={<Check size={11} aria-hidden="true" />}>
|
||||||
|
{stay.entry?.title
|
||||||
|
? t('journey.dayImport.statusHere', { title: stay.entry.title })
|
||||||
|
: t('journey.dayImport.statusHereUntitled')}
|
||||||
|
</SoftPill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stay.status === 'elsewhere') {
|
||||||
|
return (
|
||||||
|
<Hinted label={t('journey.dayImport.statusElsewhereHint')}>
|
||||||
|
<SoftPill icon={<Check size={11} aria-hidden="true" />}>{t('journey.dayImport.statusElsewhere')}</SoftPill>
|
||||||
|
</Hinted>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stay.status === 'skipped') return <SoftPill>{t('journey.dayImport.statusSkipped')}</SoftPill>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When, how long, how sure Dawarich is and whether it has a place: every fact the recording gives, no more. */
|
||||||
|
function StayFacts({ stay }: { stay: JourneyDayStay }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1">
|
||||||
|
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
|
||||||
|
{timeRange(stay.startedAt, stay.endedAt, is12h)}
|
||||||
|
</SoftPill>
|
||||||
|
<SoftPill icon={<Hourglass size={11} aria-hidden="true" />}>{formatDuration(stay.durationMinutes, t)}</SoftPill>
|
||||||
|
{stay.sourceStatus === 'suggested' && (
|
||||||
|
<Hinted label={t('dawarich.sourceStatus.suggested')}>
|
||||||
|
<SoftPill tone="warning" icon={<HelpCircle size={11} aria-hidden="true" />}>
|
||||||
|
{t('journey.dayImport.unconfirmed')}
|
||||||
|
</SoftPill>
|
||||||
|
</Hinted>
|
||||||
|
)}
|
||||||
|
{stay.confidenceBand === 'low' && (
|
||||||
|
<Hinted label={t('dawarich.confidence.low')}>
|
||||||
|
<SoftPill tone="warning" icon={<Gauge size={11} aria-hidden="true" />}>
|
||||||
|
{t('dawarich.badge.lowConfidence')}
|
||||||
|
</SoftPill>
|
||||||
|
</Hinted>
|
||||||
|
)}
|
||||||
|
{stay.lat === null && (
|
||||||
|
<Hinted label={t('journey.dayImport.noPlaceHint')}>
|
||||||
|
<SoftPill icon={<MapPinOff size={11} aria-hidden="true" />}>{t('journey.dayImport.noPlace')}</SoftPill>
|
||||||
|
</Hinted>
|
||||||
|
)}
|
||||||
|
<StatusPill stay={stay} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stay: chosen or not, its number on the map, its name to correct, and its facts. */
|
||||||
|
function StayRow({ state, dayColor, stay, index }: Props & { stay: JourneyDayStay; index: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { choice } = state;
|
||||||
|
const takeable = isTakeable(stay);
|
||||||
|
const chosen = takeable && !!choice?.selected.has(stay.suggestionId);
|
||||||
|
const name = choice ? nameOf(choice, stay) : stay.name;
|
||||||
|
const active = state.activeStay === stay.suggestionId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-stay-id={stay.suggestionId}
|
||||||
|
onMouseEnter={() => state.setActiveStay(stay.suggestionId)}
|
||||||
|
onMouseLeave={() => state.setActiveStay(null)}
|
||||||
|
className={`flex items-start gap-2.5 rounded-[12px] border px-2.5 py-2 transition-opacity ${
|
||||||
|
active ? 'border-edge' : 'border-edge-faint'
|
||||||
|
} ${chosen ? 'bg-surface-card' : 'bg-surface-secondary opacity-60'}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={chosen}
|
||||||
|
aria-label={t('journey.dayImport.choose', { name })}
|
||||||
|
disabled={!takeable || state.busy !== null}
|
||||||
|
onClick={() => state.toggle(stay.suggestionId)}
|
||||||
|
className={`mt-1.5 grid h-[18px] w-[18px] flex-none place-items-center rounded-[5px] border ${
|
||||||
|
chosen ? 'border-transparent bg-accent text-accent-text' : 'border-edge bg-surface-card'
|
||||||
|
} disabled:cursor-default`}
|
||||||
|
>
|
||||||
|
{chosen && <Check size={12} strokeWidth={3} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`mt-1 grid h-[22px] min-w-[22px] flex-none place-items-center rounded-full border-2 bg-surface-card px-1 font-bold tabular-nums ${
|
||||||
|
chosen ? 'text-content' : 'border-edge text-content-faint'
|
||||||
|
}`}
|
||||||
|
style={{ ...fs(10.5), ...(chosen ? { borderColor: dayColor } : {}) }}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => state.rename(stay, e.target.value)}
|
||||||
|
disabled={!chosen || state.busy !== null}
|
||||||
|
aria-label={t('journey.dayImport.name', { name: stay.name })}
|
||||||
|
placeholder={stay.name}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
<StayFacts stay={stay} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Between two chosen stays: join them into one entry, or separate them again. */
|
||||||
|
function JoinToggle({ state, stay }: { state: JourneyDayImportState; stay: JourneyDayStay }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const joined = !!state.choice?.joined.has(stay.suggestionId);
|
||||||
|
const label = joined ? t('journey.dayImport.split') : t('journey.dayImport.join');
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => state.join(stay.suggestionId)}
|
||||||
|
disabled={state.busy !== null}
|
||||||
|
aria-pressed={joined}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||||
|
style={fs(11)}
|
||||||
|
>
|
||||||
|
{joined ? <Unlink2 size={12} aria-hidden="true" /> : <Link2 size={12} aria-hidden="true" />}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The title and the map position of an entry made of several stays: both chosen on purpose, both shown. */
|
||||||
|
function GroupHead({ state, group }: { state: JourneyDayImportState; group: DayImportGroup }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isReport = group.key === REPORT_KEY;
|
||||||
|
const typed = state.choice?.titles[group.key];
|
||||||
|
const anchor = state.anchorOf(group.key);
|
||||||
|
const options = [
|
||||||
|
...group.stays
|
||||||
|
.filter((stay) => stay.lat !== null)
|
||||||
|
.map((stay) => ({
|
||||||
|
value: String(stay.suggestionId),
|
||||||
|
label: state.choice ? nameOf(state.choice, stay) : stay.name,
|
||||||
|
})),
|
||||||
|
...(isReport ? [{ value: 'none', label: t('journey.dayImport.noPin') }] : []),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={typed ?? (isReport ? state.reportDefault : nameOfAnchor(state, group, anchor))}
|
||||||
|
onChange={(e) => state.setTitle(group.key, e.target.value)}
|
||||||
|
aria-label={t('journey.dayImport.groupTitle')}
|
||||||
|
disabled={state.busy !== null}
|
||||||
|
className={`${INPUT} min-w-[160px] flex-1`}
|
||||||
|
/>
|
||||||
|
{options.length > 0 && (
|
||||||
|
<PillSelect
|
||||||
|
quiet
|
||||||
|
label={t('journey.dayImport.groupPin')}
|
||||||
|
value={anchor === null ? 'none' : String(anchor)}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => state.setAnchor(group.key, value === 'none' ? null : Number(value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOfAnchor(state: JourneyDayImportState, group: DayImportGroup, anchor: number | null): string {
|
||||||
|
const stay = group.stays.find((s) => s.suggestionId === anchor) ?? group.stays[0];
|
||||||
|
return stay && state.choice ? nameOf(state.choice, stay) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day report: one entry for all chosen stays, its title and its one map position. */
|
||||||
|
function ReportBox({ state }: { state: JourneyDayImportState }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const report = state.groups.find((group) => group.kind === 'report');
|
||||||
|
return (
|
||||||
|
<div className={PANEL}>
|
||||||
|
{report ? (
|
||||||
|
<GroupHead state={state} group={report} />
|
||||||
|
) : (
|
||||||
|
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
{t('journey.dayImport.reportNeedsTwo')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.dayImport.reportHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stays of the day in the order they were lived, numbered like their pins:
|
||||||
|
* chosen ones in front, the rest dimmed, joined ones framed under the title and
|
||||||
|
* position of the entry they become. In report mode all chosen stays become
|
||||||
|
* one report, so there is nothing to join.
|
||||||
|
*/
|
||||||
|
export function JourneyDayImportStays({ state, dayColor }: Props) {
|
||||||
|
const { stays, choice } = state;
|
||||||
|
if (!choice) return null;
|
||||||
|
const merged = new Map(
|
||||||
|
state.groups.filter((group) => group.kind === 'merged').map((group) => [group.key, group] as const)
|
||||||
|
);
|
||||||
|
const inGroup = new Set([...merged.values()].flatMap((group) => group.stays.slice(1).map((s) => s.suggestionId)));
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{choice.mode === 'report' && <ReportBox state={state} />}
|
||||||
|
{stays.map((stay, index) => {
|
||||||
|
if (inGroup.has(stay.suggestionId)) return null;
|
||||||
|
const group = merged.get(stay.suggestionId);
|
||||||
|
const toggle = canJoin(choice, stays, index) ? <JoinToggle state={state} stay={stay} /> : null;
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<div key={stay.suggestionId} className="flex flex-col gap-1.5">
|
||||||
|
{toggle}
|
||||||
|
<StayRow state={state} dayColor={dayColor} stay={stay} index={index} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={stay.suggestionId} className="flex flex-col gap-1.5">
|
||||||
|
{toggle}
|
||||||
|
<div className={PANEL}>
|
||||||
|
<GroupHead state={state} group={group} />
|
||||||
|
{group.stays.map((member, offset) => {
|
||||||
|
const at = index + offset;
|
||||||
|
return (
|
||||||
|
<div key={member.suggestionId} className="flex flex-col gap-1.5">
|
||||||
|
{offset > 0 && <JoinToggle state={state} stay={member} />}
|
||||||
|
<StayRow state={state} dayColor={dayColor} stay={member} index={at} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// FE-JRN-DAY-RECORDING-BTN-001 to FE-JRN-DAY-RECORDING-BTN-005
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildRecordedDay } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JourneyDayRecordingButton from './JourneyDayRecordingButton';
|
||||||
|
|
||||||
|
function renderButton(props: Partial<Parameters<typeof JourneyDayRecordingButton>[0]> = {}) {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(
|
||||||
|
<JourneyDayRecordingButton
|
||||||
|
day={undefined}
|
||||||
|
canEdit
|
||||||
|
canTakeOver={false}
|
||||||
|
needsConnection={false}
|
||||||
|
onOpen={onOpen}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return onOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JourneyDayRecordingButton', () => {
|
||||||
|
it('FE-JRN-DAY-RECORDING-BTN-001: a kept day shows how far it went and whose recording it is, and opens for an editor', async () => {
|
||||||
|
const onOpen = renderButton({ day: buildRecordedDay() });
|
||||||
|
const button = screen.getByTestId('journey-day-recording');
|
||||||
|
expect(button).toHaveTextContent('1.5 km');
|
||||||
|
expect(button.getAttribute('aria-label')).toMatch(/1\.5 km, taken over by marta/);
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-RECORDING-BTN-002: a viewer sees the kept day but cannot open it, and a running day says so', async () => {
|
||||||
|
const onOpen = renderButton({ day: buildRecordedDay({ provisional: true, importedBy: null }), canEdit: false });
|
||||||
|
const button = screen.getByTestId('journey-day-recording');
|
||||||
|
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(button.getAttribute('aria-label')).toMatch(/taken over on .*The day was still running then\./);
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-RECORDING-BTN-003: without a kept day an editor whose Dawarich answers may take it over', async () => {
|
||||||
|
const onOpen = renderButton({ canTakeOver: true });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Take over recording' }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-RECORDING-BTN-004: offline the offer stays, dimmed, and says why', async () => {
|
||||||
|
const onOpen = renderButton({ needsConnection: true });
|
||||||
|
const button = screen.getByTestId('journey-day-recording');
|
||||||
|
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(button.getAttribute('aria-label')).toMatch(/connection/);
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAY-RECORDING-BTN-005: nothing kept and nothing to offer draws nothing', () => {
|
||||||
|
renderButton();
|
||||||
|
expect(screen.queryByTestId('journey-day-recording')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { JourneyRecordedDay } from '@trek/shared';
|
||||||
|
import { Route } from 'lucide-react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { formatDistance } from '../../utils/units';
|
||||||
|
import DawarichIcon from '../shared/DawarichIcon';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
|
||||||
|
// aria-disabled rather than disabled: a disabled button gets no pointer events, and
|
||||||
|
// the tooltip is what tells a viewer or an offline editor what they are looking at.
|
||||||
|
const ROUND =
|
||||||
|
'grid h-7 w-7 place-items-center rounded-full transition-colors hover:bg-surface-hover aria-disabled:cursor-default aria-disabled:opacity-40';
|
||||||
|
const PILL =
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-bold tabular-nums transition-colors aria-disabled:cursor-default [&:not([aria-disabled=true])]:hover:bg-surface-hover';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorded route of one journey day in its header (JRN-01).
|
||||||
|
*
|
||||||
|
* A day that keeps its route shows how far it went, for everyone who can open
|
||||||
|
* the journey, with whose recording it is and since when in the tooltip; an
|
||||||
|
* editor opens it to check it against Dawarich again, replace it or drop it.
|
||||||
|
* A day without one offers an editor whose Dawarich answers to take it over;
|
||||||
|
* offline the offer stays, dimmed, and says why.
|
||||||
|
*/
|
||||||
|
export default function JourneyDayRecordingButton({
|
||||||
|
day,
|
||||||
|
canEdit,
|
||||||
|
canTakeOver,
|
||||||
|
needsConnection,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
day: JourneyRecordedDay | undefined;
|
||||||
|
canEdit: boolean;
|
||||||
|
canTakeOver: boolean;
|
||||||
|
needsConnection: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||||
|
|
||||||
|
if (day) {
|
||||||
|
const distance = formatDistance(day.stats.distanceMeters / 1000, unit);
|
||||||
|
const date = new Date(day.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
|
||||||
|
const kept = day.importedBy
|
||||||
|
? t('journey.recording.keptBy', { distance, name: day.importedBy.username, date })
|
||||||
|
: t('journey.recording.keptOn', { distance, date });
|
||||||
|
const label = day.provisional ? `${kept} ${t('journey.recording.keptRunning')}` : kept;
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={canEdit ? onOpen : undefined}
|
||||||
|
aria-disabled={!canEdit}
|
||||||
|
aria-label={label}
|
||||||
|
data-testid="journey-day-recording"
|
||||||
|
className={PILL}
|
||||||
|
style={{ ...fs(10.5), background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
|
||||||
|
>
|
||||||
|
<Route size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||||
|
{distance}
|
||||||
|
{day.provisional && <span className="h-1.5 w-1.5 rounded-full bg-warning" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canTakeOver && !needsConnection) return null;
|
||||||
|
const label = canTakeOver ? t('journey.recording.takeOver') : t('dawarich.error.offline');
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={canTakeOver ? onOpen : undefined}
|
||||||
|
aria-disabled={!canTakeOver}
|
||||||
|
aria-label={label}
|
||||||
|
data-testid="journey-day-recording"
|
||||||
|
className={ROUND}
|
||||||
|
style={{ background: 'var(--vg-surf2)' }}
|
||||||
|
>
|
||||||
|
<DawarichIcon size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-027
|
// FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-029
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
@@ -7,6 +7,7 @@ import { render, screen, waitFor, within, fireEvent, act } from '../../../tests/
|
|||||||
import { server } from '../../../tests/helpers/msw/server'
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
import { useJourneyStore, type JourneyDetail } from '../../store/journeyStore'
|
import { useJourneyStore, type JourneyDetail } from '../../store/journeyStore'
|
||||||
import { JourneySettingsDialog } from './JourneyDetailPageSettingsDialog'
|
import { JourneySettingsDialog } from './JourneyDetailPageSettingsDialog'
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep'
|
||||||
|
|
||||||
type ToastKind = 'success' | 'error' | 'warning' | 'info'
|
type ToastKind = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountDialog(journey: JourneyDetail = buildJourney()) {
|
function mountDialog(journey: JourneyDetail = buildJourney(), recordingPrep?: JourneyRecordingPrepState) {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
const onSaved = vi.fn()
|
const onSaved = vi.fn()
|
||||||
const onOpenInvite = vi.fn()
|
const onOpenInvite = vi.fn()
|
||||||
@@ -55,6 +56,7 @@ function mountDialog(journey: JourneyDetail = buildJourney()) {
|
|||||||
onSaved={onSaved}
|
onSaved={onSaved}
|
||||||
onOpenInvite={onOpenInvite}
|
onOpenInvite={onOpenInvite}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
|
recordingPrep={recordingPrep}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
return { ...utils, onClose, onSaved, onOpenInvite, onRefresh }
|
return { ...utils, onClose, onSaved, onOpenInvite, onRefresh }
|
||||||
@@ -475,4 +477,40 @@ describe('JourneySettingsDialog', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: /Bring back dismissed suggestions/ }))
|
await userEvent.click(screen.getByRole('button', { name: /Bring back dismissed suggestions/ }))
|
||||||
expect(onRestoreSuggestions).toHaveBeenCalledTimes(1)
|
expect(onRestoreSuggestions).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function prepState(over: Partial<JourneyRecordingPrepState> = {}): JourneyRecordingPrepState {
|
||||||
|
return {
|
||||||
|
prep: { enabled: true, connection: 'ready', lastAttemptAt: null, lastSuccessAt: null, lastError: 'unreachable' },
|
||||||
|
saving: false,
|
||||||
|
fetching: false,
|
||||||
|
statusLine: { text: 'The first fetch failed: TREK could not reach that address.', tone: 'warning' },
|
||||||
|
canFetch: true,
|
||||||
|
setEnabled: vi.fn(async () => {}),
|
||||||
|
fetchNow: vi.fn(async () => {}),
|
||||||
|
dayPrep: () => null,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-JRN-SETTINGS-028: the owner switches the preparation of recorded days and fetches by hand after a failure (JRN-03)', async () => {
|
||||||
|
const prep = prepState()
|
||||||
|
mountDialog(buildJourney({ auto_prepare_recordings: 1 }), prep)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('journey-prep-status')).toHaveTextContent('The first fetch failed: TREK could not reach that address.')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Fetch now/ }))
|
||||||
|
expect(prep.fetchNow).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Prepare recorded days automatically' }))
|
||||||
|
expect(prep.setEnabled).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-SETTINGS-029: without the Dawarich addon there is no switch, and no fetch where nothing can be reached', () => {
|
||||||
|
const { unmount } = mountDialog()
|
||||||
|
expect(screen.queryByText('Prepare recorded days automatically')).not.toBeInTheDocument()
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
mountDialog(buildJourney(), prepState({ canFetch: false, statusLine: { text: 'Link a trip with dates: only its days are prepared.', tone: 'warning' } }))
|
||||||
|
expect(screen.getByText('Prepare recorded days automatically')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /Fetch now/ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
|||||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||||
import JourneyShareSection from './JourneyShareSection'
|
import JourneyShareSection from './JourneyShareSection'
|
||||||
import { useJourneySettings } from './useJourneySettings'
|
import { useJourneySettings } from './useJourneySettings'
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep'
|
||||||
|
import { JourneyRecordingPrepRow, JourneyRecordingPrepStatus } from './JourneyRecordingPrepSetting'
|
||||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||||
@@ -31,7 +33,7 @@ function RowAction({ label, onClick, children }: { label: string; onClick: () =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
|
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions, recordingPrep }: {
|
||||||
journey: JourneyDetail
|
journey: JourneyDetail
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
@@ -39,6 +41,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
|
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
|
||||||
onRestoreSuggestions?: () => Promise<void> | void
|
onRestoreSuggestions?: () => Promise<void> | void
|
||||||
|
/** Preparing the recorded days (JRN-03); absent without the Dawarich addon. */
|
||||||
|
recordingPrep?: JourneyRecordingPrepState
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
@@ -173,6 +177,16 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
</div>
|
</div>
|
||||||
</DialogSection>
|
</DialogSection>
|
||||||
|
|
||||||
|
{/* Days recorded in Dawarich, prepared for review while a trip runs (JRN-03) */}
|
||||||
|
{recordingPrep && (
|
||||||
|
<DialogSection label={t('journey.settings.recordings')}>
|
||||||
|
<div className={ROWS}>
|
||||||
|
<JourneyRecordingPrepRow on={!!journey.auto_prepare_recordings} prep={recordingPrep} rowClass={ROW} />
|
||||||
|
</div>
|
||||||
|
<JourneyRecordingPrepStatus prep={recordingPrep} />
|
||||||
|
</DialogSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */}
|
{/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */}
|
||||||
<DialogSection label={t('journey.settings.photosSection')}>
|
<DialogSection label={t('journey.settings.photosSection')}>
|
||||||
<div className={ROWS}>
|
<div className={ROWS}>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-003
|
||||||
|
//
|
||||||
|
// JRN-04: the journey map with its layers: what the renderer is handed, and a
|
||||||
|
// picked mark's card.
|
||||||
|
import type { JourneyMapPlace } from '@trek/shared';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JourneyLayeredMap from './JourneyLayeredMap';
|
||||||
|
import { ALL_VISIBLE } from './journeyMapLayers';
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks';
|
||||||
|
import type { JourneyMapLayersView } from './useJourneyMapLayers';
|
||||||
|
|
||||||
|
const seen = vi.hoisted(() => ({ props: [] as Record<string, unknown>[], engine: [] as string[] }));
|
||||||
|
|
||||||
|
vi.mock('./JourneyMap', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
seen.props.push(props);
|
||||||
|
seen.engine.push('leaflet');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./JourneyMapAuto', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
seen.props.push(props);
|
||||||
|
seen.engine.push('auto');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MARK: JourneyMapMark = {
|
||||||
|
kind: 'plan',
|
||||||
|
key: 'plan:4',
|
||||||
|
lat: 45.434,
|
||||||
|
lng: 12.3388,
|
||||||
|
color: '#64748b',
|
||||||
|
place: {
|
||||||
|
name: 'Piazza San Marco',
|
||||||
|
trip_title: 'Venice',
|
||||||
|
days: [],
|
||||||
|
time: null,
|
||||||
|
entry_id: null,
|
||||||
|
} as unknown as JourneyMapPlace,
|
||||||
|
};
|
||||||
|
|
||||||
|
function layers(over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
|
||||||
|
return {
|
||||||
|
visibility: ALL_VISIBLE,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
controls: [{ layer: 'entries', count: 1, state: 'ready' }],
|
||||||
|
tracks: [],
|
||||||
|
marks: [MARK],
|
||||||
|
plannedEntryIds: new Set(['3']),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = () => seen.props[seen.props.length - 1];
|
||||||
|
const PHOTOS = [{ id: 'p', lat: 1, lng: 2, thumbUrl: '/t.jpg' }];
|
||||||
|
|
||||||
|
describe('JourneyLayeredMap', () => {
|
||||||
|
it('FE-JOURNEY-LAYEREDMAP-001: without layers the map draws as it always did, on the renderer asked for', () => {
|
||||||
|
render(<JourneyLayeredMap engine="leaflet" checkins={[]} entries={[]} photos={PHOTOS} tracks={[]} />);
|
||||||
|
expect(seen.engine[seen.engine.length - 1]).toBe('leaflet');
|
||||||
|
expect(last()).toMatchObject({ photos: PHOTOS, marks: undefined, layerVisibility: undefined });
|
||||||
|
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-LAYEREDMAP-002: hands the renderer the layers, the photos only while their switch is on', () => {
|
||||||
|
const view = layers();
|
||||||
|
const { rerender } = render(<JourneyLayeredMap layers={view} checkins={[]} entries={[]} photos={PHOTOS} />);
|
||||||
|
expect(seen.engine[seen.engine.length - 1]).toBe('auto');
|
||||||
|
expect(last()).toMatchObject({
|
||||||
|
photos: PHOTOS,
|
||||||
|
marks: [MARK],
|
||||||
|
layerVisibility: ALL_VISIBLE,
|
||||||
|
plannedEntryIds: view.plannedEntryIds,
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('group', { name: 'Map layers' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<JourneyLayeredMap
|
||||||
|
layers={layers({ visibility: { ...ALL_VISIBLE, photos: false } })}
|
||||||
|
checkins={[]}
|
||||||
|
entries={[]}
|
||||||
|
photos={PHOTOS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(last().photos).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
|
||||||
|
const onOpenEntry = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<JourneyLayeredMap layers={layers()} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />
|
||||||
|
);
|
||||||
|
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
|
||||||
|
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
|
||||||
|
rerender(<JourneyLayeredMap layers={layers({ marks: [] })} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />);
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
|
||||||
|
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
||||||
|
import JourneyMapAuto from './JourneyMapAuto';
|
||||||
|
import JourneyMapLayerControl from './JourneyMapLayerControl';
|
||||||
|
import JourneyMapPickCard from './JourneyMapPickCard';
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks';
|
||||||
|
import type { JourneyMapLayersView } from './useJourneyMapLayers';
|
||||||
|
|
||||||
|
type MapProps = Omit<
|
||||||
|
ComponentProps<typeof JourneyMapAuto>,
|
||||||
|
'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds'
|
||||||
|
> & {
|
||||||
|
/** CARTO key of a shared page, which the Leaflet renderer reads; see JourneyMap. */
|
||||||
|
cartoApiKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props extends MapProps {
|
||||||
|
ref?: Ref<JourneyMapHandle>;
|
||||||
|
/** The layers of this map; without them it draws as it always did. */
|
||||||
|
layers?: JourneyMapLayersView | null;
|
||||||
|
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
|
||||||
|
engine?: 'auto' | 'leaflet';
|
||||||
|
/** Icon-only switches, for a phone. */
|
||||||
|
compactControls?: boolean;
|
||||||
|
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
|
||||||
|
controlsTop?: number | string;
|
||||||
|
/** Leads from a picked place or stay to its entry. */
|
||||||
|
onOpenEntry?: (entryId: string) => void;
|
||||||
|
entryTitleOf?: (entryId: number) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A journey map with its layers (JRN-04): the renderer, the reader's layer
|
||||||
|
* switches and the card of a picked plan place or stay. Every journey map
|
||||||
|
* surface draws through here, the desktop aside, the phone screens and the
|
||||||
|
* shared page, so the layers mean the same everywhere. A switched-off layer
|
||||||
|
* is hidden in place: the map is not rebuilt and keeps its view.
|
||||||
|
*/
|
||||||
|
export default function JourneyLayeredMap({
|
||||||
|
ref,
|
||||||
|
layers,
|
||||||
|
engine = 'auto',
|
||||||
|
compactControls = false,
|
||||||
|
controlsTop = 12,
|
||||||
|
onOpenEntry,
|
||||||
|
entryTitleOf,
|
||||||
|
photos,
|
||||||
|
tracks,
|
||||||
|
height = 220,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null);
|
||||||
|
const visibility = layers?.visibility;
|
||||||
|
const onMarksPick = useCallback((marks: JourneyMapMark[]) => {
|
||||||
|
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
||||||
|
}, []);
|
||||||
|
const close = useCallback(() => setPicked(null), []);
|
||||||
|
const openEntry = useCallback(
|
||||||
|
(entryId: string) => {
|
||||||
|
setPicked(null);
|
||||||
|
onOpenEntry?.(entryId);
|
||||||
|
},
|
||||||
|
[onOpenEntry]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A pick whose marks a switch took off the map closes with them.
|
||||||
|
const shownKeys = new Set(layers?.marks.map((m) => m.key));
|
||||||
|
const card = picked && picked.marks.every((m) => shownKeys.has(m.key)) ? picked : null;
|
||||||
|
|
||||||
|
const mapProps = {
|
||||||
|
...rest,
|
||||||
|
height,
|
||||||
|
photos: !visibility || visibility.photos ? photos : undefined,
|
||||||
|
tracks: layers ? layers.tracks : tracks,
|
||||||
|
layerVisibility: visibility,
|
||||||
|
marks: layers?.marks,
|
||||||
|
onMarksPick,
|
||||||
|
plannedEntryIds: layers?.plannedEntryIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full" style={{ height: height === 9999 ? '100%' : height }}>
|
||||||
|
<div className="isolate h-full w-full">
|
||||||
|
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
|
||||||
|
</div>
|
||||||
|
{layers && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
|
||||||
|
style={{ top: controlsTop }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto max-w-full">
|
||||||
|
<JourneyMapLayerControl layers={layers} compact={compactControls} />
|
||||||
|
</div>
|
||||||
|
{card && (
|
||||||
|
<JourneyMapPickCard
|
||||||
|
key={card.key}
|
||||||
|
marks={card.marks}
|
||||||
|
onClose={close}
|
||||||
|
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
||||||
|
entryTitleOf={entryTitleOf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
|
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
|
||||||
|
|
||||||
vi.mock('../../api/websocket', () => ({
|
vi.mock('../../api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -623,8 +623,31 @@ describe('JourneyMap', () => {
|
|||||||
expect(line).toBeDefined();
|
expect(line).toBeDefined();
|
||||||
// Leaflet wants [lat, lng], which is the order the geometry is stored in.
|
// Leaflet wants [lat, lng], which is the order the geometry is stored in.
|
||||||
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
||||||
// Solid, unlike the dashed line that merely connects entries in time order.
|
// A trip's GPX track is the plan: dashed, and wider apart than the faint
|
||||||
expect((line![1] as any).dashArray).toBeUndefined();
|
// line that merely connects entries in time order.
|
||||||
|
expect((line![1] as any).dashArray).toBe('8 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-051: a recorded route is drawn solid, apart from the planned one (JRN-01)', () => {
|
||||||
|
vi.mocked(L.polyline).mockClear();
|
||||||
|
render(
|
||||||
|
<JourneyMap
|
||||||
|
checkins={[]}
|
||||||
|
entries={entriesWithCoords}
|
||||||
|
tracks={[
|
||||||
|
{ ...track({ color: '#00aa00' }), kind: 'day_recording' as const },
|
||||||
|
{ ...track({ color: '#0000aa' }), kind: 'dawarich_live' as const },
|
||||||
|
{ ...track({ color: '#aa0000' }), kind: 'trip_gpx' as const },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const style = (color: string) =>
|
||||||
|
vi.mocked(L.polyline).mock.calls.map(c => c[1] as L.PolylineOptions | undefined).find(o => o?.color === color);
|
||||||
|
expect(style('#00aa00')).toBeDefined();
|
||||||
|
expect(style('#00aa00')?.dashArray).toBeUndefined();
|
||||||
|
expect(style('#0000aa')?.dashArray).toBeUndefined();
|
||||||
|
expect(style('#aa0000')?.dashArray).toBe('8 7');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
|
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
|
||||||
@@ -800,4 +823,59 @@ describe('JourneyMap', () => {
|
|||||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// JRN-04: the layer switches hide in place, and the plan's places and stays draw beside the entries.
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-052: switching the entries off takes their pin off the map and back, without a rebuild', () => {
|
||||||
|
const marker = mockedMarkerWithRemove();
|
||||||
|
const entry = [entriesWithCoords[0]];
|
||||||
|
const shown = { plan: true, recorded: true, entries: true };
|
||||||
|
const { rerender } = render(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
|
||||||
|
expect(marker.addTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={{ ...shown, entries: false }} />);
|
||||||
|
expect(marker.remove).toHaveBeenCalledTimes(1);
|
||||||
|
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
|
||||||
|
expect(marker.addTo).toHaveBeenCalledTimes(2);
|
||||||
|
expect(L.map).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-053: a planned place is drawn as its hollow pin, and a click hands it up', () => {
|
||||||
|
const onMarksPick = vi.fn();
|
||||||
|
const mark = {
|
||||||
|
kind: 'plan' as const,
|
||||||
|
key: 'plan:4',
|
||||||
|
lat: 45.43,
|
||||||
|
lng: 12.33,
|
||||||
|
color: '#e11d48',
|
||||||
|
place: {
|
||||||
|
place_id: 4, trip_id: 2, trip_title: 'Venice', name: 'San Marco', lat: 45.43, lng: 12.33,
|
||||||
|
days: [], time: null, category_color: '#e11d48', category_icon: null, entry_id: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<JourneyMap checkins={[]} entries={[]} marks={[mark]} onMarksPick={onMarksPick} />);
|
||||||
|
|
||||||
|
const pins = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('stroke-dasharray'));
|
||||||
|
expect(pins).toHaveLength(1);
|
||||||
|
expect(pins[0]).toContain('#e11d48');
|
||||||
|
const click = mockedMarker().on.mock.calls.filter((c: unknown[]) => c[0] === 'click').pop()?.[1] as () => void;
|
||||||
|
click();
|
||||||
|
expect(onMarksPick).toHaveBeenCalledWith([mark]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-054: an entry pin whose place the plan had too carries the check', () => {
|
||||||
|
render(<JourneyMap checkins={[]} entries={entriesWithCoords} plannedEntryIds={new Set(['e2'])} />);
|
||||||
|
expect(divIconHtml(0)).not.toContain('#16a34a');
|
||||||
|
expect(divIconHtml(1)).toContain('#16a34a');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
|
||||||
|
function mockedMarkerWithRemove(): { addTo: Mock; remove: Mock } {
|
||||||
|
L.marker([0, 0]);
|
||||||
|
const marker = mockedMarker() as unknown as { addTo: Mock; remove: Mock };
|
||||||
|
marker.remove = vi.fn();
|
||||||
|
vi.mocked(L.marker).mockClear();
|
||||||
|
marker.addTo.mockClear();
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
|
||||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -9,6 +9,9 @@ import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLa
|
|||||||
import { crsForBasemap } from '../Map/gcj02Crs'
|
import { crsForBasemap } from '../Map/gcj02Crs'
|
||||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||||
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||||
|
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
|
||||||
|
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||||
|
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
|
||||||
|
|
||||||
export interface MapMarkerItem {
|
export interface MapMarkerItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -74,8 +77,32 @@ interface Props {
|
|||||||
paddingBottom?: number
|
paddingBottom?: number
|
||||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||||
cartoApiKey?: string
|
cartoApiKey?: string
|
||||||
|
/**
|
||||||
|
* Which drawn layers the reader shows (JRN-04). A hidden one is taken off the
|
||||||
|
* map and put back as it was, never rebuilt, so the view stays where it is.
|
||||||
|
*/
|
||||||
|
layerVisibility?: JourneyMapLayerVisibility
|
||||||
|
/** The plan's places and the stays of day reports, clustered where they meet. */
|
||||||
|
marks?: JourneyMapMark[]
|
||||||
|
onMarksPick?: (marks: JourneyMapMark[]) => void
|
||||||
|
/** Entry pins whose place the plan had too, marked with a check. */
|
||||||
|
plannedEntryIds?: ReadonlySet<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The map's own zoom buttons, frosted so they hold on any tile. */
|
||||||
|
const zoomButtonStyle = (dark?: boolean): CSSProperties => ({
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||||
|
color: dark ? '#fff' : '#18181B',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
|
||||||
|
const NO_PLANNED: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||||
const items: MapMarkerItem[] = []
|
const items: MapMarkerItem[] = []
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
@@ -101,7 +128,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
|
||||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
@@ -114,10 +141,28 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
|
|||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
|
${planned ? PLANNED_BADGE_SVG : ''}
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryIcon(item: MapMarkerItem, highlighted: boolean, planned: boolean): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
|
html: markerSvg(item.dayColor, item.dayLabel, highlighted, planned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintEntryMarker(
|
||||||
|
marker: L.Marker | undefined, item: MapMarkerItem | undefined, highlighted: boolean, planned: ReadonlySet<string>,
|
||||||
|
): void {
|
||||||
|
if (!marker || !item) return
|
||||||
|
marker.setIcon(entryIcon(item, highlighted, planned.has(item.id)))
|
||||||
|
marker.setZIndexOffset(highlighted ? 1000 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
|
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
|
||||||
* and a focus keeps that (discussion #2299).
|
* and a focus keeps that (discussion #2299).
|
||||||
@@ -143,9 +188,19 @@ const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
|||||||
const EMPTY_TRACKS: JourneyTrack[] = []
|
const EMPTY_TRACKS: JourneyTrack[] = []
|
||||||
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
||||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||||
|
/** The dash of a planned track; wide enough not to pass for the faint line between entries. */
|
||||||
|
const PLANNED_DASH = '8 7'
|
||||||
|
|
||||||
|
function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
|
||||||
|
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
|
||||||
|
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
|
||||||
|
}
|
||||||
|
|
||||||
function JourneyMap(
|
function JourneyMap(
|
||||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
|
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||||
) {
|
) {
|
||||||
// Read through a ref: the flag is fixed per surface, and putting it in the
|
// Read through a ref: the flag is fixed per surface, and putting it in the
|
||||||
// marker effect's deps would rebuild every marker for nothing.
|
// marker effect's deps would rebuild every marker for nothing.
|
||||||
@@ -169,8 +224,6 @@ function JourneyMap(
|
|||||||
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
||||||
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
||||||
const glLayerRef = useRef<BasemapLayer | null>(null)
|
const glLayerRef = useRef<BasemapLayer | null>(null)
|
||||||
// The vector basemap loads async; a map torn down before it lands must not get one.
|
|
||||||
const cancelledRef = useRef(false)
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mapRef = useRef<L.Map | null>(null)
|
const mapRef = useRef<L.Map | null>(null)
|
||||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||||
@@ -181,42 +234,36 @@ function JourneyMap(
|
|||||||
const photoLayerRef = useRef<L.LayerGroup | null>(null)
|
const photoLayerRef = useRef<L.LayerGroup | null>(null)
|
||||||
const onPhotoClickRef = useRef(onPhotoClick)
|
const onPhotoClickRef = useRef(onPhotoClick)
|
||||||
onPhotoClickRef.current = onPhotoClick
|
onPhotoClickRef.current = onPhotoClick
|
||||||
|
const markLayerRef = useRef<L.LayerGroup | null>(null)
|
||||||
const darkRef = useRef(dark)
|
const onMarksPickRef = useRef(onMarksPick)
|
||||||
darkRef.current = dark
|
const marksRef = useRef(marks)
|
||||||
|
// The plan and the stays load after the entries, so the first view is framed
|
||||||
|
// again once they arrive, around both, unless the reader has moved it by then.
|
||||||
|
const entryCoordsRef = useRef<L.LatLngTuple[]>([])
|
||||||
|
const marksFramedRef = useRef(false)
|
||||||
|
const readerMovedRef = useRef(false)
|
||||||
|
// What each switchable layer drew, and which of them are on the map right now.
|
||||||
|
const drawnRef = useRef<DrawnLayers>(NOTHING_DRAWN())
|
||||||
|
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
|
||||||
|
const visibilityRef = useRef(layerVisibility)
|
||||||
|
const plannedRef = useRef(plannedEntryIds)
|
||||||
|
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
|
||||||
|
// Before the map effects below, so a rebuild draws with what the reader shows now.
|
||||||
|
useEffect(() => {
|
||||||
|
onMarksPickRef.current = onMarksPick
|
||||||
|
marksRef.current = marks
|
||||||
|
visibilityRef.current = layerVisibility
|
||||||
|
plannedRef.current = plannedEntryIds
|
||||||
|
})
|
||||||
|
|
||||||
const highlightMarker = useCallback((id: string | null) => {
|
const highlightMarker = useCallback((id: string | null) => {
|
||||||
const prev = highlightedRef.current
|
const prev = highlightedRef.current
|
||||||
highlightedRef.current = id
|
highlightedRef.current = id
|
||||||
const isDark = !!darkRef.current
|
const paint = (target: string, highlighted: boolean) => paintEntryMarker(
|
||||||
|
markersRef.current.get(target), itemsRef.current.find(i => i.id === target), highlighted, plannedRef.current,
|
||||||
if (prev && prev !== id) {
|
)
|
||||||
const marker = markersRef.current.get(prev)
|
if (prev && prev !== id) paint(prev, false)
|
||||||
const item = itemsRef.current.find(i => i.id === prev)
|
if (id) paint(id, true)
|
||||||
if (marker && item) {
|
|
||||||
marker.setIcon(L.divIcon({
|
|
||||||
className: '',
|
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
|
||||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
|
||||||
}))
|
|
||||||
marker.setZIndexOffset(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
const marker = markersRef.current.get(id)
|
|
||||||
const item = itemsRef.current.find(i => i.id === id)
|
|
||||||
if (marker && item) {
|
|
||||||
marker.setIcon(L.divIcon({
|
|
||||||
className: '',
|
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
|
||||||
html: markerSvg(item.dayColor, item.dayLabel, true),
|
|
||||||
}))
|
|
||||||
marker.setZIndexOffset(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,12 +306,20 @@ function JourneyMap(
|
|||||||
})
|
})
|
||||||
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
|
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
cancelledRef.current = false
|
// The vector basemap loads async; a map torn down before it lands must not get
|
||||||
|
// one. Per build, not a shared ref: the next build would reset a shared flag and
|
||||||
|
// hand the old map, already removed, the layer it was still waiting for.
|
||||||
|
let cancelled = false
|
||||||
|
marksFramedRef.current = false
|
||||||
|
readerMovedRef.current = false
|
||||||
|
const readerMoved = () => { readerMovedRef.current = true }
|
||||||
|
map.on('dragstart', readerMoved)
|
||||||
|
containerRef.current.addEventListener('wheel', readerMoved, { passive: true })
|
||||||
|
|
||||||
// The basemap is a vector style unless the user brought their own raster
|
// The basemap is a vector style unless the user brought their own raster
|
||||||
// template, so which layer draws it is decided per template rather than once.
|
// template, so which layer draws it is decided per template rather than once.
|
||||||
if (isVectorStyle(tileUrlRef.current)) {
|
if (isVectorStyle(tileUrlRef.current)) {
|
||||||
void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelledRef.current)
|
void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelled)
|
||||||
} else {
|
} else {
|
||||||
const tiles = L.tileLayer(tileUrlRef.current, {
|
const tiles = L.tileLayer(tileUrlRef.current, {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
@@ -284,6 +339,18 @@ function JourneyMap(
|
|||||||
const items = buildMarkerItems(entries)
|
const items = buildMarkerItems(entries)
|
||||||
itemsRef.current = items
|
itemsRef.current = items
|
||||||
|
|
||||||
|
// Each switchable layer is drawn whatever its switch says and only put on
|
||||||
|
// the map while it is shown, so switching it later needs no rebuild.
|
||||||
|
const drawn = NOTHING_DRAWN()
|
||||||
|
drawnRef.current = drawn
|
||||||
|
shownRef.current = visibilityRef.current
|
||||||
|
iconPlannedRef.current = plannedRef.current
|
||||||
|
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
|
||||||
|
drawn[kind].push(layer)
|
||||||
|
if (visibilityRef.current[kind]) layer.addTo(map)
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
const allCoords: L.LatLngTuple[] = []
|
const allCoords: L.LatLngTuple[] = []
|
||||||
/**
|
/**
|
||||||
* Track geometry is kept OUT of the fit set (#2194) and used only when there
|
* Track geometry is kept OUT of the fit set (#2194) and used only when there
|
||||||
@@ -305,41 +372,47 @@ function JourneyMap(
|
|||||||
|
|
||||||
if (stableTrail.length > 1) {
|
if (stableTrail.length > 1) {
|
||||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||||
L.polyline(coords, {
|
place('recorded', L.polyline(coords, {
|
||||||
color: '#6366f1', weight: 3, opacity: 0.4,
|
color: '#6366f1', weight: 3, opacity: 0.4,
|
||||||
dashArray: '6 4', lineCap: 'round',
|
dashArray: '6 4', lineCap: 'round',
|
||||||
}).addTo(map)
|
}))
|
||||||
coords.forEach(c => allCoords.push(c))
|
coords.forEach(c => allCoords.push(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPX tracks — drawn solid and in their own colour, so they read as a recorded
|
// Tracks, in their own colour over a white casing that keeps them legible on
|
||||||
// route rather than as the dashed line that merely connects entries in time order.
|
// satellite tiles, same as the planner map. What was recorded (a day the journey
|
||||||
// A white casing keeps them legible on satellite tiles, same as the planner map.
|
// keeps, the live Dawarich trail) is drawn solid; a trip's GPX track is the plan
|
||||||
|
// and drawn dashed, so the two never read as the same thing (JRN-01).
|
||||||
for (const track of stableTracks) {
|
for (const track of stableTracks) {
|
||||||
if (track.points.length < 2) continue
|
if (track.points.length < 2) continue
|
||||||
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
|
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
|
||||||
const color = track.color || TRACK_FALLBACK_COLOR
|
const color = track.color || TRACK_FALLBACK_COLOR
|
||||||
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map)
|
const planned = isPlannedTrack(track)
|
||||||
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' })
|
const kind: LeafletLayerKind = planned ? 'plan' : 'recorded'
|
||||||
|
place(kind, L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }))
|
||||||
|
const line = L.polyline(coords, {
|
||||||
|
color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round',
|
||||||
|
...(planned ? { dashArray: PLANNED_DASH } : {}),
|
||||||
|
})
|
||||||
// Same tooltip the markers on this map use, rather than Leaflet's default box:
|
// Same tooltip the markers on this map use, rather than Leaflet's default box:
|
||||||
// it follows the appearance tokens, so it lands right in dark mode and with
|
// it follows the appearance tokens, so it lands right in dark mode and with
|
||||||
// transparency switched off. Escaped because a string handed to bindTooltip
|
// transparency switched off. Escaped because a string handed to bindTooltip
|
||||||
// becomes innerHTML, and a track name is a place name off a shared trip.
|
// becomes innerHTML, and a track name is a place name off a shared trip.
|
||||||
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
|
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
|
||||||
line.addTo(map)
|
place(kind, line)
|
||||||
coords.forEach(c => trackCoords.push(c))
|
coords.forEach(c => trackCoords.push(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
|
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
|
||||||
if (items.length > 1) {
|
if (items.length > 1) {
|
||||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||||
L.polyline(routeCoords, {
|
place('entries', L.polyline(routeCoords, {
|
||||||
color: dark ? '#71717A' : '#A1A1AA',
|
color: dark ? '#71717A' : '#A1A1AA',
|
||||||
weight: 1.5,
|
weight: 1.5,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
dashArray: '4 6',
|
dashArray: '4 6',
|
||||||
lineCap: 'round', lineJoin: 'round',
|
lineCap: 'round', lineJoin: 'round',
|
||||||
}).addTo(map)
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// place markers
|
// place markers
|
||||||
@@ -347,14 +420,7 @@ function JourneyMap(
|
|||||||
const pos: L.LatLngTuple = [item.lat, item.lng]
|
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||||||
allCoords.push(pos)
|
allCoords.push(pos)
|
||||||
|
|
||||||
const icon = L.divIcon({
|
const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
|
||||||
className: '',
|
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
|
||||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
|
||||||
})
|
|
||||||
|
|
||||||
const marker = L.marker(pos, { icon }).addTo(map)
|
|
||||||
// The same card the GL renderer shows, from the same builder: which map
|
// The same card the GL renderer shows, from the same builder: which map
|
||||||
// engine a reader happens to have selected should not change what a marker
|
// engine a reader happens to have selected should not change what a marker
|
||||||
// tells them (discussion #2299). The builder escapes everything that came
|
// tells them (discussion #2299). The builder escapes everything that came
|
||||||
@@ -391,13 +457,12 @@ function JourneyMap(
|
|||||||
map.invalidateSize()
|
map.invalidateSize()
|
||||||
// Tracks only get a say when the journey has nothing located of its own —
|
// Tracks only get a say when the journey has nothing located of its own —
|
||||||
// a world view would be worse than framing the one thing on the map.
|
// a world view would be worse than framing the one thing on the map.
|
||||||
const fitCoords = allCoords.length > 0 ? allCoords : trackCoords
|
entryCoordsRef.current = allCoords
|
||||||
if (fitCoords.length > 0) {
|
const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
|
||||||
const pb = paddingBottom || 50
|
const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
|
||||||
map.fitBounds(L.latLngBounds(fitCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
|
||||||
} else {
|
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
|
||||||
map.setView([30, 0], 2)
|
else map.setView([30, 0], 2)
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -405,17 +470,36 @@ function JourneyMap(
|
|||||||
if (mapRef.current) map.invalidateSize()
|
if (mapRef.current) map.invalidateSize()
|
||||||
}, 200)
|
}, 200)
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
return () => {
|
return () => {
|
||||||
cancelledRef.current = true
|
cancelled = true
|
||||||
|
container.removeEventListener('wheel', readerMoved)
|
||||||
map.remove()
|
map.remove()
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
tileLayerRef.current = null
|
tileLayerRef.current = null
|
||||||
detachBasemapLayer(glLayerRef.current)
|
detachBasemapLayer(glLayerRef.current)
|
||||||
glLayerRef.current = null
|
glLayerRef.current = null
|
||||||
markersRef.current.clear()
|
markersRef.current.clear()
|
||||||
|
drawnRef.current = NOTHING_DRAWN()
|
||||||
}
|
}
|
||||||
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
|
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
|
||||||
|
|
||||||
|
// A switch puts its layer back on the map or takes it off, exactly as drawn.
|
||||||
|
useEffect(() => {
|
||||||
|
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
|
||||||
|
}, [layerVisibility])
|
||||||
|
|
||||||
|
// The plan's check on an entry pin follows the plan layer without redrawing the map.
|
||||||
|
useEffect(() => {
|
||||||
|
const before = iconPlannedRef.current
|
||||||
|
if (before === plannedEntryIds) return
|
||||||
|
for (const item of itemsRef.current) {
|
||||||
|
if (before.has(item.id) === plannedEntryIds.has(item.id)) continue
|
||||||
|
markersRef.current.get(item.id)?.setIcon(entryIcon(item, highlightedRef.current === item.id, plannedEntryIds.has(item.id)))
|
||||||
|
}
|
||||||
|
iconPlannedRef.current = plannedEntryIds
|
||||||
|
}, [plannedEntryIds])
|
||||||
|
|
||||||
// Retile in place rather than through the effect above, which would drop every
|
// Retile in place rather than through the effect above, which would drop every
|
||||||
// marker and track it just drew. A vector basemap restyles instead, which also
|
// marker and track it just drew. A vector basemap restyles instead, which also
|
||||||
// avoids spending a WebGL context on every theme toggle.
|
// avoids spending a WebGL context on every theme toggle.
|
||||||
@@ -477,6 +561,35 @@ function JourneyMap(
|
|||||||
}
|
}
|
||||||
}, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
|
}, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
|
||||||
|
|
||||||
|
// The plan's places and the stays of day reports (JRN-04), in their own layer
|
||||||
|
// like the photos: below the entry pins and above the thumbnails, regrouped
|
||||||
|
// on every move because marks are only joined where they would overlap.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
markLayerRef.current?.remove()
|
||||||
|
markLayerRef.current = drawLeafletMarks(map, marks, members => onMarksPickRef.current?.(members))
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = markCoords(marks)
|
||||||
|
if (!marksFramedRef.current && coords.length > 0) {
|
||||||
|
marksFramedRef.current = true
|
||||||
|
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
|
||||||
|
}
|
||||||
|
|
||||||
|
draw()
|
||||||
|
map.on('zoomend', draw)
|
||||||
|
map.on('moveend', draw)
|
||||||
|
return () => {
|
||||||
|
map.off('zoomend', draw)
|
||||||
|
map.off('moveend', draw)
|
||||||
|
markLayerRef.current?.remove()
|
||||||
|
markLayerRef.current = null
|
||||||
|
}
|
||||||
|
}, [marks, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
|
||||||
|
|
||||||
// react to activeMarkerId prop changes — runs after map is built
|
// react to activeMarkerId prop changes — runs after map is built
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeMarkerId || !mapRef.current) return
|
if (!activeMarkerId || !mapRef.current) return
|
||||||
@@ -490,8 +603,11 @@ function JourneyMap(
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [activeMarkerId])
|
}, [activeMarkerId])
|
||||||
|
|
||||||
const zoomIn = () => mapRef.current?.zoomIn()
|
const zoom = (closer: boolean) => {
|
||||||
const zoomOut = () => mapRef.current?.zoomOut()
|
readerMovedRef.current = true
|
||||||
|
if (closer) mapRef.current?.zoomIn()
|
||||||
|
else mapRef.current?.zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||||
@@ -500,30 +616,8 @@ function JourneyMap(
|
|||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<button type="button"
|
<button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
|
||||||
onClick={zoomIn}
|
<button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
|
||||||
style={{
|
|
||||||
width: 32, height: 32, borderRadius: 8,
|
|
||||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
|
||||||
color: dark ? '#fff' : '#18181B',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>+</button>
|
|
||||||
<button type="button"
|
|
||||||
onClick={zoomOut}
|
|
||||||
style={{
|
|
||||||
width: 32, height: 32, borderRadius: 8,
|
|
||||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
|
||||||
color: dark ? '#fff' : '#18181B',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>−</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
|
|||||||
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
||||||
import type { JourneyTrack } from '@trek/shared'
|
import type { JourneyTrack } from '@trek/shared'
|
||||||
import type { MapPhoto } from './journeyPhotoLayer'
|
import type { MapPhoto } from './journeyPhotoLayer'
|
||||||
|
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks'
|
||||||
|
|
||||||
// Unified handle — both providers expose the same three methods.
|
// Unified handle — both providers expose the same three methods.
|
||||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||||
@@ -41,6 +43,11 @@ interface Props {
|
|||||||
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
|
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
|
||||||
photos?: MapPhoto[]
|
photos?: MapPhoto[]
|
||||||
onPhotoClick?: (photoIds: string[]) => void
|
onPhotoClick?: (photoIds: string[]) => void
|
||||||
|
/** JRN-04: the layers the reader shows, and the plan and stay marks, both renderers. */
|
||||||
|
layerVisibility?: JourneyMapLayerVisibility
|
||||||
|
marks?: JourneyMapMark[]
|
||||||
|
onMarksPick?: (marks: JourneyMapMark[]) => void
|
||||||
|
plannedEntryIds?: ReadonlySet<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function JourneyMapAuto({ ref, ...props }: Props) {
|
function JourneyMapAuto({ ref, ...props }: Props) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026, FE-COMP-JMAPGL-2453
|
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
|
||||||
|
|
||||||
type Spy = ReturnType<typeof vi.fn>
|
type Spy = ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ const gl = vi.hoisted(() => {
|
|||||||
getSource: vi.fn((): unknown => null),
|
getSource: vi.fn((): unknown => null),
|
||||||
setTerrain: vi.fn(),
|
setTerrain: vi.fn(),
|
||||||
setConfigProperty: vi.fn(),
|
setConfigProperty: vi.fn(),
|
||||||
|
setLayoutProperty: vi.fn(),
|
||||||
}
|
}
|
||||||
const markers: MarkerStub[] = []
|
const markers: MarkerStub[] = []
|
||||||
const popups: PopupStub[] = []
|
const popups: PopupStub[] = []
|
||||||
@@ -270,6 +271,34 @@ describe('JourneyMapGL', () => {
|
|||||||
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
|
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-JMAPGL-038: a planned GPX track goes on the dashed layer, a recorded route on the solid one (JRN-01)', () => {
|
||||||
|
withToken()
|
||||||
|
render(
|
||||||
|
<JourneyMapGL
|
||||||
|
checkins={[]}
|
||||||
|
entries={singleEntry}
|
||||||
|
tracks={[
|
||||||
|
{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] },
|
||||||
|
{ place_id: -100001, trip_id: 0, name: 'Recorded on 14 May', color: '#00aa00', points: [[47, 11], [47.2, 11.2]], kind: 'day_recording' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const source = vi.mocked(gl.map.addSource).mock.calls.find(c => c[0] === 'journey-tracks')?.[1] as
|
||||||
|
| { data: { features: Array<{ properties: { planned: boolean } }> } }
|
||||||
|
| undefined
|
||||||
|
expect(source?.data.features.map(f => f.properties.planned)).toEqual([true, false])
|
||||||
|
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
id: 'journey-tracks-planned',
|
||||||
|
filter: ['==', ['get', 'planned'], true],
|
||||||
|
paint: expect.objectContaining({ 'line-dasharray': [2.2, 2] }),
|
||||||
|
}))
|
||||||
|
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
id: 'journey-tracks-line',
|
||||||
|
filter: ['!=', ['get', 'planned'], true],
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-COMP-JMAPGL-009: a single entry draws no route line', () => {
|
it('FE-COMP-JMAPGL-009: a single entry draws no route line', () => {
|
||||||
withToken()
|
withToken()
|
||||||
render(<JourneyMapGL checkins={[]} entries={singleEntry} />)
|
render(<JourneyMapGL checkins={[]} entries={singleEntry} />)
|
||||||
@@ -658,4 +687,38 @@ describe('JourneyMapGL', () => {
|
|||||||
const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />)
|
const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />)
|
||||||
expect((container.firstChild as HTMLElement).style.height).toBe('100%')
|
expect((container.firstChild as HTMLElement).style.height).toBe('100%')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-JMAPGL-039: the plan has its own casing, and a layer switched off is added hidden (JRN-04)', () => {
|
||||||
|
withToken()
|
||||||
|
render(
|
||||||
|
<JourneyMapGL
|
||||||
|
checkins={[]}
|
||||||
|
entries={entries}
|
||||||
|
tracks={[{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] }]}
|
||||||
|
layerVisibility={{ plan: false, recorded: true, entries: false }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const layer = (id: string) => vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown>; filter?: unknown }).find(l => l.id === id)
|
||||||
|
expect(layer('journey-tracks-planned-casing')?.filter).toEqual(['==', ['get', 'planned'], true])
|
||||||
|
expect(layer('journey-tracks-planned-casing')?.layout.visibility).toBe('none')
|
||||||
|
expect(layer('journey-tracks-planned')?.layout.visibility).toBe('none')
|
||||||
|
expect(layer('journey-tracks-line')?.layout.visibility).toBeUndefined()
|
||||||
|
expect(layer('journey-route-line')?.layout.visibility).toBe('none')
|
||||||
|
expect(gl.markers.every(m => m.element.style.display === 'none')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-JMAPGL-040: a switch shows and hides its layers in place, and the check follows the plan', () => {
|
||||||
|
withToken()
|
||||||
|
const shown = { plan: true, recorded: true, entries: true }
|
||||||
|
const { rerender } = render(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={shown} />)
|
||||||
|
expect(gl.markers[0].element.innerHTML).not.toContain('#16a34a')
|
||||||
|
|
||||||
|
rerender(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={{ ...shown, recorded: false }} plannedEntryIds={new Set(['e1'])} />)
|
||||||
|
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-line', 'visibility', 'none')
|
||||||
|
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-casing', 'visibility', 'none')
|
||||||
|
expect(gl.map.setLayoutProperty).not.toHaveBeenCalledWith('journey-tracks-planned', 'visibility', expect.anything())
|
||||||
|
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
|
||||||
|
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
|
|||||||
import type { JourneyTrack } from '@trek/shared'
|
import type { JourneyTrack } from '@trek/shared'
|
||||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||||
|
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
|
||||||
|
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||||
|
|
||||||
export interface JourneyMapGLHandle {
|
export interface JourneyMapGLHandle {
|
||||||
highlightMarker: (id: string | null) => void
|
highlightMarker: (id: string | null) => void
|
||||||
@@ -52,6 +54,11 @@ interface Props {
|
|||||||
photos?: MapPhoto[]
|
photos?: MapPhoto[]
|
||||||
onPhotoClick?: (photoIds: string[]) => void
|
onPhotoClick?: (photoIds: string[]) => void
|
||||||
paddingBottom?: number
|
paddingBottom?: number
|
||||||
|
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
|
||||||
|
layerVisibility?: JourneyMapLayerVisibility
|
||||||
|
marks?: JourneyMapMark[]
|
||||||
|
onMarksPick?: (marks: JourneyMapMark[]) => void
|
||||||
|
plannedEntryIds?: ReadonlySet<string>
|
||||||
glProvider?: GlMapProvider
|
glProvider?: GlMapProvider
|
||||||
/**
|
/**
|
||||||
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
|
* The GL engine, injected instead of imported. Both SDKs used to be pulled in
|
||||||
@@ -98,7 +105,16 @@ function buildItems(entries: MapEntry[]): Item[] {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
/** The map's own layers each switch takes off and puts back. */
|
||||||
|
const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
|
||||||
|
plan: ['journey-tracks-planned-casing', 'journey-tracks-planned'],
|
||||||
|
recorded: ['journey-tracks-casing', 'journey-tracks-line'],
|
||||||
|
entries: ['journey-route-line'],
|
||||||
|
}
|
||||||
|
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
|
||||||
|
const NO_PLANNED: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
|
||||||
const fill = dayColor
|
const fill = dayColor
|
||||||
const textColor = '#fff'
|
const textColor = '#fff'
|
||||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
@@ -121,6 +137,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
|||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
|
${planned ? PLANNED_BADGE_SVG : ''}
|
||||||
</svg>`
|
</svg>`
|
||||||
wrap.appendChild(inner)
|
wrap.appendChild(inner)
|
||||||
return wrap
|
return wrap
|
||||||
@@ -131,8 +148,24 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
|||||||
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
||||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||||
|
|
||||||
|
function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, number][] = []) {
|
||||||
|
const bounds = new gl.LngLatBounds()
|
||||||
|
points.forEach(point => bounds.extend(point))
|
||||||
|
marks.forEach(mark => bounds.extend([mark.lng, mark.lat]))
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
|
||||||
|
return {
|
||||||
|
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
|
||||||
|
maxZoom: 16,
|
||||||
|
pitch: tilted ? 45 : 0,
|
||||||
|
duration: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function JourneyMapGL(
|
function JourneyMapGL(
|
||||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props,
|
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||||
) {
|
) {
|
||||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||||
@@ -165,6 +198,25 @@ function JourneyMapGL(
|
|||||||
const onPhotoClickRef = useRef(onPhotoClick)
|
const onPhotoClickRef = useRef(onPhotoClick)
|
||||||
onPhotoClickRef.current = onPhotoClick
|
onPhotoClickRef.current = onPhotoClick
|
||||||
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
|
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
|
||||||
|
const markMarkersRef = useRef<{ remove: () => void }[]>([])
|
||||||
|
const onMarksPickRef = useRef(onMarksPick)
|
||||||
|
const marksRef = useRef(marks)
|
||||||
|
// As on the Leaflet map: the plan and the stays arrive after the entries, so
|
||||||
|
// the first view is framed again around both, unless the reader moved it.
|
||||||
|
const entryPointsRef = useRef<[number, number][]>([])
|
||||||
|
const marksFramedRef = useRef(false)
|
||||||
|
const readerMovedRef = useRef(false)
|
||||||
|
const visibilityRef = useRef(layerVisibility)
|
||||||
|
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
|
||||||
|
const plannedRef = useRef(plannedEntryIds)
|
||||||
|
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
|
||||||
|
// Before the map effects below, so a rebuild draws with what the reader shows now.
|
||||||
|
useEffect(() => {
|
||||||
|
onMarksPickRef.current = onMarksPick
|
||||||
|
marksRef.current = marks
|
||||||
|
visibilityRef.current = layerVisibility
|
||||||
|
plannedRef.current = plannedEntryIds
|
||||||
|
})
|
||||||
const darkRef = useRef(dark)
|
const darkRef = useRef(dark)
|
||||||
darkRef.current = dark
|
darkRef.current = dark
|
||||||
const mapLangRef = useRef(mapLang)
|
const mapLangRef = useRef(mapLang)
|
||||||
@@ -242,7 +294,7 @@ function JourneyMapGL(
|
|||||||
if (!currentInner) return
|
if (!currentInner) return
|
||||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
// would wipe mapbox's positional transform and make the marker flicker.
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted, plannedRef.current.has(id))
|
||||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
currentInner.style.cssText = nextInner.style.cssText
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
currentInner.innerHTML = nextInner.innerHTML
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
@@ -314,6 +366,17 @@ function JourneyMapGL(
|
|||||||
|
|
||||||
const map = new gl.Map(mapOptions as any)
|
const map = new gl.Map(mapOptions as any)
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
|
marksFramedRef.current = false
|
||||||
|
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
|
||||||
|
readerMovedRef.current = false
|
||||||
|
map.on('movestart', (ev: { originalEvent?: unknown }) => {
|
||||||
|
if (ev.originalEvent) readerMovedRef.current = true
|
||||||
|
})
|
||||||
|
shownRef.current = visibilityRef.current
|
||||||
|
iconPlannedRef.current = plannedRef.current
|
||||||
|
// A layer whose switch is off is added hidden, so switching it on later needs no rebuild.
|
||||||
|
const layout = (kind: keyof JourneyMapLayerVisibility, base: Record<string, unknown>) =>
|
||||||
|
visibilityRef.current[kind] ? base : { ...base, visibility: 'none' }
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
if (enableMapbox3d) {
|
if (enableMapbox3d) {
|
||||||
@@ -352,14 +415,16 @@ function JourneyMapGL(
|
|||||||
'line-opacity': 0.5,
|
'line-opacity': 0.5,
|
||||||
'line-dasharray': [2, 3],
|
'line-dasharray': [2, 3],
|
||||||
},
|
},
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPX tracks — one source for all of them, coloured per feature so a journey
|
// Tracks, one source for all of them, coloured per feature so a journey
|
||||||
// with several recorded routes keeps them apart. Casing underneath, same as the
|
// with several routes keeps them apart. Casing underneath, same as the
|
||||||
// planner map, so a track stays readable on satellite tiles.
|
// planner map, so a track stays readable on satellite tiles. A trip's GPX
|
||||||
|
// track is the plan and goes on a dashed layer, what was recorded on a solid
|
||||||
|
// one (JRN-01): a dash pattern cannot follow a feature property.
|
||||||
if (stableTracks.length > 0) {
|
if (stableTracks.length > 0) {
|
||||||
const featureCollection: GeoJSON.FeatureCollection = {
|
const featureCollection: GeoJSON.FeatureCollection = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
@@ -367,7 +432,11 @@ function JourneyMapGL(
|
|||||||
.filter(track => track.points.length > 1)
|
.filter(track => track.points.length > 1)
|
||||||
.map(track => ({
|
.map(track => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: { color: track.color || TRACK_FALLBACK_COLOR, name: track.name },
|
properties: {
|
||||||
|
color: track.color || TRACK_FALLBACK_COLOR,
|
||||||
|
name: track.name,
|
||||||
|
planned: isPlannedTrack(track),
|
||||||
|
},
|
||||||
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
|
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -376,19 +445,38 @@ function JourneyMapGL(
|
|||||||
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
|
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
|
||||||
} else {
|
} else {
|
||||||
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
|
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
|
||||||
|
// Casing apart for the plan and what was recorded, so each goes with its switch.
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'journey-tracks-casing',
|
id: 'journey-tracks-casing',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'journey-tracks',
|
source: 'journey-tracks',
|
||||||
|
filter: ['!=', ['get', 'planned'], true],
|
||||||
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
|
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'journey-tracks-planned-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'journey-tracks',
|
||||||
|
filter: ['==', ['get', 'planned'], true],
|
||||||
|
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
|
||||||
|
layout: layout('plan', { 'line-cap': 'round', 'line-join': 'round' }),
|
||||||
})
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'journey-tracks-line',
|
id: 'journey-tracks-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'journey-tracks',
|
source: 'journey-tracks',
|
||||||
|
filter: ['!=', ['get', 'planned'], true],
|
||||||
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
|
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'journey-tracks-planned',
|
||||||
|
type: 'line',
|
||||||
|
source: 'journey-tracks',
|
||||||
|
filter: ['==', ['get', 'planned'], true],
|
||||||
|
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95, 'line-dasharray': [2.2, 2] },
|
||||||
|
layout: layout('plan', { 'line-cap': 'butt', 'line-join': 'round' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,9 +484,10 @@ function JourneyMapGL(
|
|||||||
|
|
||||||
// markers
|
// markers
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
const el = markerHtml(item.dayColor, item.dayLabel, false, plannedRef.current.has(item.id))
|
||||||
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
|
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
|
||||||
el.style.zIndex = '1'
|
el.style.zIndex = '1'
|
||||||
|
if (!visibilityRef.current.entries) el.style.display = 'none'
|
||||||
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
@@ -410,15 +499,12 @@ function JourneyMapGL(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// fit bounds to all points
|
// fit bounds to all points
|
||||||
if (hasPoints) {
|
const known = marksRef.current ?? []
|
||||||
const pb = paddingBottom || 50
|
marksFramedRef.current = known.length > 0
|
||||||
|
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
|
||||||
|
if (hasPoints || known.length > 0) {
|
||||||
try {
|
try {
|
||||||
map.fitBounds(bounds, {
|
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
|
||||||
maxZoom: 16,
|
|
||||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
|
||||||
duration: 0,
|
|
||||||
})
|
|
||||||
} catch { /* empty bounds */ }
|
} catch { /* empty bounds */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -472,6 +558,76 @@ function JourneyMapGL(
|
|||||||
// gl is the injected engine and does not change for the life of a map.
|
// gl is the injected engine and does not change for the life of a map.
|
||||||
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
|
// A switch shows or hides its layers where they are (JRN-04): the GL layers by
|
||||||
|
// their visibility, the entry pins by their element.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
for (const kind of Object.keys(LAYER_IDS) as (keyof JourneyMapLayerVisibility)[]) {
|
||||||
|
if (shownRef.current[kind] === layerVisibility[kind]) continue
|
||||||
|
for (const id of LAYER_IDS[kind]) {
|
||||||
|
try { map.setLayoutProperty(id, 'visibility', layerVisibility[kind] ? 'visible' : 'none') } catch { /* not drawn on this map */ }
|
||||||
|
}
|
||||||
|
if (kind === 'entries') {
|
||||||
|
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shownRef.current = layerVisibility
|
||||||
|
}, [layerVisibility])
|
||||||
|
|
||||||
|
// The plan's check on an entry pin follows the plan layer in place.
|
||||||
|
useEffect(() => {
|
||||||
|
const before = iconPlannedRef.current
|
||||||
|
if (before === plannedEntryIds) return
|
||||||
|
for (const item of itemsRef.current) {
|
||||||
|
if (before.has(item.id) !== plannedEntryIds.has(item.id)) setMarkerStyle(item.id, highlightedRef.current === item.id)
|
||||||
|
}
|
||||||
|
iconPlannedRef.current = plannedEntryIds
|
||||||
|
}, [plannedEntryIds, setMarkerStyle])
|
||||||
|
|
||||||
|
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
|
||||||
|
// regrouped on every move, between the photos and the entry pins.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
const clear = () => {
|
||||||
|
markMarkersRef.current.forEach(m => m.remove())
|
||||||
|
markMarkersRef.current = []
|
||||||
|
}
|
||||||
|
const draw = () => {
|
||||||
|
clear()
|
||||||
|
if (!marks?.length) return
|
||||||
|
if (!marksFramedRef.current) {
|
||||||
|
marksFramedRef.current = true
|
||||||
|
if (!readerMovedRef.current) {
|
||||||
|
try {
|
||||||
|
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||||
|
} catch { /* empty bounds */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
|
||||||
|
const look = markHitSvg(hit.members)
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
|
||||||
|
el.innerHTML = look.html
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onMarksPickRef.current?.(hit.members)
|
||||||
|
})
|
||||||
|
markMarkersRef.current.push(new gl.Marker({ element: el, anchor: look.anchor }).setLngLat([hit.lng, hit.lat]).addTo(map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (map.loaded()) draw()
|
||||||
|
else map.once('load', draw)
|
||||||
|
map.on('moveend', draw)
|
||||||
|
return () => {
|
||||||
|
map.off('moveend', draw)
|
||||||
|
map.off('load', draw)
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
// gl is the injected engine and does not change for the life of a map.
|
||||||
|
}, [marks, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
// Switching the UI language has to repin the basemap labels without tearing
|
// Switching the UI language has to repin the basemap labels without tearing
|
||||||
// the map down. The load handler covers the initial run.
|
// the map down. The load handler covers the initial run.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// FE-JOURNEY-MAPLAYERCTL-001 to FE-JOURNEY-MAPLAYERCTL-003
|
||||||
|
//
|
||||||
|
// JRN-04: the journey map's layer switches and its key.
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JourneyMapLayerControl from './JourneyMapLayerControl';
|
||||||
|
import { ALL_VISIBLE, type JourneyMapLayerControl as Control } from './journeyMapLayers';
|
||||||
|
import type { JourneyMapLayersView } from './useJourneyMapLayers';
|
||||||
|
|
||||||
|
function view(controls: Control[], over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
|
||||||
|
return {
|
||||||
|
visibility: ALL_VISIBLE,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
controls,
|
||||||
|
tracks: [],
|
||||||
|
marks: [],
|
||||||
|
plannedEntryIds: new Set(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JourneyMapLayerControl', () => {
|
||||||
|
it('FE-JOURNEY-MAPLAYERCTL-001: one pressed switch per offered layer with its count, a withheld one not at all', () => {
|
||||||
|
const layers = view(
|
||||||
|
[
|
||||||
|
{ layer: 'recorded', count: 2, state: 'ready' },
|
||||||
|
{ layer: 'entries', count: 5, state: 'ready' },
|
||||||
|
{ layer: 'photos', count: 0, state: 'empty' },
|
||||||
|
],
|
||||||
|
{ visibility: { ...ALL_VISIBLE, photos: false } }
|
||||||
|
);
|
||||||
|
render(<JourneyMapLayerControl layers={layers} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('journey-map-layer-plan')).toBeNull();
|
||||||
|
expect(screen.getByTestId('journey-map-layer-recorded')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByTestId('journey-map-layer-entries-badge')).toHaveTextContent('5');
|
||||||
|
const photos = screen.getByTestId('journey-map-layer-photos');
|
||||||
|
expect(photos).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(photos.className).toContain('opacity-55');
|
||||||
|
expect(screen.queryByTestId('journey-map-layer-photos-badge')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(photos);
|
||||||
|
expect(layers.toggle).toHaveBeenCalledWith('photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERCTL-002: a layer that could not be read carries the warning tone, a loading one the spinner', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMapLayerControl
|
||||||
|
layers={view([
|
||||||
|
{ layer: 'plan', count: 0, state: 'unavailable' },
|
||||||
|
{ layer: 'entries', count: 0, state: 'loading' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('journey-map-layer-plan-badge')).toHaveAttribute('data-tone', 'warning');
|
||||||
|
expect(screen.getByTestId('journey-map-layer-entries')).toHaveAttribute('data-state', 'loading');
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERCTL-003: the key opens beside the switches and says a plan is no visit', () => {
|
||||||
|
render(<JourneyMapLayerControl layers={view([{ layer: 'entries', count: 1, state: 'ready' }])} compact />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Entries')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId('journey-map-key-button'));
|
||||||
|
expect(screen.getByTestId('journey-map-key')).toHaveTextContent('A planned place is no proof of a visit');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { CalendarRange, Images, Info, Loader2, NotebookPen, Route } from 'lucide-react';
|
||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { FROSTED_SHELL, segmentStyle } from '../Map/mapControlShadow';
|
||||||
|
import { LABEL, PopoverListPanel } from '../shared/dialogParts';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { usePopoverList } from '../shared/usePopoverList';
|
||||||
|
import { LAYER_TEXT, type JourneyMapLayerControl as Control, type JourneyMapLayer } from './journeyMapLayers';
|
||||||
|
import { PLAN_FALLBACK_COLOR } from './journeyMapMarks';
|
||||||
|
import type { JourneyMapLayersView } from './useJourneyMapLayers';
|
||||||
|
|
||||||
|
const ICONS: Record<JourneyMapLayer, ComponentType<{ size?: number; strokeWidth?: number }>> = {
|
||||||
|
plan: CalendarRange,
|
||||||
|
recorded: Route,
|
||||||
|
entries: NotebookPen,
|
||||||
|
photos: Images,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The sample colours of the key: an entry pin of the first day and a recorded line, as the map draws them. */
|
||||||
|
const SAMPLE_DAY = '#6366f1';
|
||||||
|
const SAMPLE_ROUTE = '#2563eb';
|
||||||
|
|
||||||
|
/** What a switch says about its layer: its name, and why it shows nothing when it does not. */
|
||||||
|
function hintOf(control: Control, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||||
|
const text = LAYER_TEXT[control.layer];
|
||||||
|
const name = t(text.labelKey);
|
||||||
|
if (control.state === 'unavailable') return `${name}: ${t(text.unavailableKey)}`;
|
||||||
|
if (control.state === 'empty') return `${name}: ${t(text.emptyKey)}`;
|
||||||
|
if (control.state === 'loading') return `${name}: ${t('journey.map.layer.loading')}`;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayerToggle({
|
||||||
|
control,
|
||||||
|
on,
|
||||||
|
onToggle,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
control: Control;
|
||||||
|
on: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
compact: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = ICONS[control.layer];
|
||||||
|
const name = t(LAYER_TEXT[control.layer].labelKey);
|
||||||
|
const problem = control.state === 'unavailable';
|
||||||
|
// Nothing to draw: dimmed rather than gone, and the tooltip says why.
|
||||||
|
const quiet = control.state === 'empty' || control.state === 'idle';
|
||||||
|
const showBadge = control.state === 'loading' || problem || (control.state === 'ready' && on);
|
||||||
|
return (
|
||||||
|
<Tooltip label={hintOf(control, t)} placement="bottom">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={name}
|
||||||
|
aria-pressed={on}
|
||||||
|
data-testid={`journey-map-layer-${control.layer}`}
|
||||||
|
data-state={control.state}
|
||||||
|
className={`${on ? 'bg-accent text-accent-text' : 'text-content-muted hover:bg-surface-hover'} ${quiet ? 'opacity-55' : ''} transition-colors`}
|
||||||
|
style={segmentStyle(on)}
|
||||||
|
>
|
||||||
|
<Icon size={14} strokeWidth={2} />
|
||||||
|
{!compact && <span>{name}</span>}
|
||||||
|
{showBadge && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-testid={`journey-map-layer-${control.layer}-badge`}
|
||||||
|
data-tone={problem ? 'warning' : 'default'}
|
||||||
|
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
|
||||||
|
problem
|
||||||
|
? 'bg-warning-soft text-warning'
|
||||||
|
: on
|
||||||
|
? 'bg-accent-text text-accent'
|
||||||
|
: 'bg-surface-hover text-content-secondary'
|
||||||
|
}`}
|
||||||
|
style={fs(10)}
|
||||||
|
>
|
||||||
|
{control.state === 'loading' ? (
|
||||||
|
<Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" />
|
||||||
|
) : problem ? (
|
||||||
|
'!'
|
||||||
|
) : (
|
||||||
|
control.count
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hollow plan pin, as the map draws it. */
|
||||||
|
function PlanPinSample() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="20" viewBox="0 0 22 28" aria-hidden className="flex-none">
|
||||||
|
<path
|
||||||
|
d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
stroke={PLAN_FALLBACK_COLOR}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="3 2"
|
||||||
|
/>
|
||||||
|
<circle cx="11" cy="10.5" r="3" fill={PLAN_FALLBACK_COLOR} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A journal entry's pin, with the plan's check when the plan had that place too. */
|
||||||
|
function EntryPinSample({ planned = false }: { planned?: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="20" viewBox="0 0 28 36" aria-hidden className="flex-none">
|
||||||
|
<path
|
||||||
|
d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z"
|
||||||
|
fill={SAMPLE_DAY}
|
||||||
|
/>
|
||||||
|
{planned && (
|
||||||
|
<>
|
||||||
|
<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" strokeWidth="1.2" />
|
||||||
|
<path
|
||||||
|
d="M20.4 5.6l1.5 1.5 2.8-2.9"
|
||||||
|
fill="none"
|
||||||
|
stroke="#16a34a"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaySample() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 14 14" aria-hidden className="flex-none">
|
||||||
|
<circle cx="7" cy="7" r="5" fill={SAMPLE_DAY} stroke="#ffffff" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeveralSample() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 28 28" aria-hidden className="flex-none">
|
||||||
|
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke={SAMPLE_DAY} strokeWidth="2" />
|
||||||
|
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke={PLAN_FALLBACK_COLOR} strokeWidth="2.5" />
|
||||||
|
<text x="12" y="16" textAnchor="middle" dominantBaseline="central" fontSize="11" fontWeight="700" fill="#111827">
|
||||||
|
3
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineSample({ dashed }: { dashed: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="flex-none">
|
||||||
|
<line x1="1" y1="4" x2="23" y2="4" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
|
||||||
|
<line
|
||||||
|
x1="1"
|
||||||
|
y1="4"
|
||||||
|
x2="23"
|
||||||
|
y2="4"
|
||||||
|
stroke={SAMPLE_ROUTE}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap={dashed ? 'butt' : 'round'}
|
||||||
|
strokeDasharray={dashed ? '4 3' : undefined}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW = 'flex min-w-0 items-center gap-2.5 text-content-secondary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key of the journey map: what each pin, dot and line stands for, with the
|
||||||
|
* one rule that matters most: a planned place is no proof of a visit.
|
||||||
|
*/
|
||||||
|
function JourneyMapKey() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const rows: { sample: ReactNode; labelKey: string }[] = [
|
||||||
|
{ sample: <PlanPinSample />, labelKey: 'journey.map.legend.planned' },
|
||||||
|
{ sample: <EntryPinSample />, labelKey: 'journey.map.legend.entry' },
|
||||||
|
{ sample: <EntryPinSample planned />, labelKey: 'journey.map.legend.plannedWritten' },
|
||||||
|
{ sample: <StaySample />, labelKey: 'journey.map.legend.stay' },
|
||||||
|
{ sample: <SeveralSample />, labelKey: 'journey.map.legend.several' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-1.5" data-testid="journey-map-key">
|
||||||
|
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
|
||||||
|
{t('journey.map.legend')}
|
||||||
|
</span>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.labelKey} className={ROW} style={fs(11.5, 'body')}>
|
||||||
|
<span className="flex w-6 flex-none justify-center">{row.sample}</span>
|
||||||
|
<span className="min-w-0">{t(row.labelKey)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="border-t border-edge-faint pt-3">
|
||||||
|
<span className={LABEL}>{t('journey.map.legend.lines')}</span>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
<li className={ROW} style={fs(11.5, 'body')}>
|
||||||
|
<span className="flex w-6 flex-none justify-center">
|
||||||
|
<LineSample dashed />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">{t('journey.map.legend.plannedRoute')}</span>
|
||||||
|
</li>
|
||||||
|
<li className={ROW} style={fs(11.5, 'body')}>
|
||||||
|
<span className="flex w-6 flex-none justify-center">
|
||||||
|
<LineSample dashed={false} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">{t('journey.map.legend.recordedRoute')}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="flex items-start gap-1.5 rounded-lg bg-surface-hover px-2 py-1.5 text-content-secondary"
|
||||||
|
style={fs(11)}
|
||||||
|
>
|
||||||
|
<Info size={12} strokeWidth={2} aria-hidden className="mt-px flex-none" />
|
||||||
|
{t('journey.map.legend.planIsNoVisit')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const list = usePopoverList(-1, { estimatedHeight: 360 });
|
||||||
|
const { open, toggle, triggerRef, listId } = list;
|
||||||
|
const label = t('journey.map.legend');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip label={label} placement="bottom" disabled={open}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? listId : undefined}
|
||||||
|
data-testid="journey-map-key-button"
|
||||||
|
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-hover ${open ? 'bg-surface-hover' : ''}`}
|
||||||
|
>
|
||||||
|
<Info size={15} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverListPanel list={list} label={label} className="w-[268px]">
|
||||||
|
<JourneyMapKey />
|
||||||
|
</PopoverListPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layer switches of a journey map (JRN-04), in the frosted pill the
|
||||||
|
* planner's map controls wear: one per layer the reader is offered, each with
|
||||||
|
* its count, a spinner while it loads and the warning tone when it could not
|
||||||
|
* be read, and the key beside them. On a phone the switches drop their words;
|
||||||
|
* the tooltip and the accessible name still say what each is.
|
||||||
|
*/
|
||||||
|
export default function JourneyMapLayerControl({
|
||||||
|
layers,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
layers: JourneyMapLayersView;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const live = layers.controls
|
||||||
|
.filter((control) => control.state === 'loading' || control.state === 'unavailable')
|
||||||
|
.map((control) => hintOf(control, t))
|
||||||
|
.join('. ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={FROSTED_SHELL}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('journey.map.layers')}
|
||||||
|
className="max-w-full gap-0.5 overflow-x-auto"
|
||||||
|
>
|
||||||
|
{layers.controls.map((control) => (
|
||||||
|
<LayerToggle
|
||||||
|
key={control.layer}
|
||||||
|
control={control}
|
||||||
|
on={layers.visibility[control.layer]}
|
||||||
|
onToggle={() => layers.toggle(control.layer)}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />
|
||||||
|
<KeyButton />
|
||||||
|
<span role="status" aria-live="polite" className="sr-only">
|
||||||
|
{live}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// FE-JOURNEY-MAPPICK-001 to FE-JOURNEY-MAPPICK-003
|
||||||
|
//
|
||||||
|
// JRN-04: what a picked plan place or stay is, and several on one spot as a list.
|
||||||
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JourneyMapPickCard from './JourneyMapPickCard';
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks';
|
||||||
|
|
||||||
|
const PLACE: JourneyMapPlace = {
|
||||||
|
place_id: 4,
|
||||||
|
trip_id: 2,
|
||||||
|
trip_title: 'Venice',
|
||||||
|
name: 'Piazza San Marco',
|
||||||
|
lat: 45.434,
|
||||||
|
lng: 12.3388,
|
||||||
|
days: ['2026-05-14', '2026-05-15'],
|
||||||
|
time: '10:00',
|
||||||
|
category_color: null,
|
||||||
|
category_icon: null,
|
||||||
|
entry_id: null,
|
||||||
|
};
|
||||||
|
const STAY: JourneyMapStay = {
|
||||||
|
id: 9,
|
||||||
|
entry_id: 7,
|
||||||
|
name: 'Rialto market',
|
||||||
|
lat: 45.438,
|
||||||
|
lng: 12.336,
|
||||||
|
started_at: '2026-05-14T09:00:00+02:00',
|
||||||
|
ended_at: '2026-05-14T10:00:00+02:00',
|
||||||
|
};
|
||||||
|
const plan = (over: Partial<JourneyMapPlace> = {}): JourneyMapMark => ({
|
||||||
|
kind: 'plan',
|
||||||
|
key: 'plan:4',
|
||||||
|
lat: 45.434,
|
||||||
|
lng: 12.3388,
|
||||||
|
color: '#64748b',
|
||||||
|
place: { ...PLACE, ...over },
|
||||||
|
});
|
||||||
|
const stay: JourneyMapMark = { kind: 'stay', key: 'stay:9', lat: 45.438, lng: 12.336, color: '#14b8a6', stay: STAY };
|
||||||
|
|
||||||
|
describe('JourneyMapPickCard', () => {
|
||||||
|
it('FE-JOURNEY-MAPPICK-001: a planned place names its trip and days and says when nothing was written about it', () => {
|
||||||
|
render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onOpenEntry={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Planned')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Venice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('10:00')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nothing written about it yet.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Open entry' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPPICK-002: a place written about and a stay lead to their entry', () => {
|
||||||
|
const onOpenEntry = vi.fn();
|
||||||
|
const { unmount } = render(
|
||||||
|
<JourneyMapPickCard marks={[plan({ entry_id: 12 })]} onClose={vi.fn()} onOpenEntry={onOpenEntry} />
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
|
||||||
|
expect(onOpenEntry).toHaveBeenCalledWith('12');
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JourneyMapPickCard
|
||||||
|
marks={[stay]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onOpenEntry={onOpenEntry}
|
||||||
|
entryTitleOf={() => 'A day in Venice'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Part of “A day in Venice”')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
|
||||||
|
expect(onOpenEntry).toHaveBeenLastCalledWith('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPPICK-003: several on one spot come as a list, each reachable, and Escape closes', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<JourneyMapPickCard marks={[plan(), stay]} onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'At this spot' })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Rialto market/ }));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Rialto market' })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Back to the list' }));
|
||||||
|
expect(screen.getByRole('button', { name: /Piazza San Marco/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
|
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** What was picked on the map: one mark, or several that lie on one spot. */
|
||||||
|
marks: JourneyMapMark[];
|
||||||
|
onClose: () => void;
|
||||||
|
/** Leads to an entry; absent where the reader cannot open one. */
|
||||||
|
onOpenEntry?: (entryId: string) => void;
|
||||||
|
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
||||||
|
entryTitleOf?: (entryId: number) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(date: string, locale: string): string {
|
||||||
|
return new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(iso: string, locale: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (entryId: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!onOpenEntry) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenEntry(String(entryId))}
|
||||||
|
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
|
||||||
|
style={fs(11.5, 'body')}
|
||||||
|
>
|
||||||
|
<NotebookPen size={12} strokeWidth={2.2} aria-hidden />
|
||||||
|
{t('journey.map.pick.openEntry')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<SoftPill icon={<CalendarRange size={11} strokeWidth={2} />}>{t('journey.map.pick.planned')}</SoftPill>
|
||||||
|
<WhiteBadge>{place.trip_title}</WhiteBadge>
|
||||||
|
{place.days.map((day) => (
|
||||||
|
<WhiteBadge key={day}>{dayLabel(day, locale)}</WhiteBadge>
|
||||||
|
))}
|
||||||
|
{place.time && <WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>{place.time}</WhiteBadge>}
|
||||||
|
</div>
|
||||||
|
{place.entry_id !== null ? (
|
||||||
|
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
||||||
|
) : (
|
||||||
|
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||||
|
{t('journey.map.pick.notWritten')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StayDetail({
|
||||||
|
stay,
|
||||||
|
onOpenEntry,
|
||||||
|
entryTitleOf,
|
||||||
|
}: { stay: JourneyMapStay } & Pick<Props, 'onOpenEntry' | 'entryTitleOf'>) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const entryTitle = entryTitleOf?.(stay.entry_id);
|
||||||
|
const from = timeLabel(stay.started_at, locale);
|
||||||
|
const to = timeLabel(stay.ended_at, locale);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<SoftPill tone="info" icon={<MapPin size={11} strokeWidth={2} />}>
|
||||||
|
{t('journey.map.pick.stay')}
|
||||||
|
</SoftPill>
|
||||||
|
{from && (
|
||||||
|
<WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>
|
||||||
|
{to ? t('journey.map.pick.timeRange', { from, to }) : from}
|
||||||
|
</WhiteBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entryTitle && (
|
||||||
|
<p className="truncate text-content-secondary" style={fs(11.5, 'body')}>
|
||||||
|
{t('journey.map.pick.partOf', { title: entryTitle })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<OpenEntry entryId={stay.entry_id} onOpenEntry={onOpenEntry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markName(mark: JourneyMapMark): string {
|
||||||
|
return mark.kind === 'plan' ? mark.place.name : mark.stay.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a mark picked on the journey map is (JRN-04), docked over the map's
|
||||||
|
* corner: a planned place with its trip, its days and the entry written about
|
||||||
|
* it, or a stay with its times and the report it belongs to. Several marks on
|
||||||
|
* one spot come as a list first, each with what kind it is, so overlapping
|
||||||
|
* hits stay reachable one by one.
|
||||||
|
*/
|
||||||
|
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const title = picked ? markName(picked) : t('journey.map.pick.here');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
data-testid="journey-map-pick"
|
||||||
|
className="pointer-events-auto flex w-[280px] max-w-[calc(100%-24px)] flex-col gap-2 rounded-xl border border-edge bg-surface-card p-3 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
{picked && marks.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicked(null)}
|
||||||
|
aria-label={t('journey.map.pick.back')}
|
||||||
|
className="-ms-1 grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} strokeWidth={2.2} className="rtl:rotate-180" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 font-semibold text-content" style={fs(13, 'body')}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{!picked && <WhiteBadge>{marks.length}</WhiteBadge>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
|
||||||
|
{picked?.kind === 'stay' && (
|
||||||
|
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
||||||
|
)}
|
||||||
|
{!picked && (
|
||||||
|
<ul className="-mx-1 flex max-h-[220px] flex-col overflow-y-auto">
|
||||||
|
{marks.map((mark) => (
|
||||||
|
<li key={mark.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicked(mark)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-1.5 py-1.5 text-start hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: mark.color }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
|
||||||
|
{markName(mark)}
|
||||||
|
</span>
|
||||||
|
<SoftPill tone={mark.kind === 'plan' ? 'neutral' : 'info'}>
|
||||||
|
{mark.kind === 'plan' ? t('journey.map.pick.planned') : t('journey.map.pick.stay')}
|
||||||
|
</SoftPill>
|
||||||
|
{mark.kind === 'plan' && mark.place.days[0] && (
|
||||||
|
<WhiteBadge>{dayLabel(mark.place.days[0], locale)}</WhiteBadge>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={13} strokeWidth={2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// FE-JRN-RECORDING-DIALOG-001 to FE-JRN-RECORDING-DIALOG-005
|
||||||
|
//
|
||||||
|
// JRN-01: the desktop dialog that takes one day's recorded route over. It names
|
||||||
|
// the journey and the day, shows what would be kept, and keeps it only on its
|
||||||
|
// button.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import JourneyRecordingDialog from './JourneyRecordingDialog';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
|
||||||
|
|
||||||
|
function renderDialog(stored?: ReturnType<typeof buildRecordedDay>) {
|
||||||
|
const props = { onKept: vi.fn(), onRemoved: vi.fn(), onClose: vi.fn() };
|
||||||
|
render(<JourneyRecordingDialog journeyId={7} journeyTitle="Venice" date="2026-05-14" stored={stored} {...props} />);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyRecordingDialog', () => {
|
||||||
|
it('FE-JRN-RECORDING-DIALOG-001: names the journey and the day and shows what the day covers before anything is kept', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ audience: 2 }));
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Venice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/May 14, 2026/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1.5 km')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 piece')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Walking')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 other people on this journey will see the route/)).toBeInTheDocument();
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DIALOG-002: keeps the day on its button and closes', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
const day = buildRecordedDay();
|
||||||
|
repo.save.mockResolvedValue({ day, outcome: 'created' });
|
||||||
|
const props = renderDialog();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Keep route' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(props.onKept).toHaveBeenCalledWith(day));
|
||||||
|
expect(props.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DIALOG-003: gaps and an unfinished day are said out loud, a kept twin cannot be kept again', async () => {
|
||||||
|
const preview = buildRecordingPreview();
|
||||||
|
repo.preview.mockResolvedValue({
|
||||||
|
...preview,
|
||||||
|
provisional: true,
|
||||||
|
stats: { ...preview.stats, gapCount: 2 },
|
||||||
|
stored: buildRecordedDay(),
|
||||||
|
sameAsStored: true,
|
||||||
|
});
|
||||||
|
renderDialog(buildRecordedDay());
|
||||||
|
|
||||||
|
expect(await screen.findByText('2 gaps')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/not over yet in this time zone/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This journey keeps exactly this recording already.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Already kept' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DIALOG-004: when Dawarich cannot be read it says why, that the kept route stays, and offers to try again', async () => {
|
||||||
|
repo.preview.mockRejectedValueOnce(httpError('unauthorized'));
|
||||||
|
renderDialog(buildRecordedDay());
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Dawarich rejected the API key\./)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/The route this journey keeps stays as it is\./)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Keep route' })).toBeDisabled();
|
||||||
|
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||||
|
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DIALOG-005: removing the kept route asks first, also when Dawarich does not answer', async () => {
|
||||||
|
repo.preview.mockRejectedValue(httpError('not_connected'));
|
||||||
|
repo.remove.mockResolvedValue(undefined);
|
||||||
|
const props = renderDialog(buildRecordedDay());
|
||||||
|
await screen.findByText(/No Dawarich instance is connected yet\./);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Remove route' }));
|
||||||
|
expect(repo.remove).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText('Remove the kept route?')).toBeInTheDocument();
|
||||||
|
const buttons = screen.getAllByRole('button', { name: 'Remove route' });
|
||||||
|
const confirm = buttons[buttons.length - 1];
|
||||||
|
if (!confirm) throw new Error('no confirm button');
|
||||||
|
await userEvent.click(confirm);
|
||||||
|
|
||||||
|
await waitFor(() => expect(props.onRemoved).toHaveBeenCalledWith('2026-05-14'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import DawarichDayDialog from './DawarichDayDialog';
|
||||||
|
import { JourneyRecordingActions, JourneyRecordingReview } from './JourneyRecordingReview';
|
||||||
|
import { useJourneyRecordingDay, type JourneyRecordingViewProps } from './useJourneyRecordingDay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking one day's recorded route from Dawarich into the journey (JRN-01),
|
||||||
|
* on the desktop: the journey and the day in the head band, the preview in the
|
||||||
|
* body, keeping it only on the button that says so.
|
||||||
|
*/
|
||||||
|
export default function JourneyRecordingDialog({
|
||||||
|
journeyTitle,
|
||||||
|
date,
|
||||||
|
stored,
|
||||||
|
onClose,
|
||||||
|
...rest
|
||||||
|
}: JourneyRecordingViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const day = useJourneyRecordingDay({ date, onClose, ...rest });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DawarichDayDialog
|
||||||
|
journeyTitle={day.preview?.journey.title ?? journeyTitle}
|
||||||
|
date={date}
|
||||||
|
sub={t('journey.recording.sub')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<JourneyRecordingActions day={day} stored={stored} onCancel={onClose} />}
|
||||||
|
>
|
||||||
|
<JourneyRecordingReview day={day} stored={stored} />
|
||||||
|
</DawarichDayDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's switch for preparing the recorded days (JRN-03) in the desktop
|
||||||
|
* journey settings, a row among the map switches. `rowClass` is the row look
|
||||||
|
* of the list it sits in.
|
||||||
|
*/
|
||||||
|
export function JourneyRecordingPrepRow({
|
||||||
|
on,
|
||||||
|
prep,
|
||||||
|
rowClass,
|
||||||
|
}: {
|
||||||
|
on: boolean;
|
||||||
|
prep: JourneyRecordingPrepState;
|
||||||
|
rowClass: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={prep.saving ? `${rowClass} opacity-60` : rowClass}>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-content" style={fs(13, 'body')}>
|
||||||
|
{t('journey.settings.autoPrepare')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.settings.autoPrepareHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
on={on}
|
||||||
|
onToggle={() => {
|
||||||
|
if (!prep.saving) void prep.setEnabled(!on);
|
||||||
|
}}
|
||||||
|
label={t('journey.settings.autoPrepare')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under the switches: how the caller's own preparation stands, in warning
|
||||||
|
* colour when something keeps it from working, with a fetch by hand where one
|
||||||
|
* would reach anything. Nothing while the switch is off.
|
||||||
|
*/
|
||||||
|
export function JourneyRecordingPrepStatus({ prep }: { prep: JourneyRecordingPrepState }): React.ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const line = prep.statusLine;
|
||||||
|
if (!line) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex items-start gap-2" data-testid="journey-prep-status">
|
||||||
|
<p
|
||||||
|
className={`m-0 flex-1 leading-normal ${line.tone === 'warning' ? 'text-warning' : 'text-content-faint'}`}
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</p>
|
||||||
|
{prep.canFetch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void prep.fetchNow();
|
||||||
|
}}
|
||||||
|
disabled={prep.fetching}
|
||||||
|
className="inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content-secondary ring-1 ring-edge-faint hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={prep.fetching ? 'animate-spin' : undefined} aria-hidden="true" />
|
||||||
|
{prep.fetching ? t('journey.prep.fetching') : t('journey.prep.fetchNow')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
|
||||||
|
import { AlertTriangle, Clock, Info, Loader2, RefreshCw, Route, Users } from 'lucide-react';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { formatDistance } from '../../utils/units';
|
||||||
|
import { SoftPill } from '../Planner/planParts';
|
||||||
|
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||||
|
import CustomSelect from '../shared/CustomSelect';
|
||||||
|
import { PANEL } from '../shared/dialogParts';
|
||||||
|
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { modeLabel, sketchPaths } from './journeyRecordingModel';
|
||||||
|
import type { useJourneyRecordingDay } from './useJourneyRecordingDay';
|
||||||
|
|
||||||
|
type RecordingDayState = ReturnType<typeof useJourneyRecordingDay>;
|
||||||
|
|
||||||
|
const SKETCH_W = 320;
|
||||||
|
const SKETCH_H = 150;
|
||||||
|
|
||||||
|
/** A sentence about the recording, its icon saying how much it matters. */
|
||||||
|
export function Notice({ tone, children }: { tone: 'info' | 'warning' | 'danger'; children: ReactNode }) {
|
||||||
|
const look = {
|
||||||
|
info: 'bg-surface-secondary text-content-secondary',
|
||||||
|
warning: 'bg-warning-soft text-warning',
|
||||||
|
danger: 'bg-danger-soft text-danger',
|
||||||
|
}[tone];
|
||||||
|
const Icon = tone === 'info' ? Info : AlertTriangle;
|
||||||
|
return (
|
||||||
|
<p className={`m-0 flex items-start gap-2 rounded-[12px] px-3 py-2.5 ${look}`} style={fs(12.5, 'body')}>
|
||||||
|
<Icon size={15} strokeWidth={2} className="mt-px flex-none" aria-hidden="true" />
|
||||||
|
<span className="min-w-0">{children}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day's pieces drawn into a box, each its own line, so a gap stays a gap. */
|
||||||
|
function RecordingSketch({ segments }: { segments: JourneyRecordingPreview['segments'] }) {
|
||||||
|
const { paths, ends } = sketchPaths(segments, SKETCH_W, SKETCH_H);
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SKETCH_W} ${SKETCH_H}`}
|
||||||
|
className="block h-[150px] w-full"
|
||||||
|
role="img"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="recording-sketch"
|
||||||
|
>
|
||||||
|
{paths.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{ends.map(([x, y], i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={3} fill="var(--bg-card)" stroke="var(--text-faint)" strokeWidth={1.5} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time of the recording on the clock of the zone the day is cut in. */
|
||||||
|
function clockIn(iso: string, zoneMinutes: number, locale: string, is12h: boolean): string {
|
||||||
|
return new Date(Date.parse(iso) + zoneMinutes * 60_000).toLocaleTimeString(locale, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
hour12: is12h,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day in figures: the sketch, how far, when, in how many pieces and gaps, how and from what. */
|
||||||
|
function RecordingFacts({ preview }: { preview: JourneyRecordingPreview }) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||||
|
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||||
|
const { stats } = preview;
|
||||||
|
return (
|
||||||
|
<div className={PANEL}>
|
||||||
|
<RecordingSketch segments={preview.segments} />
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<SoftPill icon={<Route size={11} aria-hidden="true" />}>
|
||||||
|
{formatDistance(stats.distanceMeters / 1000, unit)}
|
||||||
|
</SoftPill>
|
||||||
|
{stats.firstAt && stats.lastAt && (
|
||||||
|
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
|
||||||
|
{t('journey.recording.span', {
|
||||||
|
from: clockIn(stats.firstAt, preview.utcOffsetMinutes, locale, is12h),
|
||||||
|
to: clockIn(stats.lastAt, preview.utcOffsetMinutes, locale, is12h),
|
||||||
|
})}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
<SoftPill>{t('journey.recording.pieces', { count: stats.segmentCount })}</SoftPill>
|
||||||
|
{stats.gapCount > 0 && (
|
||||||
|
<Tooltip label={t('journey.recording.gapsHint')} placement="top">
|
||||||
|
<button type="button" className="inline-flex cursor-default">
|
||||||
|
<SoftPill tone="info">{t('journey.recording.gaps', { count: stats.gapCount })}</SoftPill>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{stats.modes.map((mode) => (
|
||||||
|
<SoftPill key={mode}>{modeLabel(mode, t)}</SoftPill>
|
||||||
|
))}
|
||||||
|
<Tooltip label={t('journey.recording.sourceHint')} placement="top">
|
||||||
|
<button type="button" className="inline-flex cursor-default">
|
||||||
|
<SoftPill>
|
||||||
|
{t(preview.sourceKind === 'tracks' ? 'journey.recording.sourceTracks' : 'journey.recording.sourcePoints')}
|
||||||
|
</SoftPill>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every reason to look twice before keeping the day, and who will see it. */
|
||||||
|
function RecordingNotices({
|
||||||
|
preview,
|
||||||
|
changedSincePreview,
|
||||||
|
}: {
|
||||||
|
preview: JourneyRecordingPreview;
|
||||||
|
changedSincePreview: boolean;
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||||
|
const { stored } = preview;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{changedSincePreview && <Notice tone="warning">{t('journey.recording.changedSincePreview')}</Notice>}
|
||||||
|
{preview.truncated && <Notice tone="warning">{t('journey.recording.truncated')}</Notice>}
|
||||||
|
{preview.provisional && <Notice tone="warning">{t('journey.recording.provisional')}</Notice>}
|
||||||
|
{preview.sameAsStored && <Notice tone="info">{t('journey.recording.same')}</Notice>}
|
||||||
|
{stored && !preview.sameAsStored && (
|
||||||
|
<Notice tone="info">
|
||||||
|
{t('journey.recording.differs', {
|
||||||
|
date: new Date(stored.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
|
||||||
|
distance: formatDistance(stored.stats.distanceMeters / 1000, unit),
|
||||||
|
})}
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
{preview.audience > 0 && (
|
||||||
|
<p className="m-0 flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
<Users size={14} className="mt-px flex-none" aria-hidden="true" />
|
||||||
|
{t('journey.recording.audience', { count: preview.audience })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reading, failed, nothing recorded, or the day to look at. */
|
||||||
|
function RecordingState({ day, stored }: { day: RecordingDayState; stored: JourneyRecordedDay | undefined }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { preview } = day;
|
||||||
|
if (day.loading) {
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex items-center gap-2 py-6 text-content-muted" role="status" style={fs(12.5, 'body')}>
|
||||||
|
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||||
|
{t('journey.recording.loading')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (day.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Notice tone="danger">
|
||||||
|
{day.error}
|
||||||
|
{stored && ` ${t('journey.recording.keptStays')}`}
|
||||||
|
</Notice>
|
||||||
|
<span>
|
||||||
|
<DialogButton onClick={day.retry} icon={<RefreshCw size={14} aria-hidden="true" />}>
|
||||||
|
{t('journey.recording.retry')}
|
||||||
|
</DialogButton>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!preview) return null;
|
||||||
|
if (preview.contentHash === null) return <Notice tone="info">{t('journey.recording.empty')}</Notice>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<RecordingFacts preview={preview} />
|
||||||
|
<RecordingNotices preview={preview} changedSincePreview={day.changedSincePreview} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What taking a day over would keep (JRN-01): the zone the day is cut in, the
|
||||||
|
* recording as a sketch and in figures, and every reason to look twice. The
|
||||||
|
* body of the desktop dialog and of the phone sheet alike.
|
||||||
|
*/
|
||||||
|
export function JourneyRecordingReview({
|
||||||
|
day,
|
||||||
|
stored,
|
||||||
|
}: {
|
||||||
|
day: RecordingDayState;
|
||||||
|
/** What the journey keeps for the day, known even when Dawarich does not answer. */
|
||||||
|
stored: JourneyRecordedDay | undefined;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogSection label={t('journey.recording.zone')}>
|
||||||
|
<CustomSelect
|
||||||
|
ariaLabel={t('journey.recording.zone')}
|
||||||
|
value={day.zone}
|
||||||
|
onChange={(value) => day.chooseZone(Number(value))}
|
||||||
|
options={day.zoneOptions}
|
||||||
|
disabled={day.loading}
|
||||||
|
/>
|
||||||
|
<p className="m-0 mt-1.5 text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('journey.recording.zoneHint')}
|
||||||
|
</p>
|
||||||
|
</DialogSection>
|
||||||
|
<DialogSection label={t('journey.recording.scope')}>
|
||||||
|
<RecordingState day={day} stored={stored ?? day.preview?.stored ?? undefined} />
|
||||||
|
</DialogSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buttons under the review: drop the kept route (asked first), close, and
|
||||||
|
* the one that keeps, replaces or says the day is kept already.
|
||||||
|
*/
|
||||||
|
export function JourneyRecordingActions({
|
||||||
|
day,
|
||||||
|
stored,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
day: RecordingDayState;
|
||||||
|
stored: JourneyRecordedDay | undefined;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { action, busy } = day;
|
||||||
|
const hasStored = Boolean(stored ?? day.preview?.stored);
|
||||||
|
const onSave = () => void day.save();
|
||||||
|
const onRemove = () => void day.remove();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const primary =
|
||||||
|
action === 'replace'
|
||||||
|
? t('journey.recording.replace')
|
||||||
|
: action === 'same'
|
||||||
|
? t('journey.recording.alreadyKept')
|
||||||
|
: t('journey.recording.keep');
|
||||||
|
const canSave = (action === 'keep' || action === 'replace') && busy === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasStored && (
|
||||||
|
<DeleteButton
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
label={t('journey.recording.remove')}
|
||||||
|
disabled={busy !== null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
|
||||||
|
<DialogButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
icon={busy === 'save' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
|
||||||
|
>
|
||||||
|
{primary}
|
||||||
|
</DialogButton>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirming}
|
||||||
|
onClose={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
title={t('journey.recording.removeTitle')}
|
||||||
|
message={t('journey.recording.removeMessage')}
|
||||||
|
confirmLabel={t('journey.recording.remove')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-011
|
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { http, HttpResponse, delay } from 'msw'
|
import { http, HttpResponse, delay } from 'msw'
|
||||||
@@ -214,4 +214,35 @@ describe('JourneyShareSection', () => {
|
|||||||
// The temporary textarea is removed again.
|
// The temporary textarea is removed again.
|
||||||
expect(document.querySelector('textarea')).toBeNull()
|
expect(document.querySelector('textarea')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-SHARE-013: the trip plan and the recorded routes switch on under the map, each on its own (JRN-04)', async () => {
|
||||||
|
const bodies: Record<string, unknown>[] = []
|
||||||
|
server.use(
|
||||||
|
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })),
|
||||||
|
http.post('/api/journeys/7/share-link', async ({ request }) => {
|
||||||
|
bodies.push(await request.json() as Record<string, unknown>)
|
||||||
|
return HttpResponse.json({ token: 'tok-abc' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||||
|
|
||||||
|
const plan = await screen.findByRole('button', { name: /Trip plan/ })
|
||||||
|
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
act(() => { plan.click() })
|
||||||
|
|
||||||
|
await waitFor(() => expect(bodies).toHaveLength(1))
|
||||||
|
expect(bodies[0]).toMatchObject({ share_plan: true })
|
||||||
|
expect(bodies[0]).not.toHaveProperty('share_recordings')
|
||||||
|
expect(screen.getByRole('button', { name: /Trip plan/ })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-SHARE-014: without the map shared the map extras are dimmed and cannot be switched', async () => {
|
||||||
|
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink({ share_map: false, share_plan: true }) })))
|
||||||
|
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||||
|
|
||||||
|
const plan = await screen.findByRole('button', { name: /Trip plan/ })
|
||||||
|
expect(plan).toBeDisabled()
|
||||||
|
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,48 @@
|
|||||||
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
|
import { Link, List, Grid, MapPin, Check, CalendarRange, Route } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useJourneyShareLink } from './useJourneyShareLink'
|
import { fs } from '../shared/DialogShell'
|
||||||
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
|
import { SHARE_MAP_EXTRAS, useJourneyShareLink, type JourneyShareLink, type JourneyShareMapExtra } from './useJourneyShareLink'
|
||||||
|
|
||||||
|
const EXTRA_ICONS = { share_plan: CalendarRange, share_recordings: Route } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the shared map shows besides the entries (JRN-04): the trips' plan and
|
||||||
|
* the recorded days, each off until the owner switches it on, nested under the
|
||||||
|
* map and dimmed with the reason while the map is not shared.
|
||||||
|
*/
|
||||||
|
function ShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const mapShared = !!link.share_map
|
||||||
|
return (
|
||||||
|
<div className="ms-5 flex flex-col gap-1.5 border-s border-edge ps-3">
|
||||||
|
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => {
|
||||||
|
const Icon = EXTRA_ICONS[key]
|
||||||
|
const on = mapShared && !!link[key]
|
||||||
|
return (
|
||||||
|
<Tooltip key={key} label={mapShared ? '' : t('journey.share.needsMap')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
disabled={!mapShared}
|
||||||
|
onClick={() => onToggle(key)}
|
||||||
|
className={`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-start transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
on ? 'border-accent bg-accent text-accent-text' : 'border-edge text-content-muted hover:border-content-faint'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} className="mt-0.5 flex-none" />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="font-medium" style={fs(12, 'body')}>{t(labelKey)}</span>
|
||||||
|
<span className={on ? 'text-accent-text opacity-80' : 'text-content-faint'} style={fs(11)}>{t(hintKey)}</span>
|
||||||
|
</span>
|
||||||
|
{on && <Check size={12} className="mt-0.5 flex-none" />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -61,6 +103,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
|||||||
{link[key] && <Check size={12} className="ms-auto" />}
|
{link[key] && <Check size={12} className="ms-auto" />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<ShareMapExtras link={link} onToggle={key => void togglePerm(key)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import JourneyMap from './JourneyMap'
|
import JourneyLayeredMap from './JourneyLayeredMap'
|
||||||
|
import type { JourneyMapLayersView } from './useJourneyMapLayers'
|
||||||
|
import type { MapPhoto } from './journeyPhotoLayer'
|
||||||
import JourneyEntryCover from './JourneyEntryCover'
|
import JourneyEntryCover from './JourneyEntryCover'
|
||||||
import JourneyDayScrubber from './JourneyDayScrubber'
|
import JourneyDayScrubber from './JourneyDayScrubber'
|
||||||
import { dayColorOf } from './journeyCard'
|
import { dayColorOf } from './journeyCard'
|
||||||
@@ -35,6 +37,14 @@ interface Props {
|
|||||||
showWeather?: boolean
|
showWeather?: boolean
|
||||||
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
|
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
|
||||||
initialEntryId?: string | null
|
initialEntryId?: string | null
|
||||||
|
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
|
||||||
|
layers?: JourneyMapLayersView | null
|
||||||
|
entryTitleOf?: (entryId: number) => string | null
|
||||||
|
/** Geotagged photos on the map, a tap opening them. */
|
||||||
|
photos?: MapPhoto[]
|
||||||
|
onPhotoClick?: (photoIds: string[]) => void
|
||||||
|
/** Where the layer switches sit, below a bar laid over the top of the map. */
|
||||||
|
controlsTop?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMapTimeline({
|
export default function MobileMapTimeline({
|
||||||
@@ -52,6 +62,11 @@ export default function MobileMapTimeline({
|
|||||||
showMood = true,
|
showMood = true,
|
||||||
showWeather = true,
|
showWeather = true,
|
||||||
initialEntryId,
|
initialEntryId,
|
||||||
|
layers,
|
||||||
|
entryTitleOf,
|
||||||
|
photos,
|
||||||
|
onPhotoClick,
|
||||||
|
controlsTop,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
|
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
|
||||||
@@ -96,12 +111,20 @@ export default function MobileMapTimeline({
|
|||||||
className="fixed inset-x-0 z-10"
|
className="fixed inset-x-0 z-10"
|
||||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
>
|
>
|
||||||
<JourneyMap
|
<JourneyLayeredMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
|
engine="leaflet"
|
||||||
|
layers={layers}
|
||||||
|
compactControls
|
||||||
|
controlsTop={controlsTop}
|
||||||
|
onOpenEntry={handleMarkerClick}
|
||||||
|
entryTitleOf={entryTitleOf}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
checkins={[]}
|
checkins={[]}
|
||||||
trail={trail}
|
trail={trail}
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
|
photos={photos}
|
||||||
|
onPhotoClick={onPhotoClick}
|
||||||
height={9999}
|
height={9999}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
onMarkerClick={handleMarkerClick}
|
onMarkerClick={handleMarkerClick}
|
||||||
@@ -128,12 +151,20 @@ export default function MobileMapTimeline({
|
|||||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
>
|
>
|
||||||
{/* Full-screen map */}
|
{/* Full-screen map */}
|
||||||
<JourneyMap
|
<JourneyLayeredMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
|
engine="leaflet"
|
||||||
|
layers={layers}
|
||||||
|
compactControls
|
||||||
|
controlsTop={controlsTop}
|
||||||
|
onOpenEntry={handleMarkerClick}
|
||||||
|
entryTitleOf={entryTitleOf}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
checkins={[]}
|
checkins={[]}
|
||||||
trail={trail}
|
trail={trail}
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
|
photos={photos}
|
||||||
|
onPhotoClick={onPhotoClick}
|
||||||
height={9999}
|
height={9999}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
activeMarkerId={activeEntryId}
|
activeMarkerId={activeEntryId}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// FE-JRN-DAYIMPORT-MODEL-001 to FE-JRN-DAYIMPORT-MODEL-013
|
||||||
|
//
|
||||||
|
// JRN-02: what the traveller decided in the day take-over, before anything is
|
||||||
|
// written. Two stays at San Marco, a parking stop and Rialto, as the review
|
||||||
|
// lists them.
|
||||||
|
import type { JourneyDayStay } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildDayStay, buildDayStays } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import {
|
||||||
|
canJoin,
|
||||||
|
defaultAnchor,
|
||||||
|
groupsOf,
|
||||||
|
initialChoice,
|
||||||
|
problemOf,
|
||||||
|
rename,
|
||||||
|
REPORT_KEY,
|
||||||
|
requestOf,
|
||||||
|
titleOf,
|
||||||
|
toggleJoin,
|
||||||
|
toggleStay,
|
||||||
|
type DayImportChoice,
|
||||||
|
} from './journeyDayImportModel';
|
||||||
|
|
||||||
|
const STAYS = buildDayStays().stays;
|
||||||
|
const [PARKING, MARKUS, , RIALTO] = STAYS as [JourneyDayStay, JourneyDayStay, JourneyDayStay, JourneyDayStay];
|
||||||
|
const REPORT = 'Day of 14 May';
|
||||||
|
|
||||||
|
describe('journeyDayImportModel', () => {
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-001: every open stay starts chosen, and the same place twice in a row starts joined', () => {
|
||||||
|
const choice = initialChoice(STAYS);
|
||||||
|
expect([...choice.selected]).toEqual([10, 11, 12, 13]);
|
||||||
|
expect([...choice.joined]).toEqual([12]);
|
||||||
|
expect(groupsOf(choice, STAYS).map((g) => [g.kind, g.stays.map((s) => s.suggestionId)])).toEqual([
|
||||||
|
['single', [10]],
|
||||||
|
['merged', [11, 12]],
|
||||||
|
['single', [13]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-002: stays the journal holds or that went elsewhere are never chosen; one left out before starts unchosen', () => {
|
||||||
|
const stays = [
|
||||||
|
buildDayStay({ suggestionId: 1, status: 'here', entry: { id: 4, title: 'Morning' } }),
|
||||||
|
buildDayStay({ suggestionId: 2, status: 'elsewhere', name: 'Rialto' }),
|
||||||
|
buildDayStay({ suggestionId: 3, status: 'skipped', name: 'Lido' }),
|
||||||
|
];
|
||||||
|
const choice = initialChoice(stays);
|
||||||
|
expect(choice.selected.size).toBe(0);
|
||||||
|
expect(problemOf(choice, stays, REPORT)).toBe('nothing');
|
||||||
|
const picked = toggleStay(choice, 3);
|
||||||
|
expect(groupsOf(picked, stays).map((g) => g.key)).toEqual([3]);
|
||||||
|
expect(requestOf(picked, stays, REPORT)).toEqual({ groups: [{ kind: 'single', stays: [{ suggestionId: 3 }] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-003: an open stay left unchosen is sent as left out, and a renamed one under its new name', () => {
|
||||||
|
let choice = toggleStay(initialChoice(STAYS), 10);
|
||||||
|
choice = rename(choice, RIALTO, ' Ponte di Rialto ');
|
||||||
|
expect(requestOf(choice, STAYS, REPORT)).toEqual({
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
kind: 'merged',
|
||||||
|
stays: [{ suggestionId: 11 }, { suggestionId: 12 }],
|
||||||
|
title: 'Piazza San Marco',
|
||||||
|
anchorSuggestionId: 12,
|
||||||
|
},
|
||||||
|
{ kind: 'single', stays: [{ suggestionId: 13, name: 'Ponte di Rialto' }] },
|
||||||
|
],
|
||||||
|
skipSuggestionIds: [10],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-004: joining needs both neighbours chosen; unchoosing a stay separates it', () => {
|
||||||
|
const choice = toggleStay(initialChoice(STAYS), 11);
|
||||||
|
expect(canJoin(choice, STAYS, 2)).toBe(false);
|
||||||
|
expect(groupsOf(choice, STAYS).map((g) => g.kind)).toEqual(['single', 'single', 'single']);
|
||||||
|
const back = toggleStay(choice, 11);
|
||||||
|
expect(back.joined.has(12)).toBe(true);
|
||||||
|
expect(canJoin(back, STAYS, 1)).toBe(true);
|
||||||
|
const joined = toggleJoin(back, 11);
|
||||||
|
expect(groupsOf(joined, STAYS).map((g) => g.stays.length)).toEqual([3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-005: a day report takes every chosen stay, titled by default, at the longest stay or off the map', () => {
|
||||||
|
const choice = { ...initialChoice(STAYS), mode: 'report' as const };
|
||||||
|
const [report] = groupsOf(choice, STAYS);
|
||||||
|
if (!report) throw new Error('no report');
|
||||||
|
expect(report).toMatchObject({ key: REPORT_KEY, kind: 'report' });
|
||||||
|
expect(report.stays).toHaveLength(4);
|
||||||
|
expect(titleOf(choice, report, REPORT)).toBe(REPORT);
|
||||||
|
expect(requestOf(choice, STAYS, REPORT).groups[0]).toMatchObject({ title: REPORT, anchorSuggestionId: 12 });
|
||||||
|
const offMap = { ...choice, anchors: { [REPORT_KEY]: null } };
|
||||||
|
expect(requestOf(offMap, STAYS, REPORT).groups[0]).toMatchObject({ anchorSuggestionId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-006: a report of one chosen stay is that stay on its own', () => {
|
||||||
|
let choice: DayImportChoice = { ...initialChoice(STAYS), mode: 'report' };
|
||||||
|
for (const id of [10, 11, 12]) choice = toggleStay(choice, id);
|
||||||
|
expect(groupsOf(choice, STAYS)).toEqual([{ key: 13, kind: 'single', stays: [RIALTO] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-007: a blank name or a blank title holds the take-over back, so no card is empty', () => {
|
||||||
|
const choice = initialChoice(STAYS);
|
||||||
|
expect(problemOf(choice, STAYS, REPORT)).toBeNull();
|
||||||
|
expect(problemOf(rename(choice, PARKING, ' '), STAYS, REPORT)).toBe('name');
|
||||||
|
expect(problemOf({ ...choice, titles: { 11: ' ' } }, STAYS, REPORT)).toBe('title');
|
||||||
|
expect(problemOf({ ...choice, mode: 'report', titles: { [REPORT_KEY]: '' } }, STAYS, REPORT)).toBe('title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-008: the default position is the longest stay that has a place', () => {
|
||||||
|
expect(defaultAnchor(STAYS)).toBe(12);
|
||||||
|
expect(defaultAnchor([buildDayStay({ suggestionId: 5, lat: null, lng: null })])).toBeNull();
|
||||||
|
expect(rename(rename(initialChoice(STAYS), MARKUS, 'X'), MARKUS, MARKUS.name).names).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-009: a picked position whose stay was unchosen or split off falls back to one in the group', () => {
|
||||||
|
const report = { ...initialChoice(STAYS), mode: 'report' as const, anchors: { [REPORT_KEY]: 10 } };
|
||||||
|
expect(requestOf(toggleStay(report, 10), STAYS, REPORT).groups[0]).toMatchObject({ anchorSuggestionId: 12 });
|
||||||
|
const three = { ...toggleJoin(initialChoice(STAYS), 13), anchors: { 11: 13 } };
|
||||||
|
expect(requestOf(three, STAYS, REPORT).groups[1]).toMatchObject({ kind: 'merged', anchorSuggestionId: 13 });
|
||||||
|
expect(requestOf(toggleJoin(three, 13), STAYS, REPORT).groups[1]).toMatchObject({
|
||||||
|
kind: 'merged',
|
||||||
|
anchorSuggestionId: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-010: the same place twice without a position is joined and sent without a pin', () => {
|
||||||
|
const stays = [
|
||||||
|
buildDayStay({ suggestionId: 1, lat: null, lng: null }),
|
||||||
|
buildDayStay({ suggestionId: 2, lat: null, lng: null }),
|
||||||
|
];
|
||||||
|
const choice = initialChoice(stays);
|
||||||
|
expect(problemOf(choice, stays, REPORT)).toBeNull();
|
||||||
|
expect(requestOf(choice, stays, REPORT)).toEqual({
|
||||||
|
groups: [{ kind: 'merged', stays: [{ suggestionId: 1 }, { suggestionId: 2 }], title: 'Piazza San Marco' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-011: a stay taken over meanwhile is never joined across, even while still ticked', () => {
|
||||||
|
const stays = [
|
||||||
|
buildDayStay({ suggestionId: 1, name: 'Lido' }),
|
||||||
|
buildDayStay({ suggestionId: 2, name: 'Rialto', status: 'here' }),
|
||||||
|
buildDayStay({ suggestionId: 3, name: 'Zattere' }),
|
||||||
|
];
|
||||||
|
const choice = { ...initialChoice(stays), selected: new Set([1, 2, 3]), joined: new Set([3]) };
|
||||||
|
expect(canJoin(choice, stays, 2)).toBe(false);
|
||||||
|
expect(groupsOf(choice, stays).map((g) => g.stays.map((s) => s.suggestionId))).toEqual([[1], [3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-012: a stay Dawarich itself doubts starts unchosen, unless it was confirmed there', () => {
|
||||||
|
const stays = [
|
||||||
|
buildDayStay({ suggestionId: 1, name: 'Car park', confidenceBand: 'low', sourceStatus: 'suggested' }),
|
||||||
|
buildDayStay({ suggestionId: 2, name: 'Bridge', confidenceBand: 'low', sourceStatus: 'confirmed' }),
|
||||||
|
buildDayStay({ suggestionId: 3, name: 'Cathedral', confidenceBand: 'medium', sourceStatus: 'suggested' }),
|
||||||
|
];
|
||||||
|
expect([...initialChoice(stays).selected]).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-MODEL-013: a night at the hotel is the position only when no stay of the day has one', () => {
|
||||||
|
const night = buildDayStay({
|
||||||
|
suggestionId: 1,
|
||||||
|
startedAt: '2026-05-14T19:00:00+02:00',
|
||||||
|
endedAt: '2026-05-15T08:00:00+02:00',
|
||||||
|
durationMinutes: 780,
|
||||||
|
});
|
||||||
|
const sight = buildDayStay({ suggestionId: 2, durationMinutes: 50 });
|
||||||
|
expect(defaultAnchor([sight, night])).toBe(2);
|
||||||
|
expect(defaultAnchor([night, buildDayStay({ suggestionId: 3, lat: null, lng: null })])).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { JourneyDayImportRequest, JourneyDayStay } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the traveller decided in the day take-over (JRN-02), before anything is
|
||||||
|
* written: which stays, under which names, which of them belong together, and
|
||||||
|
* whether the day becomes one report. Pure, so the dialog and the phone sheet
|
||||||
|
* share it and the tests need no rendering.
|
||||||
|
*/
|
||||||
|
export interface DayImportChoice {
|
||||||
|
selected: ReadonlySet<number>;
|
||||||
|
/** Names as corrected, by suggestion id; a stay not in here keeps its recorded name. */
|
||||||
|
names: Readonly<Record<number, string>>;
|
||||||
|
/** Stays joined to the stay listed right before them. */
|
||||||
|
joined: ReadonlySet<number>;
|
||||||
|
mode: 'entries' | 'report';
|
||||||
|
/** Titles as typed, by the key of the group they head. */
|
||||||
|
titles: Readonly<Record<number, string>>;
|
||||||
|
/** The stay a group is pinned to, by group key; null keeps a report off the map. */
|
||||||
|
anchors: Readonly<Record<number, number | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayImportGroup {
|
||||||
|
/** The first stay's id, or REPORT_KEY for the day report. */
|
||||||
|
key: number;
|
||||||
|
kind: 'single' | 'merged' | 'report';
|
||||||
|
stays: JourneyDayStay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REPORT_KEY = 0;
|
||||||
|
|
||||||
|
/** Two stays this close under the same name are one place visited twice. */
|
||||||
|
const SAME_PLACE_METERS = 200;
|
||||||
|
|
||||||
|
export function isTakeable(stay: JourneyDayStay): boolean {
|
||||||
|
return stay.status === 'open' || stay.status === 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every open stay chosen but those Dawarich itself doubts (the car park passed
|
||||||
|
* on the way, more often than not), and the same place twice in a row already
|
||||||
|
* joined: the square somebody left for a coffee and came back to is one memory.
|
||||||
|
*/
|
||||||
|
export function initialChoice(stays: JourneyDayStay[]): DayImportChoice {
|
||||||
|
const selected = new Set(
|
||||||
|
stays
|
||||||
|
.filter((stay) => stay.status === 'open' && !(stay.confidenceBand === 'low' && stay.sourceStatus !== 'confirmed'))
|
||||||
|
.map((stay) => stay.suggestionId)
|
||||||
|
);
|
||||||
|
const joined = new Set<number>();
|
||||||
|
stays.forEach((stay, index) => {
|
||||||
|
const before = stays[index - 1];
|
||||||
|
if (before && selected.has(stay.suggestionId) && selected.has(before.suggestionId) && samePlace(before, stay)) {
|
||||||
|
joined.add(stay.suggestionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { selected, names: {}, joined, mode: 'entries', titles: {}, anchors: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePlace(a: JourneyDayStay, b: JourneyDayStay): boolean {
|
||||||
|
if (a.name.trim().toLowerCase() !== b.name.trim().toLowerCase()) return false;
|
||||||
|
if (a.lat === null || a.lng === null || b.lat === null || b.lng === null) return true;
|
||||||
|
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||||
|
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||||
|
const h =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * 6_371_000 * Math.asin(Math.sqrt(h)) <= SAME_PLACE_METERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nameOf(choice: DayImportChoice, stay: JourneyDayStay): string {
|
||||||
|
return choice.names[stay.suggestionId] ?? stay.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stay can be joined to the one listed before it: both chosen and both free to take. */
|
||||||
|
export function canJoin(choice: DayImportChoice, stays: JourneyDayStay[], index: number): boolean {
|
||||||
|
const stay = stays[index];
|
||||||
|
const before = stays[index - 1];
|
||||||
|
return (
|
||||||
|
choice.mode === 'entries' &&
|
||||||
|
!!stay &&
|
||||||
|
!!before &&
|
||||||
|
isTakeable(stay) &&
|
||||||
|
isTakeable(before) &&
|
||||||
|
choice.selected.has(stay.suggestionId) &&
|
||||||
|
choice.selected.has(before.suggestionId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entries the choice writes, in the order the day was lived. */
|
||||||
|
export function groupsOf(choice: DayImportChoice, stays: JourneyDayStay[]): DayImportGroup[] {
|
||||||
|
const chosen = stays.filter((stay) => isTakeable(stay) && choice.selected.has(stay.suggestionId));
|
||||||
|
if (choice.mode === 'report' && chosen.length > 1) return [{ key: REPORT_KEY, kind: 'report', stays: chosen }];
|
||||||
|
const groups: DayImportGroup[] = [];
|
||||||
|
stays.forEach((stay, index) => {
|
||||||
|
if (!chosen.includes(stay)) return;
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && choice.joined.has(stay.suggestionId) && canJoin(choice, stays, index)) {
|
||||||
|
last.stays.push(stay);
|
||||||
|
last.kind = 'merged';
|
||||||
|
} else {
|
||||||
|
groups.push({ key: stay.suggestionId, kind: 'single', stays: [stay] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The longest stay that has a place: where a group sits on the map unless
|
||||||
|
* somebody picks another. A night somewhere outlasts any sight of the day, so
|
||||||
|
* a stay that ends on the same day goes first and the hotel only when there is
|
||||||
|
* nothing else.
|
||||||
|
*/
|
||||||
|
export function defaultAnchor(stays: JourneyDayStay[]): number | null {
|
||||||
|
const placed = stays.filter((stay) => stay.lat !== null && stay.lng !== null);
|
||||||
|
const sameDay = placed.filter((stay) => stay.startedAt.slice(0, 10) === stay.endedAt.slice(0, 10));
|
||||||
|
let best: JourneyDayStay | null = null;
|
||||||
|
for (const stay of sameDay.length > 0 ? sameDay : placed) {
|
||||||
|
if (!best || stay.durationMinutes > best.durationMinutes) best = stay;
|
||||||
|
}
|
||||||
|
return best?.suggestionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picked stay while it is still in the group; one unchosen or split off since falls back to the default. */
|
||||||
|
export function anchorOf(choice: DayImportChoice, group: DayImportGroup): number | null {
|
||||||
|
const picked = choice.anchors[group.key];
|
||||||
|
if (picked === undefined) return defaultAnchor(group.stays);
|
||||||
|
if (picked !== null && !group.stays.some((stay) => stay.suggestionId === picked)) return defaultAnchor(group.stays);
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group's title as typed, else what it starts with: the place for a merged entry, the given default for a report. */
|
||||||
|
export function titleOf(choice: DayImportChoice, group: DayImportGroup, reportDefault: string): string {
|
||||||
|
const typed = choice.titles[group.key];
|
||||||
|
if (typed !== undefined) return typed;
|
||||||
|
if (group.kind === 'report') return reportDefault;
|
||||||
|
const anchor = group.stays.find((stay) => stay.suggestionId === anchorOf(choice, group)) ?? group.stays[0];
|
||||||
|
return anchor ? nameOf(choice, anchor) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the choice cannot be taken over yet, or null. A blank name or title would write an empty card. */
|
||||||
|
export function problemOf(
|
||||||
|
choice: DayImportChoice,
|
||||||
|
stays: JourneyDayStay[],
|
||||||
|
reportDefault: string
|
||||||
|
): 'nothing' | 'name' | 'title' | null {
|
||||||
|
const groups = groupsOf(choice, stays);
|
||||||
|
if (groups.length === 0) return 'nothing';
|
||||||
|
if (groups.some((group) => group.stays.some((stay) => !nameOf(choice, stay).trim()))) return 'name';
|
||||||
|
if (groups.some((group) => group.kind !== 'single' && !titleOf(choice, group, reportDefault).trim())) return 'title';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The take-over as the server reads it; open stays left unchosen are sent as left out. */
|
||||||
|
export function requestOf(
|
||||||
|
choice: DayImportChoice,
|
||||||
|
stays: JourneyDayStay[],
|
||||||
|
reportDefault: string
|
||||||
|
): JourneyDayImportRequest {
|
||||||
|
const groups = groupsOf(choice, stays).map((group) => {
|
||||||
|
const named = group.stays.map((stay) => {
|
||||||
|
const name = nameOf(choice, stay).trim();
|
||||||
|
return name === stay.name ? { suggestionId: stay.suggestionId } : { suggestionId: stay.suggestionId, name };
|
||||||
|
});
|
||||||
|
if (group.kind === 'single') return { kind: group.kind, stays: named };
|
||||||
|
const title = titleOf(choice, group, reportDefault).trim();
|
||||||
|
const anchor = anchorOf(choice, group);
|
||||||
|
// A merged entry of stays without a position has nothing to pin; the server then writes it without one.
|
||||||
|
if (anchor === null && group.kind === 'merged') return { kind: group.kind, stays: named, title };
|
||||||
|
return { kind: group.kind, stays: named, title, anchorSuggestionId: anchor };
|
||||||
|
});
|
||||||
|
const skipSuggestionIds = stays
|
||||||
|
.filter((stay) => stay.status === 'open' && !choice.selected.has(stay.suggestionId))
|
||||||
|
.map((stay) => stay.suggestionId);
|
||||||
|
return skipSuggestionIds.length > 0 ? { groups, skipSuggestionIds } : { groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleStay(choice: DayImportChoice, id: number): DayImportChoice {
|
||||||
|
const selected = new Set(choice.selected);
|
||||||
|
const joined = new Set(choice.joined);
|
||||||
|
if (selected.has(id)) {
|
||||||
|
selected.delete(id);
|
||||||
|
joined.delete(id);
|
||||||
|
} else {
|
||||||
|
selected.add(id);
|
||||||
|
}
|
||||||
|
return { ...choice, selected, joined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleJoin(choice: DayImportChoice, id: number): DayImportChoice {
|
||||||
|
const joined = new Set(choice.joined);
|
||||||
|
if (joined.has(id)) joined.delete(id);
|
||||||
|
else joined.add(id);
|
||||||
|
return { ...choice, joined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rename(choice: DayImportChoice, stay: JourneyDayStay, name: string): DayImportChoice {
|
||||||
|
const names = { ...choice.names };
|
||||||
|
if (name === stay.name) delete names[stay.suggestionId];
|
||||||
|
else names[stay.suggestionId] = name;
|
||||||
|
return { ...choice, names };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import L from 'leaflet';
|
||||||
|
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
|
||||||
|
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
|
||||||
|
|
||||||
|
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
|
||||||
|
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
|
||||||
|
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
|
||||||
|
|
||||||
|
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts each layer whose switch changed back on the map or takes it off,
|
||||||
|
* exactly as it was drawn, and answers what is shown now.
|
||||||
|
*/
|
||||||
|
export function switchLeafletLayers(
|
||||||
|
map: L.Map | null,
|
||||||
|
drawn: DrawnLayers,
|
||||||
|
shown: JourneyMapLayerVisibility,
|
||||||
|
next: JourneyMapLayerVisibility
|
||||||
|
): JourneyMapLayerVisibility {
|
||||||
|
if (!map) return shown;
|
||||||
|
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
|
||||||
|
if (shown[kind] === next[kind]) continue;
|
||||||
|
for (const layer of drawn[kind]) {
|
||||||
|
if (next[kind]) layer.addTo(map);
|
||||||
|
else layer.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan's places and the stays as one layer: below the entry pins, above
|
||||||
|
* the photo thumbnails, joined only where they would overlap on screen.
|
||||||
|
* Null while there is nothing to draw or the map has no view yet.
|
||||||
|
*/
|
||||||
|
export function drawLeafletMarks(
|
||||||
|
map: L.Map,
|
||||||
|
marks: readonly JourneyMapMark[] | undefined,
|
||||||
|
onPick: (members: JourneyMapMark[]) => void
|
||||||
|
): L.LayerGroup | null {
|
||||||
|
if (!marks?.length) return null;
|
||||||
|
try {
|
||||||
|
map.getCenter();
|
||||||
|
} catch {
|
||||||
|
// No view yet: the moveend after the first fit draws them.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const group = L.layerGroup();
|
||||||
|
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
|
||||||
|
const look = markHitSvg(hit.members);
|
||||||
|
const marker = L.marker([hit.lat, hit.lng], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [look.width, look.height],
|
||||||
|
iconAnchor: [look.width / 2, look.anchor === 'bottom' ? look.height : look.height / 2],
|
||||||
|
html: look.html,
|
||||||
|
}),
|
||||||
|
zIndexOffset: -200,
|
||||||
|
keyboard: false,
|
||||||
|
});
|
||||||
|
marker.on('click', () => onPick(hit.members));
|
||||||
|
group.addLayer(marker);
|
||||||
|
}
|
||||||
|
group.addTo(map);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// FE-JOURNEY-MAPLAYERS-001 to FE-JOURNEY-MAPLAYERS-009
|
||||||
|
//
|
||||||
|
// JRN-04: the journey map's layer switches as kept in this browser, the marks
|
||||||
|
// the plan and the stays become, and the switches a reader is offered.
|
||||||
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ALL_VISIBLE,
|
||||||
|
buildControls,
|
||||||
|
buildMarks,
|
||||||
|
layersStorageKey,
|
||||||
|
parseStoredLayers,
|
||||||
|
readStoredLayers,
|
||||||
|
sourcesOf,
|
||||||
|
stateOf,
|
||||||
|
writeStoredLayers,
|
||||||
|
} from './journeyMapLayers';
|
||||||
|
|
||||||
|
const place = (over: Partial<JourneyMapPlace> = {}): JourneyMapPlace => ({
|
||||||
|
place_id: 4,
|
||||||
|
trip_id: 2,
|
||||||
|
trip_title: 'Venice',
|
||||||
|
name: 'Piazza San Marco',
|
||||||
|
lat: 45.434,
|
||||||
|
lng: 12.3388,
|
||||||
|
days: ['2026-05-14'],
|
||||||
|
time: null,
|
||||||
|
category_color: '#e11d48',
|
||||||
|
category_icon: null,
|
||||||
|
entry_id: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stay = (over: Partial<JourneyMapStay> = {}): JourneyMapStay => ({
|
||||||
|
id: 9,
|
||||||
|
entry_id: 7,
|
||||||
|
name: 'Rialto',
|
||||||
|
lat: 45.438,
|
||||||
|
lng: 12.336,
|
||||||
|
started_at: '2026-05-14T09:00:00+02:00',
|
||||||
|
ended_at: '2026-05-14T10:00:00+02:00',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('journey map layers', () => {
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-001: keeps the switches per journey, and a link only by a hash of its token', () => {
|
||||||
|
expect(layersStorageKey({ journeyId: 12 })).toBe('trek:journey-map-layers:12');
|
||||||
|
const pub = layersStorageKey({ token: 'secret-token-value' });
|
||||||
|
expect(pub).toMatch(/^trek:journey-map-layers:pub:[0-9a-z]+$/);
|
||||||
|
expect(pub).not.toContain('secret');
|
||||||
|
expect(layersStorageKey({ token: 'secret-token-value' })).toBe(pub);
|
||||||
|
expect(layersStorageKey({ token: 'other' })).not.toBe(pub);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-002: reads a stored value leniently, a layer it does not name at its default', () => {
|
||||||
|
expect(parseStoredLayers(null)).toEqual(ALL_VISIBLE);
|
||||||
|
expect(parseStoredLayers('not json')).toEqual(ALL_VISIBLE);
|
||||||
|
expect(parseStoredLayers('[true]')).toEqual(ALL_VISIBLE);
|
||||||
|
expect(parseStoredLayers('{"plan":false,"photos":"no","extra":true}')).toEqual({ ...ALL_VISIBLE, plan: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-003: storage that refuses leaves every layer on and the switch working for the visit', () => {
|
||||||
|
writeStoredLayers('k', { ...ALL_VISIBLE, entries: false });
|
||||||
|
expect(readStoredLayers('k')).toEqual({ ...ALL_VISIBLE, entries: false });
|
||||||
|
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
expect(readStoredLayers('k')).toEqual(ALL_VISIBLE);
|
||||||
|
expect(() => writeStoredLayers('k', ALL_VISIBLE)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-004: a planned place an entry pin came from is that pin, with the check; without entries it stands alone', () => {
|
||||||
|
const written = place({ place_id: 4, entry_id: 7 });
|
||||||
|
const alone = place({ place_id: 5, entry_id: null, category_color: 'red;evil' });
|
||||||
|
const onMap = new Set(['7']);
|
||||||
|
|
||||||
|
const both = buildMarks({
|
||||||
|
places: [written, alone],
|
||||||
|
stays: [],
|
||||||
|
visibility: ALL_VISIBLE,
|
||||||
|
entryIdsOnMap: onMap,
|
||||||
|
stayColor: () => null,
|
||||||
|
});
|
||||||
|
expect(both.marks.map((m) => m.key)).toEqual(['plan:5']);
|
||||||
|
expect(both.marks[0].color).toBe('#64748b');
|
||||||
|
expect([...both.plannedEntryIds]).toEqual(['7']);
|
||||||
|
|
||||||
|
const noEntries = buildMarks({
|
||||||
|
places: [written, alone],
|
||||||
|
stays: [stay()],
|
||||||
|
visibility: { ...ALL_VISIBLE, entries: false },
|
||||||
|
entryIdsOnMap: onMap,
|
||||||
|
stayColor: () => '#14b8a6',
|
||||||
|
});
|
||||||
|
expect(noEntries.marks.map((m) => m.key)).toEqual(['plan:4', 'plan:5']);
|
||||||
|
expect(noEntries.plannedEntryIds.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-005: stays follow the entries switch in the colour of their day, the plan its own switch', () => {
|
||||||
|
const marks = buildMarks({
|
||||||
|
places: [place()],
|
||||||
|
stays: [stay(), stay({ id: 10, entry_id: 8 })],
|
||||||
|
visibility: { ...ALL_VISIBLE, plan: false },
|
||||||
|
entryIdsOnMap: new Set(),
|
||||||
|
stayColor: (entryId) => (entryId === 7 ? '#14b8a6' : null),
|
||||||
|
}).marks;
|
||||||
|
expect(marks.map((m) => [m.key, m.color])).toEqual([
|
||||||
|
['stay:9', '#14b8a6'],
|
||||||
|
['stay:10', '#64748b'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-006: a failed read makes every source unavailable; nothing read yet is idle, not empty', () => {
|
||||||
|
expect(sourcesOf(null).places).toEqual({ status: 'unavailable' });
|
||||||
|
expect(sourcesOf(undefined)).toEqual({});
|
||||||
|
expect(stateOf(undefined, false, 0)).toBe('idle');
|
||||||
|
expect(stateOf(undefined, true, 0)).toBe('loading');
|
||||||
|
expect(stateOf({ status: 'ok', items: [] }, false, 0)).toBe('empty');
|
||||||
|
expect(stateOf({ status: 'ok', items: [1] }, false, 1)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-007: a link offers no switch for what it withholds; a journey always offers its recordings', () => {
|
||||||
|
const withheld = { status: 'withheld' } as const;
|
||||||
|
const shared = buildControls({
|
||||||
|
ownJourney: false,
|
||||||
|
sources: { places: withheld, routes: withheld, stays: { status: 'ok', items: [] }, recordings: withheld },
|
||||||
|
loading: false,
|
||||||
|
counts: { places: 0, routes: 0, recordedDays: 0, entries: 3, stays: 0, photos: 0 },
|
||||||
|
});
|
||||||
|
expect(shared.map((c) => [c.layer, c.state])).toEqual([
|
||||||
|
['entries', 'ready'],
|
||||||
|
['photos', 'empty'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const own = buildControls({
|
||||||
|
ownJourney: true,
|
||||||
|
sources: {},
|
||||||
|
loading: false,
|
||||||
|
counts: { places: 0, routes: 0, recordedDays: 2, entries: 0, stays: 0, photos: 4 },
|
||||||
|
});
|
||||||
|
expect(own.map((c) => [c.layer, c.state, c.count])).toEqual([
|
||||||
|
['plan', 'idle', 0],
|
||||||
|
['recorded', 'ready', 2],
|
||||||
|
['entries', 'empty', 0],
|
||||||
|
['photos', 'ready', 4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-008: a failed route or stay read warns on its switch while the rest still counts', () => {
|
||||||
|
const controls = buildControls({
|
||||||
|
ownJourney: true,
|
||||||
|
sources: {
|
||||||
|
places: { status: 'ok', items: [] },
|
||||||
|
routes: { status: 'unavailable' },
|
||||||
|
stays: { status: 'unavailable' },
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
counts: { places: 3, routes: 0, recordedDays: 0, entries: 5, stays: 0, photos: 0 },
|
||||||
|
});
|
||||||
|
expect(controls.find((c) => c.layer === 'plan')).toEqual({ layer: 'plan', count: 3, state: 'unavailable' });
|
||||||
|
expect(controls.find((c) => c.layer === 'entries')).toEqual({ layer: 'entries', count: 5, state: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-009: a journey whose recordings could not be read says so instead of nothing recorded', () => {
|
||||||
|
const recorded = (recordedRead: 'loading' | 'ready' | 'unavailable', recordedDays: number) =>
|
||||||
|
buildControls({
|
||||||
|
ownJourney: true,
|
||||||
|
sources: {},
|
||||||
|
loading: false,
|
||||||
|
recordedRead,
|
||||||
|
counts: { places: 0, routes: 0, recordedDays, entries: 0, stays: 0, photos: 0 },
|
||||||
|
}).find((c) => c.layer === 'recorded')?.state;
|
||||||
|
|
||||||
|
expect(recorded('unavailable', 0)).toBe('unavailable');
|
||||||
|
expect(recorded('unavailable', 2)).toBe('unavailable');
|
||||||
|
expect(recorded('loading', 0)).toBe('loading');
|
||||||
|
expect(recorded('loading', 1)).toBe('ready');
|
||||||
|
expect(recorded('ready', 0)).toBe('empty');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type {
|
||||||
|
JourneyMapLayerResult,
|
||||||
|
JourneyMapLayersResponse,
|
||||||
|
JourneyMapPlace,
|
||||||
|
JourneyMapStay,
|
||||||
|
JourneyPublicMapLayersResponse,
|
||||||
|
JourneyTrack,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { safeColor, type JourneyMapMark } from './journeyMapMarks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four layers of a journey map (JRN-04), switched on and off one by one
|
||||||
|
* by each reader for themselves. A switch only ever hides: it changes no
|
||||||
|
* trip, entry or photo, and it never shows a reader what the journey does not
|
||||||
|
* give them.
|
||||||
|
*/
|
||||||
|
const JOURNEY_MAP_LAYERS = ['plan', 'recorded', 'entries', 'photos'] as const;
|
||||||
|
export type JourneyMapLayer = (typeof JOURNEY_MAP_LAYERS)[number];
|
||||||
|
export type JourneyMapVisibility = Record<JourneyMapLayer, boolean>;
|
||||||
|
|
||||||
|
/** The layers a renderer draws itself and switches in place; the photos it is handed or not. */
|
||||||
|
export type JourneyMapLayerVisibility = Pick<JourneyMapVisibility, 'plan' | 'recorded' | 'entries'>;
|
||||||
|
|
||||||
|
export const ALL_VISIBLE: JourneyMapVisibility = { plan: true, recorded: true, entries: true, photos: true };
|
||||||
|
|
||||||
|
/** What a layer's switch tells the reader beside its count; `idle` is a layer not read yet because it is off. */
|
||||||
|
type JourneyMapLayerState = 'ready' | 'loading' | 'unavailable' | 'empty' | 'idle';
|
||||||
|
|
||||||
|
export interface JourneyMapLayerControl {
|
||||||
|
layer: JourneyMapLayer;
|
||||||
|
count: number;
|
||||||
|
state: JourneyMapLayerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A switch's name, and what its tooltip adds when the layer has nothing to draw or could not be read. */
|
||||||
|
export const LAYER_TEXT: Record<JourneyMapLayer, { labelKey: string; emptyKey: string; unavailableKey: string }> = {
|
||||||
|
plan: {
|
||||||
|
labelKey: 'journey.map.layer.plan',
|
||||||
|
emptyKey: 'journey.map.layer.planEmpty',
|
||||||
|
unavailableKey: 'journey.map.layer.planUnavailable',
|
||||||
|
},
|
||||||
|
recorded: {
|
||||||
|
labelKey: 'journey.map.layer.recorded',
|
||||||
|
emptyKey: 'journey.map.layer.recordedEmpty',
|
||||||
|
unavailableKey: 'journey.map.layer.recordedUnavailable',
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
labelKey: 'journey.map.layer.entries',
|
||||||
|
emptyKey: 'journey.map.layer.entriesEmpty',
|
||||||
|
unavailableKey: 'journey.map.layer.entriesUnavailable',
|
||||||
|
},
|
||||||
|
photos: {
|
||||||
|
labelKey: 'journey.map.layer.photos',
|
||||||
|
emptyKey: 'journey.map.layer.photosEmpty',
|
||||||
|
unavailableKey: 'journey.map.layer.photosEmpty',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'trek:journey-map-layers:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a reader's switches are kept: per journey, and per link on a shared
|
||||||
|
* page. The link's token is the credential to the journey, so only a hash of
|
||||||
|
* it is written to the browser's storage.
|
||||||
|
*/
|
||||||
|
export function layersStorageKey(scope: { journeyId: number } | { token: string }): string {
|
||||||
|
if ('journeyId' in scope) return `${STORAGE_PREFIX}${scope.journeyId}`;
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < scope.token.length; i++) {
|
||||||
|
hash ^= scope.token.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return `${STORAGE_PREFIX}pub:${(hash >>> 0).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored value read leniently: a layer it does not name stays at its default. */
|
||||||
|
export function parseStoredLayers(raw: string | null): JourneyMapVisibility {
|
||||||
|
if (!raw) return ALL_VISIBLE;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return ALL_VISIBLE;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return ALL_VISIBLE;
|
||||||
|
const stored = parsed as Record<string, unknown>;
|
||||||
|
const next = { ...ALL_VISIBLE };
|
||||||
|
for (const layer of JOURNEY_MAP_LAYERS) {
|
||||||
|
if (typeof stored[layer] === 'boolean') next[layer] = stored[layer] as boolean;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredLayers(key: string): JourneyMapVisibility {
|
||||||
|
try {
|
||||||
|
return parseStoredLayers(window.localStorage.getItem(key));
|
||||||
|
} catch {
|
||||||
|
return ALL_VISIBLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredLayers(key: string, visibility: JourneyMapVisibility): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(visibility));
|
||||||
|
} catch {
|
||||||
|
// A private window or blocked storage: the switch works for this visit only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The items of a source that answered, nothing for one that did not. */
|
||||||
|
export function itemsOf<T>(layer: JourneyMapLayerResult<T> | undefined): T[] {
|
||||||
|
return layer?.status === 'ok' ? layer.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a source that the reader may see stands, as its switch shows it. */
|
||||||
|
export function stateOf(
|
||||||
|
layer: JourneyMapLayerResult<unknown> | undefined,
|
||||||
|
loading: boolean,
|
||||||
|
count: number
|
||||||
|
): JourneyMapLayerState {
|
||||||
|
if (layer?.status === 'unavailable') return 'unavailable';
|
||||||
|
if (!layer) return loading ? 'loading' : 'idle';
|
||||||
|
return count > 0 ? 'ready' : 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marks of the plan and of the stays, as the renderers draw them. A
|
||||||
|
* planned place an entry on the map came from is left to that entry's pin,
|
||||||
|
* which carries the plan's check instead: one spot, one pin. Without the
|
||||||
|
* entries layer the plan pin stands on its own again.
|
||||||
|
*/
|
||||||
|
export function buildMarks({
|
||||||
|
places,
|
||||||
|
stays,
|
||||||
|
visibility,
|
||||||
|
entryIdsOnMap,
|
||||||
|
stayColor,
|
||||||
|
}: {
|
||||||
|
places: JourneyMapPlace[];
|
||||||
|
stays: JourneyMapStay[];
|
||||||
|
visibility: JourneyMapVisibility;
|
||||||
|
entryIdsOnMap: ReadonlySet<string>;
|
||||||
|
stayColor: (entryId: number) => string | null;
|
||||||
|
}): { marks: JourneyMapMark[]; plannedEntryIds: ReadonlySet<string> } {
|
||||||
|
const marks: JourneyMapMark[] = [];
|
||||||
|
const plannedEntryIds = new Set<string>();
|
||||||
|
if (visibility.plan) {
|
||||||
|
for (const place of places) {
|
||||||
|
const entryId = place.entry_id === null ? null : String(place.entry_id);
|
||||||
|
if (visibility.entries && entryId && entryIdsOnMap.has(entryId)) {
|
||||||
|
plannedEntryIds.add(entryId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
marks.push({
|
||||||
|
kind: 'plan',
|
||||||
|
key: `plan:${place.place_id}`,
|
||||||
|
lat: place.lat,
|
||||||
|
lng: place.lng,
|
||||||
|
color: safeColor(place.category_color),
|
||||||
|
place,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visibility.entries) {
|
||||||
|
for (const stay of stays) {
|
||||||
|
marks.push({
|
||||||
|
kind: 'stay',
|
||||||
|
key: `stay:${stay.id}`,
|
||||||
|
lat: stay.lat,
|
||||||
|
lng: stay.lng,
|
||||||
|
color: safeColor(stayColor(stay.entry_id)),
|
||||||
|
stay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { marks, plannedEntryIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server answered for a map: a journey's own layers, or a link's with its recorded days. */
|
||||||
|
export type JourneyMapAnswer = JourneyMapLayersResponse | JourneyPublicMapLayersResponse;
|
||||||
|
|
||||||
|
const UNAVAILABLE = { status: 'unavailable' } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every source of an answer on its own: undefined while nothing was read, all
|
||||||
|
* unavailable when the read itself failed (null). The recorded days come only
|
||||||
|
* with a link's answer; a journey's own map reads them with their details.
|
||||||
|
*/
|
||||||
|
export function sourcesOf(answer: JourneyMapAnswer | null | undefined): {
|
||||||
|
places?: JourneyMapLayersResponse['places'];
|
||||||
|
routes?: JourneyMapLayersResponse['routes'];
|
||||||
|
stays?: JourneyMapLayersResponse['stays'];
|
||||||
|
recordings?: JourneyPublicMapLayersResponse['recordings'];
|
||||||
|
} {
|
||||||
|
if (answer === null) return { places: UNAVAILABLE, routes: UNAVAILABLE, stays: UNAVAILABLE, recordings: UNAVAILABLE };
|
||||||
|
if (!answer) return {};
|
||||||
|
const recordings = 'recordings' in answer ? (answer as JourneyPublicMapLayersResponse).recordings : undefined;
|
||||||
|
return { places: answer.places, routes: answer.routes, stays: answer.stays, recordings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The switches a reader is offered, each with its count and state. A layer a
|
||||||
|
* share link withholds is not offered at all; one with nothing to draw is,
|
||||||
|
* dimmed, with the reason in its tooltip. A journey's own map always offers
|
||||||
|
* what was recorded, which the screen reads itself.
|
||||||
|
*/
|
||||||
|
export function buildControls({
|
||||||
|
ownJourney,
|
||||||
|
sources,
|
||||||
|
loading,
|
||||||
|
counts,
|
||||||
|
recordedRead = 'ready',
|
||||||
|
}: {
|
||||||
|
ownJourney: boolean;
|
||||||
|
sources: ReturnType<typeof sourcesOf>;
|
||||||
|
loading: boolean;
|
||||||
|
/** How the screen's own read of what was recorded went, for a journey's own map. */
|
||||||
|
recordedRead?: 'loading' | 'ready' | 'unavailable';
|
||||||
|
counts: { places: number; routes: number; recordedDays: number; entries: number; stays: number; photos: number };
|
||||||
|
}): JourneyMapLayerControl[] {
|
||||||
|
const list: JourneyMapLayerControl[] = [];
|
||||||
|
if (sources.places?.status !== 'withheld') {
|
||||||
|
const state =
|
||||||
|
sources.routes?.status === 'unavailable'
|
||||||
|
? 'unavailable'
|
||||||
|
: stateOf(sources.places, loading, counts.places + counts.routes);
|
||||||
|
list.push({ layer: 'plan', count: counts.places, state });
|
||||||
|
}
|
||||||
|
if (ownJourney || sources.recordings?.status !== 'withheld') {
|
||||||
|
const state = ownJourney
|
||||||
|
? ownRecordedState(recordedRead, counts.recordedDays)
|
||||||
|
: stateOf(sources.recordings, loading, counts.recordedDays);
|
||||||
|
list.push({ layer: 'recorded', count: counts.recordedDays, state });
|
||||||
|
}
|
||||||
|
const entriesState =
|
||||||
|
sources.stays?.status === 'unavailable' ? 'unavailable' : counts.entries + counts.stays > 0 ? 'ready' : 'empty';
|
||||||
|
list.push({ layer: 'entries', count: counts.entries, state: entriesState });
|
||||||
|
list.push({ layer: 'photos', count: counts.photos, state: counts.photos > 0 ? 'ready' : 'empty' });
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownRecordedState(read: 'loading' | 'ready' | 'unavailable', days: number): JourneyMapLayerState {
|
||||||
|
if (read === 'unavailable') return 'unavailable';
|
||||||
|
if (days > 0) return 'ready';
|
||||||
|
return read === 'loading' ? 'loading' : 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a track belongs to the plan (a trip's GPX) or to what was recorded. */
|
||||||
|
export function isPlannedTrack(track: JourneyTrack): boolean {
|
||||||
|
return !track.kind || track.kind === 'trip_gpx';
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
|
||||||
|
//
|
||||||
|
// JRN-04: the plan and stay marks both renderers draw, and how they join.
|
||||||
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
MARK_CLUSTER_PX,
|
||||||
|
clusterPoints,
|
||||||
|
markHitSvg,
|
||||||
|
planPinSvg,
|
||||||
|
safeColor,
|
||||||
|
type JourneyMapMark,
|
||||||
|
} from './journeyMapMarks';
|
||||||
|
|
||||||
|
const planMark = (key: string, lat: number): JourneyMapMark => ({
|
||||||
|
kind: 'plan',
|
||||||
|
key,
|
||||||
|
lat,
|
||||||
|
lng: 12,
|
||||||
|
color: '#e11d48',
|
||||||
|
place: { name: key } as JourneyMapPlace,
|
||||||
|
});
|
||||||
|
const stayMark = (key: string, lat: number): JourneyMapMark => ({
|
||||||
|
kind: 'stay',
|
||||||
|
key,
|
||||||
|
lat,
|
||||||
|
lng: 12,
|
||||||
|
color: '#14b8a6',
|
||||||
|
stay: { name: key } as JourneyMapStay,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('journey map marks', () => {
|
||||||
|
it('FE-JOURNEY-MAPMARKS-001: joins only what would overlap on screen, anchored on the first', () => {
|
||||||
|
const project = (lat: number, lng: number) => ({ x: lng, y: lat * 100 });
|
||||||
|
const hits = clusterPoints([planMark('a', 1), stayMark('b', 1.05), planMark('c', 5)], project, MARK_CLUSTER_PX);
|
||||||
|
expect(hits.map((h) => h.members.map((m) => m.key))).toEqual([['a', 'b'], ['c']]);
|
||||||
|
expect(hits[0]).toMatchObject({ lat: 1, lng: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPMARKS-002: a lone plan is a hollow dashed pin, a lone stay a dot, several a disc with the count', () => {
|
||||||
|
const plan = markHitSvg([planMark('a', 1)]);
|
||||||
|
expect(plan).toMatchObject({ anchor: 'bottom', width: 22, height: 28 });
|
||||||
|
expect(plan.html).toContain('stroke-dasharray');
|
||||||
|
expect(markHitSvg([stayMark('b', 1)])).toMatchObject({ anchor: 'center', width: 14 });
|
||||||
|
const several = markHitSvg([planMark('a', 1), stayMark('b', 1), planMark('c', 1)]);
|
||||||
|
expect(several.anchor).toBe('center');
|
||||||
|
expect(several.html).toContain('>3<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPMARKS-003: only a hex colour reaches the markup', () => {
|
||||||
|
expect(safeColor('#ABCDEF')).toBe('#ABCDEF');
|
||||||
|
expect(safeColor('#abc')).toBe('#abc');
|
||||||
|
expect(safeColor('"/><script>')).toBe('#64748b');
|
||||||
|
expect(safeColor(null)).toBe('#64748b');
|
||||||
|
expect(planPinSvg('red" onload="x')).not.toContain('onload');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marks a journey map draws beside its entries and photos (JRN-04): the
|
||||||
|
* places the plan puts on a day, and the stays a journal entry was taken over
|
||||||
|
* from. One module for both renderers, so a mark says the same on Leaflet and
|
||||||
|
* on GL. The markup is SVG with its colours as attributes: it is drawn over
|
||||||
|
* any basemap, so it keeps its own contrast instead of following the scheme.
|
||||||
|
*/
|
||||||
|
export type JourneyMapMark =
|
||||||
|
| { kind: 'plan'; key: string; lat: number; lng: number; color: string; place: JourneyMapPlace }
|
||||||
|
| { kind: 'stay'; key: string; lat: number; lng: number; color: string; stay: JourneyMapStay };
|
||||||
|
|
||||||
|
/** The colour of a planned place whose category has none, a neutral slate that reads on light and dark tiles. */
|
||||||
|
export const PLAN_FALLBACK_COLOR = '#64748b';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks closer than this on screen are one hit: the reader could not pick one
|
||||||
|
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
|
||||||
|
* thumbnail, so marks a street apart stay apart.
|
||||||
|
*/
|
||||||
|
export const MARK_CLUSTER_PX = 26;
|
||||||
|
|
||||||
|
const PLAN_PIN_W = 22;
|
||||||
|
const PLAN_PIN_H = 28;
|
||||||
|
const STAY_DOT = 14;
|
||||||
|
const MARK_CLUSTER_SIZE = 28;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid clustering in screen space, shared by both renderers: points whose
|
||||||
|
* projected position falls in the same `px` cell become one, anchored on the
|
||||||
|
* first so the pin points where that member is. `project` is the engine's own
|
||||||
|
* lat/lng to container-pixel conversion.
|
||||||
|
*/
|
||||||
|
export function clusterPoints<T extends { lat: number; lng: number }>(
|
||||||
|
points: readonly T[],
|
||||||
|
project: (lat: number, lng: number) => { x: number; y: number },
|
||||||
|
px: number
|
||||||
|
): { lat: number; lng: number; members: T[] }[] {
|
||||||
|
const buckets = new Map<string, T[]>();
|
||||||
|
for (const point of points) {
|
||||||
|
const pt = project(point.lat, point.lng);
|
||||||
|
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
|
||||||
|
const list = buckets.get(key);
|
||||||
|
if (list) list.push(point);
|
||||||
|
else buckets.set(key, [point]);
|
||||||
|
}
|
||||||
|
return [...buckets.values()].map((members) => ({ lat: members[0].lat, lng: members[0].lng, members }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a `#rgb`/`#rrggbb` colour reaches markup; anything else a category holds falls back. */
|
||||||
|
export function safeColor(color: string | null | undefined, fallback = PLAN_FALLBACK_COLOR): string {
|
||||||
|
return color && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A planned place: a hollow pin, dashed in its category colour, smaller than
|
||||||
|
* an entry pin. Hollow on purpose: the plan is what was meant, not what was
|
||||||
|
* lived, and it never reads as a filled journal pin.
|
||||||
|
*/
|
||||||
|
export function planPinSvg(color: string): string {
|
||||||
|
const c = safeColor(color);
|
||||||
|
return `<svg width="${PLAN_PIN_W}" height="${PLAN_PIN_H}" viewBox="0 0 22 28" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.25))">
|
||||||
|
<path d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z" fill="#ffffff" fill-opacity="0.92" stroke="${c}" stroke-width="2" stroke-dasharray="3 2"/>
|
||||||
|
<circle cx="11" cy="10.5" r="3" fill="${c}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stay of a day report: a dot in the colour of its day, ringed so it holds on any tile. */
|
||||||
|
function stayDotSvg(color: string): string {
|
||||||
|
const c = safeColor(color);
|
||||||
|
return `<svg width="${STAY_DOT}" height="${STAY_DOT}" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))">
|
||||||
|
<circle cx="7" cy="7" r="5.5" fill="${c}" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several marks on one spot: a stacked disc with the count, ringed in the
|
||||||
|
* colours of up to two members so the reader sees what kinds lie beneath.
|
||||||
|
*/
|
||||||
|
function markClusterSvg(count: number, colors: string[]): string {
|
||||||
|
const [a, b] = [safeColor(colors[0]), safeColor(colors[1] ?? colors[0])];
|
||||||
|
const label = count > 99 ? '99+' : String(count);
|
||||||
|
return `<svg width="${MARK_CLUSTER_SIZE}" height="${MARK_CLUSTER_SIZE}" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" style="display:block;cursor:pointer;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))">
|
||||||
|
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke="${b}" stroke-width="2"/>
|
||||||
|
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke="${a}" stroke-width="2.5"/>
|
||||||
|
<text x="12" y="16" text-anchor="middle" dominant-baseline="central" fill="#111827" font-family="'Poppins',system-ui,sans-serif" font-size="10" font-weight="700">${label}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The markup of one hit on the map: a mark alone, or the disc of several. */
|
||||||
|
export function markHitSvg(members: readonly JourneyMapMark[]): {
|
||||||
|
html: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
anchor: 'bottom' | 'center';
|
||||||
|
} {
|
||||||
|
if (members.length > 1) {
|
||||||
|
return {
|
||||||
|
html: markClusterSvg(
|
||||||
|
members.length,
|
||||||
|
members.map((m) => m.color)
|
||||||
|
),
|
||||||
|
width: MARK_CLUSTER_SIZE,
|
||||||
|
height: MARK_CLUSTER_SIZE,
|
||||||
|
anchor: 'center',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mark = members[0];
|
||||||
|
return mark.kind === 'plan'
|
||||||
|
? { html: planPinSvg(mark.color), width: PLAN_PIN_W, height: PLAN_PIN_H, anchor: 'bottom' }
|
||||||
|
: { html: stayDotSvg(mark.color), width: STAY_DOT, height: STAY_DOT, anchor: 'center' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The small check an entry pin carries when the plan had that place too: plan
|
||||||
|
* and memory at one spot read as one pin, not two on top of each other. Sits
|
||||||
|
* at the pin's top corner, inside its 28 x 36 box.
|
||||||
|
*/
|
||||||
|
export const PLANNED_BADGE_SVG = `<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" stroke-width="1.2"/><path d="M20.4 5.6l1.5 1.5 2.8-2.9" fill="none" stroke="#16a34a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { clusterPoints } from './journeyMapMarks'
|
||||||
|
|
||||||
/** A photo that knows where it was taken (#1614). */
|
/** A photo that knows where it was taken (#1614). */
|
||||||
export interface MapPhoto {
|
export interface MapPhoto {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,21 +24,9 @@ export function clusterPhotos(
|
|||||||
photos: MapPhoto[],
|
photos: MapPhoto[],
|
||||||
project: (lat: number, lng: number) => { x: number; y: number },
|
project: (lat: number, lng: number) => { x: number; y: number },
|
||||||
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
||||||
const buckets = new Map<string, MapPhoto[]>()
|
// Anchored on the first member rather than the centroid: the thumbnail shown
|
||||||
for (const photo of photos) {
|
// is that photo's, so the pin should point where that picture was taken.
|
||||||
const pt = project(photo.lat, photo.lng)
|
return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
|
||||||
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
|
|
||||||
const list = buckets.get(key)
|
|
||||||
if (list) list.push(photo)
|
|
||||||
else buckets.set(key, [photo])
|
|
||||||
}
|
|
||||||
return [...buckets.values()].map(members => ({
|
|
||||||
// Anchor on the first member rather than the centroid: the thumbnail shown is
|
|
||||||
// that photo's, so the pin should point where that picture was taken.
|
|
||||||
lat: members[0].lat,
|
|
||||||
lng: members[0].lng,
|
|
||||||
members,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function photoMarkerHtml(thumbUrl: string, count: number): string {
|
export function photoMarkerHtml(thumbUrl: string, count: number): string {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { OnlineOnlyError } from '../../repo/onlineOnly';
|
||||||
|
import {
|
||||||
|
deviceOffsetOn,
|
||||||
|
formatOffset,
|
||||||
|
modeLabel,
|
||||||
|
recordingErrorCode,
|
||||||
|
recordingErrorText,
|
||||||
|
sketchPaths,
|
||||||
|
zoneOptions,
|
||||||
|
} from './journeyRecordingModel';
|
||||||
|
|
||||||
|
// FE-JRN-RECORDING-MODEL-001 to FE-JRN-RECORDING-MODEL-006
|
||||||
|
|
||||||
|
const t = (key: string) => `t:${key}`;
|
||||||
|
const httpError = (code: string) => ({ response: { data: { error: 'English', code } } });
|
||||||
|
|
||||||
|
describe('journeyRecordingModel', () => {
|
||||||
|
it('FE-JRN-RECORDING-MODEL-001: spells an offset the way a zone is named', () => {
|
||||||
|
expect(formatOffset(120)).toBe('UTC+02:00');
|
||||||
|
expect(formatOffset(-210)).toBe('UTC-03:30');
|
||||||
|
expect(formatOffset(0)).toBe('UTC+00:00');
|
||||||
|
expect(formatOffset(345)).toBe('UTC+05:45');
|
||||||
|
expect(deviceOffsetOn('2026-05-14')).toBe(-new Date('2026-05-14T12:00:00').getTimezoneOffset());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-MODEL-002: lists every zone once, marking the one of Dawarich and the one of this device', () => {
|
||||||
|
const options = zoneOptions(t, 120, 60);
|
||||||
|
expect(options[0]).toEqual({ value: -720, label: 'UTC-12:00' });
|
||||||
|
expect(options[options.length - 1]).toEqual({ value: 840, label: 'UTC+14:00' });
|
||||||
|
expect(options.find((o) => o.value === 120)).toEqual({
|
||||||
|
value: 120,
|
||||||
|
label: 'UTC+02:00',
|
||||||
|
badge: 't:journey.recording.zoneSource',
|
||||||
|
});
|
||||||
|
expect(options.find((o) => o.value === 60)).toEqual({
|
||||||
|
value: 60,
|
||||||
|
label: 'UTC+01:00',
|
||||||
|
badge: 't:journey.recording.zoneDevice',
|
||||||
|
});
|
||||||
|
expect(new Set(options.map((o) => o.value)).size).toBe(options.length);
|
||||||
|
// An odd zone a device really is in still gets its row.
|
||||||
|
expect(zoneOptions(t, null, 17).some((o) => o.value === 17)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-MODEL-003: names the modes TREK knows and shows any other as Dawarich spells it', () => {
|
||||||
|
expect(modeLabel('walking', t)).toBe('t:journey.recording.mode.walking');
|
||||||
|
expect(modeLabel('hovercraft', t)).toBe('hovercraft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-MODEL-004: says a refusal in the reader’s language, from the code the server sent', () => {
|
||||||
|
expect(recordingErrorText(new OnlineOnlyError(), t)).toBe('t:dawarich.error.offline');
|
||||||
|
expect(recordingErrorText(httpError('recording_exists'), t)).toBe('t:journey.recording.error.recording_exists');
|
||||||
|
expect(recordingErrorCode(httpError('recording_changed'))).toBe('recording_changed');
|
||||||
|
const echo = (key: string) => (key === 'dawarich.error.not_connected' ? 'Not connected' : key);
|
||||||
|
expect(recordingErrorText(httpError('not_connected'), echo)).toBe('Not connected');
|
||||||
|
expect(recordingErrorText(httpError('brand_new'), echo)).toBe('dawarich.error.unknown');
|
||||||
|
expect(recordingErrorText(new Error('boom'), echo)).toBe('dawarich.error.unknown');
|
||||||
|
expect(recordingErrorCode(new Error('boom'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-MODEL-005: draws each piece on its own, north up, inside the box', () => {
|
||||||
|
const { paths, ends } = sketchPaths(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
[45, 12],
|
||||||
|
[45.01, 12.01],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
[45.05, 12.05],
|
||||||
|
[45.06, 12.07],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
320,
|
||||||
|
150
|
||||||
|
);
|
||||||
|
expect(paths).toHaveLength(2);
|
||||||
|
expect(paths.every((d) => d.startsWith('M') && !d.includes('M', 1))).toBe(true);
|
||||||
|
expect(ends).toHaveLength(4);
|
||||||
|
for (const [x, y] of ends) {
|
||||||
|
expect(x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(x).toBeLessThanOrEqual(320);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(y).toBeLessThanOrEqual(150);
|
||||||
|
}
|
||||||
|
// The northernmost point sits higher in the box than the southernmost.
|
||||||
|
expect(ends[3]?.[1]).toBeLessThan(ends[0]?.[1] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-MODEL-006: nothing recorded is nothing drawn, and a lone fix is no line', () => {
|
||||||
|
expect(sketchPaths([], 320, 150)).toEqual({ paths: [], ends: [] });
|
||||||
|
expect(sketchPaths([{ points: [[45, 12]] }], 320, 150)).toEqual({ paths: [], ends: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { OnlineOnlyError } from '../../repo/onlineOnly';
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zones a day can be cut in: every whole hour from UTC-12 to UTC+14 and
|
||||||
|
* the half and three-quarter hours places actually keep (India, Nepal,
|
||||||
|
* Newfoundland, Iran, parts of Australia, the Chathams).
|
||||||
|
*/
|
||||||
|
const ZONE_OFFSETS = [
|
||||||
|
...Array.from({ length: 27 }, (_, i) => (i - 12) * 60),
|
||||||
|
-570,
|
||||||
|
-210,
|
||||||
|
210,
|
||||||
|
270,
|
||||||
|
330,
|
||||||
|
345,
|
||||||
|
390,
|
||||||
|
525,
|
||||||
|
570,
|
||||||
|
630,
|
||||||
|
765,
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** `UTC+05:30`, `UTC-03:00`, `UTC+00:00`. */
|
||||||
|
export function formatOffset(minutes: number): string {
|
||||||
|
const sign = minutes < 0 ? '-' : '+';
|
||||||
|
const abs = Math.abs(minutes);
|
||||||
|
const hours = String(Math.floor(abs / 60)).padStart(2, '0');
|
||||||
|
const rest = String(abs % 60).padStart(2, '0');
|
||||||
|
return `UTC${sign}${hours}:${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The zone this device is in on that day, so a day abroad in summer gets its summer offset. */
|
||||||
|
export function deviceOffsetOn(date: string): number {
|
||||||
|
return -new Date(`${date}T12:00:00`).getTimezoneOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoneOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone picker's list, Dawarich's own zone and this device's marked, both
|
||||||
|
* kept in the list even when they are no whole or usual offset.
|
||||||
|
*/
|
||||||
|
export function zoneOptions(t: Translate, sourceOffset: number | null, deviceOffset: number): ZoneOption[] {
|
||||||
|
const offsets = new Set(ZONE_OFFSETS);
|
||||||
|
if (sourceOffset !== null) offsets.add(sourceOffset);
|
||||||
|
offsets.add(deviceOffset);
|
||||||
|
return [...offsets]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((minutes) => {
|
||||||
|
const badge =
|
||||||
|
minutes === sourceOffset
|
||||||
|
? t('journey.recording.zoneSource')
|
||||||
|
: minutes === deviceOffset
|
||||||
|
? t('journey.recording.zoneDevice')
|
||||||
|
: undefined;
|
||||||
|
return badge
|
||||||
|
? { value: minutes, label: formatOffset(minutes), badge }
|
||||||
|
: { value: minutes, label: formatOffset(minutes) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The modes Dawarich gives a track, as far as TREK names them; anything else is shown as Dawarich spells it. */
|
||||||
|
const KNOWN_MODES = new Set([
|
||||||
|
'walking',
|
||||||
|
'running',
|
||||||
|
'cycling',
|
||||||
|
'driving',
|
||||||
|
'motorcycle',
|
||||||
|
'bus',
|
||||||
|
'train',
|
||||||
|
'flying',
|
||||||
|
'boat',
|
||||||
|
'stationary',
|
||||||
|
'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function modeLabel(mode: string, t: Translate): string {
|
||||||
|
return KNOWN_MODES.has(mode) ? t(`journey.recording.mode.${mode}`) : mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed preview, save or removal in the reader's language. The server
|
||||||
|
* names its reason as a code: Dawarich's own reasons have their strings
|
||||||
|
* already, the journey's three have theirs.
|
||||||
|
*/
|
||||||
|
export function recordingErrorText(err: unknown, t: Translate): string {
|
||||||
|
if (err instanceof OnlineOnlyError) return t('dawarich.error.offline');
|
||||||
|
const code = recordingErrorCode(err);
|
||||||
|
if (code === 'recording_exists' || code === 'recording_changed' || code === 'recording_empty') {
|
||||||
|
return t(`journey.recording.error.${code}`);
|
||||||
|
}
|
||||||
|
return reasonText(code, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reason Dawarich gave as a code, in the reader's language; one TREK has no words for reads as unknown. */
|
||||||
|
export function reasonText(code: string | null, t: Translate): string {
|
||||||
|
if (code) {
|
||||||
|
const key = `dawarich.error.${code}`;
|
||||||
|
const text = t(key);
|
||||||
|
if (text !== key) return text;
|
||||||
|
}
|
||||||
|
return t('dawarich.error.unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordingErrorCode(err: unknown): string | null {
|
||||||
|
const code = (err as { response?: { data?: { code?: unknown } } })?.response?.data?.code;
|
||||||
|
return typeof code === 'string' ? code : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pieces of a day drawn into a box, north up, longitude shortened by the
|
||||||
|
* latitude so a walk keeps its shape. Answers one SVG path per piece, never a
|
||||||
|
* line between two of them.
|
||||||
|
*/
|
||||||
|
export function sketchPaths(
|
||||||
|
segments: ReadonlyArray<{ points: ReadonlyArray<readonly unknown[]> }>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pad = 8
|
||||||
|
): { paths: string[]; ends: Array<[number, number]> } {
|
||||||
|
const all = segments.flatMap((s) => s.points);
|
||||||
|
if (all.length === 0) return { paths: [], ends: [] };
|
||||||
|
const lats = all.map((p) => Number(p[0]));
|
||||||
|
const lngs = all.map((p) => Number(p[1]));
|
||||||
|
const minLat = Math.min(...lats);
|
||||||
|
const maxLat = Math.max(...lats);
|
||||||
|
const minLng = Math.min(...lngs);
|
||||||
|
const maxLng = Math.max(...lngs);
|
||||||
|
const shrink = Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180) || 1;
|
||||||
|
const spanX = Math.max((maxLng - minLng) * shrink, 1e-9);
|
||||||
|
const spanY = Math.max(maxLat - minLat, 1e-9);
|
||||||
|
const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY);
|
||||||
|
const offX = (width - spanX * scale) / 2;
|
||||||
|
const offY = (height - spanY * scale) / 2;
|
||||||
|
const at = (point: readonly unknown[]): [number, number] => [
|
||||||
|
Math.round((offX + (Number(point[1]) - minLng) * shrink * scale) * 10) / 10,
|
||||||
|
Math.round((offY + (maxLat - Number(point[0])) * scale) * 10) / 10,
|
||||||
|
];
|
||||||
|
const paths = segments
|
||||||
|
.filter((s) => s.points.length > 1)
|
||||||
|
.map((s) =>
|
||||||
|
s.points
|
||||||
|
.map(at)
|
||||||
|
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x} ${y}`)
|
||||||
|
.join(' ')
|
||||||
|
);
|
||||||
|
const ends = segments.flatMap((s) => {
|
||||||
|
const first = s.points[0];
|
||||||
|
const last = s.points[s.points.length - 1];
|
||||||
|
return s.points.length > 1 && first && last ? [at(first), at(last)] : [];
|
||||||
|
});
|
||||||
|
return { paths, ends };
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// FE-JRN-PREP-MODEL-001 to FE-JRN-PREP-MODEL-009
|
||||||
|
//
|
||||||
|
// JRN-03: where preparing a journey day stands, and the line under the switch.
|
||||||
|
import type { JourneyRecordingPrep } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
dayPrepNotice,
|
||||||
|
dayPrepOf,
|
||||||
|
dayPrepText,
|
||||||
|
dayPrepUnsettled,
|
||||||
|
prepReachOf,
|
||||||
|
prepStatusLine,
|
||||||
|
} from './journeyRecordingPrepModel';
|
||||||
|
|
||||||
|
/** Keys come back as they are, with their params; a Dawarich reason reads as its code, as reasonText needs a hit. */
|
||||||
|
const t = (key: string, params?: Record<string, string | number>) => {
|
||||||
|
if (key.startsWith('dawarich.error.')) return key.slice('dawarich.error.'.length).toUpperCase();
|
||||||
|
return params ? `${key} ${JSON.stringify(params)}` : key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 4, 16, 12, 0);
|
||||||
|
const PERIOD = new Set(['2026-05-14', '2026-05-15', '2026-05-16', '2026-05-17']);
|
||||||
|
const REACHED = { prepared: true, outOfReach: false };
|
||||||
|
|
||||||
|
function prep(over: Partial<JourneyRecordingPrep> = {}): JourneyRecordingPrep {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
connection: 'ready',
|
||||||
|
lastAttemptAt: new Date(2026, 4, 16, 10, 0).toISOString(),
|
||||||
|
lastSuccessAt: new Date(2026, 4, 16, 10, 0).toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dayPrepOf', () => {
|
||||||
|
it('FE-JRN-PREP-MODEL-001: today runs, a day that ended before the last fetch is complete, one fetched too early is provisional', () => {
|
||||||
|
expect(dayPrepOf('2026-05-16', prep(), PERIOD, NOW)?.state).toBe('running');
|
||||||
|
expect(dayPrepOf('2026-05-14', prep(), PERIOD, NOW)?.state).toBe('complete');
|
||||||
|
const early = prep({ lastSuccessAt: new Date(2026, 4, 15, 18, 0).toISOString() });
|
||||||
|
expect(dayPrepOf('2026-05-15', early, PERIOD, NOW)).toEqual({
|
||||||
|
state: 'provisional',
|
||||||
|
failed: false,
|
||||||
|
lastSuccessAt: early.lastSuccessAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-002: nothing fetched yet is pending, and a failed run marks the day as stale', () => {
|
||||||
|
expect(dayPrepOf('2026-05-14', prep({ lastSuccessAt: null }), PERIOD, NOW)?.state).toBe('pending');
|
||||||
|
expect(dayPrepOf('2026-05-14', prep({ lastError: 'unreachable' }), PERIOD, NOW)).toMatchObject({
|
||||||
|
state: 'complete',
|
||||||
|
failed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-003: no mark while off, without a connection, outside the trips or for a day still ahead', () => {
|
||||||
|
expect(dayPrepOf('2026-05-14', prep({ enabled: false }), PERIOD, NOW)).toBeNull();
|
||||||
|
expect(dayPrepOf('2026-05-14', prep({ connection: 'not_connected' }), PERIOD, NOW)).toBeNull();
|
||||||
|
expect(dayPrepOf('2026-05-20', prep(), PERIOD, NOW)).toBeNull();
|
||||||
|
expect(dayPrepOf('2026-05-17', prep(), PERIOD, NOW)).toBeNull();
|
||||||
|
expect(dayPrepOf('2026-05-14', null, PERIOD, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dayPrepText and dayPrepUnsettled', () => {
|
||||||
|
it('FE-JRN-PREP-MODEL-004: says why a day is not final, and only such a day is unsettled', () => {
|
||||||
|
const now = NOW.getTime();
|
||||||
|
expect(dayPrepText({ state: 'running', failed: true, lastSuccessAt: null }, t, 'en', now)).toBe(
|
||||||
|
'journey.prep.dayRunning journey.prep.dayFailed'
|
||||||
|
);
|
||||||
|
expect(dayPrepText({ state: 'pending', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe(
|
||||||
|
'journey.prep.dayPending'
|
||||||
|
);
|
||||||
|
const provisional = {
|
||||||
|
state: 'provisional' as const,
|
||||||
|
failed: false,
|
||||||
|
lastSuccessAt: new Date(now - 7_200_000).toISOString(),
|
||||||
|
};
|
||||||
|
expect(dayPrepText(provisional, t, 'en', now)).toBe('journey.prep.dayProvisional {"time":"2 hours ago"}');
|
||||||
|
|
||||||
|
expect(dayPrepUnsettled(provisional)).toBe(true);
|
||||||
|
expect(dayPrepUnsettled({ state: 'complete', failed: false, lastSuccessAt: null })).toBe(false);
|
||||||
|
expect(dayPrepUnsettled({ state: 'complete', failed: true, lastSuccessAt: null })).toBe(true);
|
||||||
|
expect(dayPrepUnsettled(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepStatusLine', () => {
|
||||||
|
const now = NOW.getTime();
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-005: says when the last fetch came through, or that none did yet', () => {
|
||||||
|
expect(prepStatusLine(prep(), REACHED, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.lastSuccess {"time":"2 hours ago"}',
|
||||||
|
tone: 'muted',
|
||||||
|
});
|
||||||
|
expect(prepStatusLine(prep({ lastSuccessAt: null, lastAttemptAt: null }), REACHED, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.never',
|
||||||
|
tone: 'muted',
|
||||||
|
});
|
||||||
|
expect(prepStatusLine(prep({ enabled: false }), REACHED, t, 'en', now)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-006: warns about what keeps it from working, a failure first and beside the state still shown', () => {
|
||||||
|
expect(prepStatusLine(prep({ connection: 'not_connected' }), REACHED, t, 'en', now)?.text).toBe(
|
||||||
|
'journey.prep.notConnected'
|
||||||
|
);
|
||||||
|
expect(prepStatusLine(prep(), { prepared: false, outOfReach: false }, t, 'en', now)?.text).toBe(
|
||||||
|
'journey.prep.noPeriods'
|
||||||
|
);
|
||||||
|
expect(prepStatusLine(prep({ connection: 'sync_off' }), REACHED, t, 'en', now)?.text).toBe('journey.prep.syncOff');
|
||||||
|
expect(prepStatusLine(prep({ lastError: 'unreachable' }), REACHED, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.failed {"reason":"UNREACHABLE","time":"2 hours ago"}',
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
|
expect(prepStatusLine(prep({ lastError: 'unauthorized', lastSuccessAt: null }), REACHED, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.failedNever {"reason":"UNAUTHORIZED"}',
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepReachOf', () => {
|
||||||
|
it('FE-JRN-PREP-MODEL-007: only the periods the server reaches are prepared, an open trip up to today', () => {
|
||||||
|
const linked = new Set(['2024-01-10', '2024-01-11', ...PERIOD]);
|
||||||
|
const reach = prepReachOf(prep({ periods: [{ start: '2026-05-14', end: null }] }), linked, NOW);
|
||||||
|
|
||||||
|
expect([...reach.dates]).toEqual(['2026-05-14', '2026-05-15', '2026-05-16']);
|
||||||
|
expect(reach.outOfReach).toBe(true);
|
||||||
|
expect(dayPrepOf('2024-01-10', prep(), reach.dates, NOW)).toBeNull();
|
||||||
|
expect(prepReachOf(prep({ periods: [{ start: '2026-05-14', end: '2026-05-17' }] }), PERIOD, NOW)).toEqual({
|
||||||
|
dates: PERIOD,
|
||||||
|
outOfReach: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-008: without periods in the answer the linked dates stand in, nothing out of reach', () => {
|
||||||
|
expect(prepReachOf(prep(), PERIOD, NOW)).toEqual({ dates: PERIOD, outOfReach: false });
|
||||||
|
expect(prepReachOf(null, PERIOD, NOW)).toEqual({ dates: PERIOD, outOfReach: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-MODEL-009: trips out of reach are named, alone when none is reached and after the state otherwise', () => {
|
||||||
|
const now = NOW.getTime();
|
||||||
|
expect(prepStatusLine(prep(), { prepared: false, outOfReach: true }, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.outOfReach {"count":400}',
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
|
expect(prepStatusLine(prep(), { prepared: true, outOfReach: true }, t, 'en', now)).toEqual({
|
||||||
|
text: 'journey.prep.lastSuccess {"time":"2 hours ago"} journey.prep.outOfReach {"count":400}',
|
||||||
|
tone: 'muted',
|
||||||
|
});
|
||||||
|
expect(dayPrepNotice({ state: 'running', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe('');
|
||||||
|
expect(dayPrepNotice({ state: 'running', failed: true, lastSuccessAt: null }, t, 'en', now)).toBe(
|
||||||
|
'journey.prep.dayFailed'
|
||||||
|
);
|
||||||
|
expect(dayPrepNotice({ state: 'pending', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe(
|
||||||
|
'journey.prep.dayPending'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { JOURNEY_RECORDING_PREP_REACH_DAYS, type JourneyRecordingPrep } from '@trek/shared';
|
||||||
|
import { localIsoDate } from '../../utils/localDate';
|
||||||
|
import { relativeTime } from '../../utils/relativeTime';
|
||||||
|
import { reasonText } from './journeyRecordingModel';
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where preparing one journey day stands (JRN-03), for its badge.
|
||||||
|
* `running`: the day is today. `provisional`: the last fetch that came through
|
||||||
|
* was before the day ended, so stays may be missing. `pending`: no fetch came
|
||||||
|
* through yet. `complete`: fetched after the day was over.
|
||||||
|
*/
|
||||||
|
export interface JourneyDayPrep {
|
||||||
|
state: 'running' | 'provisional' | 'pending' | 'complete';
|
||||||
|
/** The last run failed; what the day shows is as new as `lastSuccessAt`. */
|
||||||
|
failed: boolean;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which days the caller's runs reach and whether some linked day is left out.
|
||||||
|
* The server names the trips its runs read; a trip still open runs up to
|
||||||
|
* today. Without that answer the linked dates stand in for them.
|
||||||
|
*/
|
||||||
|
export interface PrepReach {
|
||||||
|
dates: ReadonlySet<string>;
|
||||||
|
/** A linked day that is over is read by no run, its trip ended too long ago. */
|
||||||
|
outOfReach: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepReachOf(
|
||||||
|
prep: JourneyRecordingPrep | null,
|
||||||
|
linkedDates: ReadonlySet<string>,
|
||||||
|
now: Date = new Date()
|
||||||
|
): PrepReach {
|
||||||
|
if (!prep?.periods) return { dates: linkedDates, outOfReach: false };
|
||||||
|
const today = localIsoDate(now);
|
||||||
|
const dates = new Set<string>();
|
||||||
|
for (const period of prep.periods) {
|
||||||
|
const end = new Date(`${period.end ?? today}T00:00:00`);
|
||||||
|
for (let d = new Date(`${period.start}T00:00:00`); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
dates.add(localIsoDate(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const outOfReach = [...linkedDates].some((date) => date < today && !dates.has(date));
|
||||||
|
return { dates, outOfReach };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day the caller's runs reach while the journey prepares its days for a
|
||||||
|
* connected caller; null for every other day, including those still ahead.
|
||||||
|
*/
|
||||||
|
export function dayPrepOf(
|
||||||
|
date: string,
|
||||||
|
prep: JourneyRecordingPrep | null,
|
||||||
|
periodDates: ReadonlySet<string>,
|
||||||
|
now: Date = new Date()
|
||||||
|
): JourneyDayPrep | null {
|
||||||
|
if (!prep?.enabled || prep.connection === 'not_connected' || !periodDates.has(date)) return null;
|
||||||
|
const today = localIsoDate(now);
|
||||||
|
if (date > today) return null;
|
||||||
|
const base = { failed: prep.lastError !== null, lastSuccessAt: prep.lastSuccessAt };
|
||||||
|
if (date === today) return { state: 'running', ...base };
|
||||||
|
if (!prep.lastSuccessAt) return { state: 'pending', ...base };
|
||||||
|
const dayEnd = new Date(`${date}T00:00:00`);
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
return { state: Date.parse(prep.lastSuccessAt) < dayEnd.getTime() ? 'provisional' : 'complete', ...base };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a day's badge marks it as not final: still running, fetched too early, not fetched, or stale after a failure. */
|
||||||
|
export function dayPrepUnsettled(day: JourneyDayPrep | null | undefined): boolean {
|
||||||
|
return !!day && (day.state !== 'complete' || day.failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a day's badge adds to its tooltip; empty for a day fetched whole by a run that came through. */
|
||||||
|
export function dayPrepText(day: JourneyDayPrep, t: Translate, locale: string, now: number = Date.now()): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (day.state === 'running') parts.push(t('journey.prep.dayRunning'));
|
||||||
|
if (day.state === 'pending') parts.push(t('journey.prep.dayPending'));
|
||||||
|
if (day.state === 'provisional' && day.lastSuccessAt) {
|
||||||
|
parts.push(t('journey.prep.dayProvisional', { time: relativeTime(Date.parse(day.lastSuccessAt), locale, now) }));
|
||||||
|
}
|
||||||
|
if (day.failed) parts.push(t('journey.prep.dayFailed'));
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same for the day review, which says on its own that the day is still running. */
|
||||||
|
export function dayPrepNotice(day: JourneyDayPrep, t: Translate, locale: string, now: number = Date.now()): string {
|
||||||
|
return day.state === 'running' ? (day.failed ? t('journey.prep.dayFailed') : '') : dayPrepText(day, t, locale, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line under the switch in the journey settings, and whether it asks for attention. */
|
||||||
|
export interface PrepStatusLine {
|
||||||
|
text: string;
|
||||||
|
tone: 'muted' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the caller's own preparation of a journey looks like right now, for
|
||||||
|
* the settings and the day review: why nothing happens (no connection, no
|
||||||
|
* dated trip, every trip out of reach, background sync off), the last failure
|
||||||
|
* next to the state still shown, or when it last came through, followed by a
|
||||||
|
* word on trips left out. Null while the switch is off.
|
||||||
|
*/
|
||||||
|
export function prepStatusLine(
|
||||||
|
prep: JourneyRecordingPrep,
|
||||||
|
reach: { prepared: boolean; outOfReach: boolean },
|
||||||
|
t: Translate,
|
||||||
|
locale: string,
|
||||||
|
now: number = Date.now()
|
||||||
|
): PrepStatusLine | null {
|
||||||
|
if (!prep.enabled) return null;
|
||||||
|
if (prep.connection === 'not_connected') return { text: t('journey.prep.notConnected'), tone: 'warning' };
|
||||||
|
const outOfReachText = t('journey.prep.outOfReach', { count: JOURNEY_RECORDING_PREP_REACH_DAYS });
|
||||||
|
if (!reach.prepared) {
|
||||||
|
return { text: reach.outOfReach ? outOfReachText : t('journey.prep.noPeriods'), tone: 'warning' };
|
||||||
|
}
|
||||||
|
const line = baseStatusLine(prep, t, locale, now);
|
||||||
|
return reach.outOfReach ? { ...line, text: `${line.text} ${outOfReachText}` } : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseStatusLine(prep: JourneyRecordingPrep, t: Translate, locale: string, now: number): PrepStatusLine {
|
||||||
|
const success = prep.lastSuccessAt ? relativeTime(Date.parse(prep.lastSuccessAt), locale, now) : null;
|
||||||
|
if (prep.lastError) {
|
||||||
|
const reason = reasonText(prep.lastError, t);
|
||||||
|
return {
|
||||||
|
text: success ? t('journey.prep.failed', { reason, time: success }) : t('journey.prep.failedNever', { reason }),
|
||||||
|
tone: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prep.connection === 'sync_off') return { text: t('journey.prep.syncOff'), tone: 'warning' };
|
||||||
|
if (!success) return { text: t('journey.prep.never'), tone: 'muted' };
|
||||||
|
return { text: t('journey.prep.lastSuccess', { time: success }), tone: 'muted' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// FE-JRN-DAYIMPORT-001 to FE-JRN-DAYIMPORT-008
|
||||||
|
//
|
||||||
|
// JRN-02: taking a recorded day with its stays into the journal, for the
|
||||||
|
// desktop dialog and the phone sheet alike. Nothing is written before the
|
||||||
|
// button; a failed take-over reads the day again and keeps what was chosen;
|
||||||
|
// the route goes along only when asked, and a route that fails after the
|
||||||
|
// drafts keeps the dialog open to try the route alone.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildDayStays, buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||||
|
import { localIsoDate } from '../../utils/localDate';
|
||||||
|
import { useJourneyDayImport } from './useJourneyDayImport';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({
|
||||||
|
reviewDay: vi.fn(),
|
||||||
|
takeOverDay: vi.fn(),
|
||||||
|
preview: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
|
||||||
|
|
||||||
|
function setup(over: { storedRoute?: ReturnType<typeof buildRecordedDay>; date?: string } = {}) {
|
||||||
|
const onTakenOver = vi.fn();
|
||||||
|
const onKept = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const view = renderHook(
|
||||||
|
() =>
|
||||||
|
useJourneyDayImport({
|
||||||
|
journeyId: 7,
|
||||||
|
date: over.date ?? '2026-05-14',
|
||||||
|
dayColor: '#ff0055',
|
||||||
|
storedRoute: over.storedRoute,
|
||||||
|
onTakenOver,
|
||||||
|
onKept,
|
||||||
|
onClose,
|
||||||
|
}),
|
||||||
|
{ wrapper: TranslationProvider }
|
||||||
|
);
|
||||||
|
return { ...view, onTakenOver, onKept, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
|
||||||
|
repo.reviewDay.mockResolvedValue(buildDayStays());
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
repo.takeOverDay.mockResolvedValue({
|
||||||
|
entries: [{ id: 5, title: 'Piazza San Marco', stayCount: 2 }],
|
||||||
|
alreadyTaken: [],
|
||||||
|
skipped: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useJourneyDayImport', () => {
|
||||||
|
it('FE-JRN-DAYIMPORT-001: reads the day, chooses its open stays, joins the same place twice and pins them in the day colour', async () => {
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(repo.reviewDay).toHaveBeenCalledWith(7, '2026-05-14', expect.any(AbortSignal));
|
||||||
|
expect(result.current.groups.map((g) => g.kind)).toEqual(['single', 'merged', 'single']);
|
||||||
|
expect(result.current.problem).toBeNull();
|
||||||
|
expect(result.current.mapEntries).toHaveLength(4);
|
||||||
|
expect(result.current.mapEntries[0]).toMatchObject({ id: 'stay-10', dayColor: '#ff0055', dayLabel: 1 });
|
||||||
|
expect(repo.takeOverDay).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-002: takes the chosen stays over, leaving the unchosen open one out, then reloads the journey and closes', async () => {
|
||||||
|
const { result, onTakenOver, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.choice).not.toBeNull());
|
||||||
|
|
||||||
|
act(() => result.current.toggle(10));
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
|
||||||
|
expect(repo.takeOverDay).toHaveBeenCalledWith(7, '2026-05-14', {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
kind: 'merged',
|
||||||
|
stays: [{ suggestionId: 11 }, { suggestionId: 12 }],
|
||||||
|
title: 'Piazza San Marco',
|
||||||
|
anchorSuggestionId: 12,
|
||||||
|
},
|
||||||
|
{ kind: 'single', stays: [{ suggestionId: 13 }] },
|
||||||
|
],
|
||||||
|
skipSuggestionIds: [10],
|
||||||
|
});
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('1 draft taken over');
|
||||||
|
expect(onTakenOver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-003: a failed take-over says nothing was taken over, reads the day again and keeps the names given', async () => {
|
||||||
|
const { result, onTakenOver, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.choice).not.toBeNull());
|
||||||
|
repo.takeOverDay.mockRejectedValue(httpError('stay_not_on_day'));
|
||||||
|
const stays = buildDayStays().stays;
|
||||||
|
|
||||||
|
act(() => result.current.rename(stays[3] as (typeof stays)[number], 'Ponte di Rialto'));
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
|
||||||
|
await waitFor(() => expect(repo.reviewDay).toHaveBeenCalledTimes(2));
|
||||||
|
expect(result.current.outcome?.kind).toBe('failed');
|
||||||
|
expect(result.current.choice?.names).toEqual({ 13: 'Ponte di Rialto' });
|
||||||
|
expect(onTakenOver).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-004: the route goes along only when switched on, kept as previewed', async () => {
|
||||||
|
const { result, onKept, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
|
||||||
|
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
|
||||||
|
|
||||||
|
act(() => result.current.route.toggle());
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
|
||||||
|
expect(onKept).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-005: a route that fails after the drafts keeps the dialog open to keep the route alone', async () => {
|
||||||
|
const { result, onTakenOver, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
|
||||||
|
repo.save.mockRejectedValueOnce(httpError('recording_changed'));
|
||||||
|
|
||||||
|
act(() => result.current.route.toggle());
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
|
||||||
|
expect(onTakenOver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.outcome?.kind).toBe('route');
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
|
||||||
|
await act(async () => result.current.retryRoute());
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||||
|
expect(repo.takeOverDay).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-006: a day whose route is kept offers no second copy, and stays held already are announced', async () => {
|
||||||
|
repo.takeOverDay.mockResolvedValue({ entries: [], alreadyTaken: [11, 12], skipped: [] });
|
||||||
|
const { result } = setup({ storedRoute: buildRecordedDay() });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
await waitFor(() => expect(result.current.route.offer).toBe('kept'));
|
||||||
|
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
expect(toast.info).toHaveBeenCalledWith('2 stays were taken over already. Nothing was written twice.');
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-007: nothing chosen holds the take-over back, and a review that fails says why', async () => {
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(result.current.choice).not.toBeNull());
|
||||||
|
act(() => {
|
||||||
|
for (const id of [10, 11, 12, 13]) result.current.toggle(id);
|
||||||
|
});
|
||||||
|
expect(result.current.problem).toBe('nothing');
|
||||||
|
await act(async () => result.current.submit());
|
||||||
|
expect(repo.takeOverDay).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
repo.reviewDay.mockRejectedValue(httpError('unreachable'));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-DAYIMPORT-008: today is marked as still running, a past day is not (JRN-03)', async () => {
|
||||||
|
const past = setup();
|
||||||
|
await waitFor(() => expect(past.result.current.loading).toBe(false));
|
||||||
|
expect(past.result.current.running).toBe(false);
|
||||||
|
past.unmount();
|
||||||
|
|
||||||
|
const today = setup({ date: localIsoDate() });
|
||||||
|
await waitFor(() => expect(today.result.current.loading).toBe(false));
|
||||||
|
expect(today.result.current.running).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type {
|
||||||
|
JourneyDayImportResult,
|
||||||
|
JourneyDayStay,
|
||||||
|
JourneyDayStays,
|
||||||
|
JourneyRecordedDay,
|
||||||
|
JourneyRecordingPreview,
|
||||||
|
JourneyTrack,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
|
||||||
|
import { localIsoDate } from '../../utils/localDate';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
import {
|
||||||
|
anchorOf,
|
||||||
|
groupsOf,
|
||||||
|
initialChoice,
|
||||||
|
isTakeable,
|
||||||
|
nameOf,
|
||||||
|
problemOf,
|
||||||
|
rename,
|
||||||
|
requestOf,
|
||||||
|
toggleJoin,
|
||||||
|
toggleStay,
|
||||||
|
type DayImportChoice,
|
||||||
|
} from './journeyDayImportModel';
|
||||||
|
import { recordingErrorText } from './journeyRecordingModel';
|
||||||
|
import { useJourneyRecordingDay } from './useJourneyRecordingDay';
|
||||||
|
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
|
||||||
|
|
||||||
|
/** What the desktop dialog and the phone sheet are opened with. */
|
||||||
|
export interface JourneyDayImportViewProps {
|
||||||
|
journeyId: number;
|
||||||
|
journeyTitle: string;
|
||||||
|
date: string;
|
||||||
|
/** The colour of the day's pins on the journey map, so its stays read as that day. */
|
||||||
|
dayColor: string;
|
||||||
|
/** The route the journey keeps for the day, if any. */
|
||||||
|
storedRoute: JourneyRecordedDay | undefined;
|
||||||
|
/** Drafts were written: the journey and the stay list are read again. */
|
||||||
|
onTakenOver: () => void;
|
||||||
|
onKept: (day: JourneyRecordedDay) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
/** The caller's own preparation of the journey's days (JRN-03), for editors too; absent while it is off or unread. */
|
||||||
|
prep?: JourneyRecordingPrepState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something went wrong on the way. `failed`: nothing was taken over, the list
|
||||||
|
* shows what the journal holds now and sending again writes nothing twice.
|
||||||
|
* `route`: the drafts are in, only the route was not kept.
|
||||||
|
*/
|
||||||
|
type Outcome = { kind: 'failed' | 'route'; reason: string };
|
||||||
|
|
||||||
|
type RouteOffer = 'loading' | 'kept' | 'keep' | 'differs' | 'none';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
/** The map marker id of a stay, so a pin and its row find each other. */
|
||||||
|
export function stayMarkerId(suggestionId: number): string {
|
||||||
|
return `stay-${suggestionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day's stays from the traveller's own Dawarich and what they chose of
|
||||||
|
* them. A reload of the same day keeps the choice, so names and joins survive
|
||||||
|
* a failed attempt; the last answer stays shown while the next one is read.
|
||||||
|
*/
|
||||||
|
function useDayReview(journeyId: number, date: string) {
|
||||||
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
const forDay = `${journeyId}|${date}`;
|
||||||
|
const requestKey = `${forDay}|${reloadToken}`;
|
||||||
|
const [answer, setAnswer] = useState<{ key: string; review: JourneyDayStays | null; failure: unknown }>(NO_ANSWER);
|
||||||
|
const [chosen, setChosen] = useState<{ forDay: string; choice: DayImportChoice } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
journeyRecordingsRepo
|
||||||
|
.reviewDay(journeyId, date, controller.signal)
|
||||||
|
.then((review) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAnswer({ key: requestKey, review, failure: null });
|
||||||
|
setChosen((prev) => (prev?.forDay === forDay ? prev : { forDay, choice: initialChoice(review.stays) }));
|
||||||
|
})
|
||||||
|
.catch((failure: unknown) => {
|
||||||
|
if (!cancelled) setAnswer({ key: requestKey, review: null, failure });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [journeyId, date, forDay, requestKey]);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(change: (choice: DayImportChoice) => DayImportChoice) =>
|
||||||
|
setChosen((prev) => (prev ? { ...prev, choice: change(prev.choice) } : prev)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
review: answer.review,
|
||||||
|
loading: answer.key !== requestKey,
|
||||||
|
failure: answer.key === requestKey ? answer.failure : null,
|
||||||
|
choice: chosen?.forDay === forDay ? chosen.choice : null,
|
||||||
|
update,
|
||||||
|
reload: useCallback(() => setReloadToken((n) => n + 1), []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the dialog offers for the day's route: kept already, kept in another version, to keep, or nothing recorded. */
|
||||||
|
function routeOfferOf(
|
||||||
|
stored: JourneyRecordedDay | undefined,
|
||||||
|
route: ReturnType<typeof useJourneyRecordingDay>
|
||||||
|
): RouteOffer {
|
||||||
|
if (stored) return route.action === 'replace' ? 'differs' : 'kept';
|
||||||
|
if (route.loading) return 'loading';
|
||||||
|
return route.action === 'keep' ? 'keep' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stays as numbered pins in the day's colour, unchosen ones in the map's neutral pin, and the route under them. */
|
||||||
|
function useDayMap(
|
||||||
|
stays: JourneyDayStay[],
|
||||||
|
choice: DayImportChoice | null,
|
||||||
|
date: string,
|
||||||
|
dayColor: string,
|
||||||
|
route: JourneyRecordedDay | JourneyRecordingPreview | null
|
||||||
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = useMemo(
|
||||||
|
() =>
|
||||||
|
stays.flatMap((stay, index) =>
|
||||||
|
stay.lat === null || stay.lng === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: stayMarkerId(stay.suggestionId),
|
||||||
|
lat: stay.lat,
|
||||||
|
lng: stay.lng,
|
||||||
|
title: choice ? nameOf(choice, stay) : stay.name,
|
||||||
|
entry_date: date,
|
||||||
|
dayColor: choice?.selected.has(stay.suggestionId) && isTakeable(stay) ? dayColor : undefined,
|
||||||
|
dayLabel: index + 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
[stays, choice, date, dayColor]
|
||||||
|
);
|
||||||
|
const tracks = useMemo<JourneyTrack[]>(
|
||||||
|
() =>
|
||||||
|
(route?.segments ?? []).map((segment, index) => ({
|
||||||
|
place_id: -(index + 1),
|
||||||
|
trip_id: 0,
|
||||||
|
name: t('journey.recording.scope'),
|
||||||
|
color: dayColor,
|
||||||
|
points: segment.points,
|
||||||
|
kind: 'day_recording',
|
||||||
|
})),
|
||||||
|
[route, dayColor, t]
|
||||||
|
);
|
||||||
|
return { entries, tracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The take-over and, when asked, the route after it. A take-over that fails
|
||||||
|
* leaves nothing written and reads the day again; a route that fails after
|
||||||
|
* the drafts are in keeps the dialog open to try the route alone.
|
||||||
|
*/
|
||||||
|
function useDayTakeOver({
|
||||||
|
journeyId,
|
||||||
|
date,
|
||||||
|
preview,
|
||||||
|
onTakenOver,
|
||||||
|
onKept,
|
||||||
|
onClose,
|
||||||
|
reload,
|
||||||
|
}: {
|
||||||
|
journeyId: number;
|
||||||
|
date: string;
|
||||||
|
preview: JourneyRecordingPreview | null;
|
||||||
|
onTakenOver: () => void;
|
||||||
|
onKept: (day: JourneyRecordedDay) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
reload: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const [busy, setBusy] = useState<'submit' | 'route' | null>(null);
|
||||||
|
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||||
|
|
||||||
|
const saveRoute = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!preview?.contentHash) return true;
|
||||||
|
try {
|
||||||
|
const saved = await journeyRecordingsRepo.save(journeyId, date, {
|
||||||
|
utcOffsetMinutes: preview.utcOffsetMinutes,
|
||||||
|
expectedHash: preview.contentHash,
|
||||||
|
});
|
||||||
|
onKept(saved.day);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setOutcome({ kind: 'route', reason: recordingErrorText(err, t) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [preview, journeyId, date, onKept, t]);
|
||||||
|
|
||||||
|
const announce = useCallback(
|
||||||
|
(result: JourneyDayImportResult) => {
|
||||||
|
if (result.entries.length > 0) toast.success(t('journey.dayImport.done', { count: result.entries.length }));
|
||||||
|
if (result.alreadyTaken.length > 0) {
|
||||||
|
toast.info(t('journey.dayImport.alreadyTaken', { count: result.alreadyTaken.length }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async (request: Parameters<typeof journeyRecordingsRepo.takeOverDay>[2], withRoute: boolean) => {
|
||||||
|
setBusy('submit');
|
||||||
|
setOutcome(null);
|
||||||
|
try {
|
||||||
|
announce(await journeyRecordingsRepo.takeOverDay(journeyId, date, request));
|
||||||
|
onTakenOver();
|
||||||
|
if (!withRoute || (await saveRoute())) onClose();
|
||||||
|
else reload();
|
||||||
|
} catch (err) {
|
||||||
|
setOutcome({ kind: 'failed', reason: recordingErrorText(err, t) });
|
||||||
|
// The answer may be lost after the write: read again, so the list says what the journal holds.
|
||||||
|
reload();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[journeyId, date, announce, onTakenOver, saveRoute, onClose, reload, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryRoute = useCallback(async () => {
|
||||||
|
setBusy('route');
|
||||||
|
setOutcome(null);
|
||||||
|
const kept = await saveRoute();
|
||||||
|
setBusy(null);
|
||||||
|
if (!kept) return;
|
||||||
|
toast.success(t('journey.recording.keptToast'));
|
||||||
|
onClose();
|
||||||
|
}, [saveRoute, toast, t, onClose]);
|
||||||
|
|
||||||
|
return { busy, outcome, submit, retryRoute };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking a recorded day into the journal with its stays (JRN-02), for the
|
||||||
|
* desktop dialog and the phone sheet alike: the day's stays read from the
|
||||||
|
* traveller's own Dawarich, the choice of which become drafts under which
|
||||||
|
* names and in which groups, the day's route on the same map, and the
|
||||||
|
* take-over itself. Nothing is written before the button; cancelling leaves
|
||||||
|
* everything as it was.
|
||||||
|
*/
|
||||||
|
export function useJourneyDayImport({
|
||||||
|
journeyId,
|
||||||
|
date,
|
||||||
|
dayColor,
|
||||||
|
storedRoute,
|
||||||
|
onTakenOver,
|
||||||
|
onKept,
|
||||||
|
onClose,
|
||||||
|
}: Omit<JourneyDayImportViewProps, 'journeyTitle' | 'prep'>) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const day = useDayReview(journeyId, date);
|
||||||
|
const route = useJourneyRecordingDay({ journeyId, date, onKept, onRemoved: noop, onClose: noop });
|
||||||
|
const [keepRoute, setKeepRoute] = useState(false);
|
||||||
|
const [activeStay, setActiveStay] = useState<number | null>(null);
|
||||||
|
const { choice, update } = day;
|
||||||
|
const stays = useMemo(() => day.review?.stays ?? [], [day.review]);
|
||||||
|
const reportDefault = t('journey.dayImport.reportDefault', {
|
||||||
|
date: new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
|
||||||
|
});
|
||||||
|
const routeOffer = routeOfferOf(storedRoute, route);
|
||||||
|
const map = useDayMap(
|
||||||
|
stays,
|
||||||
|
choice,
|
||||||
|
date,
|
||||||
|
dayColor,
|
||||||
|
storedRoute ?? (route.preview?.contentHash ? route.preview : null)
|
||||||
|
);
|
||||||
|
const takeOver = useDayTakeOver({
|
||||||
|
journeyId,
|
||||||
|
date,
|
||||||
|
preview: route.preview,
|
||||||
|
onTakenOver,
|
||||||
|
onKept,
|
||||||
|
onClose,
|
||||||
|
reload: day.reload,
|
||||||
|
});
|
||||||
|
const groups = useMemo(() => (choice ? groupsOf(choice, stays) : []), [choice, stays]);
|
||||||
|
const problem = choice ? problemOf(choice, stays, reportDefault) : 'nothing';
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
review: day.review,
|
||||||
|
loading: day.loading,
|
||||||
|
/** The day is today: stays recorded later still arrive. */
|
||||||
|
running: date === localIsoDate(),
|
||||||
|
/** Translated; null while nothing failed. */
|
||||||
|
error: day.failure === null ? null : recordingErrorText(day.failure, t),
|
||||||
|
retry: day.reload,
|
||||||
|
choice,
|
||||||
|
stays,
|
||||||
|
groups,
|
||||||
|
problem,
|
||||||
|
reportDefault,
|
||||||
|
skipCount: stays.filter((stay) => stay.status === 'open' && !choice?.selected.has(stay.suggestionId)).length,
|
||||||
|
activeStay,
|
||||||
|
setActiveStay,
|
||||||
|
mapEntries: map.entries,
|
||||||
|
mapTracks: map.tracks,
|
||||||
|
route: {
|
||||||
|
offer: routeOffer,
|
||||||
|
distanceMeters: (storedRoute ?? route.preview)?.stats.distanceMeters ?? null,
|
||||||
|
keep: keepRoute,
|
||||||
|
toggle: () => setKeepRoute((on) => !on),
|
||||||
|
},
|
||||||
|
busy: takeOver.busy,
|
||||||
|
outcome: takeOver.outcome,
|
||||||
|
submit: () => {
|
||||||
|
if (choice && !problem) {
|
||||||
|
void takeOver.submit(requestOf(choice, stays, reportDefault), keepRoute && routeOffer === 'keep');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retryRoute: () => void takeOver.retryRoute(),
|
||||||
|
toggle: (id: number) => update((c) => toggleStay(c, id)),
|
||||||
|
join: (id: number) => update((c) => toggleJoin(c, id)),
|
||||||
|
rename: (stay: JourneyDayStay, name: string) => update((c) => rename(c, stay, name)),
|
||||||
|
setMode: (mode: DayImportChoice['mode']) => update((c) => ({ ...c, mode })),
|
||||||
|
setTitle: (key: number, title: string) => update((c) => ({ ...c, titles: { ...c.titles, [key]: title } })),
|
||||||
|
setAnchor: (key: number, id: number | null) => update((c) => ({ ...c, anchors: { ...c.anchors, [key]: id } })),
|
||||||
|
anchorOf: (key: number) => {
|
||||||
|
const group = groups.find((g) => g.key === key);
|
||||||
|
return group && choice ? anchorOf(choice, group) : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JourneyDayImportState = ReturnType<typeof useJourneyDayImport>;
|
||||||
|
|
||||||
|
const NO_ANSWER = { key: '', review: null, failure: null };
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// FE-JOURNEY-MAPLAYERS-HOOK-001 to FE-JOURNEY-MAPLAYERS-HOOK-005
|
||||||
|
//
|
||||||
|
// JRN-04: the journey map's layers as the desktop page, the phone screen and
|
||||||
|
// the shared page use them: read lazily, switched per reader, a failed source
|
||||||
|
// on its own.
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { journeyMapRepo } from '../../repo/journeyMapRepo';
|
||||||
|
import { useJourneyMapLayers } from './useJourneyMapLayers';
|
||||||
|
|
||||||
|
vi.mock('../../i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, p?: Record<string, string>) => (p?.date ? `${k}:${p.date}` : k),
|
||||||
|
locale: 'en',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PLACE: JourneyMapPlace = {
|
||||||
|
place_id: 4,
|
||||||
|
trip_id: 2,
|
||||||
|
trip_title: 'Venice',
|
||||||
|
name: 'Piazza San Marco',
|
||||||
|
lat: 45.434,
|
||||||
|
lng: 12.3388,
|
||||||
|
days: ['2026-05-14'],
|
||||||
|
time: null,
|
||||||
|
category_color: null,
|
||||||
|
category_icon: null,
|
||||||
|
entry_id: null,
|
||||||
|
};
|
||||||
|
const ROUTE: JourneyTrack = {
|
||||||
|
place_id: 4,
|
||||||
|
trip_id: 2,
|
||||||
|
name: 'Ridge',
|
||||||
|
color: null,
|
||||||
|
points: [
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
kind: 'trip_gpx',
|
||||||
|
};
|
||||||
|
const NO_ENTRIES: ReadonlySet<string> = new Set();
|
||||||
|
const colorOfDay = () => '#14b8a6';
|
||||||
|
const dateOfEntry = () => '2026-05-14';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function journeyMap(journeyId: number | null = 7, recordedTracks: JourneyTrack[] = []) {
|
||||||
|
return renderHook(() =>
|
||||||
|
useJourneyMapLayers({
|
||||||
|
source: journeyId === null ? null : { journeyId },
|
||||||
|
entryIdsOnMap: NO_ENTRIES,
|
||||||
|
photoCount: 2,
|
||||||
|
recordedTracks,
|
||||||
|
colorOfDay,
|
||||||
|
dateOfEntry,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useJourneyMapLayers', () => {
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-HOOK-001: reads the journey layers once it is loaded and draws the plan with its routes', async () => {
|
||||||
|
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
|
||||||
|
places: { status: 'ok', items: [PLACE] },
|
||||||
|
routes: { status: 'ok', items: [ROUTE] },
|
||||||
|
stays: { status: 'ok', items: [] },
|
||||||
|
});
|
||||||
|
const { result } = journeyMap();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.marks).toHaveLength(1));
|
||||||
|
expect(layers).toHaveBeenCalledWith(7, expect.any(AbortSignal));
|
||||||
|
expect(result.current.tracks).toEqual([ROUTE]);
|
||||||
|
expect(result.current.controls.find((c) => c.layer === 'plan')).toEqual({
|
||||||
|
layer: 'plan',
|
||||||
|
count: 1,
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
expect(result.current.controls.find((c) => c.layer === 'photos')).toEqual({
|
||||||
|
layer: 'photos',
|
||||||
|
count: 2,
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-HOOK-002: reads nothing before the journey is loaded, nor while plan and entries are off', async () => {
|
||||||
|
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
|
||||||
|
places: { status: 'ok', items: [] },
|
||||||
|
routes: { status: 'ok', items: [] },
|
||||||
|
stays: { status: 'ok', items: [] },
|
||||||
|
});
|
||||||
|
journeyMap(null);
|
||||||
|
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
|
||||||
|
const { result } = journeyMap(8);
|
||||||
|
|
||||||
|
expect(result.current.visibility).toMatchObject({ plan: false, entries: false, recorded: true, photos: true });
|
||||||
|
expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('idle');
|
||||||
|
await act(async () => {});
|
||||||
|
expect(layers).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => result.current.toggle('plan'));
|
||||||
|
await waitFor(() => expect(layers).toHaveBeenCalledWith(8, expect.any(AbortSignal)));
|
||||||
|
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toMatchObject({
|
||||||
|
plan: true,
|
||||||
|
entries: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
|
||||||
|
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
|
||||||
|
const recorded: JourneyTrack = {
|
||||||
|
place_id: -100001,
|
||||||
|
trip_id: 0,
|
||||||
|
name: 'Day 1',
|
||||||
|
color: '#000000',
|
||||||
|
points: [
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
kind: 'day_recording',
|
||||||
|
};
|
||||||
|
const { result } = journeyMap(7, [recorded]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('unavailable'));
|
||||||
|
expect(result.current.controls.find((c) => c.layer === 'entries')?.state).toBe('unavailable');
|
||||||
|
expect(result.current.controls.find((c) => c.layer === 'recorded')).toEqual({
|
||||||
|
layer: 'recorded',
|
||||||
|
count: 1,
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
expect(result.current.tracks).toEqual([recorded]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-HOOK-004: a shared page draws the recorded days a link shares, each piece apart, and offers no plan it withholds', async () => {
|
||||||
|
vi.spyOn(journeyMapRepo, 'publicLayers').mockResolvedValue({
|
||||||
|
places: { status: 'withheld' },
|
||||||
|
routes: { status: 'withheld' },
|
||||||
|
stays: { status: 'ok', items: [] },
|
||||||
|
recordings: {
|
||||||
|
status: 'ok',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
date: '2026-05-14',
|
||||||
|
pieces: [
|
||||||
|
[
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[3, 3],
|
||||||
|
[4, 4],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
distance_m: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useJourneyMapLayers({
|
||||||
|
source: { token: 'tok' },
|
||||||
|
entryIdsOnMap: NO_ENTRIES,
|
||||||
|
photoCount: 0,
|
||||||
|
colorOfDay,
|
||||||
|
dateOfEntry,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.tracks).toHaveLength(2));
|
||||||
|
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
|
||||||
|
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPLAYERS-HOOK-005: switching the plan off takes its marks away without reading again', async () => {
|
||||||
|
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
|
||||||
|
places: { status: 'ok', items: [PLACE] },
|
||||||
|
routes: { status: 'ok', items: [] },
|
||||||
|
stays: { status: 'ok', items: [] },
|
||||||
|
});
|
||||||
|
const { result } = journeyMap();
|
||||||
|
await waitFor(() => expect(result.current.marks).toHaveLength(1));
|
||||||
|
|
||||||
|
act(() => result.current.toggle('plan'));
|
||||||
|
expect(result.current.marks).toHaveLength(0);
|
||||||
|
expect(result.current.visibility.plan).toBe(false);
|
||||||
|
expect(layers).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import type { JourneyTrack } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { journeyMapRepo } from '../../repo/journeyMapRepo';
|
||||||
|
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
|
||||||
|
import {
|
||||||
|
ALL_VISIBLE,
|
||||||
|
buildControls,
|
||||||
|
buildMarks,
|
||||||
|
itemsOf,
|
||||||
|
layersStorageKey,
|
||||||
|
readStoredLayers,
|
||||||
|
sourcesOf,
|
||||||
|
writeStoredLayers,
|
||||||
|
type JourneyMapAnswer,
|
||||||
|
type JourneyMapLayer,
|
||||||
|
type JourneyMapLayerControl,
|
||||||
|
type JourneyMapVisibility,
|
||||||
|
} from './journeyMapLayers';
|
||||||
|
import type { JourneyMapMark } from './journeyMapMarks';
|
||||||
|
|
||||||
|
/** Whose map it is: a journey its reader belongs to, or one a share link opens. */
|
||||||
|
type JourneyMapSource = { journeyId: number } | { token: string };
|
||||||
|
|
||||||
|
export interface JourneyMapLayersView {
|
||||||
|
visibility: JourneyMapVisibility;
|
||||||
|
toggle: (layer: JourneyMapLayer) => void;
|
||||||
|
/** The switches the reader is offered: a layer a share link does not show is not among them. */
|
||||||
|
controls: JourneyMapLayerControl[];
|
||||||
|
/** The plan's routes and what was recorded, each kind hidden by its own switch on the map. */
|
||||||
|
tracks: JourneyTrack[];
|
||||||
|
marks: JourneyMapMark[];
|
||||||
|
/** Entry pins whose place the plan had too: they carry the plan's check. */
|
||||||
|
plannedEntryIds: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** Null until the journey the screen names is the one loaded. */
|
||||||
|
source: JourneyMapSource | null;
|
||||||
|
/** Changes when entries come or go, so a place's entry and a report's stays follow. */
|
||||||
|
refreshKey?: string;
|
||||||
|
/** The entries that have a pin on the map. */
|
||||||
|
entryIdsOnMap: ReadonlySet<string>;
|
||||||
|
photoCount: number;
|
||||||
|
/** What was recorded that the screen already holds: the kept days and the live trail of a journey's people. */
|
||||||
|
recordedTracks?: JourneyTrack[];
|
||||||
|
/** How reading those went: a failed read is not a journey with nothing recorded. */
|
||||||
|
recordedRead?: 'loading' | 'ready' | 'unavailable';
|
||||||
|
/** The colour of the pins of a day, so a stay and a recorded day read with their day. */
|
||||||
|
colorOfDay: (date: string) => string | null;
|
||||||
|
/** The day an entry is on, for the colour of its stays. */
|
||||||
|
dateOfEntry: (entryId: number) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_TRACKS: JourneyTrack[] = [];
|
||||||
|
|
||||||
|
/** The reader's switches, kept per journey (or per link) in this browser. */
|
||||||
|
function useLayerSwitches(storageKey: string | null) {
|
||||||
|
const [stored, setStored] = useState<{ key: string | null; visibility: JourneyMapVisibility } | null>(null);
|
||||||
|
const visibility = useMemo(() => {
|
||||||
|
if (stored && stored.key === storageKey) return stored.visibility;
|
||||||
|
return storageKey ? readStoredLayers(storageKey) : ALL_VISIBLE;
|
||||||
|
}, [stored, storageKey]);
|
||||||
|
const toggle = useCallback(
|
||||||
|
(layer: JourneyMapLayer) => {
|
||||||
|
const next = { ...visibility, [layer]: !visibility[layer] };
|
||||||
|
setStored({ key: storageKey, visibility: next });
|
||||||
|
if (storageKey) writeStoredLayers(storageKey, next);
|
||||||
|
},
|
||||||
|
[visibility, storageKey]
|
||||||
|
);
|
||||||
|
return { visibility, toggle };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layers read from the server, once per `requestKey`. What was read for
|
||||||
|
* an earlier key of the same map stays until the new answer lands; null is a
|
||||||
|
* read that failed.
|
||||||
|
*/
|
||||||
|
function useLayersRead(
|
||||||
|
journeyId: number | null,
|
||||||
|
token: string | null,
|
||||||
|
requestKey: string | null,
|
||||||
|
sourceKey: string | null
|
||||||
|
) {
|
||||||
|
const [loaded, setLoaded] = useState<{ key: string; layers: JourneyMapAnswer | null } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestKey) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const read =
|
||||||
|
journeyId !== null
|
||||||
|
? journeyMapRepo.layers(journeyId, controller.signal)
|
||||||
|
: journeyMapRepo.publicLayers(token ?? '', controller.signal);
|
||||||
|
read
|
||||||
|
.then((layers) => setLoaded({ key: requestKey, layers }))
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) setLoaded({ key: requestKey, layers: null });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [requestKey, journeyId, token]);
|
||||||
|
const layers = loaded && sourceKey && loaded.key.startsWith(`${sourceKey}|`) ? loaded.layers : undefined;
|
||||||
|
return { layers, loading: !!requestKey && loaded?.key !== requestKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layers of a journey map (JRN-04) for the desktop page, the phone screen
|
||||||
|
* and the shared page alike: the reader's switches, kept per journey in this
|
||||||
|
* browser, the plan and the stays read from the server, and what the
|
||||||
|
* renderers draw from them. Nothing is read while neither the plan nor the
|
||||||
|
* entries are shown; a source that fails says so on its switch and the
|
||||||
|
* others keep drawing.
|
||||||
|
*/
|
||||||
|
export function useJourneyMapLayers({
|
||||||
|
source,
|
||||||
|
refreshKey = '',
|
||||||
|
entryIdsOnMap,
|
||||||
|
photoCount,
|
||||||
|
recordedTracks = NO_TRACKS,
|
||||||
|
recordedRead = 'ready',
|
||||||
|
colorOfDay,
|
||||||
|
dateOfEntry,
|
||||||
|
}: Options): JourneyMapLayersView {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
|
||||||
|
const token = source && 'token' in source ? source.token : null;
|
||||||
|
const storageKey =
|
||||||
|
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
|
||||||
|
const { visibility, toggle } = useLayerSwitches(storageKey);
|
||||||
|
|
||||||
|
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
|
||||||
|
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
|
||||||
|
const wanted = token !== null || (journeyId !== null && (visibility.plan || visibility.entries));
|
||||||
|
const requestKey = wanted && sourceKey ? `${sourceKey}|${refreshKey}` : null;
|
||||||
|
const { layers, loading } = useLayersRead(journeyId, token, requestKey, sourceKey);
|
||||||
|
const sources = useMemo(() => sourcesOf(layers), [layers]);
|
||||||
|
|
||||||
|
const places = useMemo(() => itemsOf(sources.places), [sources.places]);
|
||||||
|
const routes = useMemo(() => itemsOf(sources.routes), [sources.routes]);
|
||||||
|
const stays = useMemo(() => itemsOf(sources.stays), [sources.stays]);
|
||||||
|
|
||||||
|
const sharedRecorded = useMemo(
|
||||||
|
() =>
|
||||||
|
itemsOf(sources.recordings).flatMap((day, dayIndex) => {
|
||||||
|
const name = t('journey.recording.mapLine', {
|
||||||
|
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
|
||||||
|
});
|
||||||
|
const color = colorOfDay(day.date) ?? DAWARICH_TRAIL_COLOR;
|
||||||
|
return day.pieces.map((points, index): JourneyTrack => ({
|
||||||
|
// Negative and far from the live trail's ids, as the journey's own kept days.
|
||||||
|
place_id: -(100_000 + dayIndex * 1_000 + index),
|
||||||
|
trip_id: 0,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
points,
|
||||||
|
kind: 'day_recording',
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
[sources.recordings, colorOfDay, t, locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recorded = recordedTracks.length ? recordedTracks : sharedRecorded;
|
||||||
|
const tracks = useMemo(
|
||||||
|
() => (routes.length === 0 ? recorded : recorded.length === 0 ? routes : [...routes, ...recorded]),
|
||||||
|
[routes, recorded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stayColor = useCallback(
|
||||||
|
(entryId: number) => {
|
||||||
|
const date = dateOfEntry(entryId);
|
||||||
|
return date ? colorOfDay(date) : null;
|
||||||
|
},
|
||||||
|
[dateOfEntry, colorOfDay]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { marks, plannedEntryIds } = useMemo(
|
||||||
|
() => buildMarks({ places, stays, visibility, entryIdsOnMap, stayColor }),
|
||||||
|
[places, stays, visibility, entryIdsOnMap, stayColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
|
||||||
|
const controls = useMemo(
|
||||||
|
() =>
|
||||||
|
buildControls({
|
||||||
|
ownJourney: journeyId !== null,
|
||||||
|
sources,
|
||||||
|
loading,
|
||||||
|
recordedRead,
|
||||||
|
counts: {
|
||||||
|
places: places.length,
|
||||||
|
routes: routes.length,
|
||||||
|
recordedDays,
|
||||||
|
entries: entryIdsOnMap.size,
|
||||||
|
stays: stays.length,
|
||||||
|
photos: photoCount,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
journeyId,
|
||||||
|
sources,
|
||||||
|
loading,
|
||||||
|
recordedRead,
|
||||||
|
places.length,
|
||||||
|
routes.length,
|
||||||
|
recordedDays,
|
||||||
|
entryIdsOnMap.size,
|
||||||
|
stays.length,
|
||||||
|
photoCount,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { visibility, toggle, controls, tracks, marks, plannedEntryIds };
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// FE-JRN-RECORDING-DAY-001 to FE-JRN-RECORDING-DAY-008
|
||||||
|
//
|
||||||
|
// JRN-01: taking one day's recorded route over, for the desktop dialog and the
|
||||||
|
// phone sheet alike. Nothing is kept before the button, what is kept is the
|
||||||
|
// preview that was shown, and a recording that changed in between is shown
|
||||||
|
// again instead of kept unseen.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||||
|
import { OnlineOnlyError } from '../../repo/onlineOnly';
|
||||||
|
import { useJourneyRecordingDay } from './useJourneyRecordingDay';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const HASH = 'a'.repeat(64);
|
||||||
|
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const onKept = vi.fn();
|
||||||
|
const onRemoved = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
// Wrapped, so the strings are the English ones and not their keys.
|
||||||
|
const view = renderHook(
|
||||||
|
() => useJourneyRecordingDay({ journeyId: 7, date: '2026-05-14', onKept, onRemoved, onClose }),
|
||||||
|
{ wrapper: TranslationProvider }
|
||||||
|
);
|
||||||
|
return { ...view, onKept, onRemoved, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useJourneyRecordingDay', () => {
|
||||||
|
it('FE-JRN-RECORDING-DAY-001: reads the day in the zone Dawarich records in first, and offers to keep it', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
const { result } = setup();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(repo.preview).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
{
|
||||||
|
date: '2026-05-14',
|
||||||
|
utcOffsetMinutes: -new Date('2026-05-14T12:00:00').getTimezoneOffset(),
|
||||||
|
useSourceOffset: true,
|
||||||
|
},
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
|
expect(result.current.action).toBe('keep');
|
||||||
|
expect(result.current.zone).toBe(120);
|
||||||
|
expect(result.current.zoneOptions.find((o) => o.value === 120)?.badge).toBe('From Dawarich');
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-002: another zone reads the day again, cut in that zone', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ utcOffsetMinutes: -300, offsetSource: 'chosen' }));
|
||||||
|
act(() => result.current.chooseZone(-300));
|
||||||
|
await waitFor(() => expect(result.current.zone).toBe(-300));
|
||||||
|
expect(repo.preview).toHaveBeenLastCalledWith(
|
||||||
|
7,
|
||||||
|
{ date: '2026-05-14', utcOffsetMinutes: -300, useSourceOffset: false },
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-003: keeps what was previewed, in the zone it was cut in, then closes', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
const day = buildRecordedDay();
|
||||||
|
repo.save.mockResolvedValue({ day, outcome: 'created' });
|
||||||
|
const { result, onKept, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('keep'));
|
||||||
|
|
||||||
|
await act(() => result.current.save());
|
||||||
|
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
|
||||||
|
expect(onKept).toHaveBeenCalledWith(day);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Route kept in the journey');
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-004: replaces a different kept version only through its own action, and keeps nothing twice', async () => {
|
||||||
|
const stored = buildRecordedDay({ contentHash: 'b'.repeat(64) });
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ stored }));
|
||||||
|
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'replaced' });
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('replace'));
|
||||||
|
await act(() => result.current.save());
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', {
|
||||||
|
utcOffsetMinutes: 120,
|
||||||
|
expectedHash: HASH,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
repo.save.mockClear();
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('same'));
|
||||||
|
await act(() => result.current.save());
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-005: a recording that changed since the preview is read again and said so, not kept', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
repo.save.mockRejectedValue(httpError('recording_changed'));
|
||||||
|
const { result, onKept, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('keep'));
|
||||||
|
|
||||||
|
await act(() => result.current.save());
|
||||||
|
|
||||||
|
expect(result.current.changedSincePreview).toBe(true);
|
||||||
|
await waitFor(() => expect(repo.preview).toHaveBeenCalledTimes(2));
|
||||||
|
expect(onKept).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-008: a day someone else kept meanwhile is read again and offers Replace, not a Keep that fails', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview());
|
||||||
|
repo.save.mockRejectedValue(httpError('recording_exists'));
|
||||||
|
const { result, onKept, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('keep'));
|
||||||
|
|
||||||
|
repo.preview.mockResolvedValue(
|
||||||
|
buildRecordingPreview({ stored: buildRecordedDay({ contentHash: 'b'.repeat(64) }) })
|
||||||
|
);
|
||||||
|
await act(() => result.current.save());
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('This journey already keeps another recording of that day.');
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('replace'));
|
||||||
|
expect(repo.preview).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.current.changedSincePreview).toBe(false);
|
||||||
|
expect(onKept).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-006: says why the day could not be read, and what was recorded nothing of has nothing to keep', async () => {
|
||||||
|
repo.preview.mockRejectedValueOnce(httpError('not_connected'));
|
||||||
|
const { result } = setup();
|
||||||
|
await waitFor(() => expect(result.current.error).toBe('No Dawarich instance is connected yet.'));
|
||||||
|
expect(result.current.action).toBeNull();
|
||||||
|
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ contentHash: null, segments: [] }));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
await waitFor(() => expect(result.current.action).toBe('empty'));
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDING-DAY-007: drops the kept day, and a failed drop or keep says why and stays open', async () => {
|
||||||
|
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
|
||||||
|
repo.remove.mockResolvedValueOnce(undefined);
|
||||||
|
const { result, onRemoved, onClose } = setup();
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(() => result.current.remove());
|
||||||
|
expect(repo.remove).toHaveBeenCalledWith(7, '2026-05-14');
|
||||||
|
expect(onRemoved).toHaveBeenCalledWith('2026-05-14');
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
repo.remove.mockRejectedValueOnce(new OnlineOnlyError());
|
||||||
|
await act(() => result.current.remove());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('connection'));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
import { deviceOffsetOn, recordingErrorCode, recordingErrorText, zoneOptions } from './journeyRecordingModel';
|
||||||
|
|
||||||
|
/** What the desktop dialog and the phone sheet are opened with. */
|
||||||
|
export interface JourneyRecordingViewProps {
|
||||||
|
journeyId: number;
|
||||||
|
journeyTitle: string;
|
||||||
|
date: string;
|
||||||
|
/** What the journey keeps for the day, if anything. */
|
||||||
|
stored: JourneyRecordedDay | undefined;
|
||||||
|
onKept: (day: JourneyRecordedDay) => void;
|
||||||
|
onRemoved: (date: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the dialog offers for the day.
|
||||||
|
* `keep`: nothing kept yet. `replace`: a different version is kept. `same`: this
|
||||||
|
* very recording is kept already. `empty`: nothing was recorded in this zone.
|
||||||
|
*/
|
||||||
|
type RecordingAction = 'keep' | 'replace' | 'same' | 'empty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taking one day's recorded route over into a journey (JRN-01), for the
|
||||||
|
* desktop dialog and the phone sheet alike.
|
||||||
|
*
|
||||||
|
* The preview is read when the dialog opens, cut in Dawarich's own zone where
|
||||||
|
* it reveals one and in this device's otherwise, and again whenever another
|
||||||
|
* zone is picked. Nothing is kept before the traveller says so, and what is
|
||||||
|
* kept is the preview they saw: when Dawarich changed in between, the preview
|
||||||
|
* is read again and says why instead of keeping something unseen.
|
||||||
|
*/
|
||||||
|
export function useJourneyRecordingDay({
|
||||||
|
journeyId,
|
||||||
|
date,
|
||||||
|
onKept,
|
||||||
|
onRemoved,
|
||||||
|
onClose,
|
||||||
|
}: Omit<JourneyRecordingViewProps, 'journeyTitle' | 'stored'>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const deviceOffset = useMemo(() => deviceOffsetOn(date), [date]);
|
||||||
|
const [zone, setZone] = useState({ minutes: deviceOffset, useSource: true });
|
||||||
|
const [changedSincePreview, setChangedSincePreview] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<'save' | 'remove' | null>(null);
|
||||||
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
// The answer remembers which request it answers: while it is not the one
|
||||||
|
// asked last, the day is loading, without a flag reset inside the effect.
|
||||||
|
const [answer, setAnswer] = useState<{ key: string; preview: JourneyRecordingPreview | null; failure: unknown }>(
|
||||||
|
NO_ANSWER
|
||||||
|
);
|
||||||
|
const requestKey = `${journeyId}|${date}|${zone.minutes}|${zone.useSource}|${reloadToken}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
journeyRecordingsRepo
|
||||||
|
.preview(journeyId, { date, utcOffsetMinutes: zone.minutes, useSourceOffset: zone.useSource }, controller.signal)
|
||||||
|
.then((preview) => {
|
||||||
|
if (!cancelled) setAnswer({ key: requestKey, preview, failure: null });
|
||||||
|
})
|
||||||
|
.catch((failure: unknown) => {
|
||||||
|
if (!cancelled) setAnswer({ key: requestKey, preview: null, failure });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [journeyId, date, zone, requestKey]);
|
||||||
|
|
||||||
|
const loading = answer.key !== requestKey;
|
||||||
|
const preview = loading ? null : answer.preview;
|
||||||
|
const failure = loading ? null : answer.failure;
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadToken((n) => n + 1), []);
|
||||||
|
const chooseZone = useCallback((minutes: number) => {
|
||||||
|
setChangedSincePreview(false);
|
||||||
|
setZone({ minutes, useSource: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const action: RecordingAction | null = !preview
|
||||||
|
? null
|
||||||
|
: preview.contentHash === null
|
||||||
|
? 'empty'
|
||||||
|
: preview.sameAsStored
|
||||||
|
? 'same'
|
||||||
|
: preview.stored
|
||||||
|
? 'replace'
|
||||||
|
: 'keep';
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!preview?.contentHash || (action !== 'keep' && action !== 'replace')) return;
|
||||||
|
setBusy('save');
|
||||||
|
setChangedSincePreview(false);
|
||||||
|
try {
|
||||||
|
const result = await journeyRecordingsRepo.save(journeyId, date, {
|
||||||
|
utcOffsetMinutes: preview.utcOffsetMinutes,
|
||||||
|
expectedHash: preview.contentHash,
|
||||||
|
...(action === 'replace' ? { replace: true } : {}),
|
||||||
|
});
|
||||||
|
onKept(result.day);
|
||||||
|
toast.success(t(action === 'replace' ? 'journey.recording.replacedToast' : 'journey.recording.keptToast'));
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const code = recordingErrorCode(err);
|
||||||
|
if (code === 'recording_changed') {
|
||||||
|
setChangedSincePreview(true);
|
||||||
|
retry();
|
||||||
|
} else {
|
||||||
|
toast.error(recordingErrorText(err, t));
|
||||||
|
// Someone kept the day meanwhile: read it again so the dialog shows
|
||||||
|
// their version and offers Replace instead of a Keep that cannot work.
|
||||||
|
if (code === 'recording_exists') retry();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}, [preview, action, journeyId, date, onKept, onClose, toast, t, retry]);
|
||||||
|
|
||||||
|
const remove = useCallback(async () => {
|
||||||
|
setBusy('remove');
|
||||||
|
try {
|
||||||
|
await journeyRecordingsRepo.remove(journeyId, date);
|
||||||
|
onRemoved(date);
|
||||||
|
toast.success(t('journey.recording.removedToast'));
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(recordingErrorText(err, t));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}, [journeyId, date, onRemoved, onClose, toast, t]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
loading,
|
||||||
|
/** Translated; null while nothing failed. */
|
||||||
|
error: failure === null ? null : recordingErrorText(failure, t),
|
||||||
|
action,
|
||||||
|
busy,
|
||||||
|
changedSincePreview,
|
||||||
|
zone: zone.useSource && preview ? preview.utcOffsetMinutes : zone.minutes,
|
||||||
|
zoneOptions: zoneOptions(t, preview?.sourceOffsetMinutes ?? null, deviceOffset),
|
||||||
|
chooseZone,
|
||||||
|
retry,
|
||||||
|
save,
|
||||||
|
remove,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_ANSWER = { key: '', preview: null, failure: null };
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// FE-JRN-PREP-HOOK-001 to FE-JRN-PREP-HOOK-007
|
||||||
|
//
|
||||||
|
// JRN-03: a journey preparing its recorded days, as the journey page, the
|
||||||
|
// phone screen and both settings shells read it: the caller's status, the
|
||||||
|
// owner's switch that fetches at once when turned on, a fetch by hand, and
|
||||||
|
// where each day of the linked trips stands.
|
||||||
|
import type { JourneyRecordingPrep } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { useJourneyStore } from '../../store/journeyStore';
|
||||||
|
import { useJourneyRecordingPrep, type JourneyRecordingPrepOptions } from './useJourneyRecordingPrep';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ prep: vi.fn() }));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
const dawarich = vi.hoisted(() => ({ syncNow: vi.fn() }));
|
||||||
|
vi.mock('../../repo/dawarichRepo', () => ({ dawarichRepo: dawarich }));
|
||||||
|
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const updateJourney = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
|
||||||
|
const PERIOD = new Set(['2026-05-14', '2026-05-15']);
|
||||||
|
|
||||||
|
function status(over: Partial<JourneyRecordingPrep> = {}): JourneyRecordingPrep {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
connection: 'ready',
|
||||||
|
lastAttemptAt: '2026-05-16T08:00:00.000Z',
|
||||||
|
lastSuccessAt: '2026-05-16T08:00:00.000Z',
|
||||||
|
lastError: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(over: Partial<JourneyRecordingPrepOptions> = {}): JourneyRecordingPrepOptions {
|
||||||
|
return {
|
||||||
|
journeyId: 7,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
periodDates: PERIOD,
|
||||||
|
onSwitched: vi.fn(),
|
||||||
|
onFetched: vi.fn(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo.prep.mockReset().mockResolvedValue(status());
|
||||||
|
dawarich.syncNow.mockReset().mockResolvedValue({ state: 'ok', created: 0, updated: 0, missing: 0 });
|
||||||
|
updateJourney.mockClear();
|
||||||
|
toast.error.mockClear();
|
||||||
|
toast.info.mockClear();
|
||||||
|
useJourneyStore.setState({ updateJourney });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useJourneyRecordingPrep', () => {
|
||||||
|
it('FE-JRN-PREP-HOOK-001: reads the caller status and answers for each day of the linked trips', async () => {
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.prep).not.toBeNull());
|
||||||
|
expect(repo.prep).toHaveBeenCalledWith(7, expect.any(AbortSignal));
|
||||||
|
expect(result.current.statusLine?.tone).toBe('muted');
|
||||||
|
expect(result.current.canFetch).toBe(true);
|
||||||
|
expect(result.current.dayPrep('2026-05-14')?.state).toBe('complete');
|
||||||
|
expect(result.current.dayPrep('2026-05-20')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-002: reads nothing for a reader or without the addon', async () => {
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(options({ active: false })));
|
||||||
|
await act(async () => {});
|
||||||
|
expect(repo.prep).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.prep).toBeNull();
|
||||||
|
expect(result.current.statusLine).toBeNull();
|
||||||
|
expect(result.current.canFetch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-003: switching on saves the journey and fetches at once; switching off only saves', async () => {
|
||||||
|
repo.prep.mockResolvedValue(status({ enabled: false }));
|
||||||
|
const opts = options({ enabled: false });
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
|
||||||
|
await waitFor(() => expect(result.current.prep).not.toBeNull());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setEnabled(true);
|
||||||
|
});
|
||||||
|
expect(updateJourney).toHaveBeenCalledWith(7, { auto_prepare_recordings: true });
|
||||||
|
expect(opts.onSwitched).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dawarich.syncNow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(opts.onFetched).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setEnabled(false);
|
||||||
|
});
|
||||||
|
expect(updateJourney).toHaveBeenLastCalledWith(7, { auto_prepare_recordings: false });
|
||||||
|
expect(dawarich.syncNow).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-004: a switch that fails to save says so and fetches nothing', async () => {
|
||||||
|
updateJourney.mockRejectedValueOnce(new Error('down'));
|
||||||
|
const opts = options({ enabled: false });
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
|
||||||
|
await waitFor(() => expect(result.current.prep).not.toBeNull());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setEnabled(true);
|
||||||
|
});
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('journey.settings.saveFailed');
|
||||||
|
expect(opts.onSwitched).not.toHaveBeenCalled();
|
||||||
|
expect(dawarich.syncNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-005: a fetch by hand reads the status again, and one that cannot reach Dawarich says so', async () => {
|
||||||
|
const opts = options();
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
|
||||||
|
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchNow();
|
||||||
|
});
|
||||||
|
expect(opts.onFetched).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(2));
|
||||||
|
expect(result.current.fetching).toBe(false);
|
||||||
|
|
||||||
|
dawarich.syncNow.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchNow();
|
||||||
|
});
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('journey.prep.fetchFailed');
|
||||||
|
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-006: a fetch while the background run is busy says so instead of looking like nothing happened', async () => {
|
||||||
|
dawarich.syncNow.mockResolvedValue({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true });
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
|
||||||
|
await waitFor(() => expect(result.current.prep).not.toBeNull());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchNow();
|
||||||
|
});
|
||||||
|
expect(toast.info).toHaveBeenCalledWith('journey.prep.alreadyRunning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-PREP-HOOK-007: a journey whose trips all ended out of reach marks no day and offers no fetch', async () => {
|
||||||
|
repo.prep.mockResolvedValue(status({ periods: [] }));
|
||||||
|
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
|
||||||
|
await waitFor(() => expect(result.current.prep).not.toBeNull());
|
||||||
|
|
||||||
|
expect(result.current.dayPrep('2026-05-14')).toBeNull();
|
||||||
|
expect(result.current.canFetch).toBe(false);
|
||||||
|
expect(result.current.statusLine).toEqual({
|
||||||
|
text: 'journey.prep.outOfReach',
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { JourneyRecordingPrep } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { dawarichRepo } from '../../repo/dawarichRepo';
|
||||||
|
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
|
||||||
|
import { useJourneyStore } from '../../store/journeyStore';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
import {
|
||||||
|
dayPrepOf,
|
||||||
|
prepReachOf,
|
||||||
|
prepStatusLine,
|
||||||
|
type JourneyDayPrep,
|
||||||
|
type PrepStatusLine,
|
||||||
|
} from './journeyRecordingPrepModel';
|
||||||
|
|
||||||
|
export interface JourneyRecordingPrepOptions {
|
||||||
|
/** The journey the route names, or null while it loads. */
|
||||||
|
journeyId: number | null;
|
||||||
|
/** The journey's switch as last read, so a change reads the status again. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** An editor with the Dawarich addon on: nobody else has anything of their own prepared. */
|
||||||
|
active: boolean;
|
||||||
|
/** The dates of the linked trips; only those the server says its runs reach are prepared. */
|
||||||
|
periodDates: ReadonlySet<string>;
|
||||||
|
/** The switch was saved: the journey is read again. */
|
||||||
|
onSwitched: () => void;
|
||||||
|
/** A fetch by hand came back: the stays waiting at the days are read again. */
|
||||||
|
onFetched: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyRecordingPrepState {
|
||||||
|
/** Null until read, and for anyone `active` leaves out. */
|
||||||
|
prep: JourneyRecordingPrep | null;
|
||||||
|
saving: boolean;
|
||||||
|
/** A fetch asked for by hand is running. */
|
||||||
|
fetching: boolean;
|
||||||
|
/** The line under the switch in the settings and in the day review; null while it is off or not read yet. */
|
||||||
|
statusLine: PrepStatusLine | null;
|
||||||
|
/** Whether a fetch by hand would reach anything. */
|
||||||
|
canFetch: boolean;
|
||||||
|
setEnabled: (next: boolean) => Promise<void>;
|
||||||
|
fetchNow: () => Promise<void>;
|
||||||
|
dayPrep: (date: string) => JourneyDayPrep | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A journey preparing its recorded days (JRN-03), for the journey page, the
|
||||||
|
* phone screen, both settings shells and the day review: the caller's own
|
||||||
|
* status, the owner's switch, a fetch by hand and where each day stands.
|
||||||
|
* Switching it on fetches at once, so the days fill without waiting for the
|
||||||
|
* next background run.
|
||||||
|
*/
|
||||||
|
export function useJourneyRecordingPrep({
|
||||||
|
journeyId,
|
||||||
|
enabled,
|
||||||
|
active,
|
||||||
|
periodDates,
|
||||||
|
onSwitched,
|
||||||
|
onFetched,
|
||||||
|
}: JourneyRecordingPrepOptions): JourneyRecordingPrepState {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const updateJourney = useJourneyStore((s) => s.updateJourney);
|
||||||
|
// Held with its journey, so a journey opened after another never shows the other's status.
|
||||||
|
const [loaded, setLoaded] = useState<{ journeyId: number | null; prep: JourneyRecordingPrep | null }>(NOTHING);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [readToken, setReadToken] = useState(0);
|
||||||
|
const prep = active && loaded.journeyId === journeyId ? loaded.prep : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || journeyId === null) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void readPrep(journeyId, controller.signal).then((next) => {
|
||||||
|
if (next && !controller.signal.aborted) setLoaded({ journeyId, prep: next });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [journeyId, active, enabled, readToken]);
|
||||||
|
|
||||||
|
const fetchNow = useCallback(async () => {
|
||||||
|
setFetching(true);
|
||||||
|
try {
|
||||||
|
const result = await dawarichRepo.syncNow();
|
||||||
|
if (result.alreadyRunning) toast.info(t('journey.prep.alreadyRunning'));
|
||||||
|
onFetched();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('journey.prep.fetchFailed'));
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
setReadToken((n) => n + 1);
|
||||||
|
}
|
||||||
|
}, [onFetched, toast, t]);
|
||||||
|
|
||||||
|
const setEnabled = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
if (journeyId === null) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateJourney(journeyId, { auto_prepare_recordings: next });
|
||||||
|
onSwitched();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('journey.settings.saveFailed'));
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
if (next && prep?.connection !== 'not_connected') await fetchNow();
|
||||||
|
},
|
||||||
|
[journeyId, updateJourney, onSwitched, toast, t, prep?.connection, fetchNow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reach = useMemo(() => prepReachOf(prep, periodDates), [prep, periodDates]);
|
||||||
|
const prepared = reach.dates.size > 0;
|
||||||
|
const statusLine = useMemo(
|
||||||
|
() => (prep ? prepStatusLine(prep, { prepared, outOfReach: reach.outOfReach }, t, locale) : null),
|
||||||
|
[prep, prepared, reach.outOfReach, t, locale]
|
||||||
|
);
|
||||||
|
const dayPrep = useCallback((date: string) => dayPrepOf(date, prep, reach.dates), [prep, reach.dates]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prep,
|
||||||
|
saving,
|
||||||
|
fetching,
|
||||||
|
statusLine,
|
||||||
|
canFetch: !!prep?.enabled && prep.connection !== 'not_connected' && prepared,
|
||||||
|
setEnabled,
|
||||||
|
fetchNow,
|
||||||
|
dayPrep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status, or null when it could not be read: the last one read stays, a status line is no reason for a toast. */
|
||||||
|
async function readPrep(journeyId: number, signal: AbortSignal): Promise<JourneyRecordingPrep | null> {
|
||||||
|
try {
|
||||||
|
return await journeyRecordingsRepo.prep(journeyId, signal);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTHING = { journeyId: null, prep: null };
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// FE-JRN-RECORDINGS-HOOK-001 to FE-JRN-RECORDINGS-HOOK-006
|
||||||
|
//
|
||||||
|
// JRN-01: the recorded days a journey keeps, as the desktop page and the phone
|
||||||
|
// screen read them: the list, the lines they draw on the map, which day's
|
||||||
|
// dialog is open, and a fresh read whenever somebody keeps or drops a day.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildRecordedDay, WALK_SEGMENT } from '../../../tests/helpers/journeyRecording';
|
||||||
|
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { useJourneyRecordings } from './useJourneyRecordings';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
|
||||||
|
|
||||||
|
const pings = vi.hoisted(() => ({ handlers: new Map<string, () => Promise<void>>() }));
|
||||||
|
vi.mock('../../sync/useServerPing', () => ({
|
||||||
|
useServerPing: (event: string, enabled: boolean, reload: () => Promise<void>) => {
|
||||||
|
if (enabled) pings.handlers.set(event, reload);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const JOURNEY = { id: 7, title: 'Venice' };
|
||||||
|
const colorOf = (date: string) => (date === '2026-05-14' ? '#ff0055' : null);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo.list.mockReset();
|
||||||
|
pings.handlers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useJourneyRecordings', () => {
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-001: reads the kept days and draws each piece as a recorded line in its day’s colour', async () => {
|
||||||
|
const second = {
|
||||||
|
...WALK_SEGMENT,
|
||||||
|
points: [
|
||||||
|
[45.44, 12.33],
|
||||||
|
[45.45, 12.34],
|
||||||
|
] as Array<[number, number]>,
|
||||||
|
};
|
||||||
|
repo.list.mockResolvedValue([
|
||||||
|
buildRecordedDay({ segments: [WALK_SEGMENT, second] }),
|
||||||
|
buildRecordedDay({ id: 4, date: '2026-05-15' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.days).toHaveLength(2));
|
||||||
|
expect(repo.list).toHaveBeenCalledWith(7);
|
||||||
|
expect(result.current.byDate.get('2026-05-15')?.id).toBe(4);
|
||||||
|
expect(result.current.tracks).toHaveLength(3);
|
||||||
|
expect(result.current.tracks.every((track) => track.kind === 'day_recording')).toBe(true);
|
||||||
|
// One line per piece: a gap between two pieces is never drawn over.
|
||||||
|
expect(result.current.tracks.slice(0, 2).map((track) => track.points)).toEqual([
|
||||||
|
WALK_SEGMENT.points,
|
||||||
|
second.points,
|
||||||
|
]);
|
||||||
|
expect(result.current.tracks[0]?.color).toBe('#ff0055');
|
||||||
|
// A day without entries takes the colour of the live recording.
|
||||||
|
expect(result.current.tracks[2]?.color).toBe('#2563EB');
|
||||||
|
const ids = result.current.tracks.map((track) => track.place_id);
|
||||||
|
expect(ids.every((id) => id < -1000)).toBe(true);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-002: no journey yet, nothing asked', () => {
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(null, colorOf));
|
||||||
|
expect(repo.list).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.tracks).toEqual([]);
|
||||||
|
expect(result.current.dialog).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-003: a failed read keeps what the map already showed', async () => {
|
||||||
|
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
|
||||||
|
await waitFor(() => expect(result.current.days).toHaveLength(1));
|
||||||
|
|
||||||
|
repo.list.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
await act(async () => {
|
||||||
|
await pings.handlers.get('journey:recording:updated')?.();
|
||||||
|
});
|
||||||
|
expect(result.current.days).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-004: reads again when somebody on the journey keeps or drops a day', async () => {
|
||||||
|
repo.list.mockResolvedValueOnce([]);
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
|
||||||
|
await waitFor(() => expect(repo.list).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
|
||||||
|
await act(async () => {
|
||||||
|
await pings.handlers.get('journey:recording:updated')?.();
|
||||||
|
});
|
||||||
|
expect(result.current.byDate.has('2026-05-14')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-005: opens a day’s dialog with what it keeps, and shows a kept or dropped day at once', async () => {
|
||||||
|
repo.list.mockResolvedValue([]);
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
|
||||||
|
await waitFor(() => expect(repo.list).toHaveBeenCalled());
|
||||||
|
|
||||||
|
act(() => result.current.open('2026-05-14'));
|
||||||
|
expect(result.current.dialog).toMatchObject({
|
||||||
|
journeyId: 7,
|
||||||
|
journeyTitle: 'Venice',
|
||||||
|
date: '2026-05-14',
|
||||||
|
stored: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => result.current.kept(buildRecordedDay()));
|
||||||
|
expect(result.current.dialog?.stored?.id).toBe(3);
|
||||||
|
expect(result.current.tracks).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => result.current.removed('2026-05-14'));
|
||||||
|
expect(result.current.days).toEqual([]);
|
||||||
|
act(() => result.current.close());
|
||||||
|
expect(result.current.dialog).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-RECORDINGS-HOOK-006: a first read that fails says so, rather than passing for a journey that kept no day', async () => {
|
||||||
|
repo.list.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('failed'));
|
||||||
|
expect(result.current.days).toEqual([]);
|
||||||
|
|
||||||
|
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
|
||||||
|
await act(async () => {
|
||||||
|
await pings.handlers.get('journey:recording:updated')?.();
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { JourneyRecordedDay, JourneyTrack } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
|
||||||
|
import { useServerPing } from '../../sync/useServerPing';
|
||||||
|
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
|
||||||
|
import type { JourneyRecordingViewProps } from './useJourneyRecordingDay';
|
||||||
|
|
||||||
|
interface JourneyRecordingsState {
|
||||||
|
days: JourneyRecordedDay[];
|
||||||
|
/** Whether the days were read: a failed first read is not a journey that kept none. */
|
||||||
|
status: 'idle' | 'loading' | 'ready' | 'failed';
|
||||||
|
byDate: ReadonlyMap<string, JourneyRecordedDay>;
|
||||||
|
/** The kept days as lines for the journey map, one per piece, never joined across a gap. */
|
||||||
|
tracks: JourneyTrack[];
|
||||||
|
/** The day whose recording dialog is open, or null. */
|
||||||
|
openDate: string | null;
|
||||||
|
/** What the desktop dialog or the phone sheet opens with while a day is open. */
|
||||||
|
dialog: JourneyRecordingViewProps | null;
|
||||||
|
open: (date: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
/** A day was kept or replaced from the dialog: shown at once, without asking again. */
|
||||||
|
kept: (day: JourneyRecordedDay) => void;
|
||||||
|
removed: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorded days a journey keeps (JRN-01), for the desktop page and the
|
||||||
|
* phone screen alike: the list, the lines they draw on the journey map and
|
||||||
|
* which day's dialog is open. Read again whenever somebody on the journey
|
||||||
|
* keeps or drops a day. Offline the days stay as last read; a journey has no
|
||||||
|
* offline copy to fall back on.
|
||||||
|
*
|
||||||
|
* `colorOf` gives a day the colour its entries have on the map, so a kept
|
||||||
|
* route and the pins of its day read as one; a day without entries takes the
|
||||||
|
* colour of the live recording.
|
||||||
|
*/
|
||||||
|
export function useJourneyRecordings(
|
||||||
|
journey: { id: number; title: string } | null,
|
||||||
|
colorOf: (date: string) => string | null
|
||||||
|
): JourneyRecordingsState {
|
||||||
|
const journeyId = journey?.id ?? null;
|
||||||
|
const journeyTitle = journey?.title ?? '';
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
// Held with the journey they belong to, so a journey opened after another
|
||||||
|
// never shows the other one's days, and nothing has to be reset in an effect.
|
||||||
|
const [loaded, setLoaded] = useState<{ journeyId: number | null; days: JourneyRecordedDay[] }>(NOTHING_LOADED);
|
||||||
|
const [opened, setOpened] = useState<{ journeyId: number | null; date: string | null }>(NOTHING_OPEN);
|
||||||
|
const [failedFor, setFailedFor] = useState<number | null>(null);
|
||||||
|
const days = loaded.journeyId === journeyId ? loaded.days : NO_DAYS;
|
||||||
|
const status =
|
||||||
|
journeyId === null
|
||||||
|
? 'idle'
|
||||||
|
: loaded.journeyId === journeyId
|
||||||
|
? 'ready'
|
||||||
|
: failedFor === journeyId
|
||||||
|
? 'failed'
|
||||||
|
: 'loading';
|
||||||
|
const openDate = opened.journeyId === journeyId ? opened.date : null;
|
||||||
|
|
||||||
|
const read = useCallback(async () => {
|
||||||
|
if (journeyId === null) return null;
|
||||||
|
try {
|
||||||
|
return { journeyId, days: await journeyRecordingsRepo.list(journeyId) };
|
||||||
|
} catch {
|
||||||
|
// What was read before stays on the map; a kept day does not vanish
|
||||||
|
// because one read of it failed.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [journeyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void read().then((next) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (next) setLoaded(next);
|
||||||
|
else setFailedFor(journeyId);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [read, journeyId]);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const next = await read();
|
||||||
|
if (next) setLoaded(next);
|
||||||
|
}, [read]);
|
||||||
|
|
||||||
|
useServerPing('journey:recording:updated', journeyId !== null, reload);
|
||||||
|
|
||||||
|
const byDate = useMemo(() => new Map(days.map((day) => [day.date, day])), [days]);
|
||||||
|
|
||||||
|
const tracks = useMemo(
|
||||||
|
() =>
|
||||||
|
days.flatMap((day) => {
|
||||||
|
const name = t('journey.recording.mapLine', {
|
||||||
|
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
|
||||||
|
});
|
||||||
|
const color = colorOf(day.date) ?? DAWARICH_TRAIL_COLOR;
|
||||||
|
return day.segments.map((segment, index): JourneyTrack => ({
|
||||||
|
// Negative and far from the live trail's -1, -2, ... so the map keys never meet.
|
||||||
|
place_id: -(100_000 + day.id * 1_000 + index),
|
||||||
|
trip_id: 0,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
points: segment.points,
|
||||||
|
kind: 'day_recording',
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
[days, colorOf, t, locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const kept = useCallback(
|
||||||
|
(day: JourneyRecordedDay) =>
|
||||||
|
setLoaded((prev) => ({
|
||||||
|
journeyId,
|
||||||
|
days: [...prev.days.filter((d) => d.date !== day.date), day].sort((a, b) => (a.date < b.date ? -1 : 1)),
|
||||||
|
})),
|
||||||
|
[journeyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = useCallback(
|
||||||
|
(date: string) => setLoaded((prev) => ({ journeyId, days: prev.days.filter((d) => d.date !== date) })),
|
||||||
|
[journeyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = useCallback((date: string) => setOpened({ journeyId, date }), [journeyId]);
|
||||||
|
const close = useCallback(() => setOpened(NOTHING_OPEN), []);
|
||||||
|
|
||||||
|
const dialog = useMemo(
|
||||||
|
() =>
|
||||||
|
journeyId !== null && openDate
|
||||||
|
? {
|
||||||
|
journeyId,
|
||||||
|
journeyTitle,
|
||||||
|
date: openDate,
|
||||||
|
stored: byDate.get(openDate),
|
||||||
|
onKept: kept,
|
||||||
|
onRemoved: removed,
|
||||||
|
onClose: close,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[journeyId, journeyTitle, openDate, byDate, kept, removed, close]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { days, status, byDate, tracks, openDate, dialog, open, close, kept, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable empty identity, so a journey without kept days never re-renders its map for nothing. */
|
||||||
|
const NO_DAYS: JourneyRecordedDay[] = [];
|
||||||
|
const NOTHING_LOADED = { journeyId: null, days: NO_DAYS };
|
||||||
|
const NOTHING_OPEN = { journeyId: null, date: null };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind
|
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-009: the public share link logic behind
|
||||||
// both the desktop share section and the phone settings sheet.
|
// both the desktop share section and the phone settings sheet.
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
@@ -123,4 +123,15 @@ describe('useJourneyShareLink', () => {
|
|||||||
act(() => vi.advanceTimersByTime(2000));
|
act(() => vi.advanceTimersByTime(2000));
|
||||||
expect(result.current.copied).toBe(false);
|
expect(result.current.copied).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-JRN-SHAREHOOK-009: the plan and the recorded days are switched on their own and start off (JRN-04)', async () => {
|
||||||
|
const { result } = await loaded();
|
||||||
|
await act(() => result.current.togglePerm('share_plan'));
|
||||||
|
expect(posted).toEqual([{ share_timeline: true, share_gallery: false, share_map: true, share_plan: true }]);
|
||||||
|
expect(result.current.link?.share_plan).toBe(true);
|
||||||
|
expect(result.current.link?.share_recordings).toBeUndefined();
|
||||||
|
|
||||||
|
await act(() => result.current.togglePerm('share_recordings'));
|
||||||
|
expect(posted[1]).toEqual({ share_timeline: true, share_gallery: false, share_map: true, share_recordings: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ export interface JourneyShareLink {
|
|||||||
share_timeline: boolean;
|
share_timeline: boolean;
|
||||||
share_gallery: boolean;
|
share_gallery: boolean;
|
||||||
share_map: boolean;
|
share_map: boolean;
|
||||||
|
/** JRN-04: the trips' plan and the recorded days on the shared map; off unless switched on. */
|
||||||
|
share_plan?: boolean;
|
||||||
|
share_recordings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map';
|
/** What the shared map can show besides the entries, each only while the map itself is shared. */
|
||||||
|
export type JourneyShareMapExtra = 'share_plan' | 'share_recordings';
|
||||||
|
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map' | JourneyShareMapExtra;
|
||||||
|
|
||||||
|
/** The map extras the share switches offer, with what each puts on the shared map. */
|
||||||
|
export const SHARE_MAP_EXTRAS: { key: JourneyShareMapExtra; labelKey: string; hintKey: string }[] = [
|
||||||
|
{ key: 'share_plan', labelKey: 'journey.share.plan', hintKey: 'journey.share.planHint' },
|
||||||
|
{ key: 'share_recordings', labelKey: 'journey.share.recordings', hintKey: 'journey.share.recordingsHint' },
|
||||||
|
];
|
||||||
|
|
||||||
/** The public address of a journey share token. */
|
/** The public address of a journey share token. */
|
||||||
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
|
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
|
||||||
@@ -71,6 +82,8 @@ export function useJourneyShareLink(journeyId: number) {
|
|||||||
share_timeline: updated.share_timeline,
|
share_timeline: updated.share_timeline,
|
||||||
share_gallery: updated.share_gallery,
|
share_gallery: updated.share_gallery,
|
||||||
share_map: updated.share_map,
|
share_map: updated.share_map,
|
||||||
|
// The map extras go only when switched: a link read before they existed keeps them off.
|
||||||
|
...(key === 'share_plan' || key === 'share_recordings' ? { [key]: !!updated[key] } : {}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setLink(previous);
|
setLink(previous);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-BELL-001 to FE-COMP-BELL-020
|
// FE-COMP-BELL-001 to FE-COMP-BELL-021
|
||||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
@@ -238,4 +238,13 @@ describe('InAppNotificationBell', () => {
|
|||||||
// fetchNotifications should be called once per open (2 total)
|
// fetchNotifications should be called once per open (2 total)
|
||||||
expect(fetchNotifications).toHaveBeenCalledTimes(2);
|
expect(fetchNotifications).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BELL-021: unread badge is large enough that the count is not squeezed', () => {
|
||||||
|
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 150, isLoading: false });
|
||||||
|
render(<InAppNotificationBell />);
|
||||||
|
const badge = screen.getByTestId('notification-badge');
|
||||||
|
expect(badge).toHaveTextContent('99+');
|
||||||
|
expect(badge.style.height).toBe('20px');
|
||||||
|
expect(badge.style.minWidth).toBe('20px');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
|||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="absolute -top-0.5 -end-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
data-testid="notification-badge"
|
||||||
|
className="absolute -top-1.5 -end-1.5 flex items-center justify-center rounded-full text-white font-bold tabular-nums ring-2 ring-surface-card"
|
||||||
style={{
|
style={{
|
||||||
background: '#ef4444',
|
background: '#ef4444',
|
||||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))',
|
||||||
minWidth: 14,
|
minWidth: 20,
|
||||||
height: 14,
|
height: 20,
|
||||||
padding: '0 3px',
|
padding: '0 5px',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 React from 'react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
||||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||||
|
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||||
|
|
||||||
describe('MapLayerSwitcher', () => {
|
describe('MapLayerSwitcher', () => {
|
||||||
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
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')
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user