Compare commits

...

31 Commits

Author SHA1 Message Date
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
Maurice 82295a1d3f fix(planner): hold the tests and the road trip rule to the corrected morning stay
The road trip spec and wiki described the old choice of the stay a day wakes up in,
and three tests still looked for the Tour label.
2026-11-04 14:00:35 +01:00
Maurice 3c78452c14 fix(planner): start the day after a side trip at the side trip's hotel, and name the booked tour a guided tour
With a room kept for the whole trip and a second stay for a night away, the day
after the side trip took the first stay in the list as the hotel it woke up in.
The Days view then drew a drive from the kept room, often hundreds of kilometres
away, to the side trip's first stop or airport, next to the flight back. The
morning now belongs to the stay checked into last, and the evening to the stay
that runs on past the day. The map also passes a booking's own point at the edge
of the day, as the sidebar already did, so a taxi leaving from the hotel adds no
zero-length road from the hotel to itself.

The Tour booking type read like the Tours tab, and a booked food tour left people
looking at an empty Tours tab. It is now called Guided tour in every language,
the empty Tours tab says that booked tours go under Bookings, and the help
center and wiki use the new name. The stored type stays 'tour'.
2026-11-04 13:50:06 +01:00
Maurice 74864f2543 fix(places): keep the places repository at its size baseline
The insert's note now says that a longitude is stored on the real world, in
one line fewer.
2026-11-04 12:09:29 +01:00
Maurice 178df0fbe3 fix(map): keep the world-copy fix inside the CI ratchets
Moves the Leaflet map's event bridges (click, context menu, viewport, POI drop
and the longitude-frame follower) out of MapView into leafletMapBridges, and
shares one POI drop target between the Leaflet and GL maps, so both map files
shrink instead of growing. The repair migration does its arithmetic in one
UPDATE per table, and the new tests drop their non-null assertions and loops.
2026-11-04 11:37:52 +01:00
Maurice 51af575c9a fix(map): keep places and routes on one world, and start new installs with Journey and Collections
A right-click, click or drag on a repeated world copy reported the longitude it
has on that copy (274.9 for -85.1). The place was saved there, the geocoder
found nothing, and a route to a place on the real world ran across the whole
map (#2976). Longitudes are now brought back onto [-180, 180] where the map
hands them out (Leaflet and GL), where they are stored (places, booking
endpoints, collection places, so REST, MCP, plugin RPC and importers alike)
and before reverse geocoding. A migration moves the ones already stored.

Leaflet draws a layer at its literal longitude only, so a trip from Los
Angeles to Auckland had its pins at opposite ends of the world while the
flight ran off the edge to the next copy (#2975). A trip that crosses the
antimeridian is now drawn in one longitude frame, seamed in the widest
stretch it leaves empty, so pins, routes, flights and every overlay lie on
one copy and the map opens across the Pacific. Every other trip has no frame
and draws exactly as before. The GL day fit also takes the short way.

A new install now starts with Journey and Collections switched on, next to
the trip addons (all but Road trip), Atlas, Vacay and the Naver import that
already were. Existing installs keep their addons as they are.

Closes #2975
Closes #2976
2026-11-04 10:56:36 +01:00
Maurice 3e90ece087 fix(settings): list the phone plugin version date in the feature pairs 2026-11-04 00:28:49 +00:00
Maurice 65e5d4ab72 fix(settings): format the date format files prettier flagged 2026-11-03 23:39:06 +00:00
Maurice 154620b99a fix(settings): format two settings tests and keep the plugin panel at its size 2026-11-03 23:38:52 +00:00
Maurice adbe3a40f6 fix(settings): lint, size and test follow-ups for the date format
The admin reset link is a component of its own instead of one made during render, the version line of the plugin panels moved into small modules, the new settings rows hand their save on as void, the date picker takes an accessible name for fields without a visible label (the Vacay hire date), the Vacay and poll tests type through the shared picker, and the theme, size and lint baselines are lowered to what the files hold now.
2026-11-03 23:38:52 +00:00
Maurice 44c6fbf372 fix(settings): let every date follow a date format of the user's choice
Every date in TREK was written in the interface language's format, and
English is en-US, so an English interface always showed and read
mm/dd/yyyy with no way to change it. A few native date inputs followed
the browser instead, so one screen could mix two formats.

date_format is now a user setting with an admin default, next to the
time format: automatic (the language's format, as before), YYYY-MM-DD,
YYYY/MM/DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY. The date picker shows,
pre-fills, hints and reads typed dates in it, and every date written in
numbers across the app (lists, cards, admin panels, CSV and PDF exports)
goes through one formatter. Dates written with a month name keep the
language's wording. The remaining native date inputs are the shared
picker now. The display settings MCP tool reads and writes the key.

Closes #2971
2026-11-03 23:38:37 +00:00
Maurice 45690ecd14 chore(costs): keep the money helpers used only by settlementFx module-private 2026-11-03 23:21:12 +00:00
Maurice c6149cd64d fix(costs): move the settlement currency setup into the money helpers and hold the size baselines 2026-11-03 22:16:41 +00:00
Maurice 4587cf2099 Drop the unused within import and lower the test assertion baseline 2026-11-03 22:16:41 +00:00
Maurice 4585988dfd fix(costs): keep convertBooked at five parameters and update the tests to as-typed display rows
The display-currency check is its own helper (inDisplayCurrency), used by the Costs model and the shared trip page. The tests that pinned #2525's reading of a display-currency bill through the trip currency now pin the as-typed reading, which the balances use too; read in the trip currency, such a bill and its repayment may stay a euro cent apart.
2026-11-03 22:16:41 +00:00
Maurice a37a9c0fa8 fix(costs): count an expense in the display currency as typed
On a yen trip read in euros, an expense entered in euros was booked through the yen at the rate frozen on entry and shown back at today's rate, so 1,081.09 EUR read 1,078.86 EUR a day later, and repaying exactly what was typed left 2.23 EUR owed.

Rows in the display currency now net apart in whole display cents, as entered, and the lists show them as typed. A payment in the display currency first pays what is owed between the two in such rows; only the rest goes through the trip currency at its frozen rate, so a trip-currency debt settled in the display currency stays settled when rates move (#1445). Every other row still nets in trip cents and converts once (#1382), so the balances add up to zero in both halves.

Closes #2965
2026-11-03 22:16:41 +00:00
Maurice 0cde099c7f style(assistant): format the blind-call hint 2026-11-03 22:39:24 +01:00
Maurice e798eb712e fix(assistant): run a blind call, load its group, and say why when its arguments fail
Refusing every call to a tool of a group not loaded in the turn also stopped calls whose arguments were fine. The call now runs as before; its group is loaded at once, so the next step offers the schema, and when the arguments fail the MCP check the error tells the model the schema is there now and to send the call again.
2026-11-03 22:09:54 +01:00
Maurice 0dae908e05 fix(assistant): send back a call to a tool whose group the turn has not loaded
The model could call a tool it knew from an earlier turn without loading its group in this one. TREK ran it, but the model had never seen its schema in the turn, so numbers, arrays and objects came as strings and required arguments were missing, and the MCP check refused it with -32602 (mostly the Tours tools).

Such a call no longer runs: its group is loaded for the rest of the turn and the call is answered with a tool error asking for it again with the schema the next step now offers.

Closes #2967
2026-11-03 21:48:37 +01:00
Maurice 01c7bc9de2 fix(tours): plural toast, repo route and test updates for the waypoint import
The places toast is a plural group in every language, the points import goes through tourRepo instead of src/api, the hook and dialog keep their complexity, and the tests that checked the old confirm call or the GPX left-out count follow the new behaviour.
2026-11-03 20:42:14 +01:00
Maurice 2e683f8609 fix(tours): keep the waypoints of a GPX/KML file that also holds a track
With the Tours addon on, a file picked or dropped on the places column goes
into the Tours import as soon as it holds one line, and that import reads
lines only. A Sygic or Trip-o-matic export (a route plus its stops) came in
as one tour and every place was dropped without a word.

The preview now counts a GPX file's waypoints as it already counted a KML
file's pins, and the dialog offers to add those points as places of the
trip, on by default. Confirming runs the Tours import for the lines and the
places import (points only) for the same files. A file whose tracks the
trip already holds can still bring its points.

Closes #2969
2026-11-03 20:19:43 +01:00
Maurice f8e51cb83d fix(pdf): hand the trip PDF to Safari from the iOS Home Screen app
An iPhone or iPad running TREK from the Home Screen ignores print(), in
the page and in an iframe alike, so "Save as PDF" in the preview did
nothing there. Mobile Safari prints the same preview fine.

In the installed iOS app the preview now shows "Open in Safari" in
place of the dead button, with a line saying why. The link opens the
trip with ?export=pdf, and both shells open their export dialog or
sheet for it, so the PDF is one tap away once Safari is up. The
parameter is dropped once read and survives the login redirect.
Everywhere else the preview prints as before.

The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which
TripPDF had no room left for under lint:size, and its backdrop and
stacking now use the --overlay and --z-modal tokens.
2026-11-03 09:14:55 +01:00
Maurice eedfb08597 Managed mode: list the phone OIDC card and drop the map model from the default settings pair
- MAdminOidcCard is markup over the settings section's state, so it is
  listed under mobileOnly with that reason.
- The default user settings views take their presets from
  useDefaultUserSettings now and import only a type from mapSettingsModel,
  so that pair names the map settings views alone.
2026-11-03 08:44:45 +01:00
Maurice 0aee0a019e Managed mode: lower the test any baseline the journey helper brought down 2026-11-03 08:44:45 +01:00
Maurice 91bc1944dc Managed mode: keep the admin settings and addons ratchets green
- The phone's OIDC card moves into MAdminOidcCard, so the settings section
  is back within its complexity limit after the managed-mode conditions.
- enabledPhotoProviderIds reads the enabled ids straight from the
  repository and has a test of its own, which brings the addons coverage
  back to its threshold.
2026-11-03 08:44:45 +01:00
Maurice f99b40cd81 Managed mode: keep the journey page suite within its size baseline
- The owner sign-in and the photo provider the suite runs with move into
  tests/helpers/journeyDetail.ts, so the page test is back under its line
  baseline, which drops to 4028.
- Drop help/registry.base.ts, an unused copy of the help registry whose key
  builders the i18n key check could not resolve.
2026-11-03 08:44:45 +01:00
Maurice 59e68d81b2 Managed mode: satisfy the strict, loop and test ratchets
- enabledPhotoProviderIds skips rows without an id instead of widening the type.
- The listed events read each addon once, in parallel, not in a loop.
- The narrowing test reuses the suite's service and swaps the addon answers.
- PlaceContribPanel keeps its complexity by naming the standalone check.
2026-11-03 08:44:45 +01:00
Maurice 86513fda0d Managed mode: hand place search, routing, plugins and docs to the operator
On a managed install the operator runs place search and routing, the plugin
runtime, the integrations and the documentation, so the admin and the users
no longer see switches and guides for them:

- The place search and routing card, the transit provider and the search log
  are gone from the admin settings (desktop and mobile). places_provider is a
  managed-locked setting, and the transit provider, search log and wanderer
  routes refuse writes there. Share place details stays the admin's, as a card
  of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off.
- File types and trip reminders move to the right column, so the columns stay
  balanced.
- Plugins disappear from the admin and the user settings, wanderer from Tours.
- The Help & Docs page and its links are not offered. The help center stays and
  leaves out guides about what the operator runs (keys, OIDC, backups, storage,
  releases, plugins, document sync) and about integrations that are off.
- Mapbox GL is offered only with the operator's token, Amap tiles only where
  the operator set Amap up.
- The mobile admin no longer shows OIDC and the WebAuthn relying party fields,
  matching the desktop tab.

For every install:

- Notification matrices list only events the install can raise: no plugin row
  without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or
  packing rows while those are off. On a managed install the release and
  replica alerts are left out.
- The journey entry editor offers External photos only where a photo provider
  is switched on, as the phone already did.
- app-config is asked again after a failed answer. A page opened while the
  server restarted kept every default for the session, which on a managed
  install brought back the operator's settings.
2026-11-03 08:44:45 +01:00
365 changed files with 5347 additions and 1287 deletions
+4 -4
View File
@@ -15,7 +15,7 @@
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1914,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+4 -5
View File
@@ -742,11 +742,8 @@
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Map: useMapSettingsForm",
@@ -983,8 +980,10 @@
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
+1 -1
View File
@@ -34,7 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/PDF/TripPDF.tsx": 8,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
+5 -6
View File
@@ -2,13 +2,12 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
@@ -18,7 +17,7 @@
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
+2 -2
View File
@@ -71,7 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/PDF/TripPDF.tsx": 53,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
+22
View File
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
// A page opened while the server restarts got a 502 here and kept every
// default for the session, which brought back the operator's settings.
let calls = 0
server.use(
http.get('/api/auth/app-config', () => {
calls += 1
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
})
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
expect(calls).toBe(2)
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
+25 -3
View File
@@ -323,7 +323,21 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
// Asked again until it answers: a page opened while the server restarts got a
// 502 here and kept the defaults for the whole session, which on a managed
// install put back every setting the operator owns.
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
if (cancelled) throw err
return new Promise<AppConfigAnswer>((resolve, reject) => {
retryTimer = setTimeout(() => {
fetchAppConfig(attempt + 1).then(resolve, reject)
}, Math.min(30_000, 1_000 * 2 ** attempt))
})
})
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -347,6 +361,10 @@ export default function App() {
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
return () => {
cancelled = true
clearTimeout(retryTimer)
}
}, [])
const { settings } = useSettingsStore()
@@ -402,6 +420,10 @@ export default function App() {
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
// The Help & Docs page is the wiki, written for somebody who runs TREK
// themselves: keys, backups, updates, the server behind it. A managed install
// does not offer it; the in-app help center stays.
const managed = useAuthStore(s => s.managed)
return (
<TranslationProvider>
@@ -456,7 +478,7 @@ export default function App() {
path="/help"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -464,7 +486,7 @@ export default function App() {
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
seedStore(useAuthStore, { managed: true });
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
render(<AddonManager />);
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
await unfoldShelves();
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
+13 -8
View File
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...(wandererOffered
? [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
]
: []),
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { formatDate } = useFormatDate()
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
const date = (iso: string) => formatDate(new Date(iso))
return (
<div>
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
import { deriveCaps } from './pluginCaps'
import PluginPoiCategoryList from './PluginPoiCategoryList'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const { detail, failed } = usePluginDetail(item.id)
const { formatDate } = useFormatDate()
const manifest = detail?.manifest ?? null
const permissions = manifest?.permissions ?? []
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}>
<VersionLine v={v} locale={locale} />
<VersionLine v={v} />
</DialogListItem>
))}
</DialogList>
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
return (
<DialogShell
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
{current ? (
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
) : v.compatible ? (
@@ -1,4 +1,4 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
});
});
describe('DefaultUserSettingsTab date format (#2971)', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
});
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
// The choices are labelled by how they write 31 December.
await pickFromSelect(user, '2026-12-31', '31.12.2026');
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
});
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
});
});
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import type { MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onChange={saveMapProvider}
options={[
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]}
/>
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -1,4 +1,4 @@
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
import userEvent from '@testing-library/user-event';
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
import { http, HttpResponse } from 'msw';
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
vi.useRealTimers();
}
});
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
const asked = vi.fn();
server.use(
http.get('/api/admin/places-contribute', () => {
asked();
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
})
);
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
await waitFor(() => expect(asked).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
render(<PlaceContribPanel hideWhenSwitchedOff />);
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
});
});
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
* way to take everything back. Turning it on for the first time opens the same
* prompt an admin gets on the first visit, so an agreement always comes from it.
*/
export default function PlaceContribPanel() {
/**
* Standing alone (managed settings), a card that can only say "not available"
* because the operator switched sharing off is a dead end.
*/
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
return alone && state.ineligibleReason === 'env_off';
}
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
const { t } = useTranslation();
const toast = useToast();
const contrib = usePlaceContrib();
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
if (!state) return null;
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
const status = contribStatus(state);
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
// The prompt opens by itself once per visit while the server asks for it.
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
@@ -36,6 +37,9 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
// wanderer sends a user's searches to a server of their choosing, which a
// managed install's terms do not cover; the operator decides, not the admin.
const wandererOffered = !useAuthStore((s) => s.managed);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
@@ -160,6 +164,7 @@ export function useAddonManager() {
providerOptions,
documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
};
@@ -0,0 +1,25 @@
import { ShieldCheck } from 'lucide-react';
import { useFormatDate } from '../../../hooks/useDateFormat';
import { fs } from '../../shared/DialogShell';
import type { VersionInfo } from './pluginModel';
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate();
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
style={fs(12.5)}
>
v{v.version}
</span>
{v.publishedAt && (
<span className="text-content-faint" style={fs(11.5)}>
{formatDate(new Date(v.publishedAt))}
</span>
)}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useFormatDate } from '../../hooks/useDateFormat';
export interface AuditEntry {
id: number;
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
* cards render their own markup over it.
*/
export function useAuditLog(serverTimezone?: string) {
const { locale } = useTranslation();
const { formatDateTime } = useFormatDate();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
const fmtTime = (iso: string) => {
try {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
@@ -1,6 +1,7 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
return formatDateTime(new Date(dateStr), opts);
} catch {
return dateStr;
}
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { useSettingsStore } from '../../store/settingsStore';
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
@@ -11,7 +12,13 @@ import {
normalizeStyleForProvider,
styleSettingKey,
} from '../Map/glProviders';
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
styleForProvider,
} from '../Settings/mapSettingsModel';
import { useToast } from '../shared/Toast';
import type { RoutingDefaults } from './RoutingInstanceFields';
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
date_format?: DateFormat;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
const [mapboxToken, setMapboxToken] = useState('');
const [cartoKey, setCartoKey] = useState('');
const [mapboxStyle, setMapboxStyle] = useState('');
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
defaults,
loaded,
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, operatorMapboxToken),
isSet,
save,
reset,
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
mount([hotel()])
await screen.findByText('Aparthotel Silver')
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
// the 801.76 in the dialog. The pill beside the line is the last step.
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
// Row total and payer chip stay the booked euros at today's rate, and so does what
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
// had Bob pay back more than he owed and reopened the trip.
expect(screen.getAllByText('$791.55')).toHaveLength(2)
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
expect(screen.queryByText('you lent $400.88')).toBeNull()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
await waitFor(() => expect(exported).toBeTruthy())
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
await screen.findByText('Aparthotel Silver')
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
// $910.02, on the day header and on the total spend card.
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
// Label, its text block, the header row, then the card itself.
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
expect(card.textContent).toContain('$910.02')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
})
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
mount([hotel()], {
settlements: [
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
expect(screen.queryByText('$34.65')).toBeNull()
})
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
// $791.57 in the list looked like two different expenses.
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
// Each share next to what it counts as, the figure the row's "you lent" is made of.
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -1,6 +1,6 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own — every expense maps to
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
}
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+26 -10
View File
@@ -1,6 +1,8 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const fmtDate = (iso: string) => {
if (!iso) return '';
try {
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
return formatDisplayDate(
new Date(iso + 'T00:00:00Z'),
opts.locale,
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
},
readDateFormat()
);
} catch {
return iso;
}
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
describe('expenseFxPreview', () => {
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
expect(fx.inTrip).toBeCloseTo(685.26, 2)
expect(fx.shown).toBeCloseTo(791.55, 2)
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
})
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
})
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
describe('splitShareLabel', () => {
it('prints the entered share, then the share the row lends or borrows', () => {
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
// Read in the trip currency, the share is the booked euros.
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
+4 -7
View File
@@ -1,6 +1,6 @@
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
import { useTripStore } from '../../store/tripStore'
import { currencyDecimals, formatMoney } from '../../utils/formatters'
import { formatMoney } from '../../utils/formatters'
/**
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
@@ -33,8 +33,8 @@ export interface ExpenseFx {
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
* frozen when the expense was entered (the server re-freezes only when the currency changes),
* so it is previewed at that rate, the way the list will show it after the save. Anything
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
* in the display currency that still reads back as typed.
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
* the display currency, which counts as typed (#2965).
*/
export function expenseFxPreview(args: {
total: number
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
convert: (amount: number, from: string | null | undefined) => number
}): ExpenseFx | null {
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
if (total === 0) return null
if (total === 0 || currency === base) return null
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
if (currency === base && inTrip == null) return null
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
const unit = 10 ** currencyDecimals(base)
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
}
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
onConfirm,
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const { formatDateTime } = useFormatDate()
const phone = useIsPhone()
const labelId = useId()
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
</span>
<p className="min-w-0 flex-1 text-caption text-content">
{t('dawarich.accept.recorded', {
from: new Date(suggestion.startedAt).toLocaleString(locale),
from: formatDateTime(new Date(suggestion.startedAt)),
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
})}
</p>
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { frameLine, frameLng } from '../../utils/lngFrame';
import { useLngFrame } from '../Map/lngFrameContext';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
positions: frameLine(frame, ferrataPositions(segment)),
casing,
litCasing,
line,
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
hit,
}))
);
}, [groups, paint]);
}, [groups, paint, frame]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
@@ -1,5 +1,7 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
import { readDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
} catch { return '' }
}
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { relativeTime } from '../../../utils/relativeTime'
import { Tooltip } from '../../shared/Tooltip'
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
const { t, language } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
}
/**
+7 -4
View File
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no link into it.
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
{(guide.link || docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
{(guide.link || docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
{docs && (
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
)}
</div>
)}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no links into it.
const managed = useAuthStore(s => s.managed)
const closeHelp = useHelpStore(s => s.closeHelp)
const screenRoute = useScreenRoute(context?.route)
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const docs = onePerPage(context.docs)
const docs = managed ? [] : onePerPage(context.docs)
const trail = helpContextTrail(context).slice(0, -1)
const Icon = helpIcon(context.icon)
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
// No Help & Docs page on a managed install (App.tsx): guides only.
const managed = useAuthStore(s => s.managed)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
return (
<div className="trek-help-view flex flex-col gap-8">
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
</section>
)}
{!managed && (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
</ul>
)}
</section>
)}
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
@@ -102,6 +103,8 @@ beforeEach(() => {
// The time field reads time_format, so pin it: without this the cases that
// assert a 24h string depend on whichever test ran before them (#2067).
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
// A photo provider switched on for the install, so the External photos source is offered.
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
seedStore(useAddonStore, { addons: [] })
mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
import { useAddonStore } from '../../store/addonStore'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t } = useTranslation()
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Camera size={14} />
</button>
</Tooltip>
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
{/* Only where the install has a photo provider (Immich, Synology) switched
on, as on the phone: a source that can only say "nothing connected"
is not worth its width, and the other two share the row instead. */}
{photoProvidersOn && (
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
)}
</div>
{/* The gallery picker, directly below the buttons. Safari collapses
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
)}
{photoTab === 'external' && (
{photoTab === 'external' && photoProvidersOn && (
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
<div className="min-w-0">
@@ -87,6 +87,16 @@ describe('Navbar', () => {
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
render(<Navbar />);
expect(document.querySelector('[data-help-button]')).not.toBeNull();
await user.click(screen.getByText('testuser'));
expect(screen.queryByText('Help')).not.toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
const user = userEvent.setup();
render(<Navbar />);
+3 -1
View File
@@ -35,7 +35,7 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
{t('nav.settings')}
</Link>
{!managed && (
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
)}
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
@@ -1,8 +1,10 @@
import L from 'leaflet';
import { useCallback, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
import { useLngFrame } from './lngFrameContext';
import type { AssistantCamera } from './mapViewContract';
interface AssistantCameraControllerProps {
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
*/
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
const map = useMap();
// The points are framed the way the map draws them (#2975).
const frame = useLngFrame();
const getContainer = useCallback(() => map.getContainer(), [map]);
const userMovedAt = useUserMoveClock(getContainer, true);
const request = useCameraRequest(camera, userMovedAt);
useEffect(() => {
request((next) => {
request((camera) => {
const next = frame
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
: camera;
const duration = ASSISTANT_CAMERA_MS / 1000;
if (next.mode === 'pan') {
const centre = centreOf(next.points);
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
duration,
});
});
}, [map, padding, request]);
}, [map, padding, request, frame]);
return null;
}
@@ -1,7 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
import { useLngFrame } from './lngFrameContext';
import type { AssistantGhost } from './mapViewContract';
interface AssistantGhostLayerProps {
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
*/
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
const map = useMap();
// Pinned where the map draws the trip, in its longitude frame (#2975).
const frame = useLngFrame();
const handlers = useRef<GhostHandlers>({ onHover, onClick });
useLayoutEffect(() => {
handlers.current = { onHover, onClick };
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
const label = document.createElement('span');
label.textContent = ghost.name;
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
icon,
keyboard: false,
zIndexOffset: GHOST_Z_OFFSET,
})
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
.addTo(map);
return { remove: () => marker.remove() };
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
mounted.dispose();
elements.current = new Map();
};
}, [ghosts, map]);
}, [ghosts, map, frame]);
useEffect(() => {
setGhostHot(elements.current, hoveredId);
@@ -1,8 +1,11 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
const point = (event: LeafletMouseEvent) => {
const at = onRealWorld(event.latlng);
onPoint?.(line.ref, at.lat, at.lng);
};
return (
<FeatureGroup ref={group}>
<Polyline
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
return {
...line,
lines: line.lines.map((part) => frameLine(frame, part)),
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
};
}
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
const frame = useLngFrame();
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
if (!drawn?.length) return null;
return (
<>
{lines.map((line) => (
{drawn.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
+8 -4
View File
@@ -4,11 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { Polyline } from 'react-leaflet'
import { useLngFrame } from './lngFrameContext'
import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared'
import {
DAWARICH_TRAIL_CASING,
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
const frame = useLngFrame()
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
if (segments.length === 0) return null
return (
+5 -1
View File
@@ -4,10 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
const frame = useLngFrame()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const map = useMap()
const frame = useLngFrame()
const [layers, setLayers] = useState<PluginMapLayer[]>([])
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
// optimization — if the map has no pane API (e.g. a minimal renderer), the
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
if (layers.length === 0) return null
const pane = hasPane ? PANE : undefined
// Drawn in the map's longitude frame, with the trip (#2975).
return (
<>
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
const key = `${layer.pluginId}:${layer.id}:${i}`
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
if (f.type === 'polyline' && f.points) {
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
}
if (f.type === 'polygon' && f.points) {
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
}
if (f.type === 'circle' && f.center && f.radiusM) {
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
}
return null
}))}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { pluginsApi, type PluginMapMarker } from '../../api/client'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
/**
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame()
useEffect(() => {
if (tripId == null) { setMarkers([]); return }
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
return (
<>
{markers.map(mk => (
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
{(mk.label || mk.popupText || mk.url) && (
<Popup>
<div style={{ minWidth: 120, fontSize: 13 }}>
+13 -4
View File
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
const onMapClick = vi.fn()
const { unmount } = render(<MapView onMapClick={onMapClick} />)
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
expect(bound).toEqual(expect.any(Function))
// A click on the real world reaches the caller as the very event the map fired.
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapClick).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
})
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
const onMapContextMenu = vi.fn()
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
expect(bound).toEqual(expect.any(Function))
const event = { latlng: { lat: 48, lng: 2 } }
bound(event)
expect(onMapContextMenu).toHaveBeenCalledWith(event)
unmount()
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
})
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
+46 -99
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
const frame = useLngFrame()
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
const map = useMap()
useEffect(() => {
if (!onPoiDropOnRoute) return
const container = map.getContainer()
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
}, [map, onPoiDropOnRoute])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
@@ -411,14 +360,15 @@ interface MapControllerProps {
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
map.setView([center[0], frameLng(frame, center[1])], zoom)
prevCenter.current = center
}
}, [center, zoom, map])
}, [center, zoom, map, frame])
return null
}
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const frame = useLngFrame()
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
}, [map, paddingOpts, frame])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
return null
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
import { useMarkerThumbs } from './useMarkerThumbs'
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const frame = useLngFrame()
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const frame = useLngFrame()
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute])
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
const photoUrls = useMarkerThumbs(places)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
const gpxTracks = useMemo(() => places.flatMap(place => {
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
}), [places, lngFrame])
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<LngFrameProvider value={lngFrame}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
/>
)}
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
key={`${i}-walk`}
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
{(accessLines ?? []).map((spur, i) => (
<Polyline
key={`access-${i}`}
positions={spur.line}
positions={frameLine(lngFrame, spur.line)}
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
interactive={false}
/>
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
return [
<Polyline
key={`alt-${alt.index}-casing`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
<Polyline
key={`alt-${alt.index}`}
positions={alt.coordinates}
positions={frameLine(lngFrame, alt.coordinates)}
pathOptions={{
color: alt.color,
weight: active ? 6 : 4,
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
// The drive time on the road itself, the way Apple labels them.
<Marker
key={`alt-${alt.index}-label`}
position={[alt.at.lat, alt.at.lng]}
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
zIndexOffset={600}
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
eventHandlers={onChooseAlternative ? {
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
<PluginMapLayers tripId={tripId} />
</MapContainer>
</LngFrameProvider>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
+11 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
@@ -725,7 +727,7 @@ export function MapViewGL({
return
}
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
}
// The browser fires this when the captured element is torn out of the DOM,
@@ -925,7 +927,8 @@ export function MapViewGL({
// trip reloads for a gesture the user made once. The picker is a modal
// choice about that leg; dropping a via mid-choice is not offered anywhere.
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
onRouteClick(e.lngLat.lat, e.lngLat.lng)
const at = onRealWorld(e.lngLat)
onRouteClick(at.lat, at.lng)
}
const enter = () => { map.getCanvas().style.cursor = 'copy' }
const leave = () => { map.getCanvas().style.cursor = '' }
@@ -944,35 +947,12 @@ export function MapViewGL({
}
}, [mapReady, onRouteClick])
/**
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
* canvas and has no element to aim at. The coordinate under the pointer says just as
* much once it is projected onto the routed geometry, and it works the same in all
* three renderers.
*/
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
useEffect(() => {
const map = mapRef.current
const container = map?.getContainer?.()
if (!container || !mapReady || !onPoiDropOnRoute) return
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e)
if (!osmId) return
e.preventDefault()
const rect = container.getBoundingClientRect()
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
onPoiDropOnRoute(osmId, at.lat, at.lng)
}
container.addEventListener('dragover', onDragOver)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', onDragOver)
container.removeEventListener('drop', onDrop)
}
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
}, [mapReady, onPoiDropOnRoute])
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
@@ -1343,7 +1323,7 @@ export function MapViewGL({
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
) return
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
})
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
@@ -1376,7 +1356,7 @@ export function MapViewGL({
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
@@ -1982,7 +1962,7 @@ export function MapViewGL({
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
if (fitPoints.length === 0) return
const bounds = new gl.LngLatBounds()
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
let fitted = false
const run = () => {
try {
+7 -3
View File
@@ -3,25 +3,29 @@ import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
// The route is drawn in the map's longitude frame (#2975), so its points are measured there too.
const frame = useLngFrame()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const point = map.latLngToContainerPoint([lat, frameLng(frame, lng)])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
setPosition: (lat, lng) => { marker.setLatLng([lat, frameLng(frame, lng)]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
}, [map, marker, via, controls, frame])
return null
}
@@ -1,6 +1,9 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { onRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -45,7 +48,11 @@ export default function PlannerWaypointMarker({
useLayoutEffect(() => {
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const frame = useLngFrame();
const position = useMemo(
() => [point.lat, frameLng(frame, point.lng)] as [number, number],
[point.lat, point.lng, frame]
);
const { label, shaping, stageEnd } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
@@ -67,7 +74,7 @@ export default function PlannerWaypointMarker({
if (!element) return;
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
initial: { lat: position[0], lng: position[1] },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
@@ -76,9 +83,13 @@ export default function PlannerWaypointMarker({
setPosition: ({ lat, lng }) => {
marker.setLatLng([lat, lng]);
},
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
// Saved on the real world, whichever copy of it the pin was dragged on (#2976).
onCommit: (at) => {
const { lat, lng } = onRealWorld(at);
return handlersRef.current.onMove?.(point.id, lat, lng) ?? false;
},
});
}, [map, marker, onMove, point.id, point.lat, point.lng]);
}, [map, marker, onMove, point.id, position]);
return (
<Marker
@@ -9,6 +9,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
import { frameLine } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -48,11 +50,13 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
return road && road.length >= 2 ? [frameLine(item.frame, road)] : item.arcs
}
interface TransportItem extends TransportHop {
transitSegs: TransitMapSegment[]
/** The map's longitude frame the hop was drawn in (#2975), for the road route that arrives later. */
frame: ReturnType<typeof useLngFrame>
}
interface Props {
@@ -75,16 +79,19 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const frame = useLngFrame()
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
const hop = transportHop(r, true)
const hop = transportHop(r, true, frame)
if (!hop) continue
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
const segs = hop.type === 'transit' ? getTransitMapSegments(r) : []
const transitSegs = frame ? segs.map(seg => ({ ...seg, coords: frameLine(frame, seg.coords) })) : segs
out.push({ ...hop, transitSegs, frame })
}
return out
}, [reservations])
}, [reservations, frame])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
@@ -3,6 +3,9 @@ import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
import { onRealWorld } from '../../utils/worldCopy'
import type { RoadtripVia } from '@trek/shared'
/**
@@ -64,12 +67,14 @@ export default function RoadtripViaMarkers({
return () => { map.off('zoomend', update) }
}, [map])
const frame = useLngFrame()
const markers = useMemo(() => vias.map(via => ({
via,
position: [via.lat, via.lng] as [number, number],
position: [via.lat, frameLng(frame, via.lng)] as [number, number],
handlers: {
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
const at = e.target.getLatLng()
// Saved on the real world, whichever copy of it the handle was dragged on (#2976).
const at = onRealWorld(e.target.getLatLng())
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
},
// Right-click rather than a delete handle: a 12px dot has no room for one,
@@ -79,7 +84,7 @@ export default function RoadtripViaMarkers({
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
},
},
})), [vias])
})), [vias, frame])
if (!markers.length || !zoomedIn) return null
+4 -1
View File
@@ -1,4 +1,5 @@
import { Polyline } from 'react-leaflet';
import { onRealWorld } from '../../utils/worldCopy';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
@@ -31,7 +32,9 @@ export function RouteHitBand({
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
// The via is saved on the real world, whichever copy of it the route was clicked on (#2976).
const at = onRealWorld(e.latlng);
onRouteClick(at.lat, at.lng);
},
}}
/>
+11 -2
View File
@@ -4,6 +4,7 @@ import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { bboxOnRealWorld } from '../../utils/worldCopy';
/**
* The part of the planner's map on screen, told to the assistant after every
@@ -54,7 +55,11 @@ export function useReportViewLeaflet(): void {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
map.on('moveend', read);
@@ -87,7 +92,11 @@ export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady:
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
on('moveend', read);
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
})
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
// The first hotel stays booked over the whole trip and comes first in the list; the
// side trip's hotel holds the night before. The day used to start with a 716 km drive
// from the kept room to the side trip's first stop.
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
const runs = buildDayRouteRuns(3, inputs({
days,
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
accommodations: [kept, away],
optimizeFromAccommodation: true,
}))
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
})
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
const taxi = {
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
endpoints: [
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
],
} as unknown as Reservation
const runs = buildDayRouteRuns(2, inputs({
days,
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
reservations: [taxi],
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
expect(zeroLength).toEqual([])
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
+4 -1
View File
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
// that starts or ends at the hotel then adds no zero-length leg to it.
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
}
// A carrier with a located endpoint today means the day changes geography by
// air/rail/sea — the check-in hotel is then the day's destination and the
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
import { tripLngFrame } from '../../utils/lngFrame'
const YVR: [number, number] = [49.19, -123.18]
const ICN: [number, number] = [37.46, 126.44]
@@ -61,3 +62,29 @@ describe('flightGeodesy (#1411)', () => {
expect(unwrapLngs(pts)).toEqual(pts)
})
})
describe('flightGeodesy in a longitude frame (#2975)', () => {
const LAX: [number, number] = [33.94, -118.41]
const AKL: [number, number] = [-37.01, 174.79]
const frame = tripLngFrame([LAX[1], AKL[1]])
it('draws LAX to Auckland as one arc on one world copy, between the two pins as the frame places them', () => {
expect(frame).not.toBeNull()
const arcs = geodesicArcs([LAX[0], LAX[1] + 360], AKL, true, frame)
expect(arcs).toHaveLength(1)
const [arc] = arcs
expect(maxConsecutiveDeltaLng(arc)).toBeLessThan(180)
expect(arc[0][1]).toBeCloseTo(LAX[1] + 360, 5)
expect(arc[arc.length - 1][1]).toBeCloseTo(AKL[1], 5)
})
it('still copies an arc that leaves the frame, so both of its ends keep a line', () => {
const arcs = geodesicArcs(FRA, JFK, true, { seam: -30 })
expect(arcs).toHaveLength(2)
expect(arcs[1][0][1]).toBeCloseTo(arcs[0][0][1] + 360, 10)
})
it('without a frame draws what it drew before', () => {
expect(geodesicArcs(YVR, ICN, true, null)).toEqual(geodesicArcs(YVR, ICN, true))
})
})
+12 -4
View File
@@ -1,6 +1,8 @@
// Great-circle geometry for transport routes (flights, cruises, ferries),
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
import { frameLine, type LngFrame } from '../../utils/lngFrame'
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
@@ -51,11 +53,17 @@ export function unwrapLngs(points: [number, number][]): [number, number][] {
* copies), a ±360-shifted duplicate keeps both halves visible in the
* standard [-180, 180] view; GL maps repeat features themselves
* (renderWorldCopies), so the duplicate would just double the line opacity.
*
* Given a longitude frame (Leaflet, a trip across the antimeridian, #2975), the
* arc starts at `a` as the frame draws it, and the duplicate is only needed
* when the arc leaves the frame rather than the plain world.
*/
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
const arc = unwrapLngs(greatCircle(a, b))
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean, frame: LngFrame | null = null): [number, number][][] {
const arc = frame ? frameLine(frame, greatCircle(a, b)) : unwrapLngs(greatCircle(a, b))
const west = frame ? frame.seam : -180
const east = west + 360
const crosses = arc.some(([, lng]) => lng < west || lng > east)
if (!crosses || !wrapCopies) return [arc]
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
const shift = arc.some(([, lng]) => lng < west) ? 360 : -360
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { rasterTileZoom } from './glRasterLayers';
import type { MapViewProps } from './mapViewContract';
@@ -16,6 +17,11 @@ interface ViewportSource {
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
const bounds = map.getBounds();
if (!report || !bounds) return;
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
const bbox = bboxOnRealWorld({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
});
report(bbox, rasterTileZoom(map.getZoom()));
}
@@ -0,0 +1,120 @@
// The Leaflet map's bridges to the page: the events it hands out, with their
// points on the real world (#2976), and the view kept on the world copy the
// trip is drawn on (#2975).
import { wrapLongitude } from '@trek/shared';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng, type LngFrame } from '../../utils/lngFrame';
import { bboxOnRealWorld } from '../../utils/worldCopy';
import { useLngFrame } from './lngFrameContext';
import type { MapViewProps } from './mapViewContract';
import { bindPoiDropTarget } from './poiDropTarget';
/**
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
*
* The container rather than the drawn route: a polyline is a real element here but not in
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
* once the caller projects it onto the routed geometry — one behaviour for all three.
*/
export function PoiDropTarget({
onPoiDropOnRoute,
}: {
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (!onPoiDropOnRoute) return;
return bindPoiDropTarget(map.getContainer(), (x, y) => map.containerPointToLatLng([x, y]), onPoiDropOnRoute);
}, [map, onPoiDropOnRoute]);
return null;
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
export function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
const map = useMap();
useEffect(() => {
if (!onViewportChange) return;
const emit = () => {
const b = map.getBounds();
// A view of a world copy (or of a trip drawn past the antimeridian) asks for the real places there.
onViewportChange(
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }),
map.getZoom()
);
};
map.whenReady(emit); // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit);
map.on('zoomend', emit);
return () => {
map.off('moveend', emit);
map.off('zoomend', emit);
};
}, [map, onViewportChange]);
return null;
}
/**
* Moves the view along when the longitude frame changes under it (#2975): the trip
* is then drawn one world further east or west, and a view left where it was would
* look at an empty copy. One jump of a whole world, so nothing on screen changes.
*/
export function LngFrameFollower() {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<LngFrame | null>(frame);
useEffect(() => {
const before = prev.current;
prev.current = frame;
if (before === frame || typeof map.getCenter !== 'function') return;
const center = map.getCenter();
// Without a frame the trip is back on the real world, and so is the view.
const target = frame ? frameLng(frame, center.lng) : wrapLongitude(center.lng);
const shift = target - center.lng;
if (shift !== 0) map.setView([center.lat, target], map.getZoom(), { animate: false });
}, [frame, map]);
return null;
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null;
}
/**
* The event as the map reports it, with its point moved from a repeated world copy
* back onto the real world: a right-click on the copy east of it said 274.9 for
* -85.1, and the place was saved there (#2976).
*/
function onRealWorldEvent(e: L.LeafletMouseEvent): L.LeafletMouseEvent {
const lng = wrapLongitude(e.latlng.lng);
return lng === e.latlng.lng ? e : { ...e, latlng: L.latLng(e.latlng.lat, lng) };
}
export function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap();
useEffect(() => {
if (!onClick) return;
const handle = (e: L.LeafletMouseEvent) => onClick(onRealWorldEvent(e));
map.on('click', handle);
return () => {
map.off('click', handle);
};
}, [map, onClick]);
return null;
}
export function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap();
useEffect(() => {
if (!onContextMenu) return;
const handle = (e: L.LeafletMouseEvent) => onContextMenu(onRealWorldEvent(e));
map.on('contextmenu', handle);
return () => {
map.off('contextmenu', handle);
};
}, [map, onContextMenu]);
return null;
}
@@ -0,0 +1,51 @@
// The longitude frame of the Leaflet map, for every layer that draws in it (#2975).
// See utils/lngFrame.ts for what the frame is and why only Leaflet needs it.
import { createContext, useContext, useMemo, useState } from 'react';
import type { Place, Reservation } from '../../types';
import { tripLngFrame, type LngFrame } from '../../utils/lngFrame';
const LngFrameContext = createContext<LngFrame | null>(null);
export const LngFrameProvider = LngFrameContext.Provider;
/** The frame the map draws in, or null when it draws the plain world. */
export function useLngFrame(): LngFrame | null {
return useContext(LngFrameContext);
}
type Located = Pick<Place, 'lng'>;
function tripLngs(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): number[] {
const lngs: number[] = [];
for (const p of places) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const p of dayPlaces) if (typeof p.lng === 'number') lngs.push(p.lng);
for (const r of reservations) for (const e of r.endpoints ?? []) if (typeof e.lng === 'number') lngs.push(e.lng);
return lngs;
}
/**
* The frame for a trip: its places, the day's places and every endpoint of every
* booking, shown or not, so switching a day or a booking on and off does not move
* the seam. The frame in use is handed back in, which keeps it while it still fits.
*/
export function useTripLngFrame(
places: readonly Located[],
dayPlaces: readonly Located[],
reservations: readonly Reservation[]
): LngFrame | null {
const [current, setCurrent] = useState<LngFrame | null>(() =>
tripLngFrame(tripLngs(places, dayPlaces, reservations))
);
const next = useMemo(
() => tripLngFrame(tripLngs(places, dayPlaces, reservations), current),
[places, dayPlaces, reservations, current]
);
// Derived state, adjusted during render: the next render already draws in the new frame.
if (next !== current) setCurrent(next);
return next;
}
@@ -0,0 +1,39 @@
// A corridor hit dropped anywhere on the map, for every renderer.
//
// The map takes the drop, not the drawn route: in a GL renderer the line lives in the
// canvas and has no element to aim at, and the coordinate under the pointer says just
// as much once the caller projects it onto the routed geometry. The point goes out on
// the real world, whichever world copy it was dropped on (#2976).
import { onRealWorld } from '../../utils/worldCopy';
import { draggedPoiId } from './markerDrag';
/** Where a point of the container lies, in the renderer's own coordinates. */
type PointAt = (x: number, y: number) => { lat: number; lng: number };
/** Listens for POI drops on `container` and reports each one; returns the cleanup. */
export function bindPoiDropTarget(
container: HTMLElement,
pointAt: PointAt,
onPoiDropOnRoute: (osmId: string, lat: number, lng: number) => void
): () => void {
const onDragOver = (e: DragEvent) => {
if (!draggedPoiId(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const onDrop = (e: DragEvent) => {
const osmId = draggedPoiId(e);
if (!osmId) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
const at = onRealWorld(pointAt(e.clientX - rect.left, e.clientY - rect.top));
onPoiDropOnRoute(osmId, at.lat, at.lng);
};
container.addEventListener('dragover', onDragOver);
container.addEventListener('drop', onDrop);
return () => {
container.removeEventListener('dragover', onDragOver);
container.removeEventListener('drop', onDrop);
};
}
@@ -88,6 +88,20 @@ describe('transportHop', () => {
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
});
it("draws the waypoints and arcs in the map's longitude frame, so the pins sit on the line (#2975)", () => {
const r = booking('flight', [
endpoint({ role: 'from', lat: 33.94, lng: -118.41 }),
endpoint({ role: 'to', sequence: 1, lat: -37.01, lng: 174.79 }),
]);
const hop = transportHop(r, true, { seam: 28 });
if (!hop) throw new Error('a flight with two located ends draws a hop');
expect(hop.from.lng).toBeCloseTo(241.59, 5);
expect(hop.to.lng).toBeCloseTo(174.79, 5);
expect(hop.arcs).toHaveLength(1);
expect(hop.arcs[0][0][1]).toBeCloseTo(241.59, 5);
expect(hop.arcs[0][hop.arcs[0].length - 1][1]).toBeCloseTo(174.79, 5);
});
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
+9 -5
View File
@@ -7,6 +7,7 @@
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
import type { Reservation, ReservationEndpoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
import { geodesicArcs } from './flightGeodesy';
export type TransportType =
@@ -144,14 +145,17 @@ export interface TransportHop {
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
* GL map repeats features across world copies itself, so there one continuous arc
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
* `frame` is Leaflet's longitude frame (#2975): the arcs and the waypoints come back
* drawn in it, so a pin and the line that ends at it are on the same world copy.
*/
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
export function transportHop(r: Reservation, wrapArcs: boolean, frame: LngFrame | null = null): TransportHop | null {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
const sorted = (r.endpoints || [])
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const waypoints = frame ? sorted.map((e) => ({ ...e, lng: frameLng(frame, e.lng) })) : sorted;
if (waypoints.length < 2) return null;
const from = waypoints[0];
const to = waypoints[waypoints.length - 1];
@@ -164,12 +168,12 @@ export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop |
const a = waypoints[i];
const b = waypoints[i + 1];
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs, frame)
: [
[
frameLine(frame, [
[a.lat, a.lng],
[b.lat, b.lng],
] as [number, number][],
] as [number, number][]),
];
arcs.push(...segArcs);
distanceKm += haversineKm(a, b);
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { onRealWorld } from '../../utils/worldCopy';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
@@ -245,12 +246,15 @@ export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onP
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
if (ref === null || onPlanPin(map, event)) return;
const at = onRealWorld(event.lngLat);
handlers.current.onPoint?.(ref, at.lat, at.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { onRealWorld } from '../../utils/worldCopy';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
@@ -102,7 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect();
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
return { lat: at.lat, lng: at.lng };
return onRealWorld({ lat: at.lat, lng: at.lng });
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) =>
+3 -94
View File
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
import { pdfSwitchOn } from '../../utils/pdfSwitch'
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
import { useSettingsStore } from '../../store/settingsStore'
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
import { onlyMyPlan, planAssignments } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
function renderLucideIcon(icon:LucideIcon, props = {}) {
return renderIconMarkup(
createElement(icon, props)
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
markdown.dispose()
if (signal?.aborted) return
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
if (closeBtn) closeBtn.onclick = () => overlay.remove()
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
rememberPdfSwitch(PAGE_BREAK_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
})
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
})
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
}
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
return classes.length ? ` class="${classes.join(' ')}"` : ''
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
</button>`
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
if (!button) return
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true'
button.setAttribute('aria-checked', String(on))
button.style.background = trackColour(on)
const knob = button.firstElementChild as HTMLElement | null
if (knob) knob.style.left = knobOffset(on)
onChange(on)
}
}
@@ -0,0 +1,90 @@
/**
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
* there the preview hands the export to Safari instead of offering a button that
* does nothing.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
const tr = (key: string) => key;
function open(overrides: Partial<PdfPreviewOptions> = {}) {
openPdfPreview({
html: '<html><body>plan</body></html>',
title: 'Lisbon',
tripId: 9,
tr,
breaksPerDay: true,
showTransportNotes: true,
hasTransportNotes: false,
...overrides,
});
}
function standalone(value: boolean) {
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
}
afterEach(() => {
document.getElementById('pdf-preview-overlay')?.remove();
delete (navigator as unknown as Record<string, unknown>).standalone;
localStorage.clear();
});
describe('openPdfPreview', () => {
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
open();
expect(document.getElementById('pdf-safari-link')).toBeNull();
expect(document.getElementById('pdf-safari-hint')).toBeNull();
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
const print = vi.fn();
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
const button = document.getElementById('pdf-print-btn');
expect(button?.textContent).toBe('pdf.saveAsPdf');
button?.click();
expect(print).toHaveBeenCalledTimes(1);
});
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
standalone(true);
open();
expect(document.getElementById('pdf-print-btn')).toBeNull();
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
expect(link.textContent).toBe('pdf.openInSafari');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener');
const url = new URL(link.href);
expect(url.pathname).toBe('/trips/9');
expect(url.searchParams.get('export')).toBe('pdf');
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
});
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
standalone(true);
open({ tripId: undefined });
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
expect(document.getElementById('pdf-safari-link')).toBeNull();
});
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
open({ title: '<b>Lisbon</b>' });
const overlay = document.getElementById('pdf-preview-overlay');
expect(overlay?.querySelector('b')).toBeNull();
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
document.getElementById('pdf-close-btn')?.click();
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
});
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
open();
const toggle = document.getElementById('pdf-daybreak-toggle');
expect(toggle?.getAttribute('aria-checked')).toBe('true');
toggle?.click();
expect(toggle?.getAttribute('aria-checked')).toBe('false');
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { escapeHtml } from '@trek/shared';
import { isIosStandalone } from '../../utils/iosDevice';
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK =
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
const TOGGLE_KNOB =
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
const HEADER_ACTION =
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
type Translate = (key: string, params?: Record<string, string | number>) => string;
export interface PdfPreviewOptions {
/** The finished document, shown in a sandboxed srcdoc frame. */
html: string;
title: string;
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
tripId?: number;
tr: Translate;
breaksPerDay: boolean;
showTransportNotes: boolean;
/** The transport-notes switch only shows when there is a note to hide. */
hasTransportNotes: boolean;
}
/**
* The control that saves the document. A browser prints the frame and offers
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
* Screen ignores print() altogether, so there the control opens the trip in
* Safari with the export dialog up, and a line under the header says why.
*/
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
if (tripId == null || !isIosStandalone()) {
return {
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
hint: null,
};
}
return {
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
hint: tr('pdf.safariHint'),
};
}
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
export function openPdfPreview({
html,
title,
tripId,
tr,
breaksPerDay,
showTransportNotes,
hasTransportNotes,
}: PdfPreviewOptions): void {
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div');
overlay.id = 'pdf-preview-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);display:flex;align-items:center;justify-content:center;padding:8px;';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
const card = document.createElement('div');
card.style.cssText =
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
const save = saveControl(tripId, tr);
const header = document.createElement('div');
header.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
${save.html}
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'flex:1;width:100%;border:none;';
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals';
iframe.srcdoc = html;
card.appendChild(header);
if (save.hint) {
const hint = document.createElement('p');
hint.id = 'pdf-safari-hint';
hint.style.cssText =
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
hint.textContent = save.hint;
card.appendChild(hint);
}
card.appendChild(iframe);
overlay.appendChild(card);
document.body.appendChild(overlay);
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
if (closeBtn) closeBtn.onclick = () => overlay.remove();
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
if (printBtn)
printBtn.onclick = () => {
iframe.contentWindow?.print();
};
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
rememberPdfSwitch(PAGE_BREAK_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
});
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
});
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
</button>`;
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
if (!button) return;
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true';
button.setAttribute('aria-checked', String(on));
button.style.background = trackColour(on);
const knob = button.firstElementChild as HTMLElement | null;
if (knob) knob.style.insetInlineStart = knobOffset(on);
onChange(on);
};
}
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
}, dateFormat)
} catch {
return d
}
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const dateFormat = useDateFormat()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
</span>
</span>
{already && (
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, PANEL_BAR } from './planParts'
import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal'
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
useExportDeepLink(() => setExportOpen(true))
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -1,12 +1,15 @@
import { createPortal } from 'react-dom'
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
import type { SidebarState } from './usePlacesSidebar'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
export function MobileDayPickerSheet(S: SidebarState) {
const {
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
} = S
const dateFormat = useDateFormat()
return createPortal(
<div
role="presentation"
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await pickType(/^Tour$/i);
await pickType(/^Guided tour$/i);
await waitFor(() => {
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
});
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
expect(screen.getByText('No date')).toBeInTheDocument();
const metro = screen.getByRole('button', { name: /Metro ride/ });
expect(metro).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
expect(onSelect).toHaveBeenCalledWith(early);
@@ -1,6 +1,7 @@
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFormatDate } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n/TranslationContext';
import { chargingRepo } from '../../repo/chargingRepo';
import { useTripStore } from '../../store/tripStore';
@@ -76,6 +77,7 @@ export default function ChargingInfo({
}: ChargingTarget & { compact?: boolean }) {
const tripId = useTripStore((s) => s.trip?.id);
const { t } = useTranslation();
const { formatDateTime } = useFormatDate();
const [info, setInfo] = useState<Info | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -133,7 +135,7 @@ export default function ChargingInfo({
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
const price = (value: number, currency: string) =>
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
if (compact)
return (
<ChargingBadges
@@ -211,7 +213,7 @@ export default function ChargingInfo({
</a>
<br />
{info.license}
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
</p>
)}
</div>
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { useTranslation } from '../../i18n'
import { useDateFormat } from '../../hooks/useDateFormat'
import { formatDisplayDate } from '../../utils/dateFormat'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
}
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
function formatStamp(ts: string, locale: string): string {
function formatStamp(ts: string, locale: string, dateFormat: string): string {
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
}
/**
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
const { t } = useTranslation()
const dateFormat = useDateFormat()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 px-3.5 py-3">
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
</code>
</div>
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
{', '}
{apiKey.last_used_at
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
: t('settings.apiKeys.neverUsed')}
</p>
{/* What the key may read. Shown on the row rather than behind a detail
@@ -1,6 +1,7 @@
import React from 'react'
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const { t } = useTranslation()
const S = useDawarichConnection()
return (
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
<DawarichConnectionStatus state={S} />
</Section>
)
}
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
const { formatDateTime } = useFormatDate()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
{state.connected && (
<p className="m-0 text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
</p>
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
});
});
// ── Date format (056, 057) ────────────────────────────────────────────────────
describe('DisplaySettingsTab date format (#2971)', () => {
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
render(<DisplaySettingsTab />);
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
await user.click(await screen.findByText('31.12.2026'));
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
});
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
await user.click(await screen.findByText('31/12/2026'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import { dateFormatOptions } from '../../utils/dateFormat'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
}
/>
{/* Date format: every date field and every date written in numbers (#2971) */}
<SettingRow
label={t('settings.dateFormat')}
hint={t('settings.dateFormatHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.date_format || DEFAULT_DATE_FORMAT}
onChange={v => {
void save('date_format', String(v))
}}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
</div>
}
/>
</SettingRows>
</Section>
@@ -1,6 +1,7 @@
import Section from './Section'
import React, { useId } from 'react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import PhotoProvidersSection from './PhotoProvidersSection'
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const { formatDate } = useFormatDate()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const expanded = !!oauthScopesExpanded[client.id]
return (
<div className={`${ROW} items-start`}>
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
function OAuthTab({ S }: { S: IntegrationsState }) {
const {
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
} = S
return (
<>
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
</p>
</div>
<button type="button" onClick={() => setOauthRevokeId(session.id)}
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
function ApiTokensTab({ S }: { S: IntegrationsState }) {
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
return (
<>
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
<span className="font-geist">{token.token_prefix}...</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
{token.last_used_at && (
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
)}
</p>
</div>
@@ -386,6 +386,27 @@ describe('MapSettingsTab – CARTO key', () => {
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-032b: a managed instance without an operator token offers no Mapbox and no Amap tiles', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true, hasAmapKey: false });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: '', map_tile_url: '' }) });
render(<MapSettingsTab />);
expect(screen.queryByText('Mapbox GL')).not.toBeInTheDocument();
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
await user.click(screen.getByText('Select template...'));
expect(screen.queryByText('高德地图 (Amap)')).not.toBeInTheDocument();
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
});
it('FE-COMP-MAP-032c: the operator token brings Mapbox back on a managed instance', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: 'pk.operator' }) });
render(<MapSettingsTab />);
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
});
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
@@ -15,7 +15,7 @@ import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { useMapSettingsForm } from './useMapSettingsForm'
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
@@ -130,7 +130,7 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
export default function MapSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
@@ -149,7 +149,7 @@ export default function MapSettingsTab(): React.ReactElement {
{/* Provider picker — big tiles so the choice is obvious */}
<SettingRows>
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className={`grid grid-cols-1 gap-2 ${canMapbox ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
<ProviderTile
active={provider === 'leaflet'}
onClick={() => changeProvider('leaflet')}
@@ -157,15 +157,17 @@ export default function MapSettingsTab(): React.ReactElement {
name="Leaflet"
subtitle={t('settings.mapLeafletSubtitle')}
/>
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
{canMapbox && (
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
)}
<ProviderTile
active={provider === 'maplibre-gl'}
onClick={() => changeProvider('maplibre-gl')}
@@ -185,7 +187,7 @@ export default function MapSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
id={`${fieldId}-tile`}
@@ -0,0 +1,33 @@
// FE-MAP-MODEL-001 to FE-MAP-MODEL-004
import { describe, expect, it } from 'vitest';
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults';
import { MAP_PRESETS, mapboxOffered, mapPresetsFor } from './mapSettingsModel';
const urls = (list: { url: string }[]) => list.map((p) => p.url);
describe('mapPresetsFor', () => {
it('FE-MAP-MODEL-001: a self-hosted install offers every preset, Amap included', () => {
expect(mapPresetsFor(false, false)).toEqual(MAP_PRESETS);
});
it('FE-MAP-MODEL-002: a managed install offers Amap only where the operator set it up', () => {
const without = urls(mapPresetsFor(true, false));
expect(without).not.toContain(AMAP_ROAD);
expect(without).not.toContain(AMAP_SATELLITE);
expect(without).toHaveLength(MAP_PRESETS.length - 2);
expect(mapPresetsFor(true, true)).toEqual(MAP_PRESETS);
});
});
describe('mapboxOffered', () => {
it('FE-MAP-MODEL-003: a self-hosted install always offers Mapbox, the token field follows', () => {
expect(mapboxOffered(false, null)).toBe(true);
expect(mapboxOffered(false, '')).toBe(true);
});
it('FE-MAP-MODEL-004: a managed install offers Mapbox only with the operator token', () => {
expect(mapboxOffered(true, undefined)).toBe(false);
expect(mapboxOffered(true, '')).toBe(false);
expect(mapboxOffered(true, 'pk.operator')).toBe(true);
});
});
@@ -41,6 +41,26 @@ export const MAP_PRESETS: MapPreset[] = [
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
];
/**
* The presets this install offers. On a managed install Amap is the operator's
* service like every other keyed one: its tiles are offered only where the
* operator set Amap up (has_amap_key), so a hosted user is never pointed at a
* provider the instance does not use.
*/
export function mapPresetsFor(managed: boolean, hasAmapKey: boolean): MapPreset[] {
if (!managed || hasAmapKey) return MAP_PRESETS;
return MAP_PRESETS.filter((p) => p.url !== AMAP_ROAD && p.url !== AMAP_SATELLITE);
}
/**
* Whether Mapbox GL can be picked. A managed install brings the token with the
* instance and offers no field for one, so without an operator token the
* choice would only lead to a map that never loads.
*/
export function mapboxOffered(managed: boolean, token: string | null | undefined): boolean {
return !managed || !!token;
}
export type MapProvider = 'leaflet' | GlMapProvider;
export function normalizeProvider(value: unknown): MapProvider {
@@ -2,9 +2,11 @@ import { escapeHtml } from '@trek/shared';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { adminApi, authApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage, type UserWithOidc } from '../../types';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { passwordErrorKey } from '../../utils/passwordError';
import { useToast } from '../shared/Toast';
@@ -35,6 +37,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation();
const dateFormat = useDateFormat();
const toast = useToast();
const avatarInputRef = useRef<HTMLInputElement>(null);
@@ -126,7 +129,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
if (skipBackupCodes) return;
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(formatDisplayDateTime(new Date(), undefined, {}, dateFormat))}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) return;
w.document.open();
@@ -7,7 +7,15 @@ import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
previewPlace,
slotStyle,
styleForProvider,
} from './mapSettingsModel';
export interface MapSettingsFormOptions {
/**
@@ -32,6 +40,7 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
const [provider, setProvider] = useState<MapProvider>(initialProvider);
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '');
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '');
const [mapboxStyle, setMapboxStyle] = useState<string>(
@@ -94,6 +103,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
return {
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, settings.mapbox_access_token),
saving,
provider,
changeProvider,
@@ -1,8 +1,10 @@
import { startRegistration } from '@simplewebauthn/browser';
import { useEffect, useState } from 'react';
import { authApi, type PasskeyCredential } from '../../api/client';
import { readDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { formatDisplayDate } from '../../utils/dateFormat';
import { useToast } from '../shared/Toast';
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
@@ -10,7 +12,7 @@ export function fmtDate(ts: string | null): string | null {
if (!ts) return null;
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
return Number.isNaN(d.getTime()) ? null : formatDisplayDate(d, undefined, {}, readDateFormat());
}
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { pluginsApi } from '../../api/client';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { formatDisplayDateTime } from '../../utils/dateFormat';
export interface ActivityRow {
ts: string;
@@ -18,6 +20,7 @@ export interface ActivityRow {
*/
export function usePluginActivity() {
const { locale } = useTranslation();
const dateFormat = useDateFormat();
const [rows, setRows] = useState<ActivityRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +39,7 @@ export function usePluginActivity() {
const fmtWhen = (ts: string): string => {
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
return Number.isNaN(d.getTime()) ? ts : formatDisplayDateTime(d, locale, {}, dateFormat);
};
return { rows, loading, load, fmtWhen };
@@ -1,8 +1,8 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourGpxPreviewTrack } from '@trek/shared';
import type { TourFileLeftOut, TourGpxPreviewTrack } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
@@ -27,10 +27,10 @@ function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrac
};
}
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
function gpxImport(tracks: TourGpxPreviewTrack[], leftOut?: TourFileLeftOut): TourGpxImport {
return {
importing: false,
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks, leftOut }],
confirm: vi.fn(),
cancel: vi.fn(),
pick: vi.fn(),
@@ -63,19 +63,54 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
/>
);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
expect(current.confirm).toHaveBeenCalledWith(
{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' },
{ points: false }
);
});
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
const walk = gpxImport([track()]);
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' }, { points: false });
unmount();
const hike = gpxImport([track()]);
render(<TourGpxPreviewDialog gpxImport={hike} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
});
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
// Added as places, they are not listed as left out.
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
const button = screen.getByTestId('gpx-preview-import');
expect(button).toBeEnabled();
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
expect(screen.queryByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeNull();
fireEvent.click(button);
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
});
@@ -1,6 +1,6 @@
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
import { useId, useMemo } from 'react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import ToggleSwitch from '../Settings/ToggleSwitch';
import {
DialogButton,
DialogFooter,
@@ -98,8 +99,8 @@ function PreviewTrackRow({
);
}
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
/** What all the files held besides their lines, summed by kind. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
const sum = { points: 0, areas: 0, other: 0 };
for (const { leftOut } of files) {
if (!leftOut) continue;
@@ -107,18 +108,46 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
sum.areas += leftOut.areas;
sum.other += leftOut.other;
}
return sum.points + sum.areas + sum.other > 0 ? sum : null;
return sum;
}
/**
* IO-02: what a KML or KMZ file held besides its lines, named before the
* import so nothing goes missing unseen: a dashed card with each kind as a
* counted badge, and why in the info dot.
* #2969: the single points of the files (GPX waypoints, KML pins), which no
* tour is made of, offered as places of the trip. On by default: a file with
* a route and its stops is usually meant as both.
*/
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
const { t } = useTranslation();
const leftOut = leftOutOf(files);
if (!leftOut) return null;
const hintId = useId();
return (
<div
data-testid="gpx-preview-points"
className="mt-2 flex items-center gap-2.5 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2"
>
<MapPin size={14} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex flex-wrap items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.import.pointsAsPlaces')}
<SoftPill>{t('tours.import.leftOutPoints', { count })}</SoftPill>
</span>
<span id={hintId} className="text-content-muted" style={fs(11.5, 'body')}>
{t('tours.import.pointsAsPlacesHint')}
</span>
</div>
<ToggleSwitch on={on} onToggle={onToggle} label={t('tours.import.pointsAsPlaces')} describedBy={hintId} />
</div>
);
}
/**
* IO-02: what a file held besides its lines and does not take in, named
* before the import so nothing goes missing unseen: a dashed card with each
* kind as a counted badge, and why in the info dot. Its points only while
* they are not added as places.
*/
function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
const { t } = useTranslation();
if (leftOut.points + leftOut.areas + leftOut.other === 0) return null;
const kinds = [
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
@@ -170,6 +199,29 @@ export default function TourGpxPreviewDialog({
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
/** A fresh line needs its type complete; a file of known lines can still be confirmed for its points alone. */
function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean): boolean {
return fresh > 0 ? typeComplete : addsPoints;
}
/** The points offered as places, and below them whatever else stays out. */
function PreviewExtras({
leftOut,
withPoints,
onTogglePoints,
}: {
leftOut: TourFileLeftOut;
withPoints: boolean;
onTogglePoints: () => void;
}) {
return (
<>
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
</>
);
}
function PreviewDialog({
gpxImport,
files,
@@ -183,6 +235,9 @@ function PreviewDialog({
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const choices = useGpxImportChoices(initial);
const leftOut = useMemo(() => leftOutOf(files), [files]);
const [withPoints, setWithPoints] = useState(true);
const addsPoints = withPoints && leftOut.points > 0;
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -194,7 +249,8 @@ function PreviewDialog({
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
const canImport = canConfirm(fresh, addsPoints, importMissing(choices.chosen) === null) && !gpxImport.importing;
const confirmLabel = fresh > 0 ? t('tours.import.previewConfirm', { count: fresh }) : t('tours.import.pointsConfirm');
const fields = importFields(choices.chosen);
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
@@ -231,9 +287,9 @@ function PreviewDialog({
data-testid="gpx-preview-import"
icon={<FileDown size={14} strokeWidth={2} />}
disabled={!canImport}
onClick={() => void gpxImport.confirm(fields)}
onClick={() => void gpxImport.confirm(fields, { points: addsPoints })}
>
{t('tours.import.previewConfirm', { count: fresh })}
{confirmLabel}
</DialogButton>
</DialogFooter>
}
@@ -258,8 +314,8 @@ function PreviewDialog({
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewLeftOut files={files} />
{fresh === 0 && (
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
{fresh === 0 && !addsPoints && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.import.previewAllDuplicates')}
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { placesApi } from '../../api/client'
import { tourGpxApi } from '../../api/tourGpx'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
@@ -457,6 +458,7 @@ describe('ToursSidebar', () => {
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
@@ -469,9 +471,11 @@ describe('ToursSidebar', () => {
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
// The pins are offered as places (#2969); only the area stays out.
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).not.toHaveTextContent('pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
@@ -479,6 +483,8 @@ describe('ToursSidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(toast.success).toHaveBeenCalledWith('3 places added')
vi.restoreAllMocks()
})
@@ -121,7 +121,7 @@ describe('a ride taken from a GPX file (HOR-02)', () => {
expect(gpxImport.confirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' }, { points: false });
});
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
@@ -2,6 +2,8 @@ import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../../i18n';
import { frameLine, frameLng } from '../../../utils/lngFrame';
import { useLngFrame } from '../../Map/lngFrameContext';
import {
isPaddleItemSelected,
markerSize,
@@ -74,6 +76,8 @@ function useLabel() {
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
const label = useLabel();
const map = useMap();
// Drawn in the map's longitude frame, with the trip (#2975).
const frame = useLngFrame();
const linePane = useLinePane();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
@@ -105,7 +109,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
pathOptions={{
color: item.disc,
@@ -120,7 +124,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
item.line ? (
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
positions={frameLine(frame, item.line)}
pane={linePane}
bubblingMouseEvents={false}
pathOptions={{
@@ -137,7 +141,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
{items.map((item) => (
<Marker
key={`${item.feature.ref}-pin`}
position={item.marker}
position={frame ? [item.marker[0], frameLng(frame, item.marker[1])] : item.marker}
icon={icons.get(item.feature.ref) as L.DivIcon}
zIndexOffset={
isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300
@@ -5,7 +5,9 @@ import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../tests/helpers/store';
import { placesApi } from '../../api/client';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { useTourGpxImport } from './useTourGpxImport';
// The toasts are caught at useToast, so no window global has to be set for them.
@@ -67,3 +69,66 @@ describe('useTourGpxImport without a line in the file', () => {
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
});
});
describe('useTourGpxImport with points beside the lines (#2969)', () => {
const route = new File(['<gpx/>'], 'sygic.gpx');
const pins = new File(['<kml/>'], 'pins.kml');
const line = {
name: 'Route',
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
point_count: 2,
distance_km: 1,
elevation_gain_m: null,
elevation_loss_m: null,
duplicate: false,
};
beforeEach(() => {
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
});
const previewed = async (preview: (picked: File) => object) => {
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
await act(() => hook.result.current.importFiles([route, pins]));
return hook;
};
it('adds the points of each file as places through the places import, waypoints only', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
const { result } = await previewed((picked) => ({
tracks: [line],
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
});
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const { result } = await previewed(() => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: 3, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }));
expect(importGpx).not.toHaveBeenCalled();
expect(tourRepo.importGpx).not.toHaveBeenCalled();
});
it('names a file whose points could not be added, and still takes in its tour', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
const { result } = await previewed((picked) => ({
tracks: [line],
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
});
});
+71 -16
View File
@@ -40,10 +40,62 @@ function fileErrorMessage(error: unknown, name: string, t: Translate): string {
export interface GpxPreviewFile {
file: File;
tracks: TourGpxPreviewTrack[];
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
/** What the file held besides its lines, which no tour is made of: GPX waypoints, KML pins, areas and the rest. */
leftOut?: TourFileLeftOut;
}
/** What to do with the previewed files besides their lines. */
export interface TourImportExtras {
/** Add each file's points (GPX waypoints, KML pins) as places of the trip (#2969). */
points?: boolean;
}
interface ToursImported {
tours: TourListItem[];
skipped: number;
caution: boolean;
}
/** Each file with a line the trip does not hold yet, imported as tours; a file that fails is handed to `onError`. */
async function importTours(
tripId: number,
files: readonly GpxPreviewFile[],
fields: TourGpxImportFields,
onError: (error: unknown, file: File) => void
): Promise<ToursImported> {
const sum: ToursImported = { tours: [], skipped: 0, caution: false };
for (const { file, tracks } of files) {
if (tracks.every((track) => track.duplicate)) continue;
try {
const result = await tourRepo.importGpx(tripId, file, fields);
sum.tours.push(...result.tours);
sum.skipped += result.skipped;
sum.caution ||= result.caution;
} catch (error: unknown) {
onError(error, file);
}
}
return sum;
}
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
async function importPoints(
tripId: number,
files: readonly GpxPreviewFile[],
onError: (file: File) => void
): Promise<number> {
let places = 0;
for (const { file, leftOut } of files) {
if (!leftOut?.points) continue;
try {
places += await tourRepo.importFilePoints(tripId, file);
} catch {
onError(file);
}
}
return places;
}
interface TourGpxImportOptions {
tripId: number;
canEdit: boolean;
@@ -115,26 +167,29 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
[canEdit, fileError, importing, onNoTrack, preview, t, toast, tripId]
);
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
/**
* The previewed files imported as the chosen type, summed up in one set of
* toasts. With `points`, what each file holds as single points comes in as
* places as well, so a file with a route and its stops loses neither (#2969).
* A file whose every line the trip already holds is not sent to the Tours
* import at all, only for its points.
*/
const confirm = useCallback(
async (fields: TourGpxImportFields) => {
async (fields: TourGpxImportFields, extras: TourImportExtras = {}) => {
if (!canEdit || !preview || importing) return;
setImporting(true);
const imported: TourListItem[] = [];
let skipped = 0;
let caution = false;
try {
for (const { file } of preview) {
try {
const result = await tourRepo.importGpx(tripId, file, fields);
imported.push(...result.tours);
skipped += result.skipped;
caution ||= result.caution;
} catch (error: unknown) {
fileError(error, file);
}
}
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
const places = extras.points
? await importPoints(tripId, preview, (file) =>
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
)
: 0;
setPreview(null);
if (places > 0) {
if (imported.length === 0) await refreshPlaces(tripId);
toast.success(t('tours.import.pointsImported', { count: places }));
}
if (imported.length > 0) {
// The server's place:created skips this socket, so the places are read
// again here for the new ones and their lines. Only the places: a whole
@@ -18,8 +18,10 @@ import {
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
@@ -428,12 +430,13 @@ function SnapshotNotice({
onOpen: (placeId: number) => void;
}) {
const { t } = useTranslation();
const dateFormat = useDateFormat();
const snapshot = preview.snapshot;
if (preview.state === 'new' || !snapshot) {
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
}
const when = snapshot.importedAt
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
? formatDisplayDate(new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`), locale, {}, dateFormat)
: '';
const open = (
<button
@@ -78,6 +78,19 @@ afterEach(() => {
delete window.__addToast
})
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('VacaySettings calendars', () => {
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
expect(hire()).toHaveAccessibleName(/2020/)
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
typeDate(hire(), '2021-03-02')
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
})
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
clearDate(hire())
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
@@ -3,6 +3,7 @@ import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRigh
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import type { VacayHolidayCalendar } from '../../types'
@@ -355,12 +356,12 @@ function YearTypePicker() {
{type === 'anniversary' && (
<div style={{ paddingInlineStart: 36, marginTop: 8 }}>
<input
type="date"
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={e => save({ hire_date: e.target.value || null })}
aria-label={t('vacay.hireDate')}
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
style={{ width: '100%' }}
/>
{!yearSettings.hire_date && (
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
@@ -819,3 +819,82 @@ describe('CustomDatePicker week start (#2029)', () => {
expect(await openMarch2026('saturday')).toEqual({ header: 'SSMTWTF', blanks: 1 });
});
});
// ─── Date format (#2971) ──────────────────────────────────────────────────────
describe('CustomDatePicker date format (#2971)', () => {
const onChange = vi.fn();
const original = useSettingsStore.getState().settings;
beforeEach(() => vi.clearAllMocks());
afterEach(() => useSettingsStore.setState({ settings: original }));
const seed = (dateFormat: 'auto' | 'YYYY-MM-DD' | 'DD.MM.YYYY') =>
useSettingsStore.setState({ settings: { ...original, date_format: dateFormat } });
function typeDate(value: string) {
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
fireEvent.change(input, { target: { value } });
fireEvent.keyDown(input, { key: 'Enter' });
}
it('FE-COMP-DATEPICKER-036: the trigger writes the date in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toContain('2026-11-03');
});
it('FE-COMP-DATEPICKER-037: compact mode writes the chosen format with the whole year', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} compact />);
expect(screen.getByText('03.11.2026')).toBeInTheDocument();
});
it('FE-COMP-DATEPICKER-038: the manual entry is hinted and pre-filled in the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'YYYY-MM-DD');
expect(input).toHaveProperty('value', '2026-11-03');
});
it('FE-COMP-DATEPICKER-039: a typed date is read in the order of the chosen format', () => {
seed('YYYY-MM-DD');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-040: day first under DD.MM.YYYY, even with an English interface', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('04.11.2026');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-043: a four-digit year written first is read as year, month, day in any format', () => {
seed('DD.MM.YYYY');
render(<CustomDatePicker value="" onChange={onChange} />);
typeDate('2026/11/04');
expect(onChange).toHaveBeenCalledWith('2026-11-04');
});
it('FE-COMP-DATEPICKER-041: auto keeps the short month of the language on the trigger', () => {
seed('auto');
render(<CustomDatePicker value="2026-11-03" onChange={onChange} />);
expect(screen.getAllByRole('button')[0].textContent).toMatch(/Nov/);
});
it('FE-COMP-DATEPICKER-042: auto keeps the language for typing: month first in English', () => {
seed('auto');
render(<CustomDatePicker value="" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByRole('textbox', { name: 'Enter date manually' });
expect(input).toHaveProperty('placeholder', 'MM/DD/YYYY');
fireEvent.change(input, { target: { value: '04.11.2026' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('2026-04-11');
});
});

Some files were not shown because too many files have changed in this diff Show More