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
This commit is contained in:
Maurice
2026-11-06 18:24:25 +01:00
parent f81fe8cb17
commit b7951baf23
75 changed files with 645 additions and 475 deletions
+7 -7
View File
@@ -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,
+7 -4
View File
@@ -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.",
-5
View File
@@ -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",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+2
View File
@@ -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
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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'
+2 -17
View File
@@ -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.
+15
View File
@@ -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();
}
@@ -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;
}
+9 -4
View File
@@ -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<typeof caps | undefined>(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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -61
View File
@@ -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<string, string> = {
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.
*
+5 -1
View File
@@ -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.
+6 -2
View File
@@ -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: '' }
}
@@ -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', () => {
+2 -14
View File
@@ -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
@@ -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<string, string> = {
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 };
}
@@ -36,11 +36,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
const mouseDownTarget = useRef<EventTarget | null>(null)
const [files, setFiles] = useState<File[]>([])
// 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))
@@ -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
+15 -8
View File
@@ -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(
<div
// Backdrop only — Escape (above) and the Cancel button below are the
@@ -83,7 +90,7 @@ function ConfirmDialogFrame({
own, and the two buttons below it always stay in reach. */}
<div
role="presentation"
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} p-6 bg-surface-card`}
onClick={e => e.stopPropagation()}
>
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
@@ -94,7 +101,7 @@ function ConfirmDialogFrame({
)}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-content">
{title || t('common.confirm')}
{heading}
</h3>
<p className="mt-1 text-sm text-content-secondary">
{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}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
@@ -116,7 +123,7 @@ function ConfirmDialogFrame({
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
@@ -126,7 +133,7 @@ function ConfirmDialogFrame({
// with its tile, the message below, the answers in a bar at the foot.
<div
role="presentation"
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
@@ -136,7 +143,7 @@ function ConfirmDialogFrame({
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
</DialogTile>
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
{title || t('common.confirm')}
{heading}
</h3>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
@@ -146,13 +153,13 @@ function ConfirmDialogFrame({
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
<button type="button" onClick={onClose}
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
{cancelLabel || t('common.cancel')}
{cancelText}
</button>
<button type="button"
onClick={() => { runConfirm(onConfirm); onClose() }}
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
>
{confirmLabel || t('common.delete')}
{confirmText}
</button>
</div>
</div>
+14 -8
View File
@@ -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
+51
View File
@@ -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;
}
}
+12 -13
View File
@@ -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 };
}
-36
View File
@@ -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;
@@ -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() }));
+4 -30
View File
@@ -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<Day, 'id'>;
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<number | null>(null);
+1 -1
View File
@@ -28,7 +28,7 @@ export default function MSegmented<T extends string = string>({
className = '',
}: MSegmentedProps<T>) {
const trackRef = useRef<HTMLDivElement>(null)
const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length])
const pill = useActivePill(trackRef, '[data-active="true"]', `${String(value)}:${options.length}`)
return (
<div
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { tripsApi } from '../../api/client';
import { useSharedImportStore } from '../../native/sharedFiles';
import { tripRepo } from '../../repo/tripRepo';
import NativeImportSheet from './NativeImportSheet';
const navigate = vi.hoisted(() => 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<ReturnType<typeof tripRepo.list>>);
});
describe('NativeImportSheet', () => {
@@ -38,6 +41,6 @@ describe('NativeImportSheet', () => {
it('stays closed with nothing shared', () => {
render(<NativeImportSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(tripsApi.list).not.toHaveBeenCalled();
expect(tripRepo.list).not.toHaveBeenCalled();
});
});
@@ -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 (
@@ -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<Day, 'id'>;
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;
}
+12 -11
View File
@@ -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> | 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<HTMLElement>(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 };
}
+125 -109
View File
@@ -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<Drag | null>(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<Drag | null>,
frame: MutableRefObject<number>,
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);
};
}
+4 -2
View File
@@ -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() };
+11 -11
View File
@@ -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();
});
+9 -8
View File
@@ -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;
@@ -33,7 +33,7 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }:
<MSheet open={open} onClose={onClose} ariaLabel={t('admin.mfa.reset')}>
<MAdminSheetFrame
icon={<ShieldOff size={19} strokeWidth={1.8} />}
title={t('admin.mfa.reset')}
title={t('admin.mfa.reset')}
onClose={onClose}
footer={
<>
@@ -44,7 +44,7 @@ function AddCountrySheet({
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={t('schoolCatalog.addCountry')}>
<MAdminSheetFrame
icon={<GraduationCap size={19} strokeWidth={1.8} />}
title={t('schoolCatalog.addCountry')}
title={t('schoolCatalog.addCountry')}
onClose={close}
footer={
<>
@@ -120,7 +120,7 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
>
<MAdminSheetFrame
icon={<GraduationCap size={19} strokeWidth={1.8} />}
title={name.trim() || t('schoolCatalog.region')}
title={name.trim() || t('schoolCatalog.region')}
onClose={() => {
if (!busy) onClose();
}}
@@ -213,7 +213,11 @@ function MCollTourSheet({
return (
<MSheet open={takeOver.open} onClose={takeOver.close} material="glass" ariaLabel={title}>
<SheetHeader
icon={<Route size={19} strokeWidth={1.8} />} title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
icon={<Route size={19} strokeWidth={1.8} />}
title={title}
onClose={takeOver.close}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{tour && (
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
@@ -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 && (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]"
className="pointer-events-none absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]" // rtl-lint-disable: moved by the measured offsetLeft (useActivePill)
style={dayPill}
/>
)}
@@ -271,7 +271,9 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
* store write and on every sheet it opens, this handler's own included.
*/
const latest = useRef({ planner, shell, stage })
latest.current = { planner, shell, stage }
useLayoutEffect(() => {
latest.current = { planner, shell, stage }
})
const openStagePin = useCallback((placeId?: number) => {
const { planner: now, shell: chrome, stage: card } = latest.current
const stop = placeId == null ? null : firstStopOfPlace(card ? [card] : now.roadtripRoutes.days, placeId)
@@ -24,7 +24,8 @@ import type { RouteSegment } from '../../../../types'
import type { Assignment } from '../../../../types'
import type { ReactNode } from 'react'
import { useSettingsStore } from '../../../../store/settingsStore'
import MRouteAppsSheet, { openRouteTarget } from './MRouteAppsSheet'
import MRouteAppsSheet from './MRouteAppsSheet'
import { openRouteTarget } from './openRouteTarget'
import MDayPager from '../../../components/MDayPager'
import { useDayPreviewStore } from '../../../dayPreviewStore'
@@ -2,13 +2,8 @@ import { ChevronRight, MapPinned } from 'lucide-react';
import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps';
import type { TranslationFn } from '../../../../types';
import MSheet from '../../../components/MSheet';
import { TileHeader } from '../sheets/MTripSheetUi'
/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */
export function openRouteTarget(target: DayRouteTarget): void {
if (target.url.startsWith('geo:')) window.location.assign(target.url);
else window.open(target.url, '_blank', 'noopener,noreferrer');
}
import { TileHeader } from '../sheets/MTripSheetUi';
import { openRouteTarget } from './openRouteTarget';
/**
* The plan's one Maps button, asked: which app should get the day. A docked
@@ -0,0 +1,7 @@
import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps';
/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */
export function openRouteTarget(target: DayRouteTarget): void {
if (target.url.startsWith('geo:')) window.location.assign(target.url);
else window.open(target.url, '_blank', 'noopener,noreferrer');
}
@@ -126,7 +126,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
* ones either side. Each page reads its own stage, so the one that slides in is
* exactly the one that lands.
*/
function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: MTripTabPanelProps & {
function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: Omit<MTripTabPanelProps, 'tab'> & {
dayId: number | null
alts: ReturnType<typeof useMRtAlternatives>
chrome: RowChrome
@@ -1,14 +1,9 @@
import { X } from 'lucide-react';
import { createContext, useContext } from 'react';
import { useContext } from 'react';
import type { DetailShellProps } from '../../../../components/shared/DetailShell';
import MIconBtn from '../../../components/MIconBtn';
import MSheet from '../../../components/MSheet';
/**
* Whether the tour sheet is open (false while it slides away with the tour it
* showed last) and the tour's name, which names the dialog.
*/
export const TourSheetOpen = createContext<{ open: boolean; label: string }>({ open: true, label: '' });
import { TourSheetOpen } from './tourSheetOpen';
/**
* The phone's frame for the tour detail: the desktop dialog's head, sections
@@ -16,6 +16,7 @@ import { useSearchParams } from 'react-router'
import { isTransportReservationType } from '@trek/shared'
import { useBookingLink } from '../../../../hooks/useBookingLink'
import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts'
import { parseMeta } from '../../../../components/Planner/bookings/bookingsModel'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
interface TransportSheetPayload {
@@ -178,7 +179,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const hotelStats = res.type === 'hotel'
? stayStats(
bookingFacts(res, { t, locale, timeFormat, days: planner.days, assignmentLookup: {}, hasLinkedCost: false }),
meta,
parseMeta(res),
t,
locale,
)
@@ -32,7 +32,8 @@ import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
import type { MTripSheetsProps } from '../MTripShell'
import MTourDetailShell, { TourSheetOpen } from './MTourDetailShell'
import MTourDetailShell from './MTourDetailShell'
import { TourSheetOpen } from './tourSheetOpen'
import { useLastOpen } from '../../../../components/shared/usePresence'
/**
@@ -0,0 +1,7 @@
import { createContext } from 'react';
/**
* Whether the tour sheet is open (false while it slides away with the tour it
* showed last) and the tour's name, which names the dialog.
*/
export const TourSheetOpen = createContext<{ open: boolean; label: string }>({ open: true, label: '' });
+3 -3
View File
@@ -150,7 +150,7 @@ export default function MVacay() {
aria-haspopup="dialog"
className={GLASS_PILL_BUTTON}
>
<span className="flex -space-x-1">
<span className="flex [&>*+*]:-ms-1">
{v.users.slice(0, 4).map(u => (
<span
key={u.id}
@@ -509,11 +509,11 @@ function FuseInfo({ icon: Icon, text }: { icon: LucideIcon; text: string }) {
/** The editor's month picker: the window's twelve months on one glass surface. */
function MonthQuickNav({ names, slot, onPick }: { names: string[]; slot: number; onPick: (slot: number) => void }) {
const trackRef = useRef<HTMLDivElement>(null)
const pill = useActivePill(trackRef, '[data-active="true"]', [slot, names.length], true)
const pill = useActivePill(trackRef, '[data-active="true"]', `${slot}:${names.length}`, true)
return (
<div className="mb-[10px] rounded-[22px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-1.5 shadow-[0_8px_24px_-16px_rgba(0,0,0,.3)] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div ref={trackRef} className="relative grid grid-cols-6 gap-1">
{pill && <span aria-hidden="true" className="pointer-events-none absolute left-0 rounded-full bg-m-act" style={pill} />}
{/* rtl-lint-disable: the pill moves by the measured offsetLeft (useActivePill) */}{pill && <span aria-hidden="true" className="pointer-events-none absolute left-0 rounded-full bg-m-act" style={pill} />}
{names.map((name, i) => (
<button
key={i}
+7 -8
View File
@@ -12,6 +12,11 @@ import {
useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape, type VacayMode,
} from '../../../components/Vacay/useVacayCalendarLogic'
/** The full name of a month in the user's language ("März"). */
function longMonthName(locale: string, m: { year: number; month: number }): string {
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(m.year, m.month, 1))
}
export type MVacayView = 'grid' | 'edit'
export type MVacayMode = VacayMode
export type MVacaySheet = 'invite' | 'settings' | 'share' | 'people' | 'legend' | null
@@ -91,17 +96,11 @@ export function useMVacay() {
return months.map(({ year, month }) => fmt.format(new Date(year, month, 1)))
}, [locale, months])
const monthNameLong = useMemo(
() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(activeMonth.year, activeMonth.month, 1)),
[locale, activeMonth],
)
const monthNameLong = useMemo(() => longMonthName(locale, activeMonth), [locale, activeMonth])
// The pagers draw the neighbours of the open month and year too: the long name
// of any slot of the window, and the twelve months of any year.
const monthNameLongFor = useCallback((slot: number) => {
const m = months[slot] ?? months[0]
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(m.year, m.month, 1))
}, [locale, months])
const monthNameLongFor = useCallback((slot: number) => longMonthName(locale, months[slot] ?? months[0]), [locale, months])
const monthsFor = useCallback((year: number) => windowMonths(year, yearSettings), [yearSettings])
const selectedUser = users.find(u => u.id === selectedUserId)
+8 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { tickHaptic } from '../native/haptics';
import { hasOpenOverlay } from './overlayStack';
@@ -10,6 +10,9 @@ const MIN_SPIN_MS = 450;
export type PullState = { distance: number; refreshing: boolean; armed: boolean };
/** The screen's own refresh reports its failures (a toast, its error state); the pull only lets go. */
function refreshFailed(): void {}
/**
* Pull-to-refresh on a page whose scroller is the window, as the mobile
* dashboard is. A downward drag that starts at the very top moves `contentRef`
@@ -29,7 +32,9 @@ export function usePullToRefresh(
): PullState {
const [state, setState] = useState<PullState>({ distance: 0, refreshing: false, armed: false });
const refreshRef = useRef(onRefresh);
refreshRef.current = onRefresh;
useLayoutEffect(() => {
refreshRef.current = onRefresh;
});
useEffect(() => {
if (!enabled) return;
@@ -98,7 +103,7 @@ export function usePullToRefresh(
const began = Date.now();
void Promise.resolve()
.then(() => refreshRef.current())
.catch(() => {})
.catch(refreshFailed)
.then(() => new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_SPIN_MS - (Date.now() - began)))))
.then(() => {
busy = false;
+4 -1
View File
@@ -70,7 +70,10 @@ export function nativeCapabilities(): NativeCapabilities {
*/
export async function checkAppUpdate(fetchFeatures: () => Promise<{ minNativeShellApi?: unknown }>): Promise<boolean> {
if (!isNativeApp()) return false;
const [caps, features] = await Promise.all([loadNativeCapabilities(), fetchFeatures().catch(() => ({}))]);
const [caps, features] = await Promise.all([
loadNativeCapabilities(),
fetchFeatures().catch((): { minNativeShellApi?: unknown } => ({})),
]);
const min = typeof features.minNativeShellApi === 'number' ? features.minNativeShellApi : 0;
updateRequired = caps.shellApi < min;
emit();
+1 -1
View File
@@ -23,7 +23,7 @@ beforeEach(() => {
vi.mocked(TrekShell.addListener)
.mockReset()
.mockImplementation(async (_event, listener) => {
emit = listener;
emit = listener as unknown as typeof emit;
return { remove: async () => {} };
});
});
+1 -1
View File
@@ -57,7 +57,7 @@ function toError(e: { code: number; message: string }): GeolocationPositionError
function dispatch(payload: NativeLocationPayload): void {
for (const [id, w] of [...watchers]) {
if (payload.error) w.failure?.(toError(payload.error));
else w.success(toPosition(payload));
else w.success(toPosition(payload as Extract<NativeLocationPayload, { latitude: number }>));
if (w.once) watchers.delete(id);
}
syncStream();
+1 -1
View File
@@ -30,7 +30,7 @@ describe('shared booking documents', () => {
await startSharedFiles();
expect(useSharedImportStore.getState().file?.name).toBe('booking.pdf');
const listener = vi.mocked(TrekShell.addListener).mock.calls[0][1] as (p: typeof PDF) => void;
const listener = vi.mocked(TrekShell.addListener).mock.calls[0][1] as unknown as (p: typeof PDF) => void;
listener({ ...PDF, name: 'train.ics', mimeType: 'text/calendar' });
expect(useSharedImportStore.getState().file?.name).toBe('train.ics');
});
+1 -1
View File
@@ -16,7 +16,7 @@ import { isNativeIos } from './platform';
* Nothing is prefetched through here: Mapbox forbids bulk downloads, and the
* OpenFreeMap region a trip stores goes through Cache Storage (offlineTiles.ts).
*/
export const TILE_CACHE_SCHEME = 'trek-tile';
const TILE_CACHE_SCHEME = 'trek-tile';
export function tileCacheActive(): boolean {
return isNativeIos() && hasNativeFeature('tileCache');
+3 -1
View File
@@ -99,7 +99,8 @@ describe('todayOf and tripDayState', () => {
'2026-11-06',
() => 'D',
(t) => t
)!;
);
if (!today) throw new Error('the trip runs today');
expect(tripDayState(today, 8 * 60, 'done')).toMatchObject({ nextName: 'Museum', done: 0, total: 2 });
expect(tripDayState(today, 12 * 60, 'done')).toMatchObject({ nextName: 'Dinner', nextTime: '19:30', done: 1 });
expect(tripDayState(today, 22 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2, nextTime: undefined });
@@ -119,6 +120,7 @@ describe('publishWidgets', () => {
path: '/trips/1',
dayLabel: 'D',
nextName: 'x',
nextTime: undefined,
done: 0,
total: 0,
});
+22 -15
View File
@@ -20,7 +20,7 @@ export interface WidgetLabels {
nothingPlanned: string;
}
export interface WidgetStop {
interface WidgetStop {
time: string | null;
name: string;
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
@@ -54,13 +54,13 @@ function minutesOf(time: string | null | undefined): number | null {
* and trips already over are not candidates.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const dated = trips.filter((t) => t.start_date && t.end_date && !t.is_archived);
const running = dated.find((t) => t.start_date! <= today && t.end_date! >= today);
if (running) return running;
const upcoming = dated
.filter((t) => t.start_date! > today)
.sort((a, b) => a.start_date!.localeCompare(b.start_date!));
return upcoming[0] ?? null;
const dated = trips.flatMap((t) =>
t.start_date && t.end_date && !t.is_archived ? [{ trip: t, start: t.start_date, end: t.end_date }] : []
);
const running = dated.find((d) => d.start <= today && d.end >= today);
if (running) return running.trip;
const upcoming = dated.filter((d) => d.start > today).sort((a, b) => a.start.localeCompare(b.start));
return upcoming[0]?.trip ?? null;
}
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
@@ -77,18 +77,25 @@ export function todayOf(
const day = ordered[index];
const stops = [...(day.assignments ?? [])]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.filter((a) => a.place?.name)
.map((a) => ({
name: a.place!.name,
time: a.place?.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place?.place_time),
}));
.flatMap((a) =>
a.place?.name
? [
{
name: a.place.name,
time: a.place.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place.place_time),
},
]
: []
);
return { tripTitle: trip.title, path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops };
}
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
return {
trip: trip ? { title: trip.title, start: trip.start_date!, end: trip.end_date!, path: `/trips/${trip.id}` } : null,
trip: trip
? { title: trip.title, start: trip.start_date ?? '', end: trip.end_date ?? '', path: `/trips/${trip.id}` }
: null,
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : null,
labels,
};
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useLayoutEffect } from 'react'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle'
@@ -105,7 +105,9 @@ export function useTripPlannerPage() {
// The place card's height over the desktop map, so the selection pans its pin above it (#2991).
const inspectorCard = useMeasuredHeight()
const poiViewportChangeRef = useRef(poi.onViewportChange)
poiViewportChangeRef.current = poi.onViewportChange
useLayoutEffect(() => {
poiViewportChangeRef.current = poi.onViewportChange
})
// The place search looks where the plan map looks, and only while it is on screen.
const reportViewport = usePlanMapViewport(tripId, activeTab === 'plan')
const captureViewport = useCallback((bounds: Bbox) => {
+21 -8
View File
@@ -80,7 +80,7 @@ const REGIONAL_GLYPHS: { box: [number, number, number, number]; ranges: string[]
]
/** The glyph ranges a map of `bbox` needs: the common ones plus its region's scripts. */
export function glyphRangesFor(bbox: TileBbox | null): string[] {
function glyphRangesFor(bbox: TileBbox | null): string[] {
const out = new Set(GLYPH_RANGES)
if (bbox) {
for (const { box, ranges } of REGIONAL_GLYPHS) {
@@ -148,11 +148,8 @@ async function warm(cache: Cache | null, url: string): Promise<number> {
if (cache && (await cache.match(url))) return 0
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return 0
// The length header when the server sends one; otherwise the body itself.
const header = Number(res.headers.get('content-length'))
const size = header > 0 ? header : (await res.clone().blob()).size || 1
await cache?.put(url, res)
return size
await cache?.put(url, res.clone())
return await sizeOf(res)
} catch {
return 0
}
@@ -248,12 +245,13 @@ async function prefetchStyle(styleUrl: string, bbox: TileBbox | null = null): Pr
])
}
if (style.glyphs) {
const glyphs = style.glyphs
if (glyphs) {
// Through the same pool as the tiles: a trip in East Asia asks for a few
// hundred glyph ranges, and OpenFreeMap allows four requests at a time.
const urls = fontStacks(style).flatMap(stack =>
glyphRangesFor(bbox).map(range =>
style.glyphs!.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range),
glyphs.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range),
),
)
await inPool(urls, url => warm(cache, url))
@@ -311,6 +309,21 @@ export async function prefetchVectorForPlaces(
return { tiles: fetched, template }
}
/**
* The bytes a response brought, for the run's budget: the length header when
* the server sends one, else the body itself, and at least 1 so a fetched tile
* always counts as fetched.
*/
async function sizeOf(res: Response): Promise<number> {
const header = Number(res.headers?.get('content-length'))
if (header > 0) return header
try {
return (await res.blob()).size || 1
} catch {
return 1
}
}
/** Runs `task` over `items`, CONCURRENCY at a time. */
async function inPool<T>(items: T[], task: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0
+10 -4
View File
@@ -27,10 +27,9 @@ import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
import { isStoragePersisted } from './persistentStorage'
import { isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { isOfflineForbiddenTemplate, isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchStyleAssets, prefetchVectorForPlaces } from './glPrefetcher'
import { isOfflineForbiddenTemplate } from '../components/Map/offlineBasemap'
import { isNativeIos } from '../native/platform'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -390,6 +389,13 @@ function sameBbox(a: [number, number, number, number], b: TileBbox): boolean {
/** The OpenFreeMap styles the offline fallback draws; they share one tile set. */
const OFFLINE_BASEMAPS: readonly string[] = [OFM_POSITRON, OFM_DARK]
/**
* The offline basemap is a best-effort extra: a failed request leaves its tile
* uncached and the next sync tries again, so it stops neither the raster run
* nor the sync.
*/
function basemapFailed(): void {}
/**
* The trip's region of the offline basemap: the light style's tiles, style,
* sprite and glyphs, and the dark style's own style, sprite and glyphs on top,
@@ -398,8 +404,8 @@ const OFFLINE_BASEMAPS: readonly string[] = [OFM_POSITRON, OFM_DARK]
async function prefetchOfflineBasemap(places: Place[], template: string, cancelled: () => boolean): Promise<void> {
const light = template === OFM_DARK ? OFM_DARK : OFM_POSITRON
const other = light === OFM_DARK ? OFM_POSITRON : OFM_DARK
await prefetchVectorForPlaces(places, light, cancelled).catch(console.error)
if (!cancelled()) await prefetchStyleAssets(other, computeBbox(places)).catch(console.error)
await prefetchVectorForPlaces(places, light, cancelled).catch(basemapFailed)
if (!cancelled()) await prefetchStyleAssets(other, computeBbox(places)).catch(basemapFailed)
}
/**
+13
View File
@@ -132,3 +132,16 @@ export function resolveBasemap(
if (isVectorStyle(chosen)) return { kind: 'vector', style: chosen }
return { kind: 'raster', url: chosen }
}
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
/** Whether a raster template's host forbids storing its tiles (the offline basemap stands in). */
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;
}
}
+4
View File
@@ -1,4 +1,6 @@
import type { StoreApi } from 'zustand';
import { useDayPreviewStore } from '../../src/mobile/dayPreviewStore';
import { useSharedImportStore } from '../../src/native/sharedFiles';
import { useAddonStore } from '../../src/store/addonStore';
import { useAssistantHintsStore } from '../../src/store/assistantHintsStore';
import { useAuthStore } from '../../src/store/authStore';
@@ -33,6 +35,8 @@ type ResettableStore = Pick<StoreApi<object>, 'getState' | 'setState'>;
export const RESET_STORES: Record<string, ResettableStore> = {
useAuthStore,
useDayPreviewStore,
useSharedImportStore,
useTripStore,
useSettingsStore,
useVacayStore,
@@ -63,8 +63,8 @@ describe('MCollSheetKit', () => {
render(<SheetFooter><span>footer slot</span></SheetFooter>)
expect(screen.getByText('footer slot')).toBeInTheDocument()
// Both field presets have to sit on the sheet surface token, not a raw colour.
expect(INPUT_CLS).toContain('bg-[color:var(--m-sheet)]')
expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-sheet)]')
// Both field presets sit on the field surface token, as every phone sheet's fields, not a raw colour.
expect(INPUT_CLS).toContain('bg-[color:var(--m-ic)]')
expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-ic)]')
})
})
@@ -214,13 +214,14 @@ describe('MCollections', () => {
expect(document.querySelector('.animate-spin')).not.toBeNull()
})
it('FE-MOB-COLSCR-002: with no lists at all it offers the first-list CTA', () => {
it('FE-MOB-COLSCR-002: with no lists at all the dock + button starts the first one; the screen has no button of its own', async () => {
mocks.coll = makeHook({ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false })
render(<MCollections />)
render(<MCollections />, { initialEntries: ['/collections?create=place'] })
expect(screen.getByText('collections.empty.firstTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
expect(screen.queryByRole('button', { name: /^collections.newList$/ })).not.toBeInTheDocument()
await waitFor(() => expect(fn('setEditorTarget')).toHaveBeenCalledWith('new'))
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument()
})
@@ -262,7 +262,7 @@ describe('MJourneySettingsSheet', () => {
setup();
fireEvent.click(await screen.findByRole('button', { name: 'Create share link' }));
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument();
expect(await screen.findByDisplayValue(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith('Share link created', 'success', undefined);
for (const section of ['Timeline', 'Gallery', 'Map']) {
expect(screen.getByRole('switch', { name: section })).toBeInTheDocument();
@@ -286,7 +286,7 @@ describe('MJourneySettingsSheet', () => {
Object.defineProperty(window, 'isSecureContext', { configurable: true, writable: true, value: true });
setup();
expect(await screen.findByText(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument();
expect(await screen.findByDisplayValue(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-9`);
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument();
@@ -230,9 +230,20 @@ describe('MPlanTimeline', () => {
expect(screen.getByTitle('Shibuya')).toBeInTheDocument()
})
it('FE-MOB-PLTL-006: a stay chip opens the stay editor for members who may edit days (#2210)', () => {
it('FE-MOB-PLTL-006: a stay chip opens the overview of its booking, not the editor (#2210)', () => {
const { shell } = renderTimeline(
{ hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }] },
{ reservations: [{ id: 44, type: 'hotel', accommodation_id: 3 }] as unknown as TripPlanner['reservations'] },
)
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 44 })
})
it('FE-MOB-PLTL-006d: a stay with neither booking nor place opens the editor for members who may edit days', () => {
const { shell } = renderTimeline({
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }],
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: null }],
})
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
@@ -350,7 +350,7 @@ describe('MReservationSheet', () => {
it('FE-MOB-RESSH-008: status, code, link and notes all reach the payload', async () => {
const { planner } = setup()
type(titleField(), 'Opera')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
fireEvent.click(screen.getByRole('radio', { name: 'reservations.confirmed' }))
type(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'OP-7')
type(screen.getByPlaceholderText('reservations.urlPlaceholder'), 'https://opera.test')
type(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'balcony')
@@ -236,7 +236,7 @@ describe('MTransportFormSheet', () => {
setValue(timeInputs()[1], '11:00')
setValue(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'BUS-4711')
setValue(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'Seat 4 reserved')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
fireEvent.click(screen.getByRole('radio', { name: 'reservations.confirmed' }))
await submit()
@@ -618,7 +618,7 @@ describe('MTransportFormSheet', () => {
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('LH 716')
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('ABC123')
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('Exit row')
expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
expect(screen.getByRole('radio', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
// Endpoints are read in sequence order, not array order.
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'HND'])
expect(daySelects().map(s => s.value)).toEqual(['11', '12'])
@@ -45,6 +45,8 @@ vi.mock('../../../src/native/platform', async importOriginal => ({
beforeEach(async () => {
await clearAll();
setAuthed(true);
// restoreAllMocks leaves a vi.fn's return value as a test set it; the iOS case below sets true.
vi.mocked(isNativeIos).mockReturnValue(false);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
+2
View File
@@ -95,6 +95,8 @@ export const NOT_PLURAL = [
{ key: 'help.center.step', because: 'the position of a step in a guide' },
{ key: 'help.center.stepOf', because: 'the position of a step in a guide' },
{ key: 'help.center.imageAlt', because: 'the position of a step in a guide' },
{ key: 'native.widget.dayOf', because: 'the position of a day in the trip ("Day 2 of 5")' },
{ key: 'native.widget.inDays', because: 'filled in by the native widget, which says "Tomorrow" for one day, so the number is always two or more' },
{ key: 'system_notice.pager.goto', because: 'the position of a notice in the pager' },
{ key: 'tours.addedToDay', because: 'the number of the day a tour was added to' },
{ key: 'tours.planner.waypointLabel', because: 'the position of a waypoint on the tour' },