mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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: '' });
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {} };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user