From b7951baf239d51e9c45977638addbfc4bf9f2d17 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 18:24:25 +0100 Subject: [PATCH] fix(ci): green the dev gates after the native and popup rounds - RTL: the measured pills and centred controls use logical forms or a marked line - i18n: the two native widget numbers are positions, not counts (NOT_PLURAL) - Types: styleOverride on the lazy GL map, the location and shared-file payloads, the capabilities fallback, the transport sheet's stay metadata - ESLint: latest-value refs move to layout effects, usePresence keeps the last open state in state, the booking import opens clean with a shared document kept in the list (the reset effect used to clear it again) - Layers: the phone import sheet lives under mobile/ and reads through tripRepo; the offline host check moves to utils/tileUrl - Sizes: plugin layer geometry, hardNavigate and the exit animations get modules of their own - Tests follow the status radios, the stay chip overview, the dock + button for the first list and the field token; store registry lists the new stores --- client/scripts/eslint-baseline.json | 14 +- client/scripts/feature-pairs.json | 11 +- client/scripts/format-baseline.json | 5 - client/scripts/i18n-unused-baseline.json | 2 +- client/scripts/rtl-baseline.json | 2 +- client/scripts/rtl-disable-baseline.json | 2 + client/scripts/size-baseline.json | 4 +- client/scripts/strict-baseline.json | 2 +- client/src/App.tsx | 2 +- client/src/api/client.ts | 19 +- client/src/api/hardNavigate.ts | 15 ++ .../components/Layout/AppLockGate.test.tsx | 2 +- client/src/components/Layout/AppLockGate.tsx | 13 +- client/src/components/Map/MapView.tsx | 2 +- client/src/components/Map/MapViewAuto.tsx | 2 +- client/src/components/Map/MapViewGL.tsx | 63 +---- client/src/components/Map/glLazy.tsx | 6 +- client/src/components/Map/markerLook.ts | 8 +- .../src/components/Map/offlineBasemap.test.ts | 3 +- client/src/components/Map/offlineBasemap.ts | 16 +- client/src/components/Map/pluginLayerGeo.ts | 82 ++++++ .../components/Planner/BookingImportModal.tsx | 26 +- .../Planner/bookings/bookingFacts.ts | 2 +- .../src/components/shared/ConfirmDialog.tsx | 23 +- .../src/components/shared/ErrorBoundary.tsx | 22 +- client/src/components/shared/presence.css | 51 ++++ client/src/components/shared/usePresence.ts | 25 +- client/src/index.css | 36 --- .../src/mobile/components/MDayPager.test.tsx | 3 +- client/src/mobile/components/MDayPager.tsx | 34 +-- client/src/mobile/components/MSegmented.tsx | 2 +- .../components}/NativeImportSheet.test.tsx | 11 +- .../components}/NativeImportSheet.tsx | 10 +- client/src/mobile/components/dayPagerPages.ts | 31 +++ client/src/mobile/components/useActivePill.ts | 23 +- client/src/mobile/components/useSheetDrag.ts | 234 ++++++++++-------- client/src/mobile/overlayStack.ts | 6 +- client/src/mobile/routeTransition.test.tsx | 22 +- client/src/mobile/routeTransition.ts | 17 +- .../screens/admin/MAdminResetMfaSheet.tsx | 2 +- .../admin/MAdminSchoolHolidayCatalog.tsx | 4 +- .../mobile/screens/collections/MCollTours.tsx | 6 +- client/src/mobile/screens/trip/MTripShell.tsx | 12 +- .../src/mobile/screens/trip/map/MMapArea.tsx | 4 +- .../screens/trip/plan/MPlanTimeline.tsx | 3 +- .../screens/trip/plan/MRouteAppsSheet.tsx | 9 +- .../screens/trip/plan/openRouteTarget.ts | 7 + .../screens/trip/roadtrip/MRoadtripTab.tsx | 2 +- .../screens/trip/sheets/MTourDetailShell.tsx | 9 +- .../screens/trip/sheets/MTransportSheet.tsx | 3 +- .../screens/trip/sheets/MTripSheets.tsx | 3 +- .../screens/trip/sheets/tourSheetOpen.ts | 7 + client/src/mobile/screens/vacay/MVacay.tsx | 6 +- client/src/mobile/screens/vacay/useMVacay.ts | 15 +- client/src/mobile/usePullToRefresh.ts | 11 +- client/src/native/capabilities.ts | 5 +- client/src/native/geolocation.test.ts | 2 +- client/src/native/geolocation.ts | 2 +- client/src/native/sharedFiles.test.ts | 2 +- client/src/native/tileCache.ts | 2 +- client/src/native/widgets.test.ts | 4 +- client/src/native/widgets.ts | 37 +-- .../pages/tripPlanner/useTripPlannerPage.ts | 6 +- client/src/sync/glPrefetcher.ts | 29 ++- client/src/sync/tilePrefetcher.ts | 14 +- client/src/utils/tileUrl.ts | 13 + client/tests/helpers/storeRegistry.ts | 4 + .../mobile/collections/MCollSheetKit.test.tsx | 6 +- .../mobile/collections/MCollections.test.tsx | 9 +- .../journey/MJourneySettingsSheet.test.tsx | 4 +- .../unit/mobile/trip/MPlanTimeline.test.tsx | 15 +- .../mobile/trip/MReservationSheet.test.tsx | 2 +- .../mobile/trip/MTransportFormSheet.test.tsx | 4 +- client/tests/unit/sync/tilePrefetcher.test.ts | 2 + shared/scripts/i18n-parity.mjs | 2 + 75 files changed, 645 insertions(+), 475 deletions(-) create mode 100644 client/src/api/hardNavigate.ts create mode 100644 client/src/components/Map/pluginLayerGeo.ts create mode 100644 client/src/components/shared/presence.css rename client/src/{components/Layout => mobile/components}/NativeImportSheet.test.tsx (82%) rename client/src/{components/Layout => mobile/components}/NativeImportSheet.tsx (93%) create mode 100644 client/src/mobile/components/dayPagerPages.ts create mode 100644 client/src/mobile/screens/trip/plan/openRouteTarget.ts create mode 100644 client/src/mobile/screens/trip/sheets/tourSheetOpen.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index f92a01edf..17f834070 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,18 +1,18 @@ { "src": { - "@typescript-eslint/no-non-null-assertion": 377, + "@typescript-eslint/no-non-null-assertion": 375, "@typescript-eslint/no-misused-promises": 847, "no-console": 88, "complexity": 218, "@typescript-eslint/no-explicit-any": 144, "trek/no-swallowed-catch": 128, - "max-params": 32, + "max-params": 31, "trek/store-without-selector": 67, "react-hooks/refs": 553, - "react-hooks/set-state-in-effect": 193, + "react-hooks/set-state-in-effect": 192, "@typescript-eslint/no-floating-promises": 164, - "react-hooks/exhaustive-deps": 115, - "@typescript-eslint/no-unused-vars": 124, + "react-hooks/exhaustive-deps": 114, + "@typescript-eslint/no-unused-vars": 123, "react-hooks/immutability": 28, "max-lines-per-function": 130, "react-hooks/static-components": 50, @@ -21,7 +21,7 @@ "no-empty": 19, "trek/no-window-globals": 41, "react-refresh/only-export-components": 44, - "unused-disable-directive": 6, + "unused-disable-directive": 5, "max-depth": 21, "react-hooks/void-use-memo": 1, "@typescript-eslint/no-unused-expressions": 2, @@ -35,7 +35,7 @@ }, "tests": { "@typescript-eslint/no-explicit-any": 489, - "@typescript-eslint/no-non-null-assertion": 1914, + "@typescript-eslint/no-non-null-assertion": 1913, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-floating-promises": 33, diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 3d6be0426..bd82a8255 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -620,8 +620,7 @@ "desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"], "mobile": [ "src/mobile/screens/trip/places/MPlacesBrowser.tsx", - "src/mobile/screens/trip/places/MPlacesFilterControls.tsx", - "src/mobile/screens/trip/places/MPlacesFilterSheet.tsx" + "src/mobile/screens/trip/places/MPlacesFilterControls.tsx" ] }, { @@ -1002,6 +1001,7 @@ "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/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.", + "src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.", "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/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.", @@ -1010,17 +1010,18 @@ "src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.", "src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.", "src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).", + "src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.", "src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.", "src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.", "src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.", "src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.", - "src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.", "src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.", "src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.", "src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.", "src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.", + "src/mobile/screens/trip/plan/MRouteAppsSheet.tsx": "Markup only; the route apps come from the shared components/Planner/dayRouteApps the desktop menu uses too.", + "src/mobile/screens/trip/plan/openRouteTarget.ts": "Phone only helper that opens a route target: a web link in a new tab, geo: in place.", "src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.", - "src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.", "src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.", "src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.", "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.", @@ -1039,10 +1040,12 @@ "src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.", "src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.", "src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.", + "src/mobile/screens/trip/sheets/MTourDetailShell.tsx": "Markup only phone frame for the shared TourDetailDialog, which renders its own content into it.", "src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.", "src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.", "src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).", "src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.", + "src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.", "src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.", "src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.", "src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.", diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 501a55b93..b9d1605e2 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -1351,11 +1351,9 @@ "src/mobile/screens/trip/dockTabs.ts", "src/mobile/screens/trip/lib/dayNotes.ts", "src/mobile/screens/trip/map/MMapArea.tsx", - "src/mobile/screens/trip/map/MPlacesFilterPill.tsx", "src/mobile/screens/trip/places/MPlacesBrowser.tsx", "src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx", "src/mobile/screens/trip/places/MPlacesFilterControls.tsx", - "src/mobile/screens/trip/places/MPlacesFilterSheet.tsx", "src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx", "src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx", "src/mobile/screens/trip/places/MToursSelectionList.test.tsx", @@ -1366,8 +1364,6 @@ "src/mobile/screens/trip/plan/MPlanTimelineRows.tsx", "src/mobile/screens/trip/plan/planTimelineModel.test.ts", "src/mobile/screens/trip/plan/planTimelineModel.ts", - "src/mobile/screens/trip/plan/useMPlanDaySwipe.test.tsx", - "src/mobile/screens/trip/plan/useMPlanDaySwipe.ts", "src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts", "src/mobile/screens/trip/plan/useMPlanDragReorder.ts", "src/mobile/screens/trip/plan/useMPlanTimeline.ts", @@ -1982,7 +1978,6 @@ "tests/unit/mobile/trip/MPlaceSheet.test.tsx", "tests/unit/mobile/trip/MPlacesBrowser.test.tsx", "tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx", - "tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx", "tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx", "tests/unit/mobile/trip/MPlanTimeline.test.tsx", "tests/unit/mobile/trip/MPlanTimelineRows.test.tsx", diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index 7c9e4560a..0539794a9 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -7,7 +7,7 @@ "collab.ts": 9, "collection.ts": 35, "common.ts": 7, - "dashboard.ts": 65, + "dashboard.ts": 64, "dawarich.ts": 15, "dayplan.ts": 17, "docsync.ts": 25, diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 699f4c2c8..1ee1892cb 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -78,7 +78,7 @@ "help/contexts/tripMap.ts": 1, "hooks/useAnchoredPosition.ts": 3, "hooks/useElementRect.ts": 3, - "mobile/components/MSegmented.tsx": 3, + "mobile/components/MSegmented.tsx": 1, "mobile/components/MSheet.tsx": 1, "mobile/screens/dashboard/MDashboard.tsx": 1, "mobile/screens/journey/MJourneyDetail.tsx": 3, diff --git a/client/scripts/rtl-disable-baseline.json b/client/scripts/rtl-disable-baseline.json index 253de175d..09f626534 100644 --- a/client/scripts/rtl-disable-baseline.json +++ b/client/scripts/rtl-disable-baseline.json @@ -4,5 +4,7 @@ "components/Studio/StudioSelectionChrome.tsx": 2, "components/Studio/inspector/FocalPad.tsx": 1, "mobile/components/trek3d/MascotClip.tsx": 1, + "mobile/screens/trip/MTripShell.tsx": 1, + "mobile/screens/vacay/MVacay.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4 } diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 09b4e489f..bf73a550e 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -7,7 +7,7 @@ "src/components/Budget/CostsPanel.tsx": 1798, "src/components/Map/MapView.tsx": 1214, "src/components/Map/MapViewGL.test.tsx": 2919, - "src/components/Map/MapViewGL.tsx": 2183, + "src/components/Map/MapViewGL.tsx": 2127, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2929, @@ -16,7 +16,7 @@ "src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, - "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, + "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506, "src/pages/JourneyDetailPage.test.tsx": 4028, "src/pages/LoginPage.tsx": 1347, "src/pages/TripPlannerPage.tsx": 1128, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index ca7932ca1..c3779b289 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -105,7 +105,7 @@ "src/mobile/screens/trip/map/MMapArea.tsx": 1, "src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2, "src/mobile/screens/trip/plan/planTimelineModel.ts": 1, - "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2, + "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2, diff --git a/client/src/App.tsx b/client/src/App.tsx index 316dd808f..5359712dc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -35,7 +35,7 @@ import OfflineBanner from './components/Layout/OfflineBanner' import NewVersionNotice from './components/Layout/NewVersionNotice' import NativeUpdateNotice from './components/Layout/NativeUpdateNotice' import AppLockGate from './components/Layout/AppLockGate' -import NativeImportSheet from './components/Layout/NativeImportSheet' +import NativeImportSheet from './mobile/components/NativeImportSheet' import NativeWidgetsSync from './components/Layout/NativeWidgetsSync' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' import HelpPanel from './components/Help/HelpPanel' diff --git a/client/src/api/client.ts b/client/src/api/client.ts index efe3dbb5e..64e1f97c0 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -70,14 +70,13 @@ import { RoadtripVia, RoadtripViaBatchRequest, RoadtripViaCreateRequest, - RoadtripViaReanchorRequest, - RoadtripViaUpdateRequest, + RoadtripViaReanchorRequest, RoadtripViaUpdateRequest, } from '@trek/shared' import { getSocketId } from './websocket' import { probeNow } from '../sync/connectivity' import { downloadBlob } from '../utils/fileDownload' import { translateRateLimit } from './rateLimitMessage' -import { isNativeApp } from '../native/platform' +import { hardNavigate } from './hardNavigate' /** * Validate a response payload against its @trek/shared Zod schema — but only in @@ -151,20 +150,6 @@ export function isAuthPublicPath(pathname: string): boolean { return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p)) } -/** - * A full page load to `url`. In the apps it waits for the first page to finish - * loading: started earlier, it cancels that load, and the shell answers a - * cancelled load with its "Server not reachable" page, which is where a - * signed-out user opening the app for the first time ended up. - */ -function hardNavigate(url: string): void { - const go = () => { - window.location.href = url - } - if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true }) - else go() -} - // Unregisters the SW before reloading so the navigation reaches the network. // Without this, WorkBox's NavigationRoute serves the cached SPA shell and the // upstream proxy (CF Access / Pangolin) never gets to challenge the user. diff --git a/client/src/api/hardNavigate.ts b/client/src/api/hardNavigate.ts new file mode 100644 index 000000000..c8a843ade --- /dev/null +++ b/client/src/api/hardNavigate.ts @@ -0,0 +1,15 @@ +import { isNativeApp } from '../native/platform'; + +/** + * A full page load to `url`. In the apps it waits for the first page to finish + * loading: started earlier, it cancels that load, and the shell answers a + * cancelled load with its "Server not reachable" page, which is where a + * signed-out user opening the app for the first time ended up. + */ +export function hardNavigate(url: string): void { + const go = () => { + window.location.href = url; + }; + if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true }); + else go(); +} diff --git a/client/src/components/Layout/AppLockGate.test.tsx b/client/src/components/Layout/AppLockGate.test.tsx index 916b1f819..49e1d76cf 100644 --- a/client/src/components/Layout/AppLockGate.test.tsx +++ b/client/src/components/Layout/AppLockGate.test.tsx @@ -12,7 +12,7 @@ vi.mock('../../native/appLock', () => ({ })); function stateListener(): (state: { isActive: boolean }) => void { - const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange'); + const call = vi.mocked(App.addListener).mock.calls.find(([name]) => (name as string) === 'appStateChange'); if (!call) throw new Error('no appStateChange listener'); return call[1] as unknown as (state: { isActive: boolean }) => void; } diff --git a/client/src/components/Layout/AppLockGate.tsx b/client/src/components/Layout/AppLockGate.tsx index 094f08e6f..5b95313f7 100644 --- a/client/src/components/Layout/AppLockGate.tsx +++ b/client/src/components/Layout/AppLockGate.tsx @@ -28,11 +28,16 @@ export default function AppLockGate(): React.ReactElement | null { if (ok) setLocked(false); }, [t]); - // At launch, once the capabilities are known. + // At launch, once the capabilities are known: locked in the same render the + // capabilities arrive in, so nothing of the app shows before the prompt. + const [capsSeen, setCapsSeen] = useState(undefined); + if (caps !== capsSeen) { + setCapsSeen(caps); + if (isAppLockEnabled()) setLocked(true); + } useEffect(() => { - if (!isAppLockEnabled()) return; - setLocked(true); - void ask(); + // Asked after the effect, not inside it: the prompt's answer sets state. + if (isAppLockEnabled()) void Promise.resolve().then(ask); }, [caps, ask]); // Back from the background after a while. diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 4d7311268..fb89a3196 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -154,7 +154,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { const { size, borderWidth } = look // Allow-listed, not escaped: the value lands in style="…" of a divIcon, where // escaping stops the attribute breakout but still permits a CSS url(). - const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white')) + const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, place.category_color) const bgColor = safeHexColor(place.category_color, '#6b7280') // Number badges (bottom-right), or the rating where there are none: a numbered stop diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index 37308e04c..b6e0d5e0a 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -99,7 +99,7 @@ function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined { return nativeStore ? false : undefined } -const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]' +const PILL = 'absolute inset-x-0 z-[5] mx-auto flex w-fit items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]' /** * Says the map on screen is the stored offline basemap, not the user's own. With diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 34a0b329c..56a1b6f6c 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -50,6 +50,7 @@ import { poiPinParts } from './poiMarker' import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' +import { PLUGIN_TONE_COLORS, buildPluginLayerData } from './pluginLayerGeo' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport' import { onRealWorld } from '../../utils/worldCopy' @@ -128,7 +129,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color? const look = placeMarkerLook(place, selected) const { size, borderWidth } = look // See MapView: allow-listed rather than escaped, because this is a CSS context. - const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white')) + const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, place.category_color) const bgColor = safeHexColor(place.category_color, '#6b7280') // The visual circle is `size` + 2*border on each side. To make the @@ -213,66 +214,6 @@ const PLUGIN_LINE_LAYER_IDS: Record<'solid' | 'dash' | 'dot', string> = { dot: 'trek-plugin-layers-line-dot', } const PLUGIN_FILL_LAYER_ID = 'trek-plugin-layers-fill' -const PLUGIN_TONE_COLORS: Record = { - default: '#4F46E5', - success: '#10b981', - warn: '#f59e0b', - danger: '#ef4444', -} - -// GL circle layers size in screen pixels, so a metric circle has to become a -// polygon. Equirectangular approximation — plenty for a display-only overlay. -function circleToRing(center: [number, number], radiusM: number): [number, number][] { - const [lat, lng] = center - const dLat = radiusM / 111_320 - const cos = Math.cos((lat * Math.PI) / 180) - const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos))) - const ring: [number, number][] = [] - for (let i = 0; i <= 64; i++) { - const a = (i / 64) * 2 * Math.PI - ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]) - } - return ring -} - -interface PluginLayerGeoFeature { - type: 'Feature' - properties: { id: string; color: string; width: number; opacity: number; dash: string; fillOpacity: number; label: string } - geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] } -} - -function buildPluginLayerData(layers: PluginMapLayer[]) { - const features = layers.flatMap(layer => layer.features.flatMap((f, i): PluginLayerGeoFeature[] => { - const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default - const properties = { - id: `${layer.pluginId}:${layer.id}:${i}`, - color, - width: f.width, - opacity: f.opacity, - dash: f.dash, - fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0, - label: f.label || '', - } - if (f.type === 'polyline' && f.points) { - return [{ - type: 'Feature' as const, - properties, - geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) }, - }] - } - if (f.type === 'polygon' && f.points) { - const ring = f.points.map(([lat, lng]) => [lng, lat]) - if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push(ring[0]) - return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }] - } - if (f.type === 'circle' && f.center && f.radiusM) { - return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] } }] - } - return [] - })) - return { type: 'FeatureCollection' as const, features } -} - /** * A place pin on the map, whatever is carrying it. * diff --git a/client/src/components/Map/glLazy.tsx b/client/src/components/Map/glLazy.tsx index 0015d8ebf..3207bc48b 100644 --- a/client/src/components/Map/glLazy.tsx +++ b/client/src/components/Map/glLazy.tsx @@ -4,7 +4,11 @@ import type { GlMapProvider } from './glProviders' import type { MapViewProps } from './mapViewContract' /** What a caller hands the GL trip map: the shared map contract and which provider's style to load. */ -export type MapViewGLProps = MapViewProps & { glProvider?: GlMapProvider } +export type MapViewGLProps = MapViewProps & { + glProvider?: GlMapProvider + /** A style URL that replaces the provider's, as the offline fallback map (MapViewAuto). */ + styleOverride?: string +} /** * The six ways into a GL map, one chunk per engine. diff --git a/client/src/components/Map/markerLook.ts b/client/src/components/Map/markerLook.ts index ce5b47b85..515173816 100644 --- a/client/src/components/Map/markerLook.ts +++ b/client/src/components/Map/markerLook.ts @@ -1,4 +1,5 @@ import './selectedMarker.css' +import { safeHexColor } from '../../utils/safeColor' /** * Two ways a place marker can be told apart from the rest, set on the place object the @@ -57,8 +58,11 @@ export const SELECTED_MARKER = { } as const /** The border colour and shadow of a round place marker, selected or not. */ -export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } { +export function placeMarkerFrame( + selected: boolean, + categoryColor: string | null | undefined +): { borderColor: string; shadow: string; className: string } { return selected ? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className } - : { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' } + : { borderColor: safeHexColor(categoryColor, 'white'), shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' } } diff --git a/client/src/components/Map/offlineBasemap.test.ts b/client/src/components/Map/offlineBasemap.test.ts index 3901fe722..873e2e642 100644 --- a/client/src/components/Map/offlineBasemap.test.ts +++ b/client/src/components/Map/offlineBasemap.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'; -import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'; +import { isOfflineForbiddenTemplate } from '../../utils/tileUrl'; +import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'; describe('offlineBasemap', () => { it('picks the stored style for the colour scheme', () => { diff --git a/client/src/components/Map/offlineBasemap.ts b/client/src/components/Map/offlineBasemap.ts index a89b59099..89ac0f0cc 100644 --- a/client/src/components/Map/offlineBasemap.ts +++ b/client/src/components/Map/offlineBasemap.ts @@ -1,6 +1,6 @@ import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'; import { isNativeIos } from '../../native/platform'; -import { isVectorStyle } from '../../utils/tileUrl'; +import { isOfflineForbiddenTemplate, isVectorStyle } from '../../utils/tileUrl'; import { normalizeStyleForProvider, type GlMapProvider } from './glProviders'; /** @@ -14,25 +14,13 @@ import { normalizeStyleForProvider, type GlMapProvider } from './glProviders'; * and glyphs. A fallback has to be exactly a style that was stored, or its * sprite and labels are missing offline. */ -export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK]; +const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK]; /** The stored style that matches the colour scheme. */ export function offlineStyleFor(dark: boolean): string { return dark ? OFM_DARK : OFM_POSITRON; } -/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */ -const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org']; - -export function isOfflineForbiddenTemplate(template: string): boolean { - try { - const host = new URL(template.replace(/\{s\}/g, 'a')).hostname; - return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`)); - } catch { - return false; - } -} - /** * Whether a GL map can be drawn offline as configured. Only MapLibre on one of * the stored styles can: Mapbox has nothing stored to draw from, and another diff --git a/client/src/components/Map/pluginLayerGeo.ts b/client/src/components/Map/pluginLayerGeo.ts new file mode 100644 index 000000000..b0bd934dc --- /dev/null +++ b/client/src/components/Map/pluginLayerGeo.ts @@ -0,0 +1,82 @@ +import type { PluginMapLayer } from '../../api/client'; + +/** The plugin tone palette, shared by the GL layers and markers. */ +export const PLUGIN_TONE_COLORS: Record = { + default: '#4F46E5', + success: '#10b981', + warn: '#f59e0b', + danger: '#ef4444', +}; + +// GL circle layers size in screen pixels, so a metric circle has to become a +// polygon. Equirectangular approximation — plenty for a display-only overlay. +function circleToRing(center: [number, number], radiusM: number): [number, number][] { + const [lat, lng] = center; + const dLat = radiusM / 111_320; + const cos = Math.cos((lat * Math.PI) / 180); + const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos))); + const ring: [number, number][] = []; + for (let i = 0; i <= 64; i++) { + const a = (i / 64) * 2 * Math.PI; + ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]); + } + return ring; +} + +interface PluginLayerGeoFeature { + type: 'Feature'; + properties: { + id: string; + color: string; + width: number; + opacity: number; + dash: string; + fillOpacity: number; + label: string; + }; + geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] }; +} + +/** The plugins' vector layers as one GeoJSON source for the GL map. */ +export function buildPluginLayerData(layers: PluginMapLayer[]) { + const features = layers.flatMap((layer) => + layer.features.flatMap((f, i): PluginLayerGeoFeature[] => { + const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default; + const properties = { + id: `${layer.pluginId}:${layer.id}:${i}`, + color, + width: f.width, + opacity: f.opacity, + dash: f.dash, + fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0, + label: f.label || '', + }; + if (f.type === 'polyline' && f.points) { + return [ + { + type: 'Feature' as const, + properties, + geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) }, + }, + ]; + } + if (f.type === 'polygon' && f.points) { + const ring = f.points.map(([lat, lng]) => [lng, lat]); + if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) + ring.push(ring[0]); + return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }]; + } + if (f.type === 'circle' && f.center && f.radiusM) { + return [ + { + type: 'Feature' as const, + properties, + geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] }, + }, + ]; + } + return []; + }) + ); + return { type: 'FeatureCollection' as const, features }; +} diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index 8955e12ce..40fb6dd1f 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -36,11 +36,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo const mouseDownTarget = useRef(null) const [files, setFiles] = useState([]) - // A booking document shared to TREK lands here already chosen. useEffect(() => { - if (!isOpen) return - const shared = useSharedImportStore.getState().file - if (shared) setFiles([useSharedImportStore.getState().take() ?? shared]) + if (isOpen && useSharedImportStore.getState().file) useSharedImportStore.getState().take() }, [isOpen]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) @@ -50,6 +47,21 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo // Only a model that reads images can do anything with a photo, so photos are offered only then. const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS + // A booking document shared to TREK lands here already chosen: picked up in + // the render that opens the modal, and taken out of the store once shown. + const [openSeen, setOpenSeen] = useState(false) + if (isOpen !== openSeen) { + setOpenSeen(isOpen) + if (isOpen) { + // Opens clean, but with a shared document already in the list. + const shared = useSharedImportStore.getState().file + setFiles(shared ? [shared] : []) + setIsDragOver(false) + setLoading(false) + setError('') + } + } + const reset = () => { setFiles([]) setIsDragOver(false) @@ -57,12 +69,6 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo setError('') } - useEffect(() => { - if (isOpen) reset() - // reset is stable — intentional - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]) - useEffect(() => { if (!isOpen) return healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) diff --git a/client/src/components/Planner/bookings/bookingFacts.ts b/client/src/components/Planner/bookings/bookingFacts.ts index 584fe515b..1e5cbea3d 100644 --- a/client/src/components/Planner/bookings/bookingFacts.ts +++ b/client/src/components/Planner/bookings/bookingFacts.ts @@ -134,7 +134,7 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts { } } -export function nightsBetween(a: string | null, b: string | null): number { +function nightsBetween(a: string | null, b: string | null): number { if (!a || !b) return 0 const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5) return n > 0 ? n : 0 diff --git a/client/src/components/shared/ConfirmDialog.tsx b/client/src/components/shared/ConfirmDialog.tsx index 561b7663b..bb7f494dd 100644 --- a/client/src/components/shared/ConfirmDialog.tsx +++ b/client/src/components/shared/ConfirmDialog.tsx @@ -68,6 +68,13 @@ function ConfirmDialogFrame({ if (!isOpen && !closing) return null + // Each used by both layouts, worked out once. + const heading = title || t('common.confirm') + const cancelText = cancelLabel || t('common.cancel') + const confirmText = confirmLabel || t('common.delete') + const modalExit = closing ? 'trek-modal-exit' : '' + const width = children ? 'max-w-md' : 'max-w-sm' + return createPortal(
e.stopPropagation()} >
@@ -94,7 +101,7 @@ function ConfirmDialogFrame({ )}

- {title || t('common.confirm')} + {heading}

{message} @@ -108,7 +115,7 @@ function ConfirmDialogFrame({ onClick={onClose} className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary" > - {cancelLabel || t('common.cancel')} + {cancelText}

@@ -126,7 +133,7 @@ function ConfirmDialogFrame({ // with its tile, the message below, the answers in a bar at the foot.
e.stopPropagation()} >
@@ -136,7 +143,7 @@ function ConfirmDialogFrame({ : }

- {title || t('common.confirm')} + {heading}

@@ -146,13 +153,13 @@ function ConfirmDialogFrame({
diff --git a/client/src/components/shared/ErrorBoundary.tsx b/client/src/components/shared/ErrorBoundary.tsx index 31bf0b513..943330662 100644 --- a/client/src/components/shared/ErrorBoundary.tsx +++ b/client/src/components/shared/ErrorBoundary.tsx @@ -111,6 +111,19 @@ interface FallbackProps extends FallbackState { label?: string } +/** What the fallback says: an update, a plugin, a panel or the whole page that failed. */ +function fallbackText( + t: (key: string) => string, + isChunkError: boolean, + isPlugin: boolean, + isPanel: boolean +): { title: string; body: string } { + if (isChunkError) return { title: t('common.errorUpdateTitle'), body: t('common.errorUpdateBody') } + const body = isPanel ? t('common.errorPanelBody') : t('common.errorBody') + if (isPlugin) return { title: t('common.errorPluginTitle'), body } + return { title: isPanel ? t('common.errorPanelTitle') : t('common.errorTitle'), body } +} + /** * The visible half. A function component so it can translate; anything mounted * above TranslationProvider gets RootErrorFallback instead, which has no keys to @@ -121,14 +134,7 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe const isPanel = level === 'panel' const mobile = variant === 'mobile' - const title = isChunkError - ? t('common.errorUpdateTitle') - : label - ? t('common.errorPluginTitle') - : isPanel ? t('common.errorPanelTitle') : t('common.errorTitle') - const body = isChunkError - ? t('common.errorUpdateBody') - : isPanel ? t('common.errorPanelBody') : t('common.errorBody') + const { title, body } = fallbackText(t, isChunkError, !!label, isPanel) const message = error instanceof Error ? error.message : null const Icon = isChunkError ? Sparkles : AlertTriangle diff --git a/client/src/components/shared/presence.css b/client/src/components/shared/presence.css new file mode 100644 index 000000000..8448e5362 --- /dev/null +++ b/client/src/components/shared/presence.css @@ -0,0 +1,51 @@ +/* Closing: the mirror of each entrance, shorter and easing in, so a dialog, + confirm or menu leaves as gently as it came (usePresence.ts, which imports + this file, keeps it mounted for EXIT_MS). Added next to the entrance class rather than + instead of it, because on phones the entrance class also docks the dialog. */ +@keyframes trek-modal-exit { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.97); + } +} +@keyframes trek-drawer-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(40%); + } +} +@keyframes trek-backdrop-exit { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +.trek-modal-enter.trek-modal-exit, +.trek-popover-enter.trek-popover-exit { + animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards; +} +.trek-backdrop-enter.trek-backdrop-exit { + animation: trek-backdrop-exit 170ms ease-in forwards; +} +@media (max-width: 639px) { + .trek-modal-enter.trek-modal-exit { + animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards; + } +} +@media (prefers-reduced-motion: reduce) { + .trek-modal-enter.trek-modal-exit, + .trek-popover-enter.trek-popover-exit, + .trek-backdrop-enter.trek-backdrop-exit { + animation: trek-backdrop-exit 120ms ease-out forwards; + } +} diff --git a/client/src/components/shared/usePresence.ts b/client/src/components/shared/usePresence.ts index d767321ae..cd6b5ea73 100644 --- a/client/src/components/shared/usePresence.ts +++ b/client/src/components/shared/usePresence.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import './presence.css'; /** * How long a closing dialog, confirm or menu stays for its exit animation @@ -14,25 +15,23 @@ export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170; */ export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } { const animated = ms > 0 && EXIT_MS > 0; + // The open state of the last render, kept in state so the render that closes + // already knows it closed (React's "storing information from previous renders"). + const [prevOpen, setPrevOpen] = useState(open); const [lingering, setLingering] = useState(false); - const wasOpen = useRef(open); + if (open !== prevOpen) { + setPrevOpen(open); + setLingering(!open && animated); + } useEffect(() => { - if (open) { - wasOpen.current = true; - setLingering(false); - return; - } - if (!wasOpen.current) return; - wasOpen.current = false; - if (!animated) return; - setLingering(true); + if (!lingering) return; const timer = window.setTimeout(() => setLingering(false), ms); return () => window.clearTimeout(timer); - }, [open, ms, animated]); + }, [lingering, ms]); - // The first render after closing already counts as closing, before the effect runs. - const closing = animated && !open && (lingering || wasOpen.current); + // The first render after closing already counts as closing, before the state update lands. + const closing = animated && !open && (lingering || prevOpen); return { mounted: open || closing, closing }; } diff --git a/client/src/index.css b/client/src/index.css index 83f99e9aa..2bf9bee9b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -568,42 +568,6 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti .trek-backdrop-enter { animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1); } - -/* Closing: the mirror of each entrance, shorter and easing in, so a dialog, - confirm or menu leaves as gently as it came (components/shared/usePresence.ts - keeps it mounted for EXIT_MS). Added next to the entrance class rather than - instead of it, because on phones the entrance class also docks the dialog. */ -@keyframes trek-modal-exit { - from { opacity: 1; transform: scale(1); } - to { opacity: 0; transform: scale(0.97); } -} -@keyframes trek-drawer-exit { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(40%); } -} -@keyframes trek-backdrop-exit { - from { opacity: 1; } - to { opacity: 0; } -} -.trek-modal-enter.trek-modal-exit, -.trek-popover-enter.trek-popover-exit { - animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards; -} -.trek-backdrop-enter.trek-backdrop-exit { - animation: trek-backdrop-exit 170ms ease-in forwards; -} -@media (max-width: 639px) { - .trek-modal-enter.trek-modal-exit { - animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards; - } -} -@media (prefers-reduced-motion: reduce) { - .trek-modal-enter.trek-modal-exit, - .trek-popover-enter.trek-popover-exit, - .trek-backdrop-enter.trek-backdrop-exit { - animation: trek-backdrop-exit 120ms ease-out forwards; - } -} .trek-toast-enter { animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1); will-change: transform, opacity; diff --git a/client/src/mobile/components/MDayPager.test.tsx b/client/src/mobile/components/MDayPager.test.tsx index e394c6f88..ee3ae7a46 100644 --- a/client/src/mobile/components/MDayPager.test.tsx +++ b/client/src/mobile/components/MDayPager.test.tsx @@ -2,7 +2,8 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { tickHaptic } from '../../native/haptics'; import type { Day } from '../../types'; -import MDayPager, { pagesFor } from './MDayPager'; +import MDayPager from './MDayPager'; +import { pagesFor } from './dayPagerPages'; vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); diff --git a/client/src/mobile/components/MDayPager.tsx b/client/src/mobile/components/MDayPager.tsx index c87489f0b..b8eedea56 100644 --- a/client/src/mobile/components/MDayPager.tsx +++ b/client/src/mobile/components/MDayPager.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { tickHaptic } from '../../native/haptics'; -import type { Day } from '../../types'; +import { pagesFor, type PagerItem } from './dayPagerPages'; /** * Pages through the trip's days the way a native pager does: the previous, @@ -28,7 +28,6 @@ const SETTLE_MS = 90; const REST_PX = 2; /** Anything with an id pages: trip days, a leave year's months, the plan's years. */ -export type PagerItem = Pick; export interface MDayPagerProps { days: PagerItem[]; @@ -55,33 +54,6 @@ export interface MDayPagerProps { onPreview?: (dayId: number | null) => void; } -interface Page { - key: string; - dayId: number | null; - active: boolean; -} - -/** The window of pages around the selection. A selection that points at no - * day (the whole-trip view, or a day deleted remotely) still gets its page, - * with the first day next to it as a way back. */ -export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] { - const i = days.findIndex((d) => d.id === selectedDayId); - if (i < 0) { - const first = days[0]; - return [ - { key: 'none', dayId: selectedDayId, active: true }, - ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []), - ]; - } - const pages: Page[] = []; - const prev = days[i - 1]; - const next = days[i + 1]; - if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false }); - pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true }); - if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false }); - return pages; -} - export default function MDayPager({ days, selectedDayId, @@ -99,7 +71,9 @@ export default function MDayPager({ // The handlers below are attached once, so they read the props through here. const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview }); - latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview }; + useLayoutEffect(() => { + latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview }; + }); /** The day a landed swipe asked for, until the selection catches up. Stops a * late scroll event from selecting it twice. */ const requested = useRef(null); diff --git a/client/src/mobile/components/MSegmented.tsx b/client/src/mobile/components/MSegmented.tsx index 0cfd277fc..1790c6f82 100644 --- a/client/src/mobile/components/MSegmented.tsx +++ b/client/src/mobile/components/MSegmented.tsx @@ -28,7 +28,7 @@ export default function MSegmented({ className = '', }: MSegmentedProps) { const trackRef = useRef(null) - const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length]) + const pill = useActivePill(trackRef, '[data-active="true"]', `${String(value)}:${options.length}`) return (
vi.fn()); @@ -11,14 +11,17 @@ vi.mock('react-router', async (importOriginal) => ({ })); beforeEach(() => { + // A second spyOn returns the first spy with its calls; start every test clean. + vi.restoreAllMocks(); navigate.mockReset(); useSharedImportStore.setState({ file: null, tripId: null }); - vi.spyOn(tripsApi, 'list').mockResolvedValue({ + vi.spyOn(tripRepo, 'list').mockResolvedValue({ trips: [ { id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' }, { id: 2, title: 'Japan', start_date: '2099-03-01', end_date: '2099-03-10' }, ], - }); + archivedTrips: [], + } as unknown as Awaited>); }); describe('NativeImportSheet', () => { @@ -38,6 +41,6 @@ describe('NativeImportSheet', () => { it('stays closed with nothing shared', () => { render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(tripsApi.list).not.toHaveBeenCalled(); + expect(tripRepo.list).not.toHaveBeenCalled(); }); }); diff --git a/client/src/components/Layout/NativeImportSheet.tsx b/client/src/mobile/components/NativeImportSheet.tsx similarity index 93% rename from client/src/components/Layout/NativeImportSheet.tsx rename to client/src/mobile/components/NativeImportSheet.tsx index 3f4abe700..81487f3de 100644 --- a/client/src/components/Layout/NativeImportSheet.tsx +++ b/client/src/mobile/components/NativeImportSheet.tsx @@ -6,11 +6,11 @@ import { ChevronRight, FileText } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { tripsApi } from '../../api/client'; import { useTranslation } from '../../i18n'; -import MSheet from '../../mobile/components/MSheet'; import { useSharedImportStore } from '../../native/sharedFiles'; -import { TileHeader } from '../../mobile/screens/trip/sheets/MTripSheetUi' +import { tripRepo } from '../../repo/tripRepo'; +import { TileHeader } from '../screens/trip/sheets/MTripSheetUi'; +import MSheet from './MSheet'; interface TripRow { id: number; @@ -32,7 +32,7 @@ export default function NativeImportSheet(): React.ReactElement | null { useEffect(() => { if (!open) return; - tripsApi.list().then( + tripRepo.list().then( (data: { trips?: TripRow[] }) => { const today = new Date().toISOString().slice(0, 10); const list = [...(data.trips ?? [])]; @@ -48,7 +48,7 @@ export default function NativeImportSheet(): React.ReactElement | null { const pick = (id: number) => { chooseTrip(id); - navigate(`/trips/${id}`); + void navigate(`/trips/${id}`); }; return ( diff --git a/client/src/mobile/components/dayPagerPages.ts b/client/src/mobile/components/dayPagerPages.ts new file mode 100644 index 000000000..a86533723 --- /dev/null +++ b/client/src/mobile/components/dayPagerPages.ts @@ -0,0 +1,31 @@ +import type { Day } from '../../types'; + +/** A day as the pager needs it: only its id, so the plan and Vacay can both page. */ +export type PagerItem = Pick; + +interface Page { + key: string; + dayId: number | null; + active: boolean; +} + +/** The window of pages around the selection. A selection that points at no + * day (the whole-trip view, or a day deleted remotely) still gets its page, + * with the first day next to it as a way back. */ +export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] { + const i = days.findIndex((d) => d.id === selectedDayId); + if (i < 0) { + const first = days[0]; + return [ + { key: 'none', dayId: selectedDayId, active: true }, + ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []), + ]; + } + const pages: Page[] = []; + const prev = days[i - 1]; + const next = days[i + 1]; + if (prev) pages.push({ key: `d${prev.id}`, dayId: prev.id, active: false }); + pages.push({ key: `d${days[i].id}`, dayId: days[i].id, active: true }); + if (next) pages.push({ key: `d${next.id}`, dayId: next.id, active: false }); + return pages; +} diff --git a/client/src/mobile/components/useActivePill.ts b/client/src/mobile/components/useActivePill.ts index 1b3ddcc4d..c5a49f074 100644 --- a/client/src/mobile/components/useActivePill.ts +++ b/client/src/mobile/components/useActivePill.ts @@ -1,4 +1,4 @@ -import type { CSSProperties, DependencyList, RefObject } from 'react'; +import type { CSSProperties, RefObject } from 'react'; import { useLayoutEffect, useState } from 'react'; /** @@ -17,15 +17,18 @@ export function useActivePill( /** The track, as a ref or as the element itself (a callback-ref'd rail). */ trackRef: RefObject | HTMLElement | null, selector: string, - deps: DependencyList, + /** What moves the active item, as one value: a change re-measures the pill. */ + key: string | number, /** Follow the item across rows too (a grid of chips): the pill then also takes * the item's top and height, and the caller leaves top/bottom unset. */ rows = false ): CSSProperties | null { - const [box, setBox] = useState<{ left: number; top: number; width: number; height: number; animate: boolean } | null>(null); + const [box, setBox] = useState<{ x: number; y: number; width: number; height: number; animate: boolean } | null>( + null + ); useLayoutEffect(() => { - const track = trackRef && 'current' in trackRef ? trackRef.current : trackRef; + const track = trackRef instanceof HTMLElement ? trackRef : (trackRef?.current ?? null); if (!track) return; const measure = () => { const active = track.querySelector(selector); @@ -34,8 +37,8 @@ export function useActivePill( return; } setBox((prev) => ({ - left: active.offsetLeft, - top: active.offsetTop, + x: active.offsetLeft, + y: active.offsetTop, width: active.offsetWidth, height: active.offsetHeight, animate: prev !== null, @@ -46,14 +49,12 @@ export function useActivePill( const observer = new ResizeObserver(measure); observer.observe(track); return () => observer.disconnect(); - // The caller names what moves the active item; the ref and selector are stable. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); + }, [trackRef, selector, key]); if (!box) return null; const transition = box.animate ? 'transform 320ms var(--ease-drawer), width 320ms var(--ease-drawer)' : 'none'; if (rows) { - return { top: 0, width: box.width, height: box.height, transform: `translate(${box.left}px, ${box.top}px)`, transition }; + return { top: 0, width: box.width, height: box.height, transform: `translate(${box.x}px, ${box.y}px)`, transition }; } - return { width: box.width, transform: `translateX(${box.left}px)`, transition }; + return { width: box.width, transform: `translateX(${box.x}px)`, transition }; } diff --git a/client/src/mobile/components/useSheetDrag.ts b/client/src/mobile/components/useSheetDrag.ts index 1b5f2c09f..2133a483f 100644 --- a/client/src/mobile/components/useSheetDrag.ts +++ b/client/src/mobile/components/useSheetDrag.ts @@ -1,5 +1,5 @@ -import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; +import type { MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; /** * Drag-to-dismiss for the docked bottom sheet, the way a native sheet takes it. @@ -32,7 +32,7 @@ const SAMPLE_MS = 80; /** Upward pull: a fraction of the travel, capped, as a stretch rather than a move. */ const STRETCH = 0.2; const STRETCH_MAX = 20; -export const SPRING_BACK = 'transform 280ms var(--ease-drawer)'; +const SPRING_BACK = 'transform 280ms var(--ease-drawer)'; /** Controls that own a downward finger themselves. */ const OWNS_GESTURE = @@ -77,7 +77,9 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions) const drag = useRef(null); const frame = useRef(0); const latest = useRef(onDismiss); - latest.current = onDismiss; + useLayoutEffect(() => { + latest.current = onDismiss; + }); const write = useCallback( (y: number, transition: string) => { @@ -163,111 +165,7 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions) useEffect(() => { const panel = panelRef.current; if (!enabled || !panel) return; - /** Set by a claimed drag: the click the browser sends after it is not a tap. */ - let swallowClick = false; - - const begin = (x: number, y: number, t: number, target: EventTarget | null): boolean => { - const el = target as Element | null; - if (el?.closest?.('[data-sheet-grab]') || el?.closest?.(OWNS_GESTURE)) return false; - swallowClick = false; - drag.current = { y0: y, x0: x, samples: [{ y, t }], claimed: false, scroller: nearestScroller(target, panel) }; - return true; - }; - /** False once the gesture is not ours, so the caller lets it go. */ - const advance = (x: number, y: number, t: number): boolean => { - const d = drag.current; - if (!d) return false; - if (!d.claimed) { - const dy = y - d.y0; - const dx = x - d.x0; - if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return true; - const atTop = !d.scroller || d.scroller.scrollTop <= 0; - if (!(dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop)) { - drag.current = null; - return false; - } - d.claimed = true; - // Measured from here, so the sheet does not jump by the classify slop. - d.y0 = y; - d.samples = [{ y, t }]; - } - sample(d, y, t); - follow(y - d.y0); - return true; - }; - const finish = (y: number | undefined) => { - const d = drag.current; - drag.current = null; - if (!d?.claimed) return; - swallowClick = true; - const last = d.samples[d.samples.length - 1]; - release(d, y ?? last.y); - }; - - const onTouchStart = (e: TouchEvent) => { - if (e.touches.length !== 1) { - drag.current = null; - return; - } - const t = e.touches[0]; - begin(t.clientX, t.clientY, e.timeStamp, e.target); - }; - const onTouchMove = (e: TouchEvent) => { - if (!drag.current || e.touches.length !== 1) return; - const t = e.touches[0]; - if (advance(t.clientX, t.clientY, e.timeStamp) && drag.current?.claimed && e.cancelable) e.preventDefault(); - }; - const onTouchEnd = (e: TouchEvent) => finish(e.changedTouches[0]?.clientY); - - // Touch has its own path above: a touch pointer is cancelled the moment the - // browser starts scrolling, so only mouse and pen come through here. - const onPointerDown = (e: PointerEvent) => { - if (e.pointerType === 'touch' || e.button !== 0) return; - if (!begin(e.clientX, e.clientY, e.timeStamp, e.target)) return; - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerUp); - window.addEventListener('pointercancel', onPointerUp); - }; - const onPointerMove = (e: PointerEvent) => { - if (!advance(e.clientX, e.clientY, e.timeStamp)) { - stopPointer(); - return; - } - // No text selection while the sheet is being pulled. - if (drag.current?.claimed) e.preventDefault(); - }; - const onPointerUp = (e: PointerEvent) => { - stopPointer(); - finish(e.type === 'pointercancel' ? undefined : e.clientY); - }; - const stopPointer = () => { - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerUp); - window.removeEventListener('pointercancel', onPointerUp); - }; - const onClickCapture = (e: MouseEvent) => { - if (!swallowClick) return; - swallowClick = false; - e.preventDefault(); - e.stopPropagation(); - }; - - panel.addEventListener('touchstart', onTouchStart, { passive: true }); - panel.addEventListener('touchmove', onTouchMove, { passive: false }); - panel.addEventListener('touchend', onTouchEnd, { passive: true }); - panel.addEventListener('touchcancel', onTouchEnd, { passive: true }); - panel.addEventListener('pointerdown', onPointerDown); - panel.addEventListener('click', onClickCapture, true); - return () => { - cancelAnimationFrame(frame.current); - stopPointer(); - panel.removeEventListener('touchstart', onTouchStart); - panel.removeEventListener('touchmove', onTouchMove); - panel.removeEventListener('touchend', onTouchEnd); - panel.removeEventListener('touchcancel', onTouchEnd); - panel.removeEventListener('pointerdown', onPointerDown); - panel.removeEventListener('click', onClickCapture, true); - }; + return attachContentDrag(panel, drag, frame, follow, release); }, [enabled, panelRef, follow, release]); return { @@ -280,3 +178,121 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions) }, }; } + +/** + * The content's half of the drag: a finger (touch events) or a mouse or pen + * (pointer events), claimed on the first move or let go. Returns the detach. + */ +function attachContentDrag( + panel: HTMLElement, + drag: MutableRefObject, + frame: MutableRefObject, + follow: (dy: number) => void, + release: (d: Drag, lastY: number) => void +): () => void { + /** Set by a claimed drag: the click the browser sends after it is not a tap. */ + let swallowClick = false; + + const begin = (x: number, y: number, t: number, target: EventTarget | null): boolean => { + const el = target as Element | null; + if (el?.closest?.('[data-sheet-grab]') || el?.closest?.(OWNS_GESTURE)) return false; + swallowClick = false; + drag.current = { y0: y, x0: x, samples: [{ y, t }], claimed: false, scroller: nearestScroller(target, panel) }; + return true; + }; + /** False once the gesture is not ours, so the caller lets it go. */ + const advance = (x: number, y: number, t: number): boolean => { + const d = drag.current; + if (!d) return false; + if (!d.claimed) { + const dy = y - d.y0; + const dx = x - d.x0; + if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return true; + const atTop = !d.scroller || d.scroller.scrollTop <= 0; + if (!(dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop)) { + drag.current = null; + return false; + } + d.claimed = true; + // Measured from here, so the sheet does not jump by the classify slop. + d.y0 = y; + d.samples = [{ y, t }]; + } + sample(d, y, t); + follow(y - d.y0); + return true; + }; + const finish = (y: number | undefined) => { + const d = drag.current; + drag.current = null; + if (!d?.claimed) return; + swallowClick = true; + const last = d.samples[d.samples.length - 1]; + release(d, y ?? last.y); + }; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) { + drag.current = null; + return; + } + const t = e.touches[0]; + begin(t.clientX, t.clientY, e.timeStamp, e.target); + }; + const onTouchMove = (e: TouchEvent) => { + if (!drag.current || e.touches.length !== 1) return; + const t = e.touches[0]; + if (advance(t.clientX, t.clientY, e.timeStamp) && drag.current?.claimed && e.cancelable) e.preventDefault(); + }; + const onTouchEnd = (e: TouchEvent) => finish(e.changedTouches[0]?.clientY); + + // Touch has its own path above: a touch pointer is cancelled the moment the + // browser starts scrolling, so only mouse and pen come through here. + const onPointerDown = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.button !== 0) return; + if (!begin(e.clientX, e.clientY, e.timeStamp, e.target)) return; + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); + }; + const onPointerMove = (e: PointerEvent) => { + if (!advance(e.clientX, e.clientY, e.timeStamp)) { + stopPointer(); + return; + } + // No text selection while the sheet is being pulled. + if (drag.current?.claimed) e.preventDefault(); + }; + const onPointerUp = (e: PointerEvent) => { + stopPointer(); + finish(e.type === 'pointercancel' ? undefined : e.clientY); + }; + const stopPointer = () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + }; + const onClickCapture = (e: MouseEvent) => { + if (!swallowClick) return; + swallowClick = false; + e.preventDefault(); + e.stopPropagation(); + }; + + panel.addEventListener('touchstart', onTouchStart, { passive: true }); + panel.addEventListener('touchmove', onTouchMove, { passive: false }); + panel.addEventListener('touchend', onTouchEnd, { passive: true }); + panel.addEventListener('touchcancel', onTouchEnd, { passive: true }); + panel.addEventListener('pointerdown', onPointerDown); + panel.addEventListener('click', onClickCapture, true); + return () => { + cancelAnimationFrame(frame.current); + stopPointer(); + panel.removeEventListener('touchstart', onTouchStart); + panel.removeEventListener('touchmove', onTouchMove); + panel.removeEventListener('touchend', onTouchEnd); + panel.removeEventListener('touchcancel', onTouchEnd); + panel.removeEventListener('pointerdown', onPointerDown); + panel.removeEventListener('click', onClickCapture, true); + }; +} diff --git a/client/src/mobile/overlayStack.ts b/client/src/mobile/overlayStack.ts index 0703b2718..d30bdbaab 100644 --- a/client/src/mobile/overlayStack.ts +++ b/client/src/mobile/overlayStack.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useLayoutEffect, useRef } from 'react'; /** * The overlays open on screen, newest last, so a way back closes the one on @@ -25,7 +25,9 @@ export function hasOpenOverlay(): boolean { /** Registers an overlay while `open`, with the way it closes. */ export function useOverlay(open: boolean, close: Close): void { const closeRef = useRef(close); - closeRef.current = close; + useLayoutEffect(() => { + closeRef.current = close; + }); useEffect(() => { if (!open) return; const entry = { id: nextId++, close: () => closeRef.current() }; diff --git a/client/src/mobile/routeTransition.test.tsx b/client/src/mobile/routeTransition.test.tsx index 045f14abe..b69502299 100644 --- a/client/src/mobile/routeTransition.test.tsx +++ b/client/src/mobile/routeTransition.test.tsx @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react'; -import type { Location, NavigationType } from 'react-router'; +import { NavigationType, type Location } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { skipNextRouteTransition, transitionKind, useRouteTransition } from './routeTransition'; @@ -7,11 +7,11 @@ const loc = (pathname: string, key = pathname): Location => ({ pathname, key, se describe('transitionKind', () => { it('slides deeper pages forward and back, cross-fades between sections', () => { - expect(transitionKind(loc('/dashboard'), loc('/trips/4'), 'PUSH')).toBe('forward'); - expect(transitionKind(loc('/trips/4'), loc('/dashboard'), 'POP')).toBe('back'); - expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), 'PUSH')).toBe('forward'); - expect(transitionKind(loc('/dashboard'), loc('/atlas'), 'PUSH')).toBe('tab'); - expect(transitionKind(loc('/vacay'), loc('/dashboard'), 'POP')).toBe('tab'); + expect(transitionKind(loc('/dashboard'), loc('/trips/4'), NavigationType.Push)).toBe('forward'); + expect(transitionKind(loc('/trips/4'), loc('/dashboard'), NavigationType.Pop)).toBe('back'); + expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), NavigationType.Push)).toBe('forward'); + expect(transitionKind(loc('/dashboard'), loc('/atlas'), NavigationType.Push)).toBe('tab'); + expect(transitionKind(loc('/vacay'), loc('/dashboard'), NavigationType.Pop)).toBe('tab'); }); }); @@ -37,14 +37,14 @@ describe('useRouteTransition', () => { function render(initial: Location, enabled = true) { return renderHook( ({ location, type }: { location: Location; type: NavigationType }) => useRouteTransition(location, type, enabled), - { initialProps: { location: initial, type: 'PUSH' as NavigationType } } + { initialProps: { location: initial, type: NavigationType.Push } } ); } it('swaps the page inside a view transition and marks its direction', async () => { const hook = render(loc('/dashboard')); await act(async () => { - hook.rerender({ location: loc('/trips/1'), type: 'PUSH' }); + hook.rerender({ location: loc('/trips/1'), type: NavigationType.Push }); }); expect(start).toHaveBeenCalledTimes(1); expect(hook.result.current.pathname).toBe('/trips/1'); @@ -54,16 +54,16 @@ describe('useRouteTransition', () => { const hook = render(loc('/trips/1')); skipNextRouteTransition(); await act(async () => { - hook.rerender({ location: loc('/dashboard'), type: 'POP' }); + hook.rerender({ location: loc('/dashboard'), type: NavigationType.Pop }); }); await act(async () => { - hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: 'PUSH' }); + hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: NavigationType.Push }); }); expect(start).not.toHaveBeenCalled(); expect(hook.result.current.key).toBe('k2'); const off = render(loc('/dashboard'), false); - off.rerender({ location: loc('/trips/2'), type: 'PUSH' }); + off.rerender({ location: loc('/trips/2'), type: NavigationType.Push }); expect(off.result.current.pathname).toBe('/trips/2'); expect(start).not.toHaveBeenCalled(); }); diff --git a/client/src/mobile/routeTransition.ts b/client/src/mobile/routeTransition.ts index 2987efe23..12a31732c 100644 --- a/client/src/mobile/routeTransition.ts +++ b/client/src/mobile/routeTransition.ts @@ -22,6 +22,9 @@ const SETTLE_MS = 800; let skipNext = false; let current: ViewTransition | null = null; +/** A newer navigation skipped this transition: its promises reject, and that is no error. */ +function skippedByNewer(): void {} + /** The edge-swipe back gesture already slid the page away: the next change goes without a transition. */ export function skipNextRouteTransition(): void { skipNext = true; @@ -85,14 +88,12 @@ export function useRouteTransition(location: Location, navigationType: Navigatio }); current = transition; // A newer navigation skips this one: both promises then reject, which is no error. - transition.ready.catch(() => {}); - void transition.finished - .catch(() => {}) - .finally(() => { - // Only the newest transition clears the direction, or a skipped one would - // take it away from the one that replaced it. - if (current === transition) delete root.dataset.routeTransition; - }); + transition.ready.catch(skippedByNewer); + void transition.finished.catch(skippedByNewer).finally(() => { + // Only the newest transition clears the direction, or a skipped one would + // take it away from the one that replaced it. + if (current === transition) delete root.dataset.routeTransition; + }); }, [location, navigationType, enabled]); return enabled ? shown : location; diff --git a/client/src/mobile/screens/admin/MAdminResetMfaSheet.tsx b/client/src/mobile/screens/admin/MAdminResetMfaSheet.tsx index 18d72d391..7cc524a30 100644 --- a/client/src/mobile/screens/admin/MAdminResetMfaSheet.tsx +++ b/client/src/mobile/screens/admin/MAdminResetMfaSheet.tsx @@ -33,7 +33,7 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }: } - title={t('admin.mfa.reset')} + title={t('admin.mfa.reset')} onClose={onClose} footer={ <> diff --git a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx index 1005de086..f4c786484 100644 --- a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx +++ b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx @@ -44,7 +44,7 @@ function AddCountrySheet({ } - title={t('schoolCatalog.addCountry')} + title={t('schoolCatalog.addCountry')} onClose={close} footer={ <> @@ -120,7 +120,7 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr > } - title={name.trim() || t('schoolCatalog.region')} + title={name.trim() || t('schoolCatalog.region')} onClose={() => { if (!busy) onClose(); }} diff --git a/client/src/mobile/screens/collections/MCollTours.tsx b/client/src/mobile/screens/collections/MCollTours.tsx index 8a1514370..8d6d901ea 100644 --- a/client/src/mobile/screens/collections/MCollTours.tsx +++ b/client/src/mobile/screens/collections/MCollTours.tsx @@ -213,7 +213,11 @@ function MCollTourSheet({ return ( } title={title} onClose={takeOver.close} closeLabel={t('common.close')} /> + icon={} + title={title} + onClose={takeOver.close} + closeLabel={t('common.close')} + />
{tour && (

diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 1b196b242..57394a7a8 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -300,13 +300,13 @@ export default function MTripShell({ // The active day's pill glides to the next chip rather than jumping there. // A booking document shared to TREK, and this trip picked for it: open the import. const sharedTripId = useSharedImportStore(s => s.tripId) + const { setBookingImportKind, setShowBookingImport } = planner useEffect(() => { if (sharedTripId == null || sharedTripId !== Number(tripId)) return - planner.setBookingImportKind('bookings') - planner.setShowBookingImport(true) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sharedTripId, tripId]) - const dayPill = useActivePill(dayRail, '[aria-current="true"]', [railDayId, days, dayRail, language]) + setBookingImportKind('bookings') + setShowBookingImport(true) + }, [sharedTripId, tripId, setBookingImportKind, setShowBookingImport]) + const dayPill = useActivePill(dayRail, '[aria-current="true"]', `${railDayId}:${days.map(d => d.id).join(',')}:${language}`) const trTab = planner.activeTab @@ -516,7 +516,7 @@ export default function MTripShell({ {dayPill && (

- {pill &&