Compare commits

...

101 Commits

Author SHA1 Message Date
Maurice 35e0613986 test(native): read the listener names as strings, so the appLinks case typechecks 2026-11-13 08:34:05 +01:00
Maurice ccfa92f650 feat(mobile): the assistant is an entry of the dock, sorted like the pages
The home assistant sat behind the More button on every page outside a
trip, so a dock with free slots showed "..." for a single entry. It is now
an entry of the dock list while its addon is on and it can run for the
user: an account that never sorted its dock gets it in a slot the addons
leave free, and Settings > Appearance > Mobile can move it between the bar
and More like any page.
2026-11-13 08:27:31 +01:00
Maurice fdb2095ced fix(native): a widget tap opens its page every time, also after iOS ended the page
A widget or Live Activity tap reached the UI as Capacitor's appUrlOpen, or
as the launch URL. When iOS had ended the web view's process in the
background, the event went to the old page's listeners and was lost, and
the reloaded page found the same link in getLaunchUrl that it had already
followed once and skipped it: the second tap on the packing widget opened
the start page.

The binary now takes com.liketrek.trek://navigate links itself and hands
them over like a quick action, held until the UI takes it and kept for a
page that is still loading. It reports the feature appLinks, and the UI
only follows the link through Capacitor for binaries without it.
2026-11-13 08:27:23 +01:00
Maurice d6a6b49b6b test(planner): the #3074 case in a file of its own, and the category pick inside guessedCategory, so both files stay within their size 2026-11-12 23:18:51 +01:00
Maurice 0855bafca7 fix(search): place searches find a name typed without its accents (#3075)
"Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the
places list, a collection, the saved-place picker, the import from a trip
and the location picker now compare without case and Latin accents, with
letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin
script stay, since there they change the word.
2026-11-12 23:06:36 +01:00
Maurice a39f3fe40c fix(planner): a place added from a collection keeps its category (#3074)
The place form guessed the category of a picked place from its kind, so a
place taken from a collection inside a trip lost the category it was saved
under, while adding it from the collection kept it. A picked place that
carries a category this server has now brings it along; a category chosen
in the form by hand still wins.
2026-11-12 23:06:35 +01:00
Maurice f13a0aa6ee fix(collections): pins on the map show their place's category (#3073)
The server nests a saved place's category, while the markers and the hover
card read the flat fields a trip place carries, so every pin on a
collection's map was the grey default. The map now hands its places on with
the category's colour, icon and name where the markers look for them.
2026-11-12 22:02:05 +01:00
Maurice 10d1a61614 fix(ios): a running TREK Cloud subscription is found on a fresh install too
The check before a purchase read only the entitlements on the device. Those
can be empty while the subscription runs, so the app offered the plans and
the purchase changed the running subscription's plan in the App Store. The
status of the subscription group now decides when the device has none. A
renewal the device has not finished yet no longer counts as a purchase
waiting for its instance either, which also let the check through.
2026-11-12 21:21:51 +01:00
github-actions[bot] 2bcfc1bfcb chore: bump version to 5.1.4 [skip ci] 2026-11-12 19:51:06 +00:00
Maurice b6e3c9bf39 feat(native): the app needs TREK 5.1.4 on the server
nativeApi goes to 3 with 5.1.4, whose UI follows a widget or Live Activity
tap that starts the app. The apps refuse an older server with the hint to
update, and check a server they remember once more.
2026-11-12 20:21:16 +01:00
Maurice 736dc5dda9 chore(lint): lower the non-null assertion baseline the AirTrail test change freed 2026-11-12 17:47:05 +01:00
Maurice ae26a6c61c refactor(airtrail): save the settings in one transaction
The address and the key it may no longer carry now change together, as
they do for Dawarich, so the transaction ratchet drops the AirTrail entry.
The tests that pinned the old no-transaction shape now pin the new one.
2026-11-12 17:31:56 +01:00
Maurice 8979bd1fcd test(airtrail): one case per key field instead of a loop of awaits 2026-11-12 17:14:54 +01:00
Maurice 74d243d8e0 fix(airtrail): a new host does not inherit the stored API key
Saving the AirTrail settings with a different URL and the key field left
masked or blank kept the stored key, so the next sync sent the old
instance's key to the new host. The key now goes with the host, as it
already did for the connection test and for Dawarich: a new origin without
a new key clears it, a path edit on the same origin keeps it. The key is
cleared before the new address is written.
2026-11-12 16:58:24 +01:00
Maurice 56b9bac031 fix(native): a widget tap that starts the app opens what it points at
A tap on a widget or a Live Activity hands the app
com.liketrek.trek://navigate/<path>. A running app gets it as appUrlOpen;
an app started by the tap gets it as its launch URL, which nothing read, so
the path was lost and the app opened its start page (the packing widget
landed on the trip's plan instead of its lists). The launch link is now
followed once per launch.
2026-11-12 16:53:04 +01:00
Maurice 4983b3407c fix(planner): a flight without airports keeps its day and times on save
A flight created with a day and times but no endpoints (over MCP) showed
them in the form, but buildTransportPayload took a flight's span only from
rows with an airport, so a save sent day, end day and both times as null.
Like a train without stations, such a flight now takes its day, times,
airline, flight number and seat from the raw rows. Endpoints and legs still
come only from rows with an airport.

Fixes #3070
2026-11-12 16:49:29 +01:00
Maurice ed1728feec refactor(admin): the SSO unlink section is a component of its own
The edit dialog's component crossed the complexity limit with the section's
conditions; the section and its confirm step now live in SsoUnlinkSection,
which decides itself when it shows.
2026-11-12 16:14:34 +01:00
Maurice 11472c05ff fix(native): a failed SSO round trip from the app still hands back on the retry
A sign-in the app starts runs through the server's handoff page in the
system browser. When the SSO callback failed (an expired or overwritten
state), the login page dropped the way back to that page, so the retry from
there was a plain web login and ended in the web app inside the app's
browser sheet instead of signing in the app. The login page now keeps a way
back that points at the handoff page, and only that, and renews its
fallback copy for a slow retry. State, nonce, PKCE and the handoff verifier
are unchanged; the handoff page still asks for a tap before it passes
anything on.
2026-11-12 16:10:14 +01:00
Maurice f2ac12ca4f fix: hold the ratchets after the SSO unlink and the day pager window
- The SSO unlink answers with a response contract (adminSsoUnlinkResponseSchema).
- It reuses linkOidcIdentity with nulls instead of a new repository method,
  so UsersRepository keeps its method count and size.
- The client calls the route through apiClient, so api/client.ts does not
  grow, and its confirm dialog takes no async callback.
- The pager's preview step and pagesFor keep their complexity.
2026-11-12 15:56:59 +01:00
Maurice b53130ca0d feat(admin): remove the SSO link of an account whose provider changed its subject
A sign-in with another subject of the same provider is refused, so an
account whose provider re-issued the subject (a migrated or rebuilt IdP, a
changed sub format) was locked out: editing the email, as the wiki advised,
never touched the stored link. The edit dialog now offers "Remove SSO link"
for other accounts while SSO is set up (DELETE /api/admin/users/:id/sso,
audit admin.user_sso_unlink). The next SSO sign-in links again under the
rules of a first one, so it still needs a verified, not self-set email.
The log line names the remedy, and the wiki tells the two refusals apart.

Fixes #3065
2026-11-12 15:39:21 +01:00
Maurice a3a4f7cf79 fix(mobile): days drawn ahead of a fast swipe through the day pager
Only the selected day and two on either side were drawn, and the selection
moves only once the strip comes to rest, which iOS takes a while for. A run
of fast swipes ran past the drawn pages into blank ones until it settled.
The drawn window now also follows the day the swipe is heading for, so the
next days are there before they slide in. At most two windows of five pages
are mounted, however long the trip.

Fixes #3067
2026-11-12 15:39:20 +01:00
Maurice ac7094df05 fix(planner): a refresh of the trip no longer wipes an open transport or booking form
The transport form re-seeded itself whenever the trip's costs arrived as a
new array, and the booking form whenever days, places or stays did. A
socket reconnect re-reads the whole trip, so behind a proxy that drops idle
sockets every 15 to 30 seconds everything typed so far was wiped. Both forms
now re-seed only for what opens a different form; the data is read when the
form is seeded, as before.

Fixes #3069
2026-11-12 15:39:20 +01:00
Maurice 80130f7946 fix: the provisioning line reads as a key reference, the safe-area test follows margin-inline
lint:i18n-keys took the spread in front of t() for a member call and did not
see native.provisioning.sub; the sentence is its own constant now. The
shell chrome test asks for the logical margins and their right-to-left swap.
2026-11-12 14:52:42 +01:00
Maurice fc648bc7e4 fix: green checks after the first-screen redesign and the safe-area floor
- native-shell/amount.ts: the no-break spaces in the price pattern were
  written out as characters; \s already matches them.
- widgets.ts: the Live Activity's lead time stays inside the module.
- i18n: the count strings of the new plan tiles replace the removed ones
  on NOT_PLURAL, and the address placeholders of the non-Latin locales
  are their own instead of English copies.
- mobile.css: the side safe-area margins of the floating bars use
  margin-inline, swapped for right to left, instead of physical sides.
2026-11-12 14:36:13 +01:00
Maurice 9005d6b333 fix(native): name TREK without an article in the first screens
TREK is the application, not a thing one has a copy of: "I already have
TREK", not "a TREK". The same for the not-TREK messages in German and
Dutch and the agreement title in French, Spanish, Italian and Catalan.
2026-11-12 14:18:42 +01:00
Maurice 52f0fb882f feat(native): redesign the app's first screens around a living landscape
The screens the app ships itself (welcome, plans, address, own server and
the states between) move from the dark card layout to a light design on an
animated aerial landscape drawn by a WebGL shader (native-shell/scenery.ts):
an island, a coastal town, farmland and mountains in one world.

- Welcome: the landscape with the TREK wordmark, three perks in one card,
  "Explore cloud plans" and "I already have a TREK" as two buttons.
- Plans: one tab per plan; choosing one flies the camera to its place,
  further inland with every plan. Each plan reads as a kind of trip, the
  price sits in a card with a monthly/yearly switch and counts to its new
  amount in the store's own format (native-shell/amount.ts), and a yearly
  price shows what it makes a month. Restore purchases, the renewal terms
  and the links stay as the store rules require.
- Address: the address writes itself on a glass card over the landscape;
  free, the password rules and the agreement sit on white cards.
- Own server: the server address on the glass card; a failed check turns
  the card red with what went wrong, proxy sign-in and client certificate
  panels in the same style.
- States: server not reachable, certificate needed, already subscribed,
  setting up (with its steps, then "Arrived") and the slow setup.
- iPad, an unfolded phone and a phone on its side get two columns, the
  landscape beside the content; on a dual-screen device each half takes
  one screen. Arabic and Hebrew mirror through logical sides only.
- The shader caps the pixel ratio at 2, drops to a lower resolution on a
  slow device, stops in the background and is one still frame with
  reduced motion.
- The sign-in and error page bars over a proxy's page (iOS and Android)
  take the same look: a white card and a dark button.
- Plus Jakarta Sans is bundled for the text, with Montserrat standing in
  for Cyrillic; the wordmark fonts and the old preview images are gone.
- New strings in all 28 languages; the keys of the old screens removed.
- The dev sample plans follow the cloud's plans (start, basis, plus, pro).
2026-11-12 14:18:42 +01:00
Maurice b8ec428ad1 fix(native): show the travel day's Live Activity from three hours before its first stop
The Live Activity started with the first sync of a travel day and stayed on
the lock screen all day, so a day whose first stop is an evening flight held
it from the early morning on. It now appears three hours before the day's
first timed stop and stays until the day is done; liveDayDue() decides.
2026-11-12 14:00:47 +01:00
Maurice a1d3ffc2ce feat(native): the 'server not reachable' screen names the cause, small, for a report
A load that failed sent the app to the address screen with a general message only, and a tester's screenshot told nothing. The web view's own error (net::ERR_..., an HTTP status, NSURLErrorDomain and its code, a client certificate the proxy asked for) is kept natively and shown under the message once.
2026-11-12 10:11:01 +01:00
Maurice 46b9df54bf feat(native): the TREK Cloud screens show the app and make the choice of a plan easy
The cloud screen shows real screens of the app. The plans are a choice now: one tap marks a plan, the recommended one is marked, what every plan has is said once above them, storage says how many photos it holds, and the yearly switch shows what a year saves; one button at the foot carries the chosen plan on. The claim screen names the plan with a way to change it, shows the address as it will be once it is free, ticks off the password rules while typing, says in three words where the servers are, that it can be cancelled and that there are no ads, and puts the photo behind the form away.
2026-11-12 10:04:31 +01:00
Maurice 3c0c3b3f6e fix(native): a store account with a running TREK Cloud subscription buys no second one
One subscription per Apple ID or Google account. Choosing another plan in the app changed the running subscription on the App Store, and the redeem then failed with 'We could not set up your TREK'. The app now asks the store first and shows a screen of its own: the account already has TREK Cloud, a new one can be taken out once it has ended, with Open my TREK and the store's page to manage it. The native purchase refuses as well; a paid one that was never set up is still redeemed. On the address screens the link 'What is a TREK server?' is gone, and the small print under a purchase is set in the system text face at regular weight.
2026-11-12 09:28:56 +01:00
Maurice 5395d53332 fix(native): the top bar keeps clear of a camera in the corner of the screen
The iOS app hands the page the safe area it measures, a floor under env() where WebKit reports none, and on a phone the floating bars keep clear of the side insets as well.
2026-11-12 08:42:17 +01:00
Maurice ec08032e98 fix(native): SSO in the Android app survives Android ending the app while the browser is open
A Samsung phone may end the app while the sign-in runs in the browser. The browser's return then started the app anew without the call that waited for it, the first page load failed while the network was still held back, and the user came back to 'server not reachable' and then to the sign-in page, in a loop. The return is now kept natively and redeemed by the sign-in page with the verifier kept until then, and a page that failed on the network is loaded once more before the app gives up.
2026-11-12 08:42:17 +01:00
Maurice cc5eca5553 feat(native): a client certificate can be added on the address screen before connecting
The import was only offered once the check saw the proxy ask for a certificate during the handshake. A proxy that refuses without asking left no way in. The wiki also points to the certificate's new place in Settings and explains why a certificate from an iOS configuration profile cannot be used by the app.
2026-11-12 08:42:16 +01:00
Maurice 1323183cce fix(assistant): Claude Sonnet 5.5 no longer ends a turn with 'came back without an answer'
Sonnet 5.5 hands back the words written before a tool call as a hidden thinking block. After the ask for the answer, an empty step got the general push with tools open again; the model answered, offered follow-ups once more, and the answer was lost a second time. That push now asks for the answer as text and stays text-only.
2026-11-12 08:42:16 +01:00
Maurice 009be05d22 fix(mobile): a sheet no longer shows the page through it while it opens
The dim faded in on the sheet's own parent, and opacity multiplies down the tree, so for its first frames even the opaque sheet let the list behind read through as ghost text (Android tester, expense editor). The dim now fades on a layer of its own beside the sheet, and the card is opaque halfway through its pop.
2026-11-12 08:42:16 +01:00
Maurice 860ffc1dee fix(planner): the drag state setters are stable, so the sidebar's dragend listener lists them and is still set up once 2026-11-12 08:42:15 +01:00
Maurice c515f95d17 fix(ci): the drag hook lists its dependencies, closeOnDone stays module-private, and the weight test waits for its save 2026-11-12 07:10:18 +00:00
Maurice 855bf8610a refactor(planner): the day plan's drag state lives in a hook of its own; the holiday provider cases get a suite of their own
DayPlanSidebar grew past its size baseline with the Firefox drag fix. Its drag state (what is dragged, the drop line, the day under the drag, whether a drag happened) and the effect that ends a lost drag move into useDayPlanDragState, typed, which also lowers the file's strict-mode count; size and strict baselines are lowered with it.

vacay.service.test.ts went past the 2000-line limit once the Nager v4 cases were back in it. They move into vacay.holiday-provider.test.ts, which takes VacayService from a Nest testing module over a snapshot db, so neither the db mock nor a hand-built service is needed.
2026-11-11 22:38:59 +00:00
Maurice 742edc4a79 fix(ci): today's changes pass the release gates
- iOS: the navigation proxy reports an error page through a callback the controller sets; it called the controller's method on itself and did not build.
- keyboard: the done key reads the field's text in the capture phase, before the field's own handler, so a list that adds an item on Enter and clears itself keeps the keyboard; the tests wire it as the app does.
- Ireland migration: the queued writes use addSql instead of awaiting inside loops; only table constants and integer ids reach the SQL.
- tests: the Dawarich GL test types its two layer specs once (strict baseline lowered), the collab test reads the id off the listed message, the reasoning test runs its four servers in parallel, and a wasm copy covers the precompressed branch without a charset.
- en wording: the deviations agreement no longer calls TREK self-hosted.
2026-11-11 22:06:50 +00:00
Maurice 1975a23aab fix(mobile): the settings group headings follow the text size setting
They used a fixed 10px size, which theme:lint refuses; they take the caption tier now.
2026-11-11 21:32:05 +00:00
Maurice 80c8d9c602 feat(mobile): settings group their sections by what they reach: your account or this device
A tester could not tell which settings follow the account to every device and which stay on the phone, and looked for offline mode for a while. The section list and dropdown now stand under two headings, "Your account" and "This device". The server the app opens, the app lock and a client certificate move out of Account into a section of their own, "App", shown only in the app, next to Offline; About stands under TREK. Labels in all locales. TestFlight feedback, 5.1.3.
2026-11-11 21:31:14 +00:00
Maurice 948c9ef57b fix(atlas): show Ireland's 26 counties instead of its four provinces
geoBoundaries has no county layer for Ireland: its ADM1 is the four
provinces and its ADM2 the 166 local electoral areas, so the Atlas showed
Ireland as four provinces next to a UK of counties.

The Atlas builder can now take a country's regions from another source.
Ireland's come from Tailte Éireann's 26 counties (CC BY 4.0), with Dublin,
Cork, Galway and Limerick each one county, under their ISO 3166-2:IE
codes. Those outlines come at full detail, so the builder simplifies them
itself and keeps the borders two counties share identical on both sides,
so no point between them falls into neither. ATLAS_ONLY rebuilds just the
named countries without downloading the whole world.

A migration clears Ireland's cached place regions so they are worked out
again as counties, moves visited rows that name a county onto its code,
and hides each county of a province a user had hidden. Provinces a user
marked as visited are kept as they are.

Fixes #2922
2026-11-11 21:26:31 +00:00
Maurice bac968c0bf fix(ai): retry chat requests with reasoning_effort none when a server refuses tools with reasoning
Azure AI Foundry reasoning deployments behind an "Own server" connection
reject function tools on /v1/chat/completions at their default reasoning
effort and answer HTTP 400 naming reasoning_effort. The Responses API
switch from #2792 only covers direct OpenAI keys, so the assistant could
not run on these deployments at all.

The chat client still never sends reasoning_effort, so servers that do not
know the field (Ollama, LM Studio, vLLM, OpenRouter) see unchanged
requests. When a request with tools is refused over reasoning_effort, it
goes out once more with reasoning_effort "none", and the rest of the turn
keeps sending it. The refused field is now carried on AiProviderError
instead of only being logged.

Fixes #3043
2026-11-11 21:26:20 +00:00
Maurice 520562ef86 fix(planner): keep day plan drags alive in Firefox
Firefox fires dragstart while it handles the mouse move that starts the
drag and delivers that move to the page afterwards, button still held.
The listener that clears a drag whose dragend never arrived took that
move as the end of the drag and dropped the drag data at pick-up, so a
stop could only be reordered with its arrows. The same held-button moves
come from the touch polyfill on hybrid laptops throughout a drag. Only a
move with no button held now counts as the drag being over.

The day list also switched its stagger animation back on whenever a drag
ended, which replayed the entrance of every day card ("weird reload").
It now plays once on mount and stays off after the first drag.

Fixes #3041
2026-11-11 21:26:15 +00:00
Maurice 001253c39b fix(ios): the strip above the page carries the page colour on releases that lay the web view out below the status bar
On iOS 27.0.x the web view started below the status bar, and the strip above it showed the view controller's black instead of the screen background. The colour behind the web view now follows the theme the UI reports through StatusBar.setStyle (the top colour of --m-scr), so the strip reads as part of the page on every release. TestFlight feedback, 5.1.3.
2026-11-11 21:24:35 +00:00
Maurice 5345b50d17 test(vacay): the holiday provider cases live in the existing suites again
The two new test files mocked the db module and built VacayService by hand,
which the test-mocks and new-service ratchets refuse for files without a
baseline entry. The cases move back into vacay.service.test.ts and
vacay.test.ts unchanged; VACAY-037 asks for a year no other case caches. The
unrelated admin addon test change is reverted and two comments name the new
provider.
2026-11-11 21:06:29 +00:00
Luffy a3622e91b6 feat(vacay): implement Nager.Holidays v4 integration and adapt holiday data structure 2026-11-11 21:04:07 +00:00
Luffy be305c3daf fix(vacay): move holiday lookups to Nager.Holidays v4 2026-11-11 21:04:07 +00:00
Sebastian Fischer ec78cfbdd0 feat(tours): find bakeries and groceries on the map and along a hike
A multi-day hike restocks on the way: bread in the morning, food for
lunch and supper every day or two. Neither could be searched for.

- Explore pill: new `bakery` and `supermarket` chips. `supermarket`
  already existed on the server (shop=supermarket|convenience) but no
  pill offered it; `bakery` is new (shop=bakery).
- Along the tour: `bakery` and `supermarket` are offered to a hike,
  after its existing kinds so a new hike still starts on the same three.
- TREK places index: `bakery` maps to food_and_drink>casual_eatery>bakery;
  `supermarket` is narrowed to grocery and convenience stores, since the
  whole food_and_beverage_store branch also brought tobacconists and
  wine shops. Both paths checked against a live answer of the index.
  As the more specific term, a bakery is no longer listed as a place to
  eat when only `food` is asked along a tour.
- Category guess: a bakery or grocer picked from the map was preselected
  as Bar/Cafe or Shopping even where the admin had made a Bakery or
  Groceries category. The guess now looks for such a category first, by
  name in the usual languages or by icon, and keeps the broad one when
  there is none. The category icon picker offers Croissant and
  ShoppingBasket, the icons the map chips use.
- Labels in every locale; MCP tool description names the new kinds.
2026-11-11 20:59:59 +00:00
Ibrahim Roshdy 8023be452a fix(atlas): give a point no polygon contains to the nearest border
A point just off a simplified coastline lies in no admin0 polygon, and the
fallback then took the candidate with the smallest bounding box. Nice and
Menton sit ~80 m outside France's coast and 20+ km from Italy, but Italy's
box is the smaller one, so they resolved to IT. The fallback now ranks the
candidates by distance to their border. A deferred loose box (PS/XK) keeps
its priority. The parity test's reference lookup mirrors the new rule.

Closes #3048
2026-11-11 20:59:53 +00:00
John Jahn 5112c96c05 perf(places): the country lookup skips polygons whose box misses the point and remembers its answers
The places list and the trip bundle resolve the country of every place
without a cached region on each read, and that lookup was about 90% of
their CPU time: it ray-cast every polygon of each candidate country, which
is thousands of rings for a country that is mostly islands.

A polygon whose outer ring's box does not hold the point is now skipped
without walking it, and an answer is kept by coordinate pair (20,000 pairs,
oldest out first), since the borders never change while the process runs.
The answers are the ones the ray-cast alone gives; ATLAS-SVC-051 and
ATLAS-SVC-055 compare every country against the reference lookup.

Measured on a production build with a 360-place trip: GET places
22.7 ms -> 3.5 ms, GET bundle 27.5 ms -> 6.2 ms. A first lookup of a
pair is about 1.8 times faster (90 us -> 50 us over 20 cities).
2026-11-11 20:59:47 +00:00
John Jahn 04b1fb72bb perf(static): serve the hashed assets from Brotli copies written at build time
Every file under assets/ was compressed again on each download, at the low
level a request path can afford. They carry their content hash in the name
and never change, so the client build now writes a Brotli copy beside each
compressible one (postbuild, scripts/precompress.mjs) and production points
a browser that accepts Brotli at that copy before express.static serves it.

Measured on a production build of 5.1.2, fetching the 652 precached files
the way a service worker install does: 16.3 MB -> 13.9 MB on the wire and
about 1.0 s -> 0.2 s of server CPU per install. A client without Brotli, a
range request and a build without copies are served exactly as before.
2026-11-11 20:59:40 +00:00
John Jahn a9d11ccb2e fix(plugins): grant plugin children the network on Node 25 and newer
From Node 25 the permission model covers the network, so a plugin child
forked with --permission and no --allow-net gets ERR_ACCESS_DENIED on every
lookup and connect, and no plugin can reach a host it was granted. Node 24
is unaffected and rejects the flag as a bad option, so it is passed only
where process.allowedNodeEnvironmentFlags knows it.

Which hosts a plugin may reach is still decided by the egress guard inside
the child.

Fixes #3045
2026-11-11 20:59:34 +00:00
John Jahn 848e3f90e1 perf(client): the icons the first load needs ship as one chunk instead of 187
Every lucide icon is its own module, and the plugin icon lookup reaches all
of them by name from the navigation. The bundler therefore put each icon
another chunk also uses into a chunk of its own: 187 files of a few hundred
bytes, each one a request on a first visit and an entry in the precache.

A `vendor-icons` group collects them. The first load asks for 32 script
files instead of 224 (the bytes stay the same, 3.9 MB before compression),
and the service worker installs 460 files instead of 652. With 50 ms added
to every response the login page reaches DOMContentLoaded in 0.5 s
instead of 3.8 s.
2026-11-11 20:59:27 +00:00
Botond Rakoczi e83b6741ad docs: add TrueNAS SCALE installation guide 2026-11-11 20:57:42 +00:00
Maurice 0a3d93b59b fix(collab): a deleted chat message keeps no text
Deleting a message set the flag and kept the text in the row and in every backup; only the client hid it. The delete now blanks the text with the flag, and a migration clears what earlier deletes left. The row stays for the placeholder.
2026-11-11 20:41:25 +00:00
Maurice 102e2141ec fix(mobile): screens with pull-to-refresh bounce like every other iOS list
The dashboard, journeys and notifications switched the iOS bounce off entirely so it would not race the pull. The pull now holds only a clearly downward move from the very top, from its first pixel, and the page bounces everywhere else, its end included. TestFlight feedback, 5.1.3.
2026-11-11 20:41:25 +00:00
Maurice 768fd6f439 fix(mobile): a plugin's page in the phone shell sits below the status bar and above the dock
The page took 100vh with no top inset inside the touch shell, so the plugin's header ran under the status bar and the frame reached past the screen and under the dock. It now takes the visible height (100dvh) less the dock, after the safe area and the rail, and the plugin's own document scrolls. TestFlight feedback, 5.1.3 (Trip Importer).
2026-11-11 20:41:24 +00:00
Maurice 231a610466 fix(map): the recorded Dawarich route reads on a busy street map
A thin, translucent dash over a nearly opaque white casing read as a pale trace and was barely visible on past trips. Full colour, a wider line and longer dashes, shared by the Leaflet and GL layers. TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice 7db5ec0721 fix(native): the keyboard's done key closes the keyboard
A field outside a form gets enterkeyhint="done", but a web view only sends Enter and nothing listened, so the key did nothing. When no handler took the Enter (same text, still focused a moment later) the field lets go of the focus; a field that acts on Enter and clears itself keeps it. TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice a2b9d96e86 fix(ios): the system sign-in prompt names the app TREK, not "App"
ASWebAuthenticationSession's prompt reads CFBundleName, which was $(PRODUCT_NAME), the Xcode target "App". TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice e0022c12c4 fix(native): an error page in place of the server's keeps a way back to the address screen
A proxy's block page or a refused client certificate (Cloudflare's "Sorry, you have been blocked") left the app on a page without TREK's UI and no way to change the server. Any 4xx main-frame page that the client-certificate path does not handle now shows the sign-in bar with its own hint and "Change server", on iOS and Android. TestFlight feedback, 5.1.2.
2026-11-11 20:41:24 +00:00
Maurice 66b8f2c906 feat(native): ask for the separate TREK Cloud deviations agreement before an in-app purchase
The claim screen loads the agreement text and its version from /api/iap/consent, shows it with its own checkbox and buys nothing until it is ticked. The redeem sends consent { acceptedDeviations, version }; a refusal for an outdated text reloads it and asks again without a second purchase.
2026-11-11 20:41:23 +00:00
github-actions[bot] 6fbe10fe63 chore: bump version to 5.1.3 [skip ci] 2026-11-10 09:56:54 +00:00
Maurice 278871a31e chore(theme): the login's fixed light colours move with its code step into MfaCodeStep
The login card is a fixed light surface whose text colours are written out,
not tokens. Four of them moved with the two-factor step, so the baseline moves
them too: 58 to 54 for the page, 4 for the new component, the same total.
2026-11-10 10:23:53 +01:00
Maurice bdcef33a3e test(bookings): two test names no longer break their string on an apostrophe 2026-11-10 10:10:24 +01:00
John Jahn 8e8cff8105 fix(reservations): keep day_id on partial updates of time-only bookings
A partial update that left day_id out (a notes-only PUT, an MCP
update_reservation or update_transport call) re-derived the day from the
stored reservation_time whenever one was set. A time-only value such as
"12:00" resolves to no day, so the booking's day_id was written as null and
it vanished from its day in the planner. The end_day_id branch did the same
when a time-only reservation_end_time was sent.

The day is now derived only when the time carries a date that resolves to a
trip day; otherwise an omitted day_id or end_day_id keeps the stored value.
A dated time still re-pins the day, an explicit day_id null still clears it,
and a cleared end time still clears end_day_id. REST and both MCP update
tools share updateInTx, so the fix covers all three.

Tests: service unit cases (RESV-SVC-044..048), an e2e case per behaviour
on PUT /api/trips/:tripId/reservations/:id, and an update_reservation MCP
case, all seeded with the ORM factories.

Fixes #3025
2026-11-10 10:05:22 +01:00
Maurice 6b086eaed5 refactor: the login's code step and the reservation row shapes get modules of their own
Both files had grown past their size limit with this round's fixes. The
two-factor step moves to pages/login/MfaCodeStep.tsx and the reservation row
types to reservation-rows.ts, re-exported from the repository, and both size
baselines come down to match.
2026-11-10 09:55:36 +01:00
Maurice 0ac06555cb test(native): reset the file preview mock between the file opening tests
The second test asserted the preview was not called while the first one's call
was still counted, so it failed whenever both ran in order.
2026-11-10 09:55:36 +01:00
Maurice 1b87c62847 chore: keep the API client within its size and the native API minimum module-private 2026-11-10 09:55:36 +01:00
Maurice 98eae61b90 chore(plugin-sdk): regenerate the host facts for the stay times on reservations 2026-11-10 09:50:30 +01:00
Maurice 19956f2742 fix(native): a leg without an arrival day lands on the day it leaves in the Live Activity 2026-11-10 09:50:30 +01:00
Maurice 7506eaa4aa fix(native): the sign-in bar's room for the keyboard holds, and Android 7 to 10 see the keyboard
On iOS the keyboard plugin sizes the body and zeroes the scroll inset in a
frame update that runs after the keyboard is up, so the room made for it was
taken back. The inset is now kept while a sign-in page shows, the body gets its
height back after that update, and with the keyboard down the page can scroll
past the bar. On Android before 11 the keyboard went unseen in the insets
Capacitor passes on, so the bar stayed over the form; it is read off the
window's own insets now.
2026-11-10 09:50:29 +01:00
Maurice 57c9c9d5f2 style: format the Japanese and Korean app strings and two tests as Prettier wants them 2026-11-10 09:43:01 +01:00
Maurice 0b3d2873c2 feat(native): a proxy's sign-in page in the app has a way out and says what cannot work there
Behind Pangolin or authentik the app shows the proxy's sign-in page itself, and
nothing on it led back to the address screen: a failed passkey left users stuck
on every launch. A bar over that page now offers to change the server and says
that passkeys and security keys do not work there, as a web view cannot use them
for a domain the app is not tied to. It steps aside while the keyboard is up.
On iOS the page also gets the room the keyboard takes, so a button under it can
be scrolled into view. The address screen hands the bar its words in the user's
language when it connects.
2026-11-10 09:36:50 +01:00
Maurice 1454b58ea1 fix(mobile): quick swipes through the days no longer stop after two
The pager held the selected day and two either side, and moved that window
only once the strip had come to rest. Swiping on before the snap settled ran
into the end of the strip after two days and sprang back until the animation
was over. Every day now has its page, only the selection and two either side
draw their content, so a run of swipes goes as far as the trip does.
2026-11-10 09:30:29 +01:00
Maurice 2ab4459bc3 fix(native): the Live Activity keeps a day with flights ahead open instead of calling it done
A booking with stopovers was one stop at its first departure, and a stop
counted as done the moment it began. With a car at one and a connecting flight
at two, the Live Activity said all done for today mid-afternoon. Each leg is
now a stop of its own, as the planner shows it, and a flight or anything else
with an end time counts as under way until it lands or ends.
2026-11-10 09:29:40 +01:00
Maurice 6ebb92af6c fix(bookings): a hotel booking's card shows check-in and check-out when only the stay has them
The edit form fell back to the linked stay's times, the cards read only the
booking's metadata, so a hotel showed 15:00 and 11:00 when edited and no times
in the list. The reservation list now carries the stay's check-in window and
check-out, and the desktop facts and the phone card fall back to them. On the
phone they come first, so a price or a seat cannot push them off the card.
2026-11-10 09:28:43 +01:00
Maurice 3ab4927cb7 fix(native): the app asks for notifications on iOS too, and again if the question went unanswered
The prompt looked at the app's features once, when the user counted as signed
in. After the sign-in the page loads with the session already there, and on
iOS the bridge had not named its features yet, so the prompt gave up for good.
It now waits for them. It also counted as asked the moment it opened, so a
sheet that went under another one never came back; it counts once answered.
2026-11-10 09:26:59 +01:00
Maurice 6a0ae482d7 fix(login): the two-factor code field is set up for one-time codes and a password manager
The code step drops the email field, so a password manager had no login to
match a one-time code to; the step now keeps the address in a hidden username
field. The code field names itself and sets its keyboard hints directly
(characters, no autocorrect), as autoFocus gave it focus before anything could
prepare it, and its placeholder is translated.
2026-11-10 09:23:16 +01:00
Maurice 20eeaeb61f fix(native): the iOS app builds again, with Quick Look in an extension of its own
The file preview kept its data source in a stored property inside an
extension, which Swift does not allow, so every iOS build since failed. It is
a static now, in its own extension, and the Wallet comment it had split is
whole again.
2026-11-10 09:18:20 +01:00
Maurice 5cbe4e2fa3 feat(native): the app needs TREK 5.1.3 on the server and checks a remembered one once after the update
The fixes that keep the app's sign-in in the app live in the server's UI,
so an updated app on a 5.1.2 server still ended in the browser. The server now
reports nativeApi 2 and the app refuses anything below it, naming 5.1.3.

A server the app remembers from before is not loaded unchecked: the first
launch after the update shows the address screen filled in with it, says why
and checks it right away. A server that is new enough loads again with its
session; an older one gets the update hint.
2026-11-10 09:17:26 +01:00
Maurice b124e1bc9c fix(mobile): a day tapped in the day rail moves the pager on short trips too
The strip re-centred only when its window of pages changed. On a trip of up
to five days a tap can keep the same pages, so the rail showed the new day
while the card below stayed on the old one.
2026-11-10 09:17:26 +01:00
Maurice 08034c4402 fix(map): the iOS app's tile store is allowed by the CSP, so its maps draw tiles again
The iOS app rewrites raster tiles to trek-tile:// and answers them from its
store. WebKit checks that scheme against img-src, which had only https:, so
every tile was refused and the app showed routes and markers on a blank map.
2026-11-10 09:17:25 +01:00
Maurice 0577a9ccb9 fix(native): the hand-off page goes straight to SSO instead of TREK's login first
The app's browser sign-in opens /native-handoff, which sends the user on to
the identity provider by itself. The page was not on the list of public auth
pages, so the 401 from loading the user sent the browser to /login with the
hand-off as its return path first. On a server that also offers passwords the
user then had to pick SSO a second time before getting back to the app.
2026-11-10 09:17:22 +01:00
Maurice 2f03554a60 feat(native): files open in the system viewer instead of the share sheet
A PDF, image or document opened in the app went straight to the share sheet.
It now opens in Quick Look on iOS, to page through and zoom with its own share
button, and in the app that views its type on Android. An app without the
viewer (feature filePreview), or a type nothing on the device shows, gets the
share sheet as before.
2026-11-10 00:21:28 +01:00
Maurice 28079197e7 fix(assistant): an answer that hands the talk on to a trip or journey chat ends the turn
The home assistant wrote its answer and offered the trip's own chat in the
same step. The loop asked the model once more after the card, and it wrote
the same answer a second time below it. A step that has its answer and only
hands the conversation on now finishes the turn; a hand-on without an answer,
or beside a read, still asks the model again.
2026-11-10 00:14:44 +01:00
Maurice 0b46b89b62 fix(mobile): swipe actions are rounded tiles beside the row, icon only; expenses keep their own buttons
The actions under a swiped row were square blocks to the row's edge, unlike
the rounded cards around them. They are now tiles of their own with the
card's radius and a gap, inset within a grouped card such as the packing
list, and show only their icon (the label names them for a screen reader).
An expense already shows edit and delete beside its card, so it no longer
swipes to a second, larger pair of the same buttons.
2026-11-10 00:11:50 +01:00
Maurice 05caf67bc4 chore(notices): the release popup comes back for 5.1.3 2026-11-09 23:43:24 +01:00
Maurice 6d3c400e4a ci(native): build the iOS app with Xcode 27.1 on the xcode-27 image
An app linked against an SDK older than iOS 27.1 runs letterboxed on the inner
display of an iPhone Duo. The iOS build and the release now run on GitHub's
xcode-27 image and select Xcode 27.1; the previews stay on Xcode 26.
2026-11-09 23:33:23 +01:00
Maurice 9932288b04 chore(deps): bump the npm minor and patch group, with React's override and one MikroORM
From #3032, adjusted where it broke:
- react and react-dom are pinned in the root overrides; the group raised the
  workspaces to 19.3.1 and left the override at 19.3.0, so two react-dom
  versions met in every client test. The override moves along.
- @mikro-orm/nestjs kept the old @mikro-orm/core at the root while the server
  got 7.2.8 of its own, two copies whose types do not match; the lockfile now
  holds one 7.2.8.
- mapbox-gl stays at 3.31: 3.34 drops setRTLTextPlugin from its types, which
  the map engine uses.
- the musl pins of rollup stay at 4.62, the rollup version the lockfile holds.
2026-11-09 23:31:38 +01:00
Maurice 56b13541bd chore(native): keep the handoff start path inside browserSignIn, nothing else reads it 2026-11-09 23:29:38 +01:00
Maurice d568e20f02 chore(plugin-sdk): bump vitest and @vitest/coverage-v8 to 5 together
The two only resolve as a pair; one at a time (#3028, #3029) npm refuses the
peer range.
2026-11-09 23:28:26 +01:00
Maurice f03d59bc22 chore(deps): bump @capacitor/cli to 8.6, vite-plugin-pwa to 2 and @nestjs/schedule to 12
@capacitor/cli 8.6 drops the old uuid 7 through its xcode dependency (#3012).
vite-plugin-pwa 2 keeps the registerSW.js in public/ in place of its own, as
1.x did (#3033). @nestjs/schedule 12 still runs on Nest 11 (#3035).
2026-11-09 23:27:18 +01:00
dependabot[bot] 0833ca28c2 chore(plugin-sdk): bump semver
Bumps the plugin-sdk-minor-and-patch group in /plugin-sdk with 1 update: [semver](https://github.com/npm/node-semver).


Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: plugin-sdk-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 3ed5f21a48 ci: bump actions/setup-java from 5 to 6
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 6.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 2eb6b79bea ci: bump the actions-minor-and-patch group with 2 updates
Bumps the actions-minor-and-patch group with 2 updates: [docker/login-action](https://github.com/docker/login-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action).


Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `SonarSource/sonarqube-scan-action` from 8.4.0 to 8.4.1
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/4d1dc0c91ee400dcf8a6dddf2f705ac36542e304...e805d875275f728fac1885b413934be6d7c63f65)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:17 +01:00
Maurice 8dd2e9716d test(native): fix the sign-in handoff tests' quoting and formatting 2026-11-09 23:15:09 +01:00
Maurice 1cdde6e9a4 fix(native): a field on a page as tall as the screen comes up above the keyboard on iOS
The login on an iPhone scrolls in no pane of its own, and the keyboard plugin
holds the body's height, so the page had nothing to scroll by and the password
field stayed under the keyboard, most of all with an error shown above it. Such
a page now gets room as tall as the keyboard at its end and scrolls the field
above it; the room goes with the keyboard.
2026-11-09 23:12:27 +01:00
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00
Maurice 5420077b98 fix(native): a proxy sign-in the address check never met loads in the app instead of the browser
A proxy that lets /api through (an Authelia or Authentik bypass for the app)
still sends the page itself to its sign-in. The app took that redirect for a
foreign link and handed it to the browser, so the sign-in happened there and
the app stayed black on every launch. A redirect away from the server while
its first page loads is now kept as the proxy's sign-in host, on Android and
iOS, and the iOS bridge guard is always in place for hosts learned that way.
2026-11-09 20:17:37 +01:00
424 changed files with 13444 additions and 5296 deletions
+2 -2
View File
@@ -175,7 +175,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+2 -2
View File
@@ -344,7 +344,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -403,7 +403,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+8 -6
View File
@@ -49,7 +49,7 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- uses: actions/setup-java@v5 - uses: actions/setup-java@v6
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@@ -111,19 +111,21 @@ jobs:
ios: ios:
if: github.repository == 'liketrek/TREK' if: github.repository == 'liketrek/TREK'
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 60 timeout-minutes: 60
environment: store environment: store
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older. # The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
- name: Select Xcode 26 # runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: | run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) XCODE=/Applications/Xcode_27.1.app
test -n "$XCODE" test -d "$XCODE"
sudo xcode-select -s "$XCODE" sudo xcode-select -s "$XCODE"
xcodebuild -version xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
+8 -6
View File
@@ -60,7 +60,7 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- uses: actions/setup-java@v5 - uses: actions/setup-java@v6
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@@ -90,18 +90,20 @@ jobs:
retention-days: 14 retention-days: 14
ios: ios:
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 40 timeout-minutes: 40
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older. # The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
- name: Select Xcode 26 # runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: | run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) XCODE=/Applications/Xcode_27.1.app
test -n "$XCODE" test -d "$XCODE"
sudo xcode-select -s "$XCODE" sudo xcode-select -s "$XCODE"
xcodebuild -version xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- name: Check the property lists - name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
load: true load: true
tags: trek:scan tags: trek:scan
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -1100,7 +1100,7 @@ jobs:
path: plugin-sdk/coverage/ path: plugin-sdk/coverage/
- name: Scan - name: Scan
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0 uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
env: env:
# SonarQube Cloud is the scan action's default host, so no # SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the # SONAR_HOST_URL — the token is a sonarcloud.io token for the
+17 -1
View File
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
The bundled files are simplified (coordinate-quantized) and re-tagged with the The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release. (gbOpen) release, except for Ireland (below).
## Tailte Éireann — counties of Ireland
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
layer for Ireland.
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source:
https://data-osi.opendata.arcgis.com/
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
generalised boundaries for reference only; they may not match the statutory
boundaries exactly.
## OpenStreetMap — geocoding, place details & descriptions ## OpenStreetMap — geocoding, place details & descriptions
+7 -4
View File
@@ -146,7 +146,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🗺️ Atlas, Vacay and Collections #### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals - **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing - **Vacay**: leave calendar with half days, public holidays from [nagerholidays.com](https://nagerholidays.com), school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite - **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas - **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
@@ -276,6 +276,8 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it. `COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
**TrueNAS SCALE:** Install TREK directly from the Community catalog. See the [TrueNAS SCALE installation guide](https://github.com/liketrek/TREK/wiki/Install-TrueNAS-SCALE).
> [!TIP] > [!TIP]
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute. > **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
@@ -348,7 +350,7 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating ## Updating
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose, See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note. Docker run, Helm, Portainer, Unraid, TrueNAS SCALE and Proxmox, plus the encryption-key note.
<br /> <br />
@@ -437,8 +439,9 @@ Every variable, its default and what it does: see
The Atlas map's country and sub-national (province/county) boundaries come from The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed [**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
for full third-party attributions. [**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
[NOTICE.md](NOTICE.md) for full third-party attributions.
<br /> <br />
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 5.1.2 version: 5.1.4
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "5.1.2" appVersion: "5.1.4"
+1 -1
View File
@@ -6,7 +6,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
```bash ```bash
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001 npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build npm run build # prebuild generates PWA icons, then vite build; postbuild writes a Brotli copy beside each hashed asset for the server to hand out
npm run typecheck # tsc --noEmit (CI) npm run typecheck # tsc --noEmit (CI)
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it) npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors) npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
@@ -1,11 +1,19 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.content.Intent; import android.content.Intent;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle; import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.KeyboardShortcutGroup; import android.view.KeyboardShortcutGroup;
import android.view.Menu; import android.view.Menu;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import com.liketrek.trek.screen.TrekKeyCommands; import com.liketrek.trek.screen.TrekKeyCommands;
import com.liketrek.trek.tiles.TrekTileStore; import com.liketrek.trek.tiles.TrekTileStore;
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
// runs and on its own origin. BridgeActivity reads `config` while it // runs and on its own origin. BridgeActivity reads `config` while it
// builds the bridge inside super.onCreate(). // builds the bridge inside super.onCreate().
boolean restarting = TrekServerStore.consumeRestart(this); boolean restarting = TrekServerStore.consumeRestart(this);
TrekServerStore.recheckAfterUpdate(this);
String server = TrekServerStore.getUrl(this); String server = TrekServerStore.getUrl(this);
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this); boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
config = TrekServerStore.configFor(this, onServer ? server : null, restarting); config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
@@ -36,7 +45,17 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included. // scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER); bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread. // Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this))); if (onServer) {
bridge.setWebViewClient(
new TrekWebViewClient(
bridge,
this::showAddressScreen,
this::showSignInBar,
this::showErrorPageBar,
TrekServerStore.getSignInHosts(this)
)
);
}
TrekSharedFiles.receive(this, getIntent()); TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip. // Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this); TrekWidgetUpdater.publishPreviews(this);
@@ -68,6 +87,122 @@ public class MainActivity extends BridgeActivity {
private boolean leavingServer; private boolean leavingServer;
private View signInBar;
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
private boolean onSignInPage;
/** Whether the page is an error page in place of the server's (TrekWebViewClient.onReceivedHttpError). */
private boolean onErrorPage;
private boolean keyboardUp;
private TextView signInHint;
/**
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
* not there, so nothing else would lead back to the address screen, and a
* passkey cannot work in a web view for a domain the app is not tied to.
* The bar says so and offers to change the server.
*/
private void showSignInBar(boolean show) {
runOnUiThread(() -> {
onSignInPage = show;
if (show && signInBar == null) signInBar = buildSignInBar();
updateSignInBar();
});
}
/** The same bar over an error page in place of the server's, with words for that. */
private void showErrorPageBar(boolean show) {
runOnUiThread(() -> {
onErrorPage = show;
if (show && signInBar == null) signInBar = buildSignInBar();
updateSignInBar();
});
}
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
private void updateSignInBar() {
if (signInBar == null) return;
if (signInHint != null) {
signInHint.setText(onSignInPage ? TrekServerStore.signInHintLabel(this) : TrekServerStore.errorPageHintLabel(this));
}
signInBar.setVisibility((onSignInPage || onErrorPage) && !keyboardUp ? View.VISIBLE : View.GONE);
}
private View buildSignInBar() {
float dp = getResources().getDisplayMetrics().density;
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPaddingRelative(Math.round(16 * dp), Math.round(10 * dp), Math.round(10 * dp), Math.round(10 * dp));
// The look of the app's own first screens: a white card with a sand edge,
// warm grey text and a dark button, over whatever page the proxy shows.
GradientDrawable background = new GradientDrawable();
background.setColor(0xF7FFFFFF);
background.setCornerRadius(18 * dp);
background.setStroke(Math.max(1, Math.round(dp)), 0xFFEFE4D8);
bar.setBackground(background);
bar.setElevation(8 * dp);
TextView hint = new TextView(this);
signInHint = hint;
hint.setText(TrekServerStore.signInHintLabel(this));
hint.setTextColor(0xFF6B5F57);
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
change.setText(TrekServerStore.changeServerLabel(this));
change.setAllCaps(false);
change.setTextColor(0xFFFFFFFF);
GradientDrawable pill = new GradientDrawable();
pill.setColor(0xFF111827);
pill.setCornerRadius(12 * dp);
change.setBackground(pill);
change.setMinHeight(0);
change.setMinimumHeight(0);
change.setPaddingRelative(Math.round(14 * dp), Math.round(9 * dp), Math.round(14 * dp), Math.round(9 * dp));
LinearLayout.LayoutParams changeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
changeParams.setMarginStart(Math.round(10 * dp));
change.setOnClickListener((v) -> {
TrekServerStore.setUrl(this, null);
TrekServerStore.markRestart(this);
recreate();
});
bar.addView(change, changeParams);
int margin = Math.round(12 * dp);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM
);
params.setMargins(margin, 0, margin, margin);
FrameLayout root = findViewById(android.R.id.content);
root.addView(bar, params);
// Above the gesture bar, and above the keyboard while it is up.
bar.setOnApplyWindowInsetsListener((v, insets) -> {
// Read off the window's own insets: Capacitor hands the views below the
// decor a copy with the system bars zeroed, and before Android 11 that
// takes the keyboard with it.
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
int bottom = window != null
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
: insets.getStableInsetBottom();
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
if (imeVisible != keyboardUp) {
keyboardUp = imeVisible;
updateSignInBar();
}
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
if (p.bottomMargin != margin + bottom) {
p.bottomMargin = margin + bottom;
v.setLayoutParams(p);
}
return insets;
});
bar.requestApplyInsets();
return bar;
}
/** /**
* The server did not load: back to the address screen, which offers retry * The server did not load: back to the address screen, which offers retry
* and change. A fresh task rather than recreate(), because this usually * and change. A fresh task rather than recreate(), because this usually
@@ -0,0 +1,57 @@
package com.liketrek.trek;
import java.net.URI;
import java.util.Locale;
/**
* A sign-in page in front of the server that the address screen never saw.
* A proxy can let the app's check through (an Authelia or Authentik rule that
* bypasses /api) and still send the page itself to its sign-in. Handed to the
* browser, that sign-in leaves its session there and the app stays blank on
* every launch. So a redirect away from the server, while the server's first
* page is still loading, is taken as the proxy's sign-in: it loads in the web
* view and its host is kept, the way the address screen keeps the ones it saw.
* A link the user follows is no redirect, and once a page of the server has
* shown, nothing is learned any more.
*/
final class SignInRedirect {
private SignInRedirect() {}
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
URI from;
URI to;
try {
from = new URI(server);
to = new URI(target);
} catch (Exception e) {
return null;
}
String fromHost = lower(from.getHost());
String toHost = lower(to.getHost());
String toScheme = lower(to.getScheme());
if (fromHost == null || toHost == null) return null;
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
// Never from https down to plain http: the sign-in would send a password in the clear.
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
if (toHost.equals(fromHost)) return null;
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
}
/** Whether `url` is on the server's host, where the app's own pages come from. */
static boolean onServer(String server, String url) {
if (server == null || url == null) return false;
try {
String serverHost = lower(new URI(server).getHost());
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
} catch (Exception e) {
return false;
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}
@@ -22,6 +22,19 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell"; private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url"; private static final String KEY_URL = "server_url";
private static final String KEY_PREVIOUS_URL = "previous_server_url";
private static final String KEY_SERVER_CHECK = "server_check";
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
private static final String KEY_LABEL_ERROR_PAGE_HINT = "label_error_page_hint";
/**
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
* server remembered under a lower value is checked once more: the first
* launch after an update that raised it puts the address screen back up,
* filled in with that server (getPreviousUrl). Raise both together.
*/
private static final int SERVER_CHECK = 3;
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts"; private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once"; private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart"; private static final String KEY_RESTART = "restart";
@@ -56,6 +69,52 @@ final class TrekServerStore {
return prefs(context).getString(KEY_URL, null); return prefs(context).getString(KEY_URL, null);
} }
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
static void recheckAfterUpdate(Context context) {
SharedPreferences prefs = prefs(context);
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
String server = getUrl(context);
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
editor.commit();
}
/**
* The words of the bar over a proxy's sign-in page, in the user's language,
* as the address screen passed them (setServer's `labels`): the server's UI
* is not there to say them. Null keeps the English fallback.
*/
static void setLabels(Context context, String signInHint, String changeServer, String errorPageHint) {
SharedPreferences.Editor editor = prefs(context).edit();
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
if (errorPageHint != null) editor.putString(KEY_LABEL_ERROR_PAGE_HINT, errorPageHint);
editor.commit();
}
static String errorPageHintLabel(Context context) {
return prefs(context).getString(
KEY_LABEL_ERROR_PAGE_HINT,
"This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
);
}
static String signInHintLabel(Context context) {
return prefs(context).getString(
KEY_LABEL_SIGN_IN_HINT,
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
);
}
static String changeServerLabel(Context context) {
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
}
/** The server an update asked to check again, until a server is set. */
static String getPreviousUrl(Context context) {
return prefs(context).getString(KEY_PREVIOUS_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */ /** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) { static void setUrl(Context context, String url) {
setServer(context, url, null); setServer(context, url, null);
@@ -66,7 +125,7 @@ final class TrekServerStore {
if (url == null) { if (url == null) {
editor.remove(KEY_URL); editor.remove(KEY_URL);
} else { } else {
editor.putString(KEY_URL, url); editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
} }
if (signInHosts == null || signInHosts.isEmpty()) { if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS); editor.remove(KEY_SIGN_IN_HOSTS);
@@ -89,7 +148,32 @@ final class TrekServerStore {
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts)); return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
} }
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
static void addSignInHost(Context context, String host) {
Set<String> hosts = new HashSet<>(getSignInHosts(context));
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
}
/** Start the next launch on the bundled address screen, even with a server remembered. */ /** Start the next launch on the bundled address screen, even with a server remembered. */
private static final String KEY_FAILURE = "last_failure";
/**
* Why the server's page did not load, in the WebView's own terms
* ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows
* it small under its message, so a report carries the cause.
*/
static void setFailure(Context context, String failure) {
prefs(context).edit().putString(KEY_FAILURE, failure).commit();
}
/** The failure behind this address screen, once. */
static String takeFailure(Context context) {
String failure = prefs(context).getString(KEY_FAILURE, null);
if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit();
return failure;
}
static void showShellOnce(Context context) { static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit(); prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
} }
@@ -1,6 +1,8 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.Manifest; import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
@@ -70,6 +72,7 @@ public class TrekShellPlugin extends Plugin {
"keyCommands", "keyCommands",
"windowLayout", "windowLayout",
"clientCertificate", "clientCertificate",
"filePreview",
}; };
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */ /** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
@@ -267,11 +270,53 @@ public class TrekShellPlugin extends Plugin {
call.resolve(); call.resolve();
} }
/**
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
* app on the device views the type; the UI then hands it to the share sheet.
*/
@PluginMethod
public void previewFile(PluginCall call) {
String path = call.getString("path");
Uri parsed = path == null ? null : Uri.parse(path);
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
java.io.File cache = getContext().getCacheDir();
boolean inCache;
try {
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
} catch (java.io.IOException e) {
inCache = false;
}
if (!inCache || !file.isFile()) {
call.reject("a file in the app's cache is required", "INVALID");
return;
}
String type = call.getString("mimeType", "application/octet-stream");
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
Intent view = new Intent(Intent.ACTION_VIEW)
.setDataAndType(content, type)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
getActivity().runOnUiThread(() -> {
JSObject result = new JSObject();
try {
getActivity().startActivity(view);
result.put("result", "shown");
} catch (ActivityNotFoundException e) {
result.put("result", "unsupported");
}
call.resolve(result);
});
}
@PluginMethod @PluginMethod
public void getServer(PluginCall call) { public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext()); String url = TrekServerStore.getUrl(getContext());
String previous = TrekServerStore.getPreviousUrl(getContext());
JSObject result = new JSObject(); JSObject result = new JSObject();
result.put("url", url == null ? JSONObject.NULL : url); result.put("url", url == null ? JSONObject.NULL : url);
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
String failure = TrekServerStore.takeFailure(getContext());
result.put("failure", failure == null ? JSONObject.NULL : failure);
call.resolve(result); call.resolve(result);
} }
@@ -284,6 +329,15 @@ public class TrekShellPlugin extends Plugin {
return; return;
} }
TrekServerStore.setServer(getContext(), url, signInHostsOf(call)); TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
JSObject labels = call.getObject("labels");
if (labels != null) {
TrekServerStore.setLabels(
getContext(),
labels.getString("signInHint"),
labels.getString("changeServer"),
labels.getString("errorPageHint")
);
}
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page. // A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
String welcome = call.getString("welcome"); String welcome = call.getString("welcome");
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null); TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
@@ -382,7 +436,11 @@ public class TrekShellPlugin extends Plugin {
} }
Uri data = intent.getData(); Uri data = intent.getData();
PluginCall call = pendingAuthCall(); PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return; if (call == null) {
keepOrphanAuthReturn(intent);
return;
}
if (data == null || !authScheme.equals(data.getScheme())) return;
JSObject result = new JSObject(); JSObject result = new JSObject();
result.put("url", data.toString()); result.put("url", data.toString());
call.resolve(result); call.resolve(result);
@@ -408,6 +466,48 @@ public class TrekShellPlugin extends Plugin {
}, CANCEL_GRACE_MS); }, CANCEL_GRACE_MS);
} }
private static final String AUTH_PREFS = "trek_auth_return";
/** As long as the server keeps the code it carries (NATIVE_HANDOFF_CODE_TTL_MS), with room to spare. */
private static final long ORPHAN_AUTH_TTL_MS = 2 * 60 * 1000;
/**
* A return to the sign-in scheme with no call waiting for it: Android ended
* the app while the browser was in front, and the browser's return started
* it anew (the launch intent comes through here as well). Kept on disk, as
* the process may be new too, until the sign-in page takes it. The return
* is then taken off the activity's intent: Capacitor hands the launch intent
* in again whenever the activity is created, and Android does as well when
* it rebuilds the task from the recent apps.
*/
private void keepOrphanAuthReturn(Intent intent) {
Uri data = intent.getData();
if (data == null || !"auth".equals(data.getHost())) return;
if (!getContext().getString(R.string.custom_url_scheme).equals(data.getScheme())) return;
if (getActivity() != null && getActivity().getIntent() != null && getActivity().getIntent().getData() != null
&& data.equals(getActivity().getIntent().getData())) {
getActivity().setIntent(new Intent(getActivity().getIntent()).setData(null));
}
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return;
getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE)
.edit()
.putString("url", data.toString())
.putLong("at", System.currentTimeMillis())
.commit();
}
/** The kept return of a sign-in (keepOrphanAuthReturn), once and while fresh; null otherwise. */
@PluginMethod
public void takeAuthCallback(PluginCall call) {
android.content.SharedPreferences prefs = getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE);
String url = prefs.getString("url", null);
long at = prefs.getLong("at", 0);
prefs.edit().clear().commit();
boolean fresh = url != null && System.currentTimeMillis() - at <= ORPHAN_AUTH_TTL_MS;
JSObject result = new JSObject();
result.put("url", fresh ? url : JSONObject.NULL);
call.resolve(result);
}
private PluginCall pendingAuthCall() { private PluginCall pendingAuthCall() {
return authCallId == null ? null : getBridge().getSavedCall(authCallId); return authCallId == null ? null : getBridge().getSavedCall(authCallId);
} }
@@ -633,6 +733,12 @@ public class TrekShellPlugin extends Plugin {
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call); com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
} }
/** Whether this Google account already holds a delivered cloud subscription. */
@PluginMethod
public void storeHeld(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.held(getContext(), call);
}
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */ /** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
@PluginMethod @PluginMethod
public void storeUnfinished(PluginCall call) { public void storeUnfinished(PluginCall call) {
@@ -1,16 +1,20 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.net.Uri; import android.net.Uri;
import android.util.Log;
import android.webkit.ClientCertRequest; import android.webkit.ClientCertRequest;
import android.webkit.WebResourceError; import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest; import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse; import android.webkit.WebResourceResponse;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient;
import com.getcapacitor.Bridge; import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient; import com.getcapacitor.BridgeWebViewClient;
import com.liketrek.trek.clientcert.TrekClientIdentity; import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
/** /**
* Used while the WebView shows the user's server. When that server cannot be * Used while the WebView shows the user's server. When that server cannot be
@@ -23,12 +27,32 @@ import java.util.Set;
class TrekWebViewClient extends BridgeWebViewClient { class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable; private final Runnable onUnreachable;
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
private final Consumer<Boolean> onSignInPage;
/** Told whether the page now shown is an error page in place of the server's, for the same bar. */
private final Consumer<Boolean> onErrorPage;
/** The main-frame address that last came back as a 4xx page nobody else handled. */
private String errorPageUrl;
private final Set<String> signInHosts; private final Set<String> signInHosts;
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
private boolean serverShown;
/** The page itself failed on the network once already; the next failure goes to the address screen. */
private boolean retriedMainFrame;
/** The page loaded again after a network error, until that load starts: its error page counts for nothing. */
private String retryUrl;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) { TrekWebViewClient(
Bridge bridge,
Runnable onUnreachable,
Consumer<Boolean> onSignInPage,
Consumer<Boolean> onErrorPage,
Set<String> signInHosts
) {
super(bridge); super(bridge);
this.onUnreachable = onUnreachable; this.onUnreachable = onUnreachable;
this.signInHosts = signInHosts; this.onSignInPage = onSignInPage;
this.onErrorPage = onErrorPage;
this.signInHosts = new HashSet<>(signInHosts);
} }
/** /**
@@ -43,13 +67,42 @@ class TrekWebViewClient extends BridgeWebViewClient {
@Override @Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false; if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
String learned = SignInRedirect.learnedHost(
TrekServerStore.getUrl(view.getContext()),
request.getUrl().toString(),
request.isForMainFrame(),
request.isRedirect(),
serverShown
);
if (learned != null) {
signInHosts.add(learned);
TrekServerStore.addSignInHost(view.getContext(), learned);
return false;
}
return super.shouldOverrideUrlLoading(view, request); return super.shouldOverrideUrlLoading(view, request);
} }
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
@Override
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
super.doUpdateVisitedHistory(view, url, isReload);
if (retryUrl != null) return;
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
// The 4xx answer arrives before this commit or after it; either way the
// flag is read once here, so the next page clears the bar again.
boolean error = url != null && url.equals(errorPageUrl);
errorPageUrl = null;
onErrorPage.accept(error);
}
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */ /** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
@Override @Override
public void onPageFinished(WebView view, String url) { public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url); super.onPageFinished(view, url);
// The error page of a load that is about to be retried: not the server's
// first page, so no welcome page and no learning of sign-in hosts yet.
if (retryUrl != null) return;
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext()); String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
if (welcome != null) view.loadUrl(welcome); if (welcome != null) view.loadUrl(welcome);
} }
@@ -90,13 +143,35 @@ class TrekWebViewClient extends BridgeWebViewClient {
String server = TrekServerStore.getUrl(view.getContext()); String server = TrekServerStore.getUrl(view.getContext());
Uri serverUri = server == null ? null : Uri.parse(server); Uri serverUri = server == null ? null : Uri.parse(server);
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) { if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable); com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, () -> {
TrekServerStore.setFailure(view.getContext(), "client certificate requested");
onUnreachable.run();
});
} }
} }
/**
* A page that failed on the network is loaded once more before the app says
* the server is unreachable. Started from the background, as when the
* browser hands a sign-in back to an app Android had ended, a Samsung phone
* may hold its network back for a moment, and the first load fails with the
* server there all along. The error is logged, as a report has nothing else.
*/
@Override @Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) { if (request.isForMainFrame()) {
Log.w("TrekShell", "Page failed: " + error.getErrorCode() + " " + error.getDescription());
if (!retriedMainFrame && isNetworkError(error.getErrorCode())) {
retriedMainFrame = true;
retryUrl = request.getUrl().toString();
view.postDelayed(() -> {
String url = retryUrl;
retryUrl = null;
view.loadUrl(url);
}, 1500);
return;
}
TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")");
onUnreachable.run(); onUnreachable.run();
return; return;
} }
@@ -105,10 +180,12 @@ class TrekWebViewClient extends BridgeWebViewClient {
/** /**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or * Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered, * 503 while TREK restarts). A 4xx page is not TREK's, which serves its UI for
* and TREK serves its UI for every other path. The exception is a proxy * every path: a proxy that asked this page for a client certificate and got
* that asked this page for a client certificate and got none, as Cloudflare * none, as Cloudflare does with a 403, sends the app to the address screen,
* does with a 403: the address screen then says so and offers to add one. * which says so and offers to add one. Any other 4xx page (a proxy's block
* page, a certificate it refused) stays up with the bar and its way out, so
* the app is never stuck on a page without TREK's UI.
*/ */
@Override @Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
@@ -116,10 +193,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
if (!request.isForMainFrame()) { if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse); super.onReceivedHttpError(view, request, errorResponse);
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) { } else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
TrekServerStore.setFailure(view.getContext(), "HTTP " + status);
onUnreachable.run(); onUnreachable.run();
} else if (status >= 400) {
errorPageUrl = request.getUrl().toString();
onErrorPage.accept(true);
} }
} }
private static boolean isNetworkError(int code) {
return code == WebViewClient.ERROR_HOST_LOOKUP
|| code == WebViewClient.ERROR_CONNECT
|| code == WebViewClient.ERROR_TIMEOUT
|| code == WebViewClient.ERROR_IO
|| code == WebViewClient.ERROR_UNKNOWN;
}
private static boolean wantedCertificate(Uri url) { private static boolean wantedCertificate(Uri url) {
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest(); TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort()); return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
@@ -119,23 +119,38 @@ object TrekBilling {
val id = call.getString("productId") ?: return call.reject("productId is required") val id = call.getString("productId") ?: return call.reject("productId is required")
ready(activity) { ok -> ready(activity) { ok ->
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed")) if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
details(activity, listOf(id)) { found -> // One subscription per Google account: a second plan would run beside the first.
val product = found.firstOrNull() // A delivered one stops the purchase here; one that was paid but never set up is
val offer = product?.let(::baseOffer) // handed back as bought, so its instance is set up now.
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed")) val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
pendingPurchase?.resolve(JSObject().put("kind", "cancelled")) client(activity).queryPurchasesAsync(params) { result, purchases ->
pendingPurchase = call val held = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
pendingProduct = id when {
val params = BillingFlowParams.newBuilder() held == null -> launchPurchase(activity, call, id)
.setProductDetailsParamsList( held.isAcknowledged -> call.resolve(JSObject().put("kind", "subscribed"))
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()), else -> call.resolve(receipt(held).put("kind", "bought"))
) }
.build() }
activity.runOnUiThread { }
val launch = client(activity).launchBillingFlow(activity, params) }
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null) private fun launchPurchase(activity: Activity, call: PluginCall, id: String) {
} details(activity, listOf(id)) { found ->
val product = found.firstOrNull()
val offer = product?.let(::baseOffer)
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
pendingPurchase = call
pendingProduct = id
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
)
.build()
activity.runOnUiThread {
val launch = client(activity).launchBillingFlow(activity, params)
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
} }
} }
} }
@@ -208,6 +223,19 @@ object TrekBilling {
} }
} }
/** Whether this Google account holds a delivered subscription under `prefix`; while one runs, the shell offers no purchase. */
fun held(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: PREFIX
ready(context) { ok ->
if (!ok) return@ready call.resolve(JSObject().put("held", false))
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
client(context).queryPurchasesAsync(params) { result, purchases ->
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
call.resolve(JSObject().put("held", found?.isAcknowledged == true))
}
}
}
/** The newest active subscription under `prefix` on this Google account, if any. */ /** The newest active subscription under `prefix` on this Google account, if any. */
fun restore(context: Context, call: PluginCall) { fun restore(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: "cloud." val prefix = call.getString("prefix") ?: "cloud."
@@ -13,11 +13,33 @@ import java.time.LocalDate
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** The native side's own decisions, apart from the views. */ /** The native side's own decisions, apart from the views. */
class NativeLogicTest { class NativeLogicTest {
@Test
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
val server = "https://trek.example.com"
// Authelia in front of the page, its check let through on /api.
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
// The server's own host, another scheme, or a step down to plain http.
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
}
@Test @Test
fun pushesLandInTheChannelOfTheirEvent() { fun pushesLandInTheChannelOfTheirEvent() {
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message")) assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
+18
View File
@@ -0,0 +1,18 @@
// The chunk group that holds the lucide icons the app loads at startup (vite.config.js).
//
// Every lucide icon is a module of its own, and the plugin icon lookup
// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation
// every page renders. Left to itself the bundler puts each icon that some other chunk
// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few
// hundred bytes each, every one a request of its own on a first visit and an entry of its
// own in the service worker's precache. In one group they are one file, whose hash changes
// only when lucide-react does.
//
// `tags: ['$initial']` for the reason the other groups carry it: without it the group would
// also take icons that today sit behind a lazy route and make them part of the first load.
export const ICON_CHUNK = {
name: 'vendor-icons',
priority: 20,
tags: ['$initial'],
test: /[\\/]node_modules[\\/]lucide-react[\\/]/,
};
+1 -1
View File
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>TREK</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+29 -2
View File
@@ -17,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
for context in connectionOptions.urlContexts where context.url.isFileURL { for context in connectionOptions.urlContexts where context.url.isFileURL {
TrekSharedFiles.receive(context.url) TrekSharedFiles.receive(context.url)
} }
// Launched by a tap on a widget or a Live Activity: held like a quick action.
// Capacitor still hears of the link, the UI ignores it there (feature appLinks).
for context in connectionOptions.urlContexts {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
} }
@@ -40,7 +45,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// com.liketrek.trek://share: the share sheet left an item for the import. // com.liketrek.trek://share: the share sheet left an item for the import.
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" } let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() } if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
let others = URLContexts.subtracting(files).subtracting(handoffs) // com.liketrek.trek://navigate/<path>: a widget or Live Activity tap. Through
// Capacitor it was an event a page reloading after the system ended its process
// never heard, and that page then took the same link for one already followed.
let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil }
for context in appLinks {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks)
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
} }
@@ -104,10 +116,25 @@ enum TrekQuickActions {
static func open(_ item: UIApplicationShortcutItem) -> Bool { static func open(_ item: UIApplicationShortcutItem) -> Bool {
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false } guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
if let onOpen { onOpen(path) } else { pending = path } open(path: path)
return true return true
} }
/// A path to open: at once when the UI listens, else held for it to take.
/// Widget and Live Activity taps come this way too, so each is followed once.
static func open(path: String) {
if let onOpen { onOpen(path) } else { pending = path }
}
/// The path in com.liketrek.trek://navigate/<path>, the link widgets and Live Activities open.
static func appLinkPath(_ url: URL) -> String? {
let prefix = "com.liketrek.trek://navigate"
guard url.absoluteString.hasPrefix(prefix) else { return nil }
let path = String(url.absoluteString.dropFirst(prefix.count))
if path.isEmpty { return "/" }
return path.hasPrefix("/") ? path : nil
}
/// The action the app was launched with, once. /// The action the app was launched with, once.
static func take() -> String? { static func take() -> String? {
defer { pending = nil } defer { pending = nil }
@@ -10,6 +10,7 @@ import WebKit
class TrekBridgeViewController: CAPBridgeViewController { class TrekBridgeViewController: CAPBridgeViewController {
override func instanceDescriptor() -> InstanceDescriptor { override func instanceDescriptor() -> InstanceDescriptor {
let descriptor = super.instanceDescriptor() let descriptor = super.instanceDescriptor()
TrekServerStore.recheckAfterUpdate()
if let server = TrekServerStore.url { if let server = TrekServerStore.url {
// The trailing slash matters: Capacitor treats every URL that starts // The trailing slash matters: Capacitor treats every URL that starts
// with this string as the app's own, and without it that would take // with this string as the app's own, and without it that would take
@@ -40,10 +41,13 @@ class TrekBridgeViewController: CAPBridgeViewController {
// reachable" with a server that was there all along. // reachable" with a server that was there all along.
if let webView, let inner = webView.navigationDelegate { if let webView, let inner = webView.navigationDelegate {
let proxy = TrekNavigationProxy(inner: inner) let proxy = TrekNavigationProxy(inner: inner)
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
navigationProxy = proxy navigationProxy = proxy
webView.navigationDelegate = proxy webView.navigationDelegate = proxy
} }
guardBridgeMessages() guardBridgeMessages()
watchSignInPages()
bridge?.registerPluginInstance(TrekShellPlugin()) bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page, // WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI // closes no sheet and takes every drag near the edge from the UI. The UI
@@ -53,6 +57,257 @@ class TrekBridgeViewController: CAPBridgeViewController {
// A long press on a link would lift a preview of the page it points to, // A long press on a link would lift a preview of the page it points to,
// which no native list does; the press means "drag" or nothing here. // which no native list does; the press means "drag" or nothing here.
webView?.allowsLinkPreview = false webView?.allowsLinkPreview = false
paintBehindWebView()
}
// MARK: - What shows where the web view does not reach
/// The UI reports its theme through StatusBar.setStyle (native/nativeApp.ts),
/// which lands here; the colour behind the web view follows it.
override func setNeedsStatusBarAppearanceUpdate() {
super.setNeedsStatusBarAppearanceUpdate()
paintBehindWebView()
}
/// Some iOS releases (27.0.x) lay the web view out below the status bar
/// instead of under it, and the strip above the page then showed this
/// view's black. It now carries the top colour of the screen background
/// (mobile.css --m-scr), so the strip reads as part of the page on every
/// release, and the rubber band at the ends shows the same colour.
private func paintBehindWebView() {
let dark: Bool
switch preferredStatusBarStyle {
case .lightContent: dark = true
case .darkContent: dark = false
default: dark = traitCollection.userInterfaceStyle == .dark
}
let color = dark
? UIColor(red: 0x17 / 255, green: 0x17 / 255, blue: 0x1C / 255, alpha: 1)
: UIColor(red: 0xFB / 255, green: 0xFA / 255, blue: 0xF7 / 255, alpha: 1)
view.backgroundColor = color
webView?.scrollView.backgroundColor = color
webView?.underPageBackgroundColor = color
}
// MARK: - The safe area the page keeps clear of
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
publishSafeArea()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
publishSafeArea()
}
private var publishedSafeArea: UIEdgeInsets?
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
/// web view overlaps it. Where a release lays the web view out below the
/// status bar it reports 0, yet a camera in a corner can still reach into
/// the page. The part of the window's safe area (and of the status bar) that
/// falls inside the web view goes to the page as --trek-native-safe-*, which
/// mobile.css takes as a floor under env(), never in its place.
func publishSafeArea(force: Bool = false) {
guard let webView, let window = view.window else { return }
let frame = webView.convert(webView.bounds, to: window)
let safe = window.safeAreaInsets
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
let insets = UIEdgeInsets(
top: max(0, max(safe.top, statusBar) - frame.minY),
left: max(0, safe.left - frame.minX),
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
)
if !force, insets == publishedSafeArea { return }
publishedSafeArea = insets
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
let js = "(function(s){"
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
+ "})(document.documentElement.style)"
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - A proxy's sign-in page
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
/// not there, so nothing else leads back to the address screen, and a passkey
/// cannot work in a web view for a domain the app is not tied to. A bar says
/// so and offers to change the server; the keyboard gets room on that page,
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
private var signInBar: UIView?
private var urlObservation: NSKeyValueObservation?
private var insetObservation: NSKeyValueObservation?
private var onSignInPage = false
/// Whether the main frame shows an error page in place of the server's (a 4xx
/// nobody else handled: a proxy's block page, a client certificate it refused).
private var onErrorPage = false
private var signInHint: UILabel?
private var keyboardUp = false
/// How far the keyboard reaches into the view while it is up.
private var keyboardRoom: CGFloat = 0
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
private var ownsInset = false
private var bodyReset: DispatchWorkItem?
private func watchSignInPages() {
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
let signIn = TrekServerStore.isSignInHost(webView.url)
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
}
// The keyboard plugin zeroes the scroll inset on every frame change it
// makes, the last one a while after the keyboard is up. On a sign-in
// page the inset wanted here is put back each time.
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async { self?.applyInset() }
}
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
private func signInPageChanged(_ signIn: Bool) {
onSignInPage = signIn
if signIn && signInBar == nil { signInBar = makeSignInBar() }
updateSignInBar()
}
private func errorPageChanged(_ error: Bool) {
onErrorPage = error
if error && signInBar == nil { signInBar = makeSignInBar() }
updateSignInBar()
}
/// Out of the way while the keyboard is up: the form needs the room.
private func updateSignInBar() {
let labels = TrekServerStore.labels
signInHint?.text = onSignInPage
? labels["signInHint"]
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
: labels["errorPageHint"] ?? "This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
signInBar?.isHidden = !((onSignInPage || onErrorPage) && !keyboardUp)
applyInset()
}
/// On a sign-in page the page can scroll past what covers its end: the
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
private func applyInset() {
guard let scrollView = webView?.scrollView else { return }
let wanted: CGFloat
if onSignInPage || onErrorPage {
if keyboardUp {
wanted = keyboardRoom
} else if let bar = signInBar {
view.layoutIfNeeded()
wanted = max(0, view.bounds.height - bar.frame.minY)
} else {
wanted = 0
}
ownsInset = true
} else if ownsInset {
wanted = 0
ownsInset = false
} else {
return
}
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
}
/// The keyboard plugin also sizes the body to the area above the keyboard,
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
/// body gets its height back once that has run, so the button under the
/// keyboard can be scrolled into view through the inset above.
@objc private func keyboardWillShow(_ note: Notification) {
keyboardUp = true
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
}
bodyReset?.cancel()
if onSignInPage {
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
let reset = DispatchWorkItem { [weak self] in
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
self.applyInset()
}
bodyReset = reset
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
}
updateSignInBar()
}
@objc private func keyboardWillHide() {
keyboardUp = false
bodyReset?.cancel()
updateSignInBar()
}
private func makeSignInBar() -> UIView {
let labels = TrekServerStore.labels
// The look of the app's own first screens: a white card with a sand edge,
// warm grey text and a dark button, over whatever page the proxy shows.
let bar = UIView()
bar.backgroundColor = UIColor(white: 1, alpha: 0.97)
bar.layer.cornerRadius = 18
bar.layer.cornerCurve = .continuous
bar.layer.borderWidth = 1
bar.layer.borderColor = UIColor(red: 0xEF / 255, green: 0xE4 / 255, blue: 0xD8 / 255, alpha: 1).cgColor
bar.layer.shadowColor = UIColor(red: 0x1F / 255, green: 0x1A / 255, blue: 0x17 / 255, alpha: 1).cgColor
bar.layer.shadowOpacity = 0.18
bar.layer.shadowRadius = 18
bar.layer.shadowOffset = CGSize(width: 0, height: 10)
bar.translatesAutoresizingMaskIntoConstraints = false
let hint = UILabel()
hint.text = labels["signInHint"]
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
hint.textColor = UIColor(red: 0x6B / 255, green: 0x5F / 255, blue: 0x57 / 255, alpha: 1)
hint.font = .preferredFont(forTextStyle: .footnote)
hint.adjustsFontForContentSizeCategory = true
hint.numberOfLines = 0
signInHint = hint
hint.translatesAutoresizingMaskIntoConstraints = false
var look = UIButton.Configuration.filled()
look.baseBackgroundColor = UIColor(red: 0x11 / 255, green: 0x18 / 255, blue: 0x27 / 255, alpha: 1)
look.baseForegroundColor = .white
look.cornerStyle = .fixed
look.background.cornerRadius = 12
look.contentInsets = NSDirectionalEdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)
let change = UIButton(configuration: look)
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
change.translatesAutoresizingMaskIntoConstraints = false
change.setContentHuggingPriority(.required, for: .horizontal)
change.setContentCompressionResistancePriority(.required, for: .horizontal)
bar.addSubview(hint)
bar.addSubview(change)
view.addSubview(bar)
NSLayoutConstraint.activate([
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
])
return bar
}
/// What resetServer does from the UI: forget the server, back to the address screen.
@objc private func changeServerFromSignIn() {
TrekServerStore.url = nil
view.window?.rootViewController = TrekBridgeViewController()
} }
/// Kept alive here: the content controller holds it, but only for as long as it is registered. /// Kept alive here: the content controller holds it, but only for as long as it is registered.
@@ -60,10 +315,10 @@ class TrekBridgeViewController: CAPBridgeViewController {
/// Capacitor answers bridge messages from any page in the web view. With a /// Capacitor answers bridge messages from any page in the web view. With a
/// proxy's sign-in page loading there, its messages are dropped before they /// proxy's sign-in page loading there, its messages are dropped before they
/// reach the plugins. /// reach the plugins. Always in place: a sign-in host can also be learned
/// while the app runs (TrekSignInRedirect).
private func guardBridgeMessages() { private func guardBridgeMessages() {
guard !TrekServerStore.signInHosts.isEmpty, guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let controller = webView?.configuration.userContentController else { return } let controller = webView?.configuration.userContentController else { return }
let guarded = TrekBridgeMessageGuard(inner: handler) let guarded = TrekBridgeMessageGuard(inner: handler)
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name) controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
@@ -157,13 +412,70 @@ enum TrekKeyCommands {
enum TrekServerStore { enum TrekServerStore {
private static let key = "TrekServerURL" private static let key = "TrekServerURL"
private static let failureKey = "TrekLastFailure"
/// Why the server's page did not load ("NSURLErrorDomain -1003"): the
/// address screen shows it small under its message, so a report carries
/// the cause. Taken once.
static func noteFailure(_ error: Error) {
let ns = error as NSError
UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey)
}
static func takeFailure() -> String? {
let failure = UserDefaults.standard.string(forKey: failureKey)
UserDefaults.standard.removeObject(forKey: failureKey)
return failure
}
private static let previousKey = "TrekPreviousServerURL"
private static let checkedKey = "TrekServerCheck"
private static let labelsKey = "TrekShellLabels"
/// The words of the bar over a proxy's sign-in page, in the user's language,
/// as the address screen passed them (setServer's `labels`): the server's UI
/// is not there to say them. Empty keeps the English fallback.
static var labels: [String: String] {
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
}
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
/// A server remembered under a lower value is checked once more: the first
/// launch after an update that raised it puts the address screen back up,
/// filled in with that server (previousURL). Raise both together.
private static let serverCheck = 3
/// Runs before the first bridge is built, so a server picked under an older
/// minimum is not loaded unchecked.
static func recheckAfterUpdate() {
let defaults = UserDefaults.standard
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
defaults.set(serverCheck, forKey: checkedKey)
guard let server = url else { return }
url = nil
defaults.set(server, forKey: previousKey)
}
/// The server an update asked to check again, until a server is set.
static var previousURL: String? {
UserDefaults.standard.string(forKey: previousKey)
}
private static let signInHostsKey = "TrekServerSignInHosts" private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it. /// Setting the server drops the sign-in hosts of the one before it.
static var url: String? { static var url: String? {
get { UserDefaults.standard.string(forKey: key) } get { UserDefaults.standard.string(forKey: key) }
set { set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } if let newValue {
UserDefaults.standard.set(newValue, forKey: key)
UserDefaults.standard.removeObject(forKey: previousKey)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
UserDefaults.standard.removeObject(forKey: signInHostsKey) UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL) try? FileManager.default.removeItem(at: offlineCopyURL)
} }
@@ -185,6 +497,13 @@ enum TrekServerStore {
} }
} }
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
static func addSignInHost(_ host: String) {
var hosts = signInHosts
guard hosts.insert(host.lowercased()).inserted else { return }
signInHosts = hosts
}
/// Whether `url` is one of the sign-in hosts, matched on host or host:port. /// Whether `url` is one of the sign-in hosts, matched on host or host:port.
static func isSignInHost(_ url: URL?) -> Bool { static func isSignInHost(_ url: URL?) -> Bool {
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false } guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
@@ -428,12 +747,48 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
} }
} }
/// A sign-in page in front of the server that the address screen never saw.
/// A proxy can let the app's check through (an Authelia or Authentik rule that
/// bypasses /api) and still send the page itself to its sign-in. Handed to
/// Safari, that sign-in leaves its session there and the app stays blank on
/// every launch. So a redirect away from the server, while the server's first
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
/// view and its host is kept, the way the address screen keeps the ones it saw.
/// A link the user taps is no redirect, and once a page of the server has
/// shown, nothing is learned any more.
enum TrekSignInRedirect {
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
guard mainFrame, redirect, !serverShown,
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
let target, let toHost = target.host?.lowercased(),
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
// Never from https down to plain http: the sign-in would send a password in the clear.
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
guard toHost != fromHost else { return nil }
if let port = target.port { return "\(toHost):\(port)" }
return toHost
}
/// Whether `url` is on the server's host, where the app's own pages come from.
static func onServer(server: String?, url: URL?) -> Bool {
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
return url?.host?.lowercased() == host
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything /// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled /// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those /// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page. /// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate { final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate private let inner: WKNavigationDelegate
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
private var serverShown = false
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
var onErrorPage: ((Bool) -> Void)?
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
var onPageShown: (() -> Void)?
init(inner: WKNavigationDelegate) { init(inner: WKNavigationDelegate) {
self.inner = inner self.inner = inner
@@ -462,6 +817,18 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
decisionHandler(.allow) decisionHandler(.allow)
return return
} }
// WebKit asks again for every server redirect, as a navigation of type .other.
if let host = TrekSignInRedirect.learnedHost(
server: TrekServerStore.url,
target: navigationAction.request.url,
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
redirect: navigationAction.navigationType == .other,
serverShown: serverShown
) {
TrekServerStore.addSignInHost(host)
decisionHandler(.allow)
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:") let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else { guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow) decisionHandler(.allow)
@@ -474,6 +841,12 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin. /// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
inner.webView?(webView, didFinish: navigation) inner.webView?(webView, didFinish: navigation)
onPageShown?()
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) {
serverShown = true
// The server's page is up: an earlier failure is no cause any more.
_ = TrekServerStore.takeFailure()
}
if let welcome = TrekServerStore.takeWelcomeURL() { if let welcome = TrekServerStore.takeWelcomeURL() {
webView.load(URLRequest(url: welcome)) webView.load(URLRequest(url: welcome))
} }
@@ -489,6 +862,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
guard let self, let webView else { return } guard let self, let webView else { return }
// Capacitor's own answer to a failed load: the address screen (errorPath). // Capacitor's own answer to a failed load: the address screen (errorPath).
TrekServerStore.noteFailure(URLError(.clientCertificateRejected))
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected)) self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
} }
return return
@@ -504,16 +878,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
/// A proxy that asked the page for a client certificate, got none and /// A proxy that asked the page for a client certificate, got none and
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the /// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
/// app to the address screen, which says so and offers to add one, rather /// app to the address screen, which says so and offers to add one, rather
/// than leaving the proxy's error page up. /// than leaving the proxy's error page up. Any other 4xx page is not TREK's,
/// which serves its UI for every path (a proxy's block page, a certificate it
/// refused): it stays up with the bar and its way out to another server.
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse, if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served, http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port { let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
decisionHandler(.cancel) decisionHandler(.cancel)
TrekServerStore.noteFailure(URLError(.clientCertificateRequired))
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired)) inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
return return
} }
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse {
let error = http.statusCode >= 400 && http.statusCode < 500
DispatchQueue.main.async { [weak self] in self?.onErrorPage?(error) }
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:") let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else { guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow) decisionHandler(.allow)
@@ -524,11 +905,13 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return } if Self.isReplaced(error) { return }
TrekServerStore.noteFailure(error)
inner.webView?(webView, didFail: navigation, withError: error) inner.webView?(webView, didFail: navigation, withError: error)
} }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return } if Self.isReplaced(error) { return }
TrekServerStore.noteFailure(error)
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error) inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
} }
} }
+146 -7
View File
@@ -11,6 +11,7 @@ import ActivityKit
#endif #endif
import LocalAuthentication import LocalAuthentication
import PassKit import PassKit
import QuickLook
import UIKit import UIKit
import WebKit import WebKit
@@ -45,6 +46,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeHeld", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
@@ -55,6 +57,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
] ]
/// What this binary offers the server's UI, raised with every native feature /// What this binary offers the server's UI, raised with every native feature
@@ -66,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] { static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"] var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"]
if canAddWalletPasses { names.append("wallet") } if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") } if TrekWatchLink.isAvailable { names.append("watch") }
return names return names
@@ -91,8 +94,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
TrekKeyCommands.onPress = { [weak self] id in TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id]) self?.notifyListeners("keyCommand", data: ["id": id])
} }
// Kept until a listener takes it: a tap while the page reloads reaches the new page.
TrekQuickActions.onOpen = { [weak self] path in TrekQuickActions.onOpen = { [weak self] path in
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) } DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) }
} }
} }
@@ -346,7 +350,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
private var authSession: ASWebAuthenticationSession? private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) { @objc func getServer(_ call: CAPPluginCall) {
call.resolve(["url": TrekServerStore.url ?? NSNull()]) call.resolve([
"url": TrekServerStore.url ?? NSNull(),
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
"failure": TrekServerStore.takeFailure() ?? NSNull(),
])
} }
@objc func setServer(_ call: CAPPluginCall) { @objc func setServer(_ call: CAPPluginCall) {
@@ -355,6 +363,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return return
} }
TrekServerStore.url = raw TrekServerStore.url = raw
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
if !labels.isEmpty { TrekServerStore.labels = labels }
// After the URL: setting it clears the hosts of the server before. // After the URL: setting it clears the hosts of the server before.
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil } let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() }) TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
@@ -555,6 +565,41 @@ extension TrekShellPlugin {
/// Positions and errors for the UI, shaped like the Geolocation API's, with the /// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time. /// When-In-Use permission asked the first time.
// MARK: - Quick Look
extension TrekShellPlugin {
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
/// One preview at a time, so a static: an extension cannot store per instance.
private static var previewSource: TrekFilePreviewSource?
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
/// an image, a document, with the system's own share button. "unsupported" for
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
@objc func previewFile(_ call: CAPPluginCall) {
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else {
call.reject("a file in the app's cache is required", "INVALID")
return
}
DispatchQueue.main.async { [weak self] in
guard let self, let presenter = self.bridge?.viewController else {
call.reject("nothing to show the preview from", "UNAVAILABLE")
return
}
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
call.resolve(["result": "unsupported"])
return
}
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
TrekShellPlugin.previewSource = source
let preview = QLPreviewController()
preview.dataSource = source
presenter.present(preview, animated: true)
call.resolve(["result": "shown"])
}
}
}
// MARK: - Apple Wallet // MARK: - Apple Wallet
extension TrekShellPlugin { extension TrekShellPlugin {
@@ -637,6 +682,17 @@ extension TrekShellPlugin {
return return
} }
Task { Task {
// One subscription per Apple ID: another plan would change it, not add one.
// A held one that was delivered stops the purchase here. One that was paid
// but never set up is handed back as bought, so its instance is set up now.
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
if await TrekStore.awaitingSetup(held) {
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
} else {
call.resolve(["kind": "subscribed"])
}
return
}
do { do {
guard let product = try await Product.products(for: [id]).first else { guard let product = try await Product.products(for: [id]).first else {
call.resolve(["kind": "failed"]) call.resolve(["kind": "failed"])
@@ -693,6 +749,21 @@ extension TrekShellPlugin {
call.resolve(["jws": found.jws, "transactionId": String(found.id)]) call.resolve(["jws": found.jws, "transactionId": String(found.id)])
} }
} }
/// Feature storeKit: whether this Apple ID holds a delivered subscription under
/// `prefix`. Read from the entitlements on the device, so no password is asked;
/// while one runs, the shell offers no purchase.
@objc func storeHeld(_ call: CAPPluginCall) {
let prefix = call.getString("prefix") ?? "cloud."
Task {
guard let held = await TrekStore.currentEntitlement(prefix: prefix) else {
call.resolve(["held": false])
return
}
let awaiting = await TrekStore.awaitingSetup(held)
call.resolve(["held": !awaiting])
}
}
} }
enum TrekStore { enum TrekStore {
@@ -723,17 +794,62 @@ enum TrekStore {
} }
} }
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? { typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
var newest: (date: Date, jws: String, id: UInt64)?
/// The subscription this Apple ID holds under `prefix`, newest first. The
/// entitlements on the device come first; on a fresh install they can still
/// be empty while the subscription runs, so the status of the subscription
/// group, which StoreKit asks the App Store for, decides then.
static func currentEntitlement(prefix: String) async -> Held? {
var newest: (date: Date, held: Held)?
for await result in Transaction.currentEntitlements { for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result, guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix), transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue } transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date { if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id) newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
} }
} }
return newest.map { ($0.jws, $0.id) } if let newest { return newest.held }
return await subscriptionGroupHeld(prefix: prefix)
}
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
/// productIdFor in iap.ts); one of them is enough to find their group.
private static let plans = ["start", "basis", "plus", "pro"]
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
guard let products = try? await Product.products(for: ids),
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
for status in statuses where running.contains(status.state) {
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
return held(status.transaction.jwsRepresentation, transaction)
}
}
return nil
}
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
}
/// A first purchase that was paid but whose instance was never set up: handed
/// back as bought so it is set up now. A renewal the device has not finished
/// yet is no such purchase; its subscription is running and counts as held.
static func awaitingSetup(_ held: Held) async -> Bool {
guard held.firstPurchase else { return false }
return await isUnfinished(held.id)
}
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
static func isUnfinished(_ id: UInt64) async -> Bool {
for await result in Transaction.unfinished {
if case .verified(let transaction) = result, transaction.id == id { return true }
}
return false
} }
} }
@@ -957,3 +1073,26 @@ enum TrekSharedKeychain {
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
} }
} }
/// The one file Quick Look shows, under the name the user knows it by.
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
private let item: Item
init(url: URL, title: String?) {
item = Item(url: url, title: title)
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
final class Item: NSObject, QLPreviewItem {
let previewItemURL: URL?
let previewItemTitle: String?
init(url: URL, title: String?) {
previewItemURL = url
previewItemTitle = title
}
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The amount inside a price as the store writes it ("4,90 €", "$4.99",
* "CHF 12.90", "1.290 ₽"), and a way to write another amount the same way: the
* same currency, the same decimal mark, the same digit grouping. The plan picker
* counts a price up to its new value with it, and turns a yearly price into what
* it makes a month, without knowing the store's locale.
*/
interface Amount {
/** The amount as a number, in the price's own unit (4.9 for "4,90 €"). */
value: number;
/** The price written for another amount, in the same format. */
render(value: number): string;
}
// Digits with the marks stores put between them: . , any space (\s takes the
// no-break ones too) and the apostrophes Swiss formats group thousands with.
const NUMBER = /\d[\d.,\s'’]*\d|\d/;
export function amountIn(label: string): Amount | null {
const match = NUMBER.exec(label);
if (!match) return null;
const raw = match[0];
// The decimal mark is the last . or , followed by one or two digits to the end.
const decimal = /([.,])(\d{1,2})$/.exec(raw);
const decimals = decimal ? decimal[2].length : 0;
const mark = decimal ? decimal[1] : '';
const whole = decimal ? raw.slice(0, raw.length - decimal[0].length) : raw;
const group = /[.,\s'’]/.exec(whole)?.[0] ?? '';
const digits = whole.replace(/\D/g, '') + (decimal ? `.${decimal[2]}` : '');
const value = Number(digits);
if (!Number.isFinite(value)) return null;
const before = label.slice(0, match.index);
const after = label.slice(match.index + raw.length);
return {
value,
render(next: number) {
const fixed = Math.max(0, next).toFixed(decimals);
const [int, frac] = fixed.split('.');
const grouped = group ? int.replace(/\B(?=(\d{3})+(?!\d))/g, group) : int;
return `${before}${grouped}${frac !== undefined ? mark + frac : ''}${after}`;
},
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

+2 -2
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="light" />
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#fffaf5" />
<title>TREK</title> <title>TREK</title>
</head> </head>
<body> <body>
+1016 -542
View File
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
/**
* The landscape behind the first screens of the app: one world seen from above,
* an island out at sea, the coast with its town, the farmland behind it and the
* mountains at the back. A fragment shader draws it every frame (water, surf,
* boats, a car on the road), so it is never a photo and never the same twice.
*
* The plan picker moves the camera through that world, one place per plan, so
* choosing reads as travelling further inland. The scenes are framed for a phone
* 390 px wide: a wider or taller box (iPad, an unfolded phone) shows more of the
* world around the same centre.
*
* Kept light for the phone it runs on: the device pixel ratio is capped at 2,
* the picture drops to a lower resolution when frames come slow, the loop stops
* while the app is in the background or the canvas has left the page, and with
* reduced motion it is one still frame. Without WebGL the box keeps its plain
* sea colour from the stylesheet.
*/
/** Where the camera stands for a place, in world pixels, for a 390 px frame. */
export type Camera = readonly [number, number];
export const WELCOME_CAMERA: Camera = [0, 0];
/** One place per plan, in the order the plans come: island, coast, farmland, mountains. */
const PLAN_CAMERAS: readonly Camera[] = [
[62, -392],
[0, 24],
[60, 290],
[70, 660],
];
export function cameraForPlan(index: number): Camera {
return PLAN_CAMERAS[Math.max(0, Math.min(PLAN_CAMERAS.length - 1, index))];
}
const FRAME_WIDTH = 390;
const FRAGMENT = `
precision highp float;
uniform vec2 uRes; uniform float uDpr; uniform float uT; uniform vec2 uCam; uniform float uHaze;
float coast(float x) { return 128.0 + 12.0 * sin(x * 0.018 + 0.6) + 7.0 * sin(x * 0.051 + 2.1) + 4.0 * sin(x * 0.11); }
float riverX(float y) { return 168.0 - (y - 120.0) * 0.30 + 9.0 * sin(y * 0.035 + 0.8) + 4.0 * sin(y * 0.09); }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
vec2 hash2(vec2 p) { return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); }
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
float fbm3(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 3; i++) { v += a * noise(p); p = p * 2.03 + vec2(1.7, 9.2); a *= 0.5; } return v; }
float wave(vec2 p, float t) {
return 0.55 * sin(dot(p, vec2(0.040, 0.085)) - t * 1.10) + 0.35 * sin(dot(p, vec2(-0.070, 0.050)) - t * 1.45)
+ 0.22 * sin(dot(p, vec2(0.110, 0.130)) - t * 2.10) + 0.12 * sin(dot(p, vec2(-0.170, 0.190)) - t * 2.90)
+ 0.90 * fbm(p * 0.055 + vec2(t * 0.18, t * 0.32));
}
float ridged(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { float n = 1.0 - abs(noise(p) * 2.0 - 1.0); v += a * n * n; p = p * 2.07 + vec2(3.1, 1.7); a *= 0.5; } return v; }
/* Height: gentle near the shore, sharp ridges further inland. */
float peaks(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 7; i++) { v += a * (1.0 - abs(noise(p) * 2.0 - 1.0)); p = p * 2.13 + vec2(1.3, 4.7); a *= 0.5; } return v; }
float ground(vec2 p) {
float inland = smoothstep(10.0, 170.0, p.y - coast(p.x));
return fbm(p * 0.011) * 0.4 + ridged(p * 0.014 + 5.0) * 1.15 * inland + fbm3(p * 0.06) * 0.08;
}
/* The island out at sea: signed distance to its shore in px (> 0 on land),
a round main body with an uneven coast and a headland to the east. */
float islD(vec2 p) {
vec2 q = (p - vec2(230.0, -250.0)) / vec2(1.5, 1.0);
float r = length(q);
float rad = 66.0 + 46.0 * (fbm(q * 0.016 + 4.0) - 0.5) + 30.0 * (fbm(q * 0.045 + 8.0) - 0.5) + 10.0 * (fbm(p * 0.09) - 0.5);
vec2 q2 = (p - vec2(372.0, -222.0)) / vec2(1.6, 1.0);
float head = 15.0 + 10.0 * (fbm(p * 0.04 + 9.0) - 0.5) - length(q2);
return max(rad - r, head);
}
float islH(vec2 p) {
float s = islD(p);
return smoothstep(0.0, 60.0, s) * (0.5 + 0.7 * fbm(p * 0.018 + 2.0)) + ridged(p * 0.03) * 0.22 * smoothstep(8.0, 50.0, s);
}
/* Trees: one per cell at most, a round crown of varying size. Returns the
crown's cover (0..1) and how far from its centre, for the shading. */
vec3 tree(vec2 p, float cell) {
vec2 c = floor(p / cell);
float best = 9.0; vec2 off = vec2(0.0); float sz = 0.0;
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
vec2 g = c + vec2(float(i), float(j));
vec2 h = hash2(g);
vec2 o = (g + 0.15 + 0.7 * h) * cell;
float r = cell * (0.38 + 0.22 * hash(g + 3.1));
float d = length(p - o) / r;
if (d < best) { best = d; off = (p - o) / r; sz = r; }
}
return vec3(1.0 - smoothstep(0.86, 1.0, best), off);
}
/* A Mediterranean town: one house per cell, a rectangle turned the way
the lane runs there (the direction drifts across town, so lanes curve),
sized at random and packed close. Whitewashed cubes with a parapet and
now and then a water tank, pastel walls, terracotta roofs pitched along
the long side, and the odd blue dome. */
float town(vec2 p, out vec3 roof, out vec2 hid) {
float cell = 9.0;
vec2 c = floor(p / cell);
roof = vec3(0.0);
hid = c;
float got = 0.0;
for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
vec2 g = c + vec2(float(i), float(j));
if (hash(g + 2.7) < 0.07) continue;
vec2 o = (g + 0.5 + 0.35 * (hash2(g) - 0.5)) * cell;
float ang = (noise(o * 0.018) - 0.5) * 2.4 + (hash(g + 7.7) - 0.5) * 0.25;
vec2 cs = vec2(cos(ang), sin(ang));
vec2 rel = p - o;
vec2 l = vec2(dot(rel, cs), dot(rel, vec2(-cs.y, cs.x)));
vec2 hs = cell * vec2(0.36 + 0.16 * hash(g + 1.3), 0.27 + 0.14 * hash(g + 4.1));
if (abs(l.x) < hs.x && abs(l.y) < hs.y) {
float k = hash(g + 5.3);
vec3 col = k < 0.44 ? vec3(0.96, 0.95, 0.91) : k < 0.68 ? vec3(0.80, 0.46, 0.32) : k < 0.80 ? vec3(0.93, 0.79, 0.56) : k < 0.90 ? vec3(0.94, 0.75, 0.67) : vec3(0.88, 0.86, 0.80);
float rim = min(hs.x - abs(l.x), hs.y - abs(l.y));
if (k >= 0.44 && k < 0.68) {
// pitched roof: the ridge runs along the long side, one slope lit, one shaded
float lit = dot(vec2(-cs.y, cs.x), vec2(-0.67, -0.73)) * sign(l.y);
col *= lit > 0.0 ? 1.08 : 0.80;
col *= 0.95 + 0.05 * sin(l.x * 3.6);
col *= 1.0 - 0.25 * (1.0 - smoothstep(0.0, 0.5, abs(l.y)));
} else {
// flat roof: a parapet round the edge, a water tank on some
col *= 1.0 - 0.13 * (1.0 - smoothstep(0.4, 1.1, rim));
vec2 tk = vec2(hs.x * 0.45, -hs.y * 0.35);
if (hash(g + 6.2) < 0.35 && length(l - tk) < 0.95) col = vec3(0.45, 0.47, 0.50) + 0.25 * step(0.0, -(l - tk).x - (l - tk).y);
if (k < 0.44 && hash(g + 4.4) < 0.08 && length(l) < min(hs.x, hs.y) * 0.85) {
col = mix(vec3(0.12, 0.30, 0.64), vec3(0.58, 0.76, 0.98), clamp(0.55 - dot(rel / (min(hs.x, hs.y) * 0.85), vec2(-0.67, -0.73)) * 0.6, 0.0, 1.0));
}
}
col *= 0.93 + 0.1 * hash(g + 9.9);
roof = col;
hid = g;
got = 1.0;
}
}
return got;
}
void main() {
vec2 sp = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y) / uDpr;
vec2 p = sp + uCam;
float t = uT;
vec3 L = normalize(vec3(-0.55, -0.6, 0.62));
float d = coast(p.x) - p.y; // > 0 at sea, < 0 inland, in px
float wash = 6.0 + 4.0 * sin(t * 1.2 + p.x * 0.045) + 3.0 * fbm3(vec2(p.x * 0.08, t * 0.6));
vec3 col;
if (d > -2.0) {
/* ── sea ── */
float e = 0.9, h = wave(p, t);
vec3 n = normalize(vec3(-(wave(p + vec2(e, 0.0), t) - h) * 1.6, -(wave(p + vec2(0.0, e), t) - h) * 1.6, 1.0));
float depth = smoothstep(0.0, 120.0, d);
col = mix(vec3(0.47, 0.83, 0.82), vec3(0.17, 0.58, 0.72), smoothstep(0.0, 0.45, depth));
col = mix(col, vec3(0.07, 0.33, 0.55), smoothstep(0.45, 1.0, depth));
col *= 0.78 + 0.32 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
vec3 Ls = normalize(vec3(0.45, -0.55, 0.70));
vec3 R = reflect(-Ls, n);
col += vec3(1.0, 0.97, 0.9) * (pow(clamp(R.z, 0.0, 1.0), 90.0) * 0.45 + pow(clamp(R.z, 0.0, 1.0), 900.0) * step(0.55, noise(p * 0.6 + t * 2.0)) * 1.4);
float ca = pow(1.0 - abs(sin(fbm(p * 0.09 + vec2(t * 0.35, -t * 0.25)) * 9.0)), 6.0);
col += vec3(0.85, 1.0, 0.95) * ca * 0.22 * (1.0 - smoothstep(0.0, 40.0, d));
float runs = smoothstep(0.82, 1.0, sin(d * 0.32 + t * 2.2 + fbm3(p * 0.05) * 3.0)) * (1.0 - smoothstep(6.0, 46.0, d));
float lace = smoothstep(0.45, 0.75, fbm3(p * 0.22 + vec2(t * 0.5, 0.0)));
float foam = max(1.0 - smoothstep(wash - 4.0, wash + 3.0, d), runs * lace * 0.8);
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(foam, 0.0, 1.0) * 0.92);
// boats crossing, each in a lane of its own (46 px apart, wider than any
// wake), at a speed that swells and eases but never turns back
for (int k = 0; k < 2; k++) {
float fk = float(k);
float dir = fk < 0.5 ? 1.0 : -1.0;
float speed = 10.0 + fk * 4.0;
float run = speed * t + 14.0 * sin(t * 0.23 + fk * 2.0) + 6.0 * sin(t * 0.61 + fk * 4.0);
float lane = 36.0 + fk * 46.0;
float drift = 2.6 * sin(t * 0.31 + fk * 1.7);
vec2 bp = vec2(mod(fk * 190.0 + run * dir, 480.0) - 45.0, lane + drift);
float yaw = 2.6 * 0.31 * cos(t * 0.31 + fk * 1.7) / speed;
vec2 q = p - bp;
q = vec2(q.x * dir, q.y);
q = vec2(q.x + yaw * q.y, q.y - yaw * q.x);
float back = -q.x;
float open = 0.29 + 0.05 * sin(t * 1.3 + fk * 3.0 + back * 0.05);
float spread = back * open;
float churn = smoothstep(0.25, 0.65, noise(vec2(back * 0.35 - t * 1.6, q.y * 0.45 + fk * 7.0)));
float edgeW = 0.7 + 0.8 * noise(vec2(back * 0.2 + fk * 3.0, t * 0.8));
float fade = step(0.0, back) * (1.0 - smoothstep(8.0, 64.0, back));
float wake = (1.0 - smoothstep(edgeW * 0.6, edgeW * 1.8, abs(abs(q.y) - spread))) * fade * (0.55 + 0.45 * churn);
wake += (1.0 - smoothstep(0.0, 2.4, abs(q.y))) * step(0.0, back) * (1.0 - smoothstep(4.0, 30.0, back)) * 0.7 * churn;
col = mix(col, vec3(0.96, 0.98, 1.0), clamp(wake, 0.0, 1.0) * 0.78);
float hull = length(q / vec2(6.5, 2.4));
col = mix(col, vec3(0.20, 0.30, 0.40), (1.0 - smoothstep(1.0, 1.5, length((q - vec2(-1.0, 1.4)) / vec2(6.5, 2.4)))) * 0.25);
col = mix(col, mix(vec3(0.98), vec3(0.80, 0.82, 0.85), step(0.0, q.y)), 1.0 - smoothstep(0.92, 1.0, hull));
col = mix(col, vec3(0.55, 0.36, 0.24), 1.0 - smoothstep(0.9, 1.0, length((q - vec2(-1.0, 0.0)) / vec2(2.4, 1.2))));
}
// the island: a shelf of shallow water, a reef, surf, a beach, a wooded hill
if (abs(p.y + 250.0) < 175.0 && abs(p.x - 265.0) < 310.0) {
float sI = islD(p);
if (sI < 0.0) {
float sd = -sI;
float shelf = 1.0 - smoothstep(4.0, 78.0, sd);
float ripple = 0.5 + 0.5 * sin(dot(p, vec2(0.42, 0.25)) + fbm(p * 0.05) * 6.0);
vec3 sh = mix(vec3(0.13, 0.52, 0.66), vec3(0.40, 0.83, 0.81), smoothstep(62.0, 10.0, sd));
sh = mix(sh, vec3(0.80, 0.86, 0.74), smoothstep(26.0, 0.0, sd) * 0.4);
sh *= 0.95 + 0.08 * ripple * smoothstep(50.0, 4.0, sd);
float swell = sin(sd * 0.3 + t * 1.8 + fbm3(p * 0.03) * 4.0);
sh *= 1.0 + 0.09 * swell * smoothstep(70.0, 20.0, sd) * smoothstep(0.0, 8.0, sd);
float reef = smoothstep(0.57, 0.68, fbm(p * 0.045 + 13.0)) * smoothstep(12.0, 22.0, sd) * (1.0 - smoothstep(46.0, 60.0, sd));
sh = mix(sh, vec3(0.18, 0.40, 0.42) * (0.82 + 0.3 * noise(p * 0.5)), reef * 0.75);
float lw = 0.85 + 0.3 * clamp(dot(n, -vec3(L.x, L.y, -L.z)), 0.0, 1.0);
col = mix(col, sh * lw, shelf);
float cs = pow(1.0 - abs(sin(fbm(p * 0.12 + vec2(t * 0.4, -t * 0.3)) * 10.0)), 7.0);
col += vec3(0.9, 1.0, 0.95) * cs * 0.24 * smoothstep(45.0, 4.0, sd);
// surf breaking along the reef edge, in pieces
float reefLine = 44.0 + 12.0 * (fbm(p * 0.02 + 5.0) - 0.5);
float brk = (1.0 - smoothstep(0.0, 2.0 + 2.5 * fbm3(p * 0.04 + vec2(t * 0.25, 0.0)), abs(sd - reefLine - 2.5 * sin(t * 0.8 + p.x * 0.03))));
brk *= smoothstep(0.35, 0.62, noise(p * 0.08 + vec2(t * 0.6, 0.0))) * smoothstep(0.42, 0.56, fbm(p * 0.03 + 13.0));
col = mix(col, vec3(0.96, 0.99, 1.0), brk * 0.6);
// waves rolling in, foam lines thinning out as they come, the swash on the sand
float lace2 = smoothstep(0.42, 0.72, fbm3(p * 0.25 + vec2(t * 0.4, 0.0)));
float roll = smoothstep(0.9, 1.0, swell) * (1.0 - smoothstep(5.0, 26.0, sd));
float iw = 4.5 + 3.5 * sin(t * 1.1 + fbm(p * 0.03) * 6.0);
float swash = 1.0 - smoothstep(iw - 3.0, iw + 2.5, sd);
col = mix(col, vec3(0.97, 0.99, 1.0), clamp(max(swash * (0.55 + 0.45 * lace2), roll * lace2 * 0.8), 0.0, 1.0) * 0.9);
} else {
float hI = islH(p);
vec3 nI = normalize(vec3(-(islH(p + vec2(1.0, 0.0)) - hI) * 110.0, -(islH(p + vec2(0.0, 1.0)) - hI) * 110.0, 1.0));
float shI = clamp(dot(nI, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
float bwI = 5.0 + 15.0 * fbm(p * 0.03 + 2.0);
vec3 sandI = vec3(0.95, 0.89, 0.74) + (hash(floor(p * 3.0)) - 0.5) * 0.05;
sandI = mix(sandI, vec3(0.76, 0.69, 0.56), (1.0 - smoothstep(0.0, 4.5, sI)) * 0.75);
float big = step(0.55, fbm(p * 0.025 + 6.0));
vec3 tr = big > 0.5 ? tree(p, 8.0) : tree(p, 4.6);
vec3 trS = big > 0.5 ? tree(p - vec2(2.6, 3.0), 8.0) : tree(p - vec2(1.7, 2.0), 4.6);
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
float jung = smoothstep(bwI, bwI + 8.0, sI);
vec3 under = mix(vec3(0.46, 0.55, 0.28), vec3(0.28, 0.42, 0.19), noise(p * 0.1)) * (1.0 - 0.35 * trS.x);
vec3 canopy = mix(vec3(0.09, 0.27, 0.12), vec3(0.42, 0.62, 0.28), lit) * (0.9 + 0.2 * noise(p * 2.0));
vec3 landI = mix(sandI, under, jung);
landI *= 1.0 - 0.3 * trS.x * smoothstep(bwI - 4.0, bwI, sI) * (1.0 - jung);
landI = mix(landI, canopy, tr.x * smoothstep(bwI - 3.0, bwI + 5.0, sI) * (0.35 + 0.65 * smoothstep(0.22, 0.36, fbm(p * 0.035 + 1.0))));
// bare rock on the steep top and boulders where the headland meets the sea
float slI = 1.0 - nI.z;
landI = mix(landI, vec3(0.62, 0.58, 0.50) * (0.9 + 0.15 * noise(p * 0.8)), smoothstep(0.7, 0.88, slI) * 0.6);
vec3 bo = tree(p, 4.0);
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
float rk = smoothstep(0.6, 0.7, fbm(p * 0.05 + 4.0)) * (1.0 - smoothstep(bwI - 2.0, bwI + 4.0, sI));
landI = mix(landI, rocks, rk);
landI *= 0.5 + 0.7 * shI;
col = mix(vec3(0.90, 0.95, 0.93), landI, smoothstep(0.0, 1.4, sI));
}
// a sailboat going round, well outside the reef
float sa = t * 0.06;
vec2 sb = vec2(230.0, -250.0) + vec2(cos(sa) * 1.5, sin(sa)) * 140.0;
vec2 tang = normalize(vec2(-sin(sa) * 1.5, cos(sa)));
vec2 q = p - sb;
q = vec2(dot(q, tang), dot(q, vec2(-tang.y, tang.x)));
float back = -q.x;
float wk = (1.0 - smoothstep(0.5, 1.6, abs(abs(q.y) - back * 0.22))) * step(0.0, back) * (1.0 - smoothstep(6.0, 44.0, back));
col = mix(col, vec3(0.96, 0.98, 1.0), wk * 0.7);
col = mix(col, vec3(0.10, 0.22, 0.32), (1.0 - smoothstep(1.0, 1.4, length((q - vec2(-1.5, 2.6)) / vec2(5.0, 3.0)))) * 0.25);
col = mix(col, vec3(0.98), 1.0 - smoothstep(0.9, 1.0, length(q / vec2(4.8, 1.7))));
float sail = max(abs(q.x + 0.4) - 2.6, abs(q.y + 1.2 - (q.x + 0.4) * 0.15) - 0.7);
col = mix(col, vec3(0.88, 0.86, 0.82), 1.0 - smoothstep(-0.2, 0.3, sail));
}
} else {
/* ── land: hillside, macchia, and the town down at the water ── */
float e = 1.2, h = ground(p);
vec3 n = normalize(vec3(-(ground(p + vec2(e, 0.0)) - h) * 17.0, -(ground(p + vec2(0.0, e)) - h) * 17.0, 1.0));
float shade = clamp(dot(n, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
float slope = 1.0 - n.z;
float veg = fbm(p * 0.03 + 2.0);
vec3 base = mix(vec3(0.78, 0.72, 0.53), vec3(0.50, 0.56, 0.33), smoothstep(0.42, 0.58, veg));
base = mix(base, vec3(0.36, 0.45, 0.25), smoothstep(0.6, 0.7, veg) * 0.8);
base *= 0.9 + 0.16 * noise(p * 1.4) + 0.06 * (hash(floor(p * 2.0)) - 0.5);
float shrub = smoothstep(0.42, 0.6, fbm(p * 0.022 + 11.0));
vec3 shT = tree(p - vec2(2.0, 2.4), 6.0);
base *= 1.0 - 0.34 * shT.x * shrub;
vec3 tr = tree(p, 6.0);
float lit = clamp(0.62 - dot(tr.yz, vec2(L.x, L.y)) * 0.55, 0.0, 1.0);
vec3 leaf = mix(vec3(0.16, 0.27, 0.14), vec3(0.45, 0.55, 0.30), lit) * (0.9 + 0.2 * noise(p * 2.3));
base = mix(base, leaf, tr.x * shrub * (1.0 - smoothstep(0.35, 0.6, slope)));
vec3 limestone = mix(vec3(0.70, 0.66, 0.58), vec3(0.88, 0.85, 0.78), noise(p * 0.7)) * (0.92 + 0.12 * noise(p * 3.1));
base = mix(base, limestone, smoothstep(0.3, 0.6, slope) * 0.75);
// the town: dense by the water, thinning out up the hill, house by house
vec3 roof, tmpRoof; vec2 hid, tmpId;
float occ = town(p, roof, hid);
float occS = town(p - vec2(2.4, 3.0), tmpRoof, tmpId);
float near = exp(d / 150.0);
float want = fbm(p * 0.011 + 3.0) * 0.75 * exp(d / 420.0) + near * 0.6;
float here = step(0.55 + 0.12 * hash(hid + 6.6), want);
float hereS = step(0.55 + 0.12 * hash(tmpId + 6.6), want);
// lanes are paved only in the dense heart; where no house stands the hillside shows
float dense = smoothstep(0.62, 0.7, want);
vec3 stone = vec3(0.87, 0.84, 0.77) * (0.95 + 0.07 * noise(p * 1.6));
vec3 open = roof.x < -0.5 ? mix(stone, vec3(0.48, 0.57, 0.34), step(0.5, hash(hid + 3.0))) : stone;
col = mix(base, open, dense);
if (occ * here > 0.5) col = roof;
else if (occS * hereS > 0.5) col *= 0.64;
// the hinterland: parcels with their own crop and row direction, hedges between
float farm = smoothstep(150.0, 200.0, -d) * (1.0 - smoothstep(450.0, 510.0, -d));
if (farm > 0.0) {
vec2 fp = (p + vec2(fbm(p * 0.015), fbm(p * 0.015 + 7.0)) * 10.0) * vec2(0.62, 1.0);
vec2 fc = floor(fp / 58.0);
float b1 = 99.0, b2 = 99.0; vec2 fid = vec2(0.0);
for (int fj = -1; fj <= 1; fj++) for (int fi = -1; fi <= 1; fi++) {
vec2 g = fc + vec2(float(fi), float(fj));
float dd = length(fp - (g + 0.25 + 0.5 * hash2(g)) * 58.0);
if (dd < b1) { b2 = b1; b1 = dd; fid = g; } else if (dd < b2) { b2 = dd; }
}
float kind = hash(fid + 1.7);
float ang = hash(fid + 4.2) * 3.1416;
vec2 dir = vec2(cos(ang), sin(ang));
float u = dot(p, dir), v = dot(p, vec2(-dir.y, dir.x));
vec3 parcel;
if (kind < 0.36) {
float r = smoothstep(-0.2, 0.6, sin(u * 6.2831 / 4.0));
vec3 vine = mix(vec3(0.24, 0.36, 0.16), vec3(0.44, 0.54, 0.25), noise(p * 1.4)) * (0.85 + 0.2 * noise(vec2(u * 0.3, v * 1.6)));
parcel = mix(vec3(0.67, 0.57, 0.42), vine, r);
} else if (kind < 0.56) {
parcel = mix(vec3(0.88, 0.78, 0.52), vec3(0.80, 0.68, 0.43), 0.5 + 0.5 * sin(u * 6.2831 / 2.4)) * (0.94 + 0.1 * noise(p * 0.5));
} else if (kind < 0.74) {
vec2 q = vec2(u, v) / 9.0;
vec2 qc = fract(q) - 0.5 + (hash2(floor(q)) - 0.5) * 0.18;
float olive = 1.0 - smoothstep(0.2, 0.33, length(qc));
float oshadow = 1.0 - smoothstep(0.2, 0.36, length(qc - vec2(0.12, 0.16)));
vec3 soil = vec3(0.76, 0.68, 0.52) * (1.0 - 0.28 * oshadow);
parcel = mix(soil, mix(vec3(0.36, 0.42, 0.28), vec3(0.56, 0.60, 0.42), clamp(0.6 - dot(qc, vec2(L.x, L.y)) * 1.4, 0.0, 1.0)), olive);
} else if (kind < 0.88) {
parcel = mix(vec3(0.50, 0.39, 0.28), vec3(0.63, 0.51, 0.37), 0.5 + 0.5 * sin(u * 6.2831 / 1.9)) * (0.95 + 0.08 * noise(p * 0.6));
} else {
parcel = mix(vec3(0.47, 0.57, 0.29), vec3(0.58, 0.63, 0.33), noise(p * 0.08));
}
// hedgerows and the odd cypress along the field edges
float edgeD = (b2 - b1) * 0.5;
parcel *= 0.92 + 0.16 * hash(fid + 5.5);
float hedge = 1.0 - smoothstep(0.5, 1.5, edgeD);
vec3 cy = tree(p, 5.0);
parcel = mix(parcel, vec3(0.30, 0.35, 0.20), hedge * 0.75);
parcel = mix(parcel, vec3(0.12, 0.20, 0.10), cy.x * (1.0 - smoothstep(1.5, 3.5, edgeD)) * step(0.7, hash(fid + 9.0)));
float level = 1.0 - smoothstep(0.38, 0.6, slope);
col = mix(col, parcel, farm * level * (1.0 - step(0.5, occ * here)));
// a country road winding through, a car on it
float ry = 425.0 + 26.0 * sin(p.x * 0.011 + 1.0) + 9.0 * sin(p.x * 0.029);
float rd = abs(p.y - ry);
col = mix(col, vec3(0.86, 0.82, 0.72), (1.0 - smoothstep(4.6, 5.6, rd)) * farm);
col = mix(col, vec3(0.36, 0.36, 0.37) * (0.95 + 0.08 * noise(p * 2.0)), (1.0 - smoothstep(3.4, 4.2, rd)) * farm);
col = mix(col, vec3(0.95), (1.0 - smoothstep(0.25, 0.6, rd)) * step(0.5, fract(p.x / 9.0)) * farm);
float cx = mod(t * 22.0 + 6.0 * sin(t * 0.4), 760.0) - 120.0;
float cyy = 425.0 + 26.0 * sin(cx * 0.011 + 1.0) + 9.0 * sin(cx * 0.029) - 1.6;
float slopeR = 26.0 * 0.011 * cos(cx * 0.011 + 1.0) + 9.0 * 0.029 * cos(cx * 0.029);
vec2 cq = p - vec2(cx, cyy);
float ca = atan(slopeR);
cq = vec2(cq.x * cos(ca) + cq.y * sin(ca), -cq.x * sin(ca) + cq.y * cos(ca));
float body = max(abs(cq.x) - 3.4, abs(cq.y) - 1.4);
col = mix(col, vec3(0.14, 0.14, 0.15), (1.0 - smoothstep(0.0, 1.4, max(abs(cq.x - 0.8) - 3.6, abs(cq.y - 0.9) - 1.6))) * 0.35);
col = mix(col, vec3(0.78, 0.16, 0.13), 1.0 - smoothstep(-0.2, 0.4, body));
col = mix(col, vec3(0.20, 0.24, 0.30), (1.0 - smoothstep(-0.2, 0.3, max(abs(cq.x - 0.9) - 1.0, abs(cq.y) - 1.0))));
}
col *= 0.64 + 0.5 * shade;
col *= 1.0 - 0.1 * smoothstep(0.55, 0.0, h);
// the high country: broad massifs, conifers in the valley, snow on the tops
float alpine = smoothstep(470.0, 590.0, -d);
if (alpine > 0.0) {
float lakeD = length((p - vec2(250.0, 790.0)) / vec2(2.3, 1.0));
float valley = mix(0.35, 1.0, smoothstep(26.0, 80.0, lakeD));
float hA = (peaks(p * 0.006 + 5.0) * 1.3 - 0.2 + fbm(p * 0.035) * 0.05) * valley;
float hX = (peaks((p + vec2(1.5, 0.0)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(1.5, 0.0)) * 0.035) * 0.05) * valley;
float hY = (peaks((p + vec2(0.0, 1.5)) * 0.006 + 5.0) * 1.3 - 0.2 + fbm((p + vec2(0.0, 1.5)) * 0.035) * 0.05) * valley;
vec3 nA = normalize(vec3(-(hX - hA) * 95.0, -(hY - hA) * 95.0, 1.0));
float shA = clamp(dot(nA, normalize(vec3(-L.x, -L.y, L.z))), 0.0, 1.0);
float slA = 1.0 - nA.z;
vec3 rockA = mix(vec3(0.46, 0.43, 0.39), vec3(0.62, 0.58, 0.52), noise(p * 0.05)) * (0.94 + 0.08 * noise(p * 0.9));
vec3 trA = tree(p, 4.4);
vec3 trS = tree(p - vec2(1.6, 2.0), 4.4);
float conifer = (1.0 - smoothstep(0.40, 0.50, hA + (noise(p * 0.06) - 0.5) * 0.08)) * (1.0 - smoothstep(0.5, 0.75, slA));
vec3 meadow = mix(vec3(0.42, 0.50, 0.28), vec3(0.52, 0.56, 0.32), noise(p * 0.1));
vec3 forestA = meadow * (1.0 - 0.4 * trS.x);
forestA = mix(forestA, mix(vec3(0.07, 0.15, 0.09), vec3(0.20, 0.33, 0.18), clamp(0.6 - dot(trA.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0)), trA.x * smoothstep(0.2, 0.5, fbm(p * 0.02 + 3.0) + 0.25));
vec3 alp = mix(rockA, forestA, conifer);
float snowA = smoothstep(0.75, 0.81, hA + (noise(p * 0.09) - 0.5) * 0.1) * (1.0 - smoothstep(0.55, 0.8, slA));
snowA = max(snowA, smoothstep(0.86, 0.94, hA));
alp *= 0.36 + 0.86 * shA;
alp = mix(alp * vec3(0.8, 0.87, 1.05), alp * vec3(1.04, 1.0, 0.95), smoothstep(0.35, 0.8, shA));
vec3 snowC = mix(vec3(0.70, 0.77, 0.88), vec3(0.98, 0.98, 0.97), smoothstep(0.2, 0.75, shA)) * (0.96 + 0.05 * noise(p * 0.6));
alp = mix(alp, snowC, snowA);
col = mix(col, alp, alpine);
}
// a mountain lake in a valley
vec2 lc = vec2(250.0, 790.0);
float ld = length((p - lc) / vec2(2.3, 1.0)) + (fbm(p * 0.05) - 0.5) * 14.0;
if (ld < 34.0) {
float lw = wave(p * 1.3, t * 0.6);
vec3 lake = mix(vec3(0.12, 0.36, 0.48), vec3(0.24, 0.55, 0.62), smoothstep(34.0, 12.0, ld));
lake *= 0.86 + 0.12 * lw;
lake += vec3(1.0, 0.98, 0.92) * pow(noise(p * 0.6 + t * 1.2), 14.0) * 0.7;
col = mix(col, lake, smoothstep(34.0, 31.0, ld));
col = mix(col, vec3(0.82, 0.80, 0.74), (1.0 - smoothstep(0.0, 1.8, abs(ld - 32.0))) * 0.8);
}
// the shore: small coves of sand between rocks, wet where the wash runs
float bw = 17.0 + 10.0 * fbm3(vec2(p.x * 0.03, 1.0));
float shore = 1.0 - smoothstep(bw - 3.0, bw + 2.0, -d);
float rock = smoothstep(0.56, 0.66, fbm(p * 0.05 + 4.0));
vec3 sand = vec3(0.93, 0.86, 0.71) + (hash(floor(p * 3.0)) - 0.5) * 0.06;
float wet = 1.0 - smoothstep(0.0, 6.0 + wash * 0.4, -d);
sand = mix(sand, vec3(0.74, 0.67, 0.55), wet * 0.8);
// boulders: round stones lit from the top left, darker in the cracks between
vec3 bo = tree(p, 4.5);
float blit = clamp(0.6 - dot(bo.yz, vec2(L.x, L.y)) * 0.6, 0.0, 1.0);
vec3 rocks = mix(vec3(0.30, 0.29, 0.27), mix(vec3(0.52, 0.50, 0.46), vec3(0.80, 0.77, 0.71), blit), bo.x);
vec3 edgeCol = mix(sand, rocks, rock);
col = mix(col, edgeCol, shore);
}
// haze into the page below: a long, uneven edge that drifts slowly
float H = uRes.y / uDpr;
float edge = H - 118.0 + 42.0 * (fbm3(vec2(sp.x * 0.011, t * 0.04)) - 0.5) * 2.0 + 22.0 * (fbm(sp * 0.028 + vec2(t * 0.05, 0.0)) - 0.5) * 2.0;
float mist = smoothstep(edge - 34.0, edge + 84.0, sp.y);
mist = clamp(mist + smoothstep(H - 24.0, H, sp.y), 0.0, 1.0);
col = mix(col, vec3(1.0, 0.98, 0.961), mist * uHaze);
gl_FragColor = vec4(col, 1.0);
}`;
const VERTEX = 'attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }';
export interface Scenery {
/** Glides the camera to another place; a still picture jumps there. */
flyTo(camera: Camera): void;
}
interface SceneryOptions {
/** The height the scene is framed for; the box may be taller or shorter. */
frame: number;
camera?: Camera;
/** The soft fade into the page at the foot of the picture; off beside a column. */
haze?: boolean;
}
const reducedMotion = (): boolean =>
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
export function startScenery(box: HTMLElement, opts: SceneryOptions): Scenery | null {
const canvas = document.createElement('canvas');
canvas.className = 'scenery';
canvas.setAttribute('aria-hidden', 'true');
box.prepend(canvas);
const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false, powerPreference: 'low-power' });
if (!gl) {
canvas.remove();
return null;
}
const compile = (type: number, src: string): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
return shader;
};
const program = gl.createProgram();
const vs = compile(gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
if (!program || !vs || !fs) {
canvas.remove();
return null;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
canvas.remove();
return null;
}
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, 'a');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uRes = gl.getUniformLocation(program, 'uRes');
const uDpr = gl.getUniformLocation(program, 'uDpr');
const uT = gl.getUniformLocation(program, 'uT');
const uCam = gl.getUniformLocation(program, 'uCam');
gl.uniform1f(gl.getUniformLocation(program, 'uHaze'), opts.haze === false ? 0 : 1);
// The picture's resolution: the screen's, capped at 2, scaled down when frames come slow.
let quality = 1;
let offset: [number, number] = [0, 0];
const resize = () => {
const cssWidth = box.clientWidth || FRAME_WIDTH;
const cssHeight = box.clientHeight || opts.frame;
const ratio = Math.min(2, Math.max(1, window.devicePixelRatio || 1)) * quality;
canvas.width = Math.max(1, Math.round(cssWidth * ratio));
canvas.height = Math.max(1, Math.round(cssHeight * ratio));
canvas.style.height = `${cssHeight}px`;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uDpr, canvas.width / cssWidth);
// The scene is framed for a phone: a bigger box shows more around the same
// centre, a smaller one (a small iPhone) the middle of it.
offset = [(cssWidth - FRAME_WIDTH) / 2, (cssHeight - opts.frame) / 2];
};
resize();
const still = reducedMotion();
const born = performance.now();
let camera: [number, number] = [opts.camera?.[0] ?? 0, opts.camera?.[1] ?? 0];
let flight: { from: [number, number]; to: Camera; start: number; duration: number } | null = null;
let running = false;
let lastFrame = 0;
let measured = 0;
let slowFrames = 0;
const draw = (now: number) => {
if (flight) {
const k = Math.min(1, (now - flight.start) / flight.duration);
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
camera = [
flight.from[0] + (flight.to[0] - flight.from[0]) * e,
flight.from[1] + (flight.to[1] - flight.from[1]) * e,
];
if (k >= 1) flight = null;
}
gl.uniform2f(uCam, camera[0] - offset[0], camera[1] - offset[1]);
gl.uniform1f(uT, still ? 4 : (now - born) / 1000 + 4);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
const frame = (now: number) => {
if (!canvas.isConnected || document.hidden) {
running = false;
return;
}
// A device that cannot keep up gets a smaller picture once, rather than a stutter.
if (lastFrame && quality === 1 && measured < 90) {
measured += 1;
if (now - lastFrame > 24) slowFrames += 1;
if (measured === 90 && slowFrames > 45) {
quality = 0.7;
resize();
}
}
lastFrame = now;
draw(now);
if (!still || flight) requestAnimationFrame(frame);
else running = false;
};
const run = () => {
if (running) return;
running = true;
lastFrame = 0;
requestAnimationFrame(frame);
};
run();
// The loop stops in the background (document.hidden) and starts again on return.
const wake = () => {
if (!document.hidden && canvas.isConnected) run();
};
document.addEventListener('visibilitychange', wake);
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => {
if (!canvas.isConnected) {
observer.disconnect();
document.removeEventListener('visibilitychange', wake);
return;
}
resize();
if (still) run();
});
observer.observe(box);
}
canvas.addEventListener('webglcontextlost', (event) => {
event.preventDefault();
canvas.remove();
});
return {
flyTo(to: Camera) {
if (still) {
camera = [to[0], to[1]];
run();
return;
}
const distance = Math.hypot(to[0] - camera[0], to[1] - camera[1]);
flight = {
from: [camera[0], camera[1]],
to,
start: performance.now(),
duration: Math.min(1700, 700 + distance * 1.1),
};
run();
},
};
}
+1212 -1102
View File
File diff suppressed because it is too large Load Diff
+23 -22
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "5.1.2", "version": "5.1.4",
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
@@ -18,6 +18,7 @@
"store:graphic": "node scripts/generate-feature-graphic.mjs", "store:graphic": "node scripts/generate-feature-graphic.mjs",
"store:check": "node scripts/check-store-metadata.mjs", "store:check": "node scripts/check-store-metadata.mjs",
"build": "vite build", "build": "vite build",
"postbuild": "node scripts/precompress.mjs",
"build:analyze": "vite build --mode analyze", "build:analyze": "vite build --mode analyze",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -52,15 +53,15 @@
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
}, },
"dependencies": { "dependencies": {
"@assistant-ui/react": "0.15.25", "@assistant-ui/react": "0.15.32",
"@capacitor/action-sheet": "^8.1.1", "@capacitor/action-sheet": "^8.1.1",
"@capacitor/android": "^8.5.0", "@capacitor/android": "^8.6.0",
"@capacitor/app": "^8.1.1", "@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0", "@capacitor/core": "^8.5.0",
"@capacitor/dialog": "^8.0.1", "@capacitor/dialog": "^8.0.1",
"@capacitor/filesystem": "^8.1.3", "@capacitor/filesystem": "^8.1.3",
"@capacitor/haptics": "^8.0.2", "@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.5.0", "@capacitor/ios": "^8.6.0",
"@capacitor/keyboard": "^8.0.5", "@capacitor/keyboard": "^8.0.5",
"@capacitor/share": "^8.0.1", "@capacitor/share": "^8.0.1",
"@capacitor/splash-screen": "^8.0.2", "@capacitor/splash-screen": "^8.0.2",
@@ -105,19 +106,19 @@
"@noble/hashes": "^1.8.0", "@noble/hashes": "^1.8.0",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@trek/shared": "*", "@trek/shared": "*",
"axios": "^1.20.0", "axios": "^1.21.0",
"dexie": "^4.4.6", "dexie": "^4.4.7",
"drag-drop-touch": "^1.3.1", "drag-drop-touch": "^1.3.1",
"heic-to": "^1.5.2", "heic-to": "^1.6.7",
"iso-3166-2": "^1.0.0", "iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0", "mapbox-gl": "^3.31.0",
"maplibre-gl": "6.15.0", "maplibre-gl": "6.16.0",
"modern-screenshot": "4.7.0", "modern-screenshot": "4.7.0",
"plyr": "^3.8.4", "plyr": "^3.8.5",
"react": "^19.3.0", "react": "^19.3.1",
"react-dom": "^19.3.0", "react-dom": "^19.3.1",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3", "react-leaflet-cluster": "^4.1.3",
@@ -131,41 +132,41 @@
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.5.0", "@capacitor/cli": "^8.6.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@jscpd/core": "^4.2.5", "@jscpd/core": "^4.2.5",
"@jscpd/tokenizer": "^4.2.6", "@jscpd/tokenizer": "^4.2.6",
"@playwright/test": "^1.63.0", "@playwright/test": "^1.65.0",
"@testing-library/jest-dom": "^6.10.0", "@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7", "@testing-library/user-event": "^14.6.8",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"@types/node": "^25.9.8", "@types/node": "^25.9.8",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.2.1",
"@vitest/coverage-v8": "^4.1.12", "@vitest/coverage-v8": "^4.1.12",
"autoprefixer": "^10.6.1", "autoprefixer": "^10.6.2",
"eslint": "^10.11.0", "eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0", "eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"msw": "^2.15.0", "msw": "^2.15.0",
"postcss": "^8.5.28", "postcss": "^8.5.28",
"prettier": "^3.9.8", "prettier": "^3.9.10",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.2",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.70.0", "typescript-eslint": "^8.72.1",
"vite": "8.3.5", "vite": "8.3.6",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^2.0.0",
"vitest": "^4.1.12" "vitest": "^4.1.12"
} }
} }
+43
View File
@@ -0,0 +1,43 @@
// The page /api/auth/native/start answers with runs this (see NativeAuthController.start).
// A browser that ran an older TREK keeps that version's app in its service worker, and
// the worker would answer the handoff page with an app that has no such page. So the
// worker goes first, and the handoff page loads from the server.
(function () {
var script = document.currentScript;
var target = script && script.getAttribute('data-target');
if (!target || target.indexOf('/native-handoff?') !== 0) return;
var gone = false;
var go = function () {
if (gone) return;
gone = true;
location.replace(target);
};
// Until the sign-in is handed back to the app, no worker is registered and no
// new version reloads the page (src/native/browserSignInFlag.ts): a tab still
// open on the older TREK keeps its worker alive, and either would bring it back.
try {
localStorage.setItem('trek_native_signin', String(Date.now()));
} catch (e) {
/* site data blocked: the worker is still taken off below */
}
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.getRegistrations) {
go();
return;
}
// Never stuck here: whatever the browser does with the workers, the page moves on.
var timer = setTimeout(go, 3000);
navigator.serviceWorker
.getRegistrations()
.then(function (registrations) {
return Promise.all(
registrations.map(function (registration) {
return registration.unregister();
})
);
})
.catch(function () {})
.then(function () {
clearTimeout(timer);
go();
});
})();
+18
View File
@@ -0,0 +1,18 @@
// Registers the service worker, as vite-plugin-pwa's own registerSW.js does; this
// file in public/ takes its place. Not while the app's sign-in runs in this browser
// (src/native/browserSignInFlag.ts): a tab still open on an older TREK keeps that
// version's worker alive, and registering now would bring it back to answer the
// sign-in's next page with an app that has no handoff page.
(function () {
if (!('serviceWorker' in navigator)) return;
try {
var started = Number(localStorage.getItem('trek_native_signin'));
var age = Date.now() - started;
if (started > 0 && age >= 0 && age < 15 * 60 * 1000) return;
} catch (e) {
/* site data blocked: register as usual */
}
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', { scope: '/' });
});
})();
+60
View File
@@ -0,0 +1,60 @@
/*
* The work behind scripts/precompress.mjs: a Brotli copy (<file>.br) beside every
* compressible build file under dist/assets.
*
* The server compresses a build file on every download otherwise, at the low level
* a request path can afford. A file under assets/ carries its content hash in its
* name and never changes, so it is compressed once here, at the level a build can
* afford, and the server hands that copy out as it is
* (server/src/nest/platform/precompressed-static.ts).
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync, constants } from 'node:zlib';
/** What the server's own compression would take; fonts, images and video are compressed formats already. */
const COMPRESSIBLE = /\.(?:m?js|css|svg|json|wasm|html|webmanifest)$/i;
/** The server leaves anything below 1 kB uncompressed too: the headers cost more than the saving. */
export const MIN_BYTES = 1024;
/** Quality 11 takes about ten times as long per byte as 9, so the largest chunks get 9. */
const BIG_BYTES = 1024 * 1024;
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else yield full;
}
}
/**
* Writes the .br copies under `dir` and returns what it did. A copy that would not be
* smaller than its file is not written, and a directory that does not exist is no work.
*/
export function precompress(dir) {
const result = { files: 0, before: 0, after: 0 };
let files;
try {
files = [...walk(dir)];
} catch (err) {
if (err.code === 'ENOENT') return result;
throw err;
}
for (const file of files) {
if (!COMPRESSIBLE.test(file)) continue;
const input = readFileSync(file);
if (input.length < MIN_BYTES) continue;
const output = brotliCompressSync(input, {
params: {
[constants.BROTLI_PARAM_QUALITY]: input.length > BIG_BYTES ? 9 : 11,
[constants.BROTLI_PARAM_SIZE_HINT]: input.length,
},
});
if (output.length >= input.length) continue;
writeFileSync(`${file}.br`, output);
result.files += 1;
result.before += input.length;
result.after += output.length;
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/*
* postbuild: writes a Brotli copy beside every compressible file under dist/assets,
* which the server hands to a browser that accepts Brotli instead of compressing
* the file again on each download. scripts/lib/precompress.mjs holds the work.
*/
import { fileURLToPath } from 'node:url';
import { precompress } from './lib/precompress.mjs';
const { files, before, after } = precompress(fileURLToPath(new URL('../dist/assets', import.meta.url)));
const mb = (bytes) => (bytes / 1048576).toFixed(1);
console.log(`precompress: ${files} files, ${mb(before)} MB -> ${mb(after)} MB (brotli)`);
+2 -2
View File
@@ -10,7 +10,7 @@
"src/components/Map/MapViewGL.tsx": 2126, "src/components/Map/MapViewGL.tsx": 2126,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929, "src/components/Planner/DayPlanSidebar.tsx": 2910,
"src/components/Planner/PlaceFormModal.test.tsx": 2836, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceInspector.tsx": 1287, "src/components/Planner/PlaceInspector.tsx": 1287,
"src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/TravelElements.tsx": 1559,
@@ -18,7 +18,7 @@
"src/index.css": 1636, "src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
"src/pages/JourneyDetailPage.test.tsx": 4028, "src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347, "src/pages/LoginPage.tsx": 1294,
"src/pages/TripPlannerPage.tsx": 1128, "src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1207, "src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
+1 -2
View File
@@ -42,12 +42,11 @@
"src/components/Map/MapViewGL.tsx": 1, "src/components/Map/MapViewGL.tsx": 1,
"src/components/Map/ReservationOverlay.test.tsx": 1, "src/components/Map/ReservationOverlay.test.tsx": 1,
"src/components/Map/transitGeometry.ts": 1, "src/components/Map/transitGeometry.ts": 1,
"src/components/Map/useDawarichTrailGL.test.ts": 4,
"src/components/Map/useMarkerThumbs.ts": 1, "src/components/Map/useMarkerThumbs.ts": 1,
"src/components/PDF/TripPDF.tsx": 40, "src/components/PDF/TripPDF.tsx": 40,
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2, "src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
"src/components/Planner/DayPlanSidebar.constants.ts": 1, "src/components/Planner/DayPlanSidebar.constants.ts": 1,
"src/components/Planner/DayPlanSidebar.tsx": 61, "src/components/Planner/DayPlanSidebar.tsx": 52,
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1, "src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3, "src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1, "src/components/Planner/PlaceFormModal.tsx": 1,
+2 -1
View File
@@ -294,7 +294,7 @@
"pages/JourneyDetailPage.tsx": 53, "pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64, "pages/JourneyPage.tsx": 64,
"pages/JourneyPublicPage.tsx": 113, "pages/JourneyPublicPage.tsx": 113,
"pages/LoginPage.tsx": 58, "pages/LoginPage.tsx": 54,
"pages/OAuthAuthorizePage.tsx": 33, "pages/OAuthAuthorizePage.tsx": 33,
"pages/PluginPage.tsx": 1, "pages/PluginPage.tsx": 1,
"pages/ResetPasswordPage.tsx": 23, "pages/ResetPasswordPage.tsx": 23,
@@ -302,6 +302,7 @@
"pages/VacayPage.tsx": 13, "pages/VacayPage.tsx": 13,
"pages/atlas/useAtlas.ts": 2, "pages/atlas/useAtlas.ts": 2,
"pages/collections/useCollections.ts": 1, "pages/collections/useCollections.ts": 1,
"pages/login/MfaCodeStep.tsx": 4,
"pages/sharedTrip/SharedChrome.tsx": 2, "pages/sharedTrip/SharedChrome.tsx": 2,
"pages/sharedTrip/SharedMap.tsx": 1, "pages/sharedTrip/SharedMap.tsx": 1,
"pages/tripPlanner/ListsContainer.tsx": 1, "pages/tripPlanner/ListsContainer.tsx": 1,
@@ -20,6 +20,7 @@
"components/Map/DawarichTrailLayer.tsx": 1, "components/Map/DawarichTrailLayer.tsx": 1,
"components/Map/dawarichTrail.ts": 10, "components/Map/dawarichTrail.ts": 10,
"components/Map/markerLook.ts": 1, "components/Map/markerLook.ts": 1,
"components/Map/poiCategories.ts": 2,
"components/Map/useDawarichTrailGL.ts": 1, "components/Map/useDawarichTrailGL.ts": 1,
"components/Packing/PackingBringersCard.tsx": 1, "components/Packing/PackingBringersCard.tsx": 1,
"components/Planner/DayReorderPopup.tsx": 1, "components/Planner/DayReorderPopup.tsx": 1,
+1 -1
View File
@@ -145,7 +145,7 @@ apiClient.interceptors.request.use(
) )
export function isAuthPublicPath(pathname: string): boolean { export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'] const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/native-handoff']
const publicPrefixes = ['/shared/', '/public/'] const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p)) return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
} }
@@ -3,10 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAdminUserActions } from './useAdminUserActions'; import { useAdminUserActions } from './useAdminUserActions';
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-007 // FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-009
const api = vi.hoisted(() => ({ rotateJwtSecret: vi.fn(), resetUserPasskeys: vi.fn(), resetUserMfa: vi.fn() })); const api = vi.hoisted(() => ({
vi.mock('../../api/client', () => ({ adminApi: api })); rotateJwtSecret: vi.fn(),
resetUserPasskeys: vi.fn(),
resetUserMfa: vi.fn(),
}));
const http = vi.hoisted(() => ({ delete: vi.fn() }));
vi.mock('../../api/client', () => ({ adminApi: api, apiClient: http }));
const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key); const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key);
@@ -28,6 +33,7 @@ beforeEach(() => {
api.rotateJwtSecret.mockReset().mockResolvedValue({}); api.rotateJwtSecret.mockReset().mockResolvedValue({});
api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 }); api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 });
api.resetUserMfa.mockReset().mockResolvedValue({ success: true }); api.resetUserMfa.mockReset().mockResolvedValue({ success: true });
http.delete.mockReset().mockResolvedValue({ data: { success: true, unlinked: true } });
}); });
describe('useAdminUserActions', () => { describe('useAdminUserActions', () => {
@@ -101,4 +107,26 @@ describe('useAdminUserActions', () => {
expect(admin.toast.error).toHaveBeenCalledWith('common.error'); expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(ok).toBe(false); expect(ok).toBe(false);
}); });
it('FE-HOOK-ADMUSERACT-008: removing the SSO link calls the API, toasts and reports success', async () => {
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.unlinkSso({ id: 11 });
});
expect(http.delete).toHaveBeenCalledWith('/admin/users/11/sso');
expect(admin.toast.success).toHaveBeenCalledWith('admin.sso.unlinkDone');
expect(ok).toBe(true);
});
it('FE-HOOK-ADMUSERACT-009: a failed SSO unlink toasts and reports failure', async () => {
http.delete.mockRejectedValue(new Error('x'));
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.unlinkSso({ id: 11 });
});
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(ok).toBe(false);
});
}); });
@@ -1,4 +1,4 @@
import { adminApi } from '../../api/client'; import { adminApi, apiClient } from '../../api/client';
import type { TranslationFn } from '../../types'; import type { TranslationFn } from '../../types';
interface UserActionsHost { interface UserActionsHost {
@@ -12,8 +12,8 @@ interface UserActionsHost {
/** /**
* The irreversible user actions behind the admin dialogs and their phone sheets: * The irreversible user actions behind the admin dialogs and their phone sheets:
* rotating the JWT secret, which signs everyone out including the admin, removing a * rotating the JWT secret, which signs everyone out including the admin, removing a
* user's passkeys and switching off a user's two-factor login. Each shell keeps its own * user's passkeys, switching off a user's two-factor login and removing a user's SSO
* confirm step and busy state around them. * link. Each shell keeps its own confirm step and busy state around them.
*/ */
export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) { export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin; const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin;
@@ -55,5 +55,17 @@ export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) {
} }
}; };
return { rotateJwt, resetPasskeys, resetMfa }; /** Forgets the user's SSO identity so the next SSO sign-in links again (#3065); true when it worked. */
const unlinkSso = async (user: { id: number }): Promise<boolean> => {
try {
await apiClient.delete(`/admin/users/${user.id}/sso`);
toast.success(t('admin.sso.unlinkDone'));
return true;
} catch {
toast.error(t('common.error'));
return false;
}
};
return { rotateJwt, resetPasskeys, resetMfa, unlinkSso };
} }
@@ -12,6 +12,7 @@ import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render'; import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
import CollectionMap from './CollectionMap'; import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({ vi.mock('../../hooks/useGeolocation', () => ({
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true); expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
}); });
}); });
describe('CollectionMap pins (#3073)', () => {
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
category_name: null,
category_color: null,
category_icon: null,
});
});
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
const { view } = await renderMap([
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
]);
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
expect(html.toLowerCase()).toContain('#7c3aed');
});
});
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto' import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared' import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel' import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl' import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults' import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport' import type { ViewportPadding } from '../../utils/mapViewport'
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
* while the map is built rather than flying in. * while the map is built rather than flying in.
*/ */
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement { export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places]) const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON) const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts]) const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a // The places as a set: another list, a filter or a new place frames again, while a
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared' import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import { textMatchesSearch } from '../../utils/placeSearch'
import { fs } from '../shared/DialogShell' import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, LABEL } from '../shared/dialogParts' import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts' import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
const [shown, setShown] = useState(PAGE) const [shown, setShown] = useState(PAGE)
const visible = useMemo(() => { const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = places.filter(p => { const list = places.filter(p => {
if (listFilter !== 'all' && p.collection_id !== listFilter) return false if (listFilter !== 'all' && p.collection_id !== listFilter) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (!q) return true return textMatchesSearch([p.name, p.address], search)
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
}) })
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center)) if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
else list.sort((a, b) => a.name.localeCompare(b.name)) else list.sort((a, b) => a.name.localeCompare(b.name))
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import { textMatchesSearch } from '../../utils/placeSearch'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { Category, TranslationFn } from '../../types' import type { Category, TranslationFn } from '../../types'
import type { CollectionImportablePlace, Trip } from '@trek/shared' import type { CollectionImportablePlace, Trip } from '@trek/shared'
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
const savedCount = items.length - newCount const savedCount = items.length - newCount
const visible = useMemo(() => { const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items return items
.filter(p => (onlyNew ? !p.already_in_list : true)) .filter(p => (onlyNew ? !p.already_in_list : true))
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)) .filter(p => textMatchesSearch([p.name, p.address], query))
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name)) .sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
}, [items, onlyNew, query]) }, [items, onlyNew, query])
@@ -20,6 +20,7 @@ import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline'; import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
import { import {
buildSnapshot, buildSnapshot,
liveDayDue,
pickTrip, pickTrip,
publishWidgets, publishWidgets,
todayOf, todayOf,
@@ -207,9 +208,10 @@ export default function NativeWidgetsSync(): null {
}); });
} }
if (cancelled) return; if (cancelled) return;
const live = main.day // The Live Activity from three hours before the day's first timed stop, not all day long.
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) const minutes = now.getHours() * 60 + now.getMinutes();
: null; const live =
main.day && liveDayDue(main.day, minutes) ? tripDayState(main.day, minutes, t('native.widget.dayDone')) : null;
await publishWidgets(main.snapshot, live, choices); await publishWidgets(main.snapshot, live, choices);
await publishToWatch(hero, main, today); await publishToWatch(hero, main, today);
}; };
@@ -1,11 +1,13 @@
// FE-W4NAVH-001 to FE-W4NAVH-005 // FE-W4NAVH-001 to FE-W4NAVH-008
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { CalendarDays, Globe, LayoutGrid } from 'lucide-react' import { CalendarDays, Globe, LayoutGrid, Sparkles } from 'lucide-react'
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext' import { TranslationProvider } from '../../i18n/TranslationContext'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { useNavItems } from './navItems' import { useAssistantStore } from '../../store/assistantStore'
import type { AiStatusResponse } from '@trek/shared'
import { useDockNavItems, useNavItems } from './navItems'
function wrapper({ children }: { children: React.ReactNode }) { function wrapper({ children }: { children: React.ReactNode }) {
return <TranslationProvider>{children}</TranslationProvider> return <TranslationProvider>{children}</TranslationProvider>
@@ -16,6 +18,7 @@ const render = () => renderHook(() => useNavItems(), { wrapper })
beforeEach(() => { beforeEach(() => {
useAddonStore.setState({ addons: [] }) useAddonStore.setState({ addons: [] })
usePluginStore.setState({ plugins: [] }) usePluginStore.setState({ plugins: [] })
useAssistantStore.setState({ status: null })
}) })
describe('useNavItems', () => { describe('useNavItems', () => {
@@ -77,3 +80,33 @@ describe('useNavItems', () => {
expect(result.current).toBe(first) expect(result.current).toBe(first)
}) })
}) })
describe('useDockNavItems', () => {
const renderDock = () => renderHook(() => useDockNavItems(), { wrapper })
const assistantAddon = (enabled: boolean) =>
useAddonStore.setState({ addons: [{ id: 'ai_assistant', name: 'AI assistant', icon: 'Sparkles', type: 'integration', enabled }] as never })
it('FE-W4NAVH-006: adds the assistant after the pages while its addon is on and it can run', () => {
assistantAddon(true)
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
const { result } = renderDock()
expect(result.current.map(i => i.id)).toEqual(['dashboard', 'assistant'])
expect(result.current[1]).toMatchObject({ to: '', label: 'Assistant', icon: Sparkles })
})
it('FE-W4NAVH-007: leaves it out while the addon is off', () => {
assistantAddon(false)
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
})
it('FE-W4NAVH-008: leaves it out while it cannot run for this user', () => {
assistantAddon(true)
useAssistantStore.setState({ status: { available: false } as AiStatusResponse })
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
})
})
+33 -5
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, type LucideIcon } from 'lucide-react' import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Sparkles, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useAssistantStore } from '../../store/assistantStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { resolvePluginIcon } from '../shared/PluginIcon' import { resolvePluginIcon } from '../shared/PluginIcon'
@@ -12,16 +13,21 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
* *
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global * Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a * addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a
* page plugin. Dashboard is always first and immovable. * page plugin. Dashboard is always first and immovable. The mobile dock adds
* `'assistant'` (see useDockNavItems).
*/ */
export interface NavItemDef { export interface NavItemDef {
id: string id: string
/** The page it opens; empty for the assistant, which opens the home chat instead. */
to: string to: string
label: string label: string
icon: LucideIcon icon: LucideIcon
pinned?: boolean pinned?: boolean
} }
/** The home assistant's entry in the mobile dock: a chat it opens, not a page. */
export const ASSISTANT_NAV_ID = 'assistant'
// Signature glyphs for the four catalog global addons, keyed by id so the known // Signature glyphs for the four catalog global addons, keyed by id so the known
// nav items keep their icon regardless of the (string) icon the addon row ships; // nav items keep their icon regardless of the (string) icon the addon row ships;
// any future/unknown global addon resolves through its manifest icon name. // any future/unknown global addon resolves through its manifest icon name.
@@ -33,7 +39,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
} }
/** The bar holds Dashboard + at most this many custom items; the rest go to More. /** The bar holds Dashboard + at most this many custom items; the rest go to More.
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */ * Two keeps the dock at 3 entries (Dashboard + 2) so the More slot still fits. */
export const MOBILE_NAV_MAX_BAR = 2 export const MOBILE_NAV_MAX_BAR = 2
/** /**
* The built-in dock next to Dashboard for an un-customised account, in priority * The built-in dock next to Dashboard for an un-customised account, in priority
@@ -44,9 +50,11 @@ export const MOBILE_NAV_MAX_BAR = 2
* to every day, not the map of countries it already visited. Journey ships * to every day, not the map of countries it already visited. Journey ships
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas * disabled though, so an instance that never turned it on keeps the Vacay/Atlas
* dock it has today. Listing all four known global addons rather than just the * dock it has today. Listing all four known global addons rather than just the
* top two also keeps the dock filled when one of them is switched off. * top two also keeps the dock filled when one of them is switched off. The
* assistant comes last: it takes a slot only one of the addons left free, rather
* than sitting alone behind the More button.
*/ */
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections'] export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections', ASSISTANT_NAV_ID]
export function buildNavItems( export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[], globalAddons: { id: string; name: string; icon: string }[],
@@ -90,6 +98,26 @@ export function useNavItems(): NavItemDef[] {
) )
} }
/**
* The entries the mobile dock arranges: the nav items, and the home assistant
* while its addon is on and it can run for this user. The settings customizer
* sorts the same list, so the assistant can go into the bar or under More.
*/
export function useDockNavItems(): NavItemDef[] {
const { t } = useTranslation()
const items = useNavItems()
const addonOn = useAddonStore((s) => s.isEnabled('ai_assistant'))
const available = useAssistantStore((s) => s.status?.available === true)
const withAssistant = addonOn && available
return useMemo(
() =>
withAssistant
? [...items, { id: ASSISTANT_NAV_ID, to: '', label: t('assistant.launcher.tooltip'), icon: Sparkles }]
: items,
[items, withAssistant, t],
)
}
export interface MobileNavSplit { export interface MobileNavSplit {
/** Items shown directly in the dock, Dashboard first. */ /** Items shown directly in the dock, Dashboard first. */
bar: NavItemDef[] bar: NavItemDef[]
@@ -122,8 +122,8 @@ describe('DawarichTrailLayer', () => {
const [casing, line] = lines() const [casing, line] = lines()
expect(casing.getAttribute('data-pane')).toBe('trek-track-casing') expect(casing.getAttribute('data-pane')).toBe('trek-track-casing')
expect(casing.getAttribute('data-weight')).toBe('6') expect(casing.getAttribute('data-weight')).toBe('7')
expect(casing.getAttribute('data-opacity')).toBe('0.55') expect(casing.getAttribute('data-opacity')).toBe('0.9')
// Only the casing takes the pane; the coloured line is left in Leaflet's // Only the casing takes the pane; the coloured line is left in Leaflet's
// default overlay pane. Pinned as the fact it is rather than as a rule: // default overlay pane. Pinned as the fact it is rather than as a rule:
@@ -131,8 +131,9 @@ describe('DawarichTrailLayer', () => {
// it, so the recorded line currently draws over the plan while the GL twin // it, so the recorded line currently draws over the plan while the GL twin
// puts both of its layers under it. // puts both of its layers under it.
expect(line.getAttribute('data-pane')).toBe('') expect(line.getAttribute('data-pane')).toBe('')
expect(line.getAttribute('data-weight')).toBe('3') // Full colour, wide and long-dashed, so it reads on a busy street map.
expect(line.getAttribute('data-dash')).toBe('6 5') expect(line.getAttribute('data-weight')).toBe('4')
expect(line.getAttribute('data-dash')).toBe('10 5')
expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]])) expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]]))
expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true) expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true)
@@ -5,6 +5,7 @@ import { frameLine } from '../../utils/lngFrame'
import type { DawarichTrack } from '@trek/shared' import type { DawarichTrack } from '@trek/shared'
import { import {
DAWARICH_TRAIL_CASING, DAWARICH_TRAIL_CASING,
DAWARICH_TRAIL_STROKE,
trailSegments, trailSegments,
type DawarichTrailSegment, type DawarichTrailSegment,
} from './dawarichTrail' } from './dawarichTrail'
@@ -55,8 +56,8 @@ export default function DawarichTrailLayer({
// theme-lint-disable — map paint // theme-lint-disable — map paint
pathOptions={{ pathOptions={{
color: DAWARICH_TRAIL_CASING, color: DAWARICH_TRAIL_CASING,
weight: 6, weight: DAWARICH_TRAIL_STROKE.casingWidth,
opacity: 0.55, opacity: DAWARICH_TRAIL_STROKE.casingOpacity,
lineCap: 'round', lineCap: 'round',
lineJoin: 'round', lineJoin: 'round',
}} }}
@@ -72,9 +73,9 @@ export default function DawarichTrailLayer({
positions={segment.points} positions={segment.points}
pathOptions={{ pathOptions={{
color: segment.color, color: segment.color,
weight: 3, weight: DAWARICH_TRAIL_STROKE.width,
opacity: 0.85, opacity: DAWARICH_TRAIL_STROKE.opacity,
dashArray: '6 5', dashArray: DAWARICH_TRAIL_STROKE.dashLeaflet,
lineCap: 'round', lineCap: 'round',
lineJoin: 'round', lineJoin: 'round',
}} }}
@@ -27,6 +27,22 @@ export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map pain
/** The white casing under the line, so it stays visible over a satellite tile. */ /** The white casing under the line, so it stays visible over a satellite tile. */
export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
/**
* How the recorded route is stroked, shared by the Leaflet and GL layers. A
* thin, translucent dash over a nearly opaque white casing read as a pale
* lavender trace on a busy street map and was barely visible (TestFlight
* feedback, 5.1.3). Full colour, a wider line and longer dashes keep it
* readable; the dash still tells it apart from the planned route.
*/
export const DAWARICH_TRAIL_STROKE = {
casingWidth: 7,
casingOpacity: 0.9,
width: 4,
opacity: 1,
dashLeaflet: '10 5',
dashGL: [2.5, 1.25] as [number, number],
} as const
/** /**
* Per-day hues, cycled. * Per-day hues, cycled.
* *
+7 -1
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, type LucideIcon } from 'lucide-react' import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, Croissant, ShoppingBasket, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared' import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared' import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the // The road-trip kinds take their colour from the one place that defines it, so the
@@ -29,6 +29,9 @@ export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' }, { key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' }, { key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' }, { key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
// Where to stock up: the bread for the day, and a supermarket or village shop.
{ key: 'bakery', labelKey: 'poi.cat.bakeries', Icon: Croissant, color: '#D97706' }, // theme-lint-disable: marker and pill paint, as the core pins above
{ key: 'supermarket', labelKey: 'poi.cat.groceries', Icon: ShoppingBasket, color: '#0D9488' }, // theme-lint-disable: marker and pill paint, as the core pins above
] ]
/** /**
@@ -70,6 +73,9 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') }, market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
// BIK-07: the road trip's charging colour, so a charger reads as one on every map. // BIK-07: the road trip's charging colour, so a charger reads as one on every map.
ebike_charging: { key: 'ebike_charging', labelKey: 'tours.poi.cat.ebike_charging', Icon: BatteryCharging, color: SERVICE_COLORS.charging }, ebike_charging: { key: 'ebike_charging', labelKey: 'tours.poi.cat.ebike_charging', Icon: BatteryCharging, color: SERVICE_COLORS.charging },
// Resupply on a multi-day hike, in the explore pill's own looks.
bakery: { key: 'bakery', labelKey: 'tours.poi.cat.bakery', Icon: Croissant, color: coreColor('bakery') },
supermarket: { key: 'supermarket', labelKey: 'tours.poi.cat.supermarket', Icon: ShoppingBasket, color: coreColor('supermarket') },
} }
// A Map rather than an object, so a category named after an Object.prototype member // A Map rather than an object, so a category named after an Object.prototype member
@@ -180,16 +180,23 @@ describe('useDawarichTrailGL', () => {
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' })) expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }))
const [casingCall, lineCall] = gl.spies.addLayer.mock.calls const [casingCall, lineCall] = gl.spies.addLayer.mock.calls
expect(casingCall[0].id).toBe(CASING_LAYER) if (!casingCall || !lineCall) throw new Error('expected the casing and the line layer')
expect(casingCall[0].paint['line-color']).toBe(DAWARICH_TRAIL_CASING) type LayerSpec = { id: string; paint: Record<string, unknown> }
expect(casingCall[0].paint['line-width']).toBe(6) const casing = casingCall[0] as LayerSpec
expect(lineCall[0].id).toBe(LINE_LAYER) const line = lineCall[0] as LayerSpec
expect(casing.id).toBe(CASING_LAYER)
expect(casing.paint['line-color']).toBe(DAWARICH_TRAIL_CASING)
expect(casing.paint['line-width']).toBe(7)
expect(casing.paint['line-opacity']).toBe(0.9)
expect(line.id).toBe(LINE_LAYER)
// Per-feature colour, with the casing white as the fallback a feature // Per-feature colour, with the casing white as the fallback a feature
// without one falls back to. The GPX layer uses the same shape. // without one falls back to. The GPX layer uses the same shape.
expect(lineCall[0].paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING]) expect(line.paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING])
// Dashed, so the recording is distinguishable from the planned route for // Dashed, so the recording is distinguishable from the planned route for
// someone who cannot tell the two hues apart. // someone who cannot tell the two hues apart.
expect(lineCall[0].paint['line-dasharray']).toEqual([2, 1.6]) expect(line.paint['line-dasharray']).toEqual([2.5, 1.25])
expect(line.paint['line-width']).toBe(4)
expect(line.paint['line-opacity']).toBe(1)
// Both go beneath the planned route's casing. The line is inserted second // Both go beneath the planned route's casing. The line is inserted second
// and therefore ends up above its own casing and still below the plan. // and therefore ends up above its own casing and still below the plan.
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import type { Map } from 'mapbox-gl' import type { Map } from 'mapbox-gl'
import type { GeoJSONSource } from 'mapbox-gl' import type { GeoJSONSource } from 'mapbox-gl'
import type { DawarichTrack } from '@trek/shared' import type { DawarichTrack } from '@trek/shared'
import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail' import { DAWARICH_TRAIL_CASING, DAWARICH_TRAIL_STROKE, trailGeoJson, trailSegments } from './dawarichTrail'
/** /**
* The recorded route on the GL renderer — the imperative twin of * The recorded route on the GL renderer — the imperative twin of
@@ -67,7 +67,11 @@ export function useDawarichTrailGL(
source: SOURCE, source: SOURCE,
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
// theme-lint-disable — map paint // theme-lint-disable — map paint
paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 }, paint: {
'line-color': DAWARICH_TRAIL_CASING,
'line-width': DAWARICH_TRAIL_STROKE.casingWidth,
'line-opacity': DAWARICH_TRAIL_STROKE.casingOpacity,
},
}, },
anchor, anchor,
) )
@@ -81,11 +85,11 @@ export function useDawarichTrailGL(
// Per-day colour off the feature, with the same fallback the GPX // Per-day colour off the feature, with the same fallback the GPX
// layer uses when a feature somehow carries none. // layer uses when a feature somehow carries none.
'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING], 'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
'line-width': 3, 'line-width': DAWARICH_TRAIL_STROKE.width,
'line-opacity': 0.85, 'line-opacity': DAWARICH_TRAIL_STROKE.opacity,
// Dashed, so the recording is distinguishable from the planned // Dashed, so the recording is distinguishable from the planned
// route without relying on colour alone. // route without relying on colour alone.
'line-dasharray': [2, 1.6], 'line-dasharray': [...DAWARICH_TRAIL_STROKE.dashGL],
}, },
}, },
anchor, anchor,
@@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende
import { WeightInput } from './PackingListPanelWeightInput'; import { WeightInput } from './PackingListPanelWeightInput';
/** A row whose stored weight changes only once a save answers, as the store does. */ /** A row whose stored weight changes only once a save answers, as the store does. */
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) { function SlowRow({ onSave, onStored }: { onSave: (grams: number | null) => void; onStored: () => void }) {
const [stored, setStored] = useState<number | null>(null); const [stored, setStored] = useState<number | null>(null);
return ( return (
<WeightInput <WeightInput
@@ -13,7 +13,10 @@ function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
width={36} width={36}
onSave={(grams) => { onSave={(grams) => {
onSave(grams); onSave(grams);
setTimeout(() => setStored(grams), 300); setTimeout(() => {
setStored(grams);
onStored();
}, 300);
}} }}
/> />
); );
@@ -22,7 +25,8 @@ function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
describe('WeightInput', () => { describe('WeightInput', () => {
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => { it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
const onSave = vi.fn(); const onSave = vi.fn();
render(<SlowRow onSave={onSave} />); const onStored = vi.fn();
render(<SlowRow onSave={onSave} onStored={onStored} />);
const input = screen.getByRole('textbox') as HTMLInputElement; const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.focus(input); fireEvent.focus(input);
@@ -35,7 +39,9 @@ describe('WeightInput', () => {
fireEvent.blur(input); fireEvent.blur(input);
expect(onSave).toHaveBeenCalledTimes(1); expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(250); expect(onSave).toHaveBeenCalledWith(250);
await waitFor(() => expect(input.value).toBe('250')); // Wait for the save to answer, so its update lands while the test still runs.
await waitFor(() => expect(onStored).toHaveBeenCalled());
expect(input.value).toBe('250');
}); });
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => { it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
@@ -254,3 +254,57 @@ describe('DayPlanSidebar timed booking drop', () => {
expect(onReorder).toHaveBeenCalledWith(10, [11, 12, 13, 14]); expect(onReorder).toHaveBeenCalledWith(10, [11, 12, 13, 14]);
}); });
}); });
// #3041: Firefox sends dragstart while it handles the mouse move that starts the
// drag, and delivers that move to the page right after. Taken as "the drag is over",
// it dropped the drag data at pick-up, so a stop could only be moved by its arrows,
// and the day list played its enter animation again.
describe('DayPlanSidebar drag in Firefox', () => {
const renderDay = () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
const stops = [buildPlace({ id: 1, name: 'Breakfast' }), buildPlace({ id: 2, name: 'Museum' })];
const assignments = {
'10': stops.map((place, i) => buildAssignment({ id: 11 + i, day_id: 10, order_index: i, place })),
};
const onReorder = vi.fn(async () => undefined);
const view = render(
<DayPlanSidebar {...makeDefaultProps({ days: [day], places: stops, assignments, onReorder })} />
);
const row = (text: string) => screen.getByText(text).closest('[draggable="true"]') as HTMLElement;
const list = view.container.querySelector('.scroll-container') as HTMLElement;
return { onReorder, row, list };
};
const dataTransfer = () => ({ setData: vi.fn(), effectAllowed: '', getData: vi.fn(() => '') });
it('FE-PLANNER-DAYPLAN-239: the mouse move that starts a drag, with the button still held, keeps the drag', async () => {
const { onReorder, row } = renderDay();
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
// Firefox order: dragstart first, then the move that triggered it. The touch
// polyfill on hybrid laptops sends the same held-button moves throughout a drag.
fireEvent.mouseMove(document, { buttons: 1 });
expect(row('Museum').style.opacity).toBe('0.4');
fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() });
expect(row('Breakfast').style.borderTop).toContain('2px');
fireEvent.drop(row('Breakfast'), { dataTransfer: dataTransfer() });
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11]));
});
it('FE-PLANNER-DAYPLAN-240: a plain mouse move after a drag that never ended still clears it', () => {
const { row } = renderDay();
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() });
fireEvent.mouseMove(document, { buttons: 0 });
expect(row('Museum').style.opacity).toBe('1');
expect(row('Breakfast').style.borderTop).toBe('');
});
it('FE-PLANNER-DAYPLAN-241: the day list plays its enter animation once, not again when a drag ends', () => {
const { row, list } = renderDay();
expect(list.classList.contains('trek-stagger')).toBe(true);
fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() });
expect(list.classList.contains('trek-stagger')).toBe(false);
fireEvent.dragEnd(row('Museum'));
expect(row('Museum').style.opacity).toBe('1');
expect(list.classList.contains('trek-stagger')).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
import type { PlannedTransitLeg } from './transitLeg' import type { PlannedTransitLeg } from './transitLeg'
import { useTransitLegPlanner } from './useTransitLegPlanner' import { useTransitLegPlanner } from './useTransitLegPlanner'
import { useDayPlanDragState } from './useDayPlanDragState'
import { WalkLateLine } from '../Tours/walk/WalkTransitNote' import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
import { TourOverrunLine } from '../Tours/TourDayTimeParts' import { TourOverrunLine } from '../Tours/TourDayTimeParts'
import { dayItemOverrun } from '../Tours/tourDayTime' import { dayItemOverrun } from '../Tours/tourDayTime'
@@ -246,12 +247,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// distances refresh instead of showing stale cached text (#1300). // distances refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const legsAbortRef = useRef<AbortController | null>(null) const legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set()) const [lockedIds, setLockedIds] = useState(new Set())
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null) const [transportDetail, setTransportDetail] = useState(null)
const [transportPosVersion, setTransportPosVersion] = useState(0) const [transportPosVersion, setTransportPosVersion] = useState(0)
@@ -268,6 +264,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null; fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
} | null>(null) } | null>(null)
const dragDataRef = useRef(null) const dragDataRef = useRef(null)
const {
draggingId, setDraggingId, hasDragged, dropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId,
} = useDayPlanDragState(dragDataRef)
const scrollContainerRef = useRef<HTMLDivElement | null>(null) const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const dayRefs = useRef<Map<number, HTMLElement>>(new Map()) const dayRefs = useRef<Map<number, HTMLElement>>(new Map())
@@ -374,28 +373,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
} }
document.addEventListener('dragend', cleanup) document.addEventListener('dragend', cleanup)
return () => document.removeEventListener('dragend', cleanup) return () => document.removeEventListener('dragend', cleanup)
}, []) }, [setDraggingId, setDropTargetKey, setDragOverDayId])
// A drag whose source row re-rendered away never sends its dragend to the
// document, and the drop line then stayed on a row until the next reload. The
// browser sends no mousemove during a drag, so the first plain mouse move or
// press after one means it is over.
useEffect(() => {
if (!draggingId && !dropTargetKey && !dragOverDayId) return
const settle = () => {
setDraggingId(null)
setDropTargetKey(null)
setDragOverDayId(null)
dragDataRef.current = null
window.__dragData = null
}
document.addEventListener('mousemove', settle)
document.addEventListener('pointerdown', settle, true)
return () => {
document.removeEventListener('mousemove', settle)
document.removeEventListener('pointerdown', settle, true)
}
}, [draggingId, dropTargetKey, dragOverDayId])
// Initialize missing transport positions outside of render to avoid setState-during-render // Initialize missing transport positions outside of render to avoid setState-during-render
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1148,6 +1126,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
hotelLegs, hotelLegs,
draggingId, draggingId,
setDraggingId, setDraggingId,
hasDragged,
lockedIds, lockedIds,
dropTargetKey, dropTargetKey,
dropTargetRef, dropTargetRef,
@@ -1316,6 +1295,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
hotelLegs, hotelLegs,
draggingId, draggingId,
setDraggingId, setDraggingId,
hasDragged,
lockedIds, lockedIds,
dropTargetKey, dropTargetKey,
dropTargetRef, dropTargetRef,
@@ -1476,7 +1456,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
/> />
{/* Tagesliste */} {/* Tagesliste */}
<div className={`scroll-container flex flex-col gap-2 p-2${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className={`scroll-container flex flex-col gap-2 p-2${hasDragged ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{days.map((day, index) => { {days.map((day, index) => {
const isSelected = selectedDayId === day.id const isSelected = selectedDayId === day.id
// Shared by the click and the key handler so the two can never drift // Shared by the click and the key handler so the two can never drift
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import { MapPin, Search } from 'lucide-react' import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { textMatchesSearch } from '../../utils/placeSearch'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias' import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions' import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) { function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) ? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
: results : results
return { showPicks, rows } return { showPicks, rows }
} }
@@ -0,0 +1,59 @@
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { collectionsApi } from '../../api/collections';
import { useAddonStore } from '../../store/addonStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal with a place from a collection', () => {
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
loaded: true,
});
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
incomingInvites: [],
});
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
places: [
{
id: 10,
collection_id: 1,
name: 'Zebra Cafe',
status: 'idea',
lat: 35.66,
lng: 139.7,
category_id: 7,
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
},
],
});
const onSave = vi.fn().mockResolvedValue(undefined);
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
const categories = [
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
];
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
fireEvent.click(await screen.findByText('Zebra Cafe'));
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
});
});
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
return { lat, lng } return { lat, lng }
} }
/**
* The category a picked place already carries, as the form's value, when this server
* has it. A place saved in a collection keeps its category_id, and categories are the
* server's own, so it fits the trip as it stands. Guessing from the place's kind
* instead dropped it (#3074).
*/
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
const id = result.category_id
if (typeof id !== 'number') return null
return categories.some(c => c.id === id) ? String(id) : null
}
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared' import type { PlaceOpeningHours } from '@trek/shared'
import { import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload, DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
prefillForm, type PlaceFormData, type PlacePrefill, prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
} from './PlaceFormModal.helpers' } from './PlaceFormModal.helpers'
import { useNewCategory, usePlaceForm } from './usePlaceForm' import { useNewCategory, usePlaceForm } from './usePlaceForm'
import { usePlaceSearch } from './usePlaceSearch' import { usePlaceSearch } from './usePlaceSearch'
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// the next pick may replace it and a hand choice is never overwritten (#2282). // the next pick may replace it and a hand choice is never overwritten (#2282).
const categoryGuessedRef = useRef(false) const categoryGuessedRef = useRef(false)
const guessedCategory = (result: Record<string, unknown>): string => { const guessedCategory = (result: Record<string, unknown>): string => {
const id = guessCategoryId(result, categories || []) const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
const id = saved ? null : guessCategoryId(result, categories || [])
categoryGuessedRef.current = id != null categoryGuessedRef.current = id != null
return id != null ? String(id) : '' return saved ?? (id != null ? String(id) : '')
} }
// The hours the details column looked up, as a week the contact block can take over (#2472). // The hours the details column looked up, as a week the contact block can take over (#2472).
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null) const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-109 // FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-110
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -1829,4 +1829,13 @@ describe('ReservationModal', () => {
expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW'); expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW');
}); });
}); });
it('FE-PLANNER-RESMODAL-110: a refresh of the trip while the dialog is open keeps what the user typed (#3069)', async () => {
const { rerender } = render(<ReservationModal {...defaultProps} />);
const title = screen.getByPlaceholderText(/e\.g\. Lufthansa/i) as HTMLInputElement;
await userEvent.type(title, 'Le Jules Verne');
// A socket reconnect re-reads the trip: days, places and stays arrive as new arrays.
rerender(<ReservationModal {...defaultProps} days={[]} places={[]} accommodations={[]} />);
expect(title).toHaveValue('Le Jules Verne');
});
}); });
@@ -132,8 +132,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setForm({ ...EMPTY_FORM, assignment_id: defaultAssignmentId ?? '' }) setForm({ ...EMPTY_FORM, assignment_id: defaultAssignmentId ?? '' })
setPendingFiles([]) setPendingFiles([])
} }
// Keyed only on what opens a different form. Days, places and stays are read
// when the form is seeded; a refresh of the trip while the dialog is open (a
// socket reconnect re-reads it) hands in new arrays of the same data and must
// not reseed it and wipe what the user is typing (#3069).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations]) }, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId])
const handleSubmit = async (e?: { preventDefault?: () => void }) => { const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e?.preventDefault?.() e?.preventDefault?.()
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-083 // FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-085
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -18,6 +18,7 @@ import {
buildAssignment, buildAssignment,
buildReservation, buildReservation,
buildTripFile, buildTripFile,
buildBudgetItem,
} from '../../../tests/helpers/factories'; } from '../../../tests/helpers/factories';
import { TransportModal } from './TransportModal'; import { TransportModal } from './TransportModal';
import type { Day, Reservation } from '../../types'; import type { Day, Reservation } from '../../types';
@@ -1725,4 +1726,22 @@ describe('TransportModal', () => {
const legs = onSave.mock.calls[0][0].metadata.legs as { confirmation_number?: string }[]; const legs = onSave.mock.calls[0][0].metadata.legs as { confirmation_number?: string }[];
expect(legs.map(l => l.confirmation_number ?? null)).toEqual([null, 'SEG-TWO']); expect(legs.map(l => l.confirmation_number ?? null)).toEqual([null, 'SEG-TWO']);
}); });
it('FE-PLANNER-TRANSMODAL-084: a store refresh while the dialog is open keeps what the user typed (#3069)', async () => {
render(<TransportModal {...defaultProps} />);
const title = screen.getByPlaceholderText(/e\.g\. Lufthansa/i) as HTMLInputElement;
await userEvent.type(title, 'LH 400');
// A socket reconnect re-reads the trip: the costs arrive as a new array.
act(() => { useTripStore.setState({ budgetItems: [buildBudgetItem()] }); });
expect(title).toHaveValue('LH 400');
});
it('FE-PLANNER-TRANSMODAL-085: opening a different transport still fills the form with it', () => {
const first = buildReservation({ title: 'First flight', type: 'flight' });
const second = buildReservation({ title: 'Second flight', type: 'flight' });
const { rerender } = render(<TransportModal {...defaultProps} reservation={first} />);
expect(screen.getByDisplayValue('First flight')).toBeInTheDocument();
rerender(<TransportModal {...defaultProps} reservation={second} />);
expect(screen.getByDisplayValue('Second flight')).toBeInTheDocument();
});
}); });
@@ -135,7 +135,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
}), }),
}) })
}, [isOpen, reservation, prefill, selectedDayId, budgetItems]) // Keyed only on what opens a different form. A store refresh while the dialog
// is open (a socket reconnect re-reads the trip, a fellow traveller's cost) must
// not reseed it and wipe what the user is typing (#3069).
}, [isOpen, reservation, prefill, selectedDayId])
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault() e?.preventDefault()
@@ -1,4 +1,4 @@
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-022 // FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-024
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildDay, buildReservation } from '../../../../tests/helpers/factories'; import { buildDay, buildReservation } from '../../../../tests/helpers/factories';
import type { ReservationEndpoint } from '../../../types'; import type { ReservationEndpoint } from '../../../types';
@@ -83,6 +83,26 @@ describe('bookingFacts: day', () => {
}); });
}); });
describe('bookingFacts: hotel times', () => {
it('FE-PLANNER-BKFACTS-023: a hotel without times of its own shows the stay check-in and check-out', () => {
const f = bookingFacts(buildReservation({
type: 'hotel', accommodation_start_day_id: 701, accommodation_end_day_id: 702,
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
}), ctx());
expect(cell(f, 'reservations.meta.checkIn')).toBe('15:00 → 18:00');
expect(cell(f, 'reservations.meta.checkOut')).toBe('11:00');
});
it('FE-PLANNER-BKFACTS-024: the booking own times win over the stay times', () => {
const f = bookingFacts(buildReservation({
type: 'hotel', metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
}), ctx());
expect(cell(f, 'reservations.meta.checkIn')).toBe('14:00');
expect(cell(f, 'reservations.meta.checkOut')).toBe('10:00');
});
});
describe('bookingFacts: time', () => { describe('bookingFacts: time', () => {
it('FE-PLANNER-BKFACTS-008: start and end time with an arrow', () => { it('FE-PLANNER-BKFACTS-008: start and end time with an arrow', () => {
const f = bookingFacts(buildReservation({ reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T11:30' }), ctx()); const f = bookingFacts(buildReservation({ reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T11:30' }), ctx());
@@ -2,7 +2,7 @@ import type { Day, Reservation, ReservationEndpoint } from '../../../types'
import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters' import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../utils/flightLegs' import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../utils/flightLegs'
import { safeExternalHref } from '../../../utils/safeUrl' import { safeExternalHref } from '../../../utils/safeUrl'
import { daySpan, parseMeta, type AssignmentLookupEntry } from './bookingsModel' import { daySpan, hotelStayTimes, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
export interface FactsContext { export interface FactsContext {
t: (key: string, params?: Record<string, string | number>) => string t: (key: string, params?: Record<string, string | number>) => string
@@ -105,10 +105,11 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
if (!c.hasLinkedCost && meta.price != null && meta.price !== '') { if (!c.hasLinkedCost && meta.price != null && meta.price !== '') {
cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) }) cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) })
} }
if (meta.check_in_time) { const stay = hotelStayTimes(r, meta)
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` → ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) if (stay.checkIn) {
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(stay.checkIn, locale, timeFormat) + (stay.checkInEnd ? ` → ${formatTime(stay.checkInEnd, locale, timeFormat)}` : '') })
} }
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (stay.checkOut) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
// Per-segment codes (#1943), only on a real stopover booking. // Per-segment codes (#1943), only on a real stopover booking.
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : [] const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : []
@@ -103,6 +103,27 @@ export interface DaySpan {
} }
/** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */ /** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */
/**
* A hotel's check-in window and check-out: the booking's own metadata first,
* then the stay it is linked to. Times often live only on the stay, and the
* edit form already falls back the same way (reservationFormModel.ts).
*/
export function hotelStayTimes(
r: Reservation,
meta: { check_in_time?: string | null; check_in_end_time?: string | null; check_out_time?: string | null },
): { checkIn: string | null; checkInEnd: string | null; checkOut: string | null } {
if (r.type !== 'hotel') {
return { checkIn: meta.check_in_time || null, checkInEnd: meta.check_in_end_time || null, checkOut: meta.check_out_time || null }
}
const checkIn = meta.check_in_time || r.accommodation_check_in || null
return {
checkIn,
// A window end belongs to the check-in it came with.
checkInEnd: meta.check_in_time ? meta.check_in_end_time || null : checkIn ? r.accommodation_check_in_end || null : null,
checkOut: meta.check_out_time || r.accommodation_check_out || null,
}
}
export function daySpan(r: Reservation, days: Day[]): DaySpan { export function daySpan(r: Reservation, days: Day[]): DaySpan {
const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined) const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined)
if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) { if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) {
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSPORTEP-001 to -021: the transport booking form both the desktop // FE-PLANNER-TRANSPORTEP-001 to -022: the transport booking form both the desktop
// TransportModal and the phone's transport sheet save through. // TransportModal and the phone's transport sheet save through.
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
@@ -499,6 +499,28 @@ describe('buildTransportPayload', () => {
expect(payload.reservation_time).toBeNull(); expect(payload.reservation_time).toBeNull();
}); });
it('FE-PLANNER-TRANSPORTEP-022: a flight without airports keeps its day, times and flight details on save (#3070)', () => {
const payload = buildTransportPayload(
draft({
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Fly', type: 'flight', notes: 'only the notes changed' },
waypoints: [
{ ...emptyWaypoint(1), depTime: '09:40', airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' },
{ ...emptyWaypoint(2), arrTime: '11:30' },
],
}),
days,
DESKTOP_SAVE
);
expect(payload).toMatchObject({
day_id: 1,
end_day_id: 2,
reservation_time: '2026-05-01T09:40',
reservation_end_time: '2026-05-02T11:30',
endpoints: [],
});
expect(payload.metadata).toEqual({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' });
});
it('FE-PLANNER-TRANSPORTEP-017: the desktop anchors a train on its raw rows, the phone on the picked stations', () => { it('FE-PLANNER-TRANSPORTEP-017: the desktop anchors a train on its raw rows, the phone on the picked stations', () => {
const station = (name: string) => ({ name, lat: 1, lng: 1 }); const station = (name: string) => ({ name, lat: 1, lng: 1 });
const trainWaypoints = [ const trainWaypoints = [
@@ -548,8 +548,12 @@ export function buildTransportPayload(
const dayDate = (id: string | number): string | null => days.find((d) => d.id === Number(id))?.date ?? null; const dayDate = (id: string | number): string | null => days.find((d) => d.id === Number(id))?.date ?? null;
// Flight route as an ordered list of airports (origin .. stops .. destination). // Flight route as an ordered list of airports (origin .. stops .. destination).
const flightWps = form.type === 'flight' ? waypoints.filter((w) => w.airport) : []; const flightWps = form.type === 'flight' ? waypoints.filter((w) => w.airport) : [];
const firstWp = flightWps[0]; // A flight without a single airport (one created over MCP with a day and times
const lastWp = flightWps[flightWps.length - 1]; // but no endpoints) takes its day, times, airline and seat from the raw rows, as
// a train without stations does; otherwise a save wiped them (#3070).
const flightAnchors = flightWps.length > 0 || form.type !== 'flight' ? flightWps : waypoints;
const firstWp = flightAnchors[0];
const lastWp = flightAnchors[flightAnchors.length - 1];
// Only geocoded stations become map endpoints and legs. // Only geocoded stations become map endpoints and legs.
const trainWps = stationRoute ? trainWaypoints : []; const trainWps = stationRoute ? trainWaypoints : [];
const trainStations = trainWps.filter((w) => w.location); const trainStations = trainWps.filter((w) => w.location);
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
/** A stop's or a day's id, as the day plan's drag handlers pass them around. */
type DragKey = string | number | null;
/**
* The drag state of the day plan (DayPlanSidebar): what is being dragged, which
* row the drop line stands on, which day the drag is over, and whether a drag
* happened yet.
*
* The day list fades in once, on mount. Switching its stagger back on when a
* drag ended played that entrance again on every drop, so the first drag turns
* it off for good (#3041).
*
* A drag whose source row re-rendered away never sends its dragend to the
* document, and the drop line then stayed on a row until the next reload. The
* browser sends no mousemove during a drag, so the first plain mouse move or
* press after one means it is over. Plain means no button held: Firefox fires
* dragstart while it handles the move that starts the drag and hands that move
* to the page afterwards, button still down. Read as the end, it threw the drag
* away at pick-up and left only the arrows to reorder a day (#3041).
*/
export function useDayPlanDragState(dragDataRef: MutableRefObject<unknown>) {
const [draggingId, setDraggingIdState] = useState<DragKey>(null);
const [hasDragged, setHasDragged] = useState(false);
const [dropTargetKey, setDropTargetKeyState] = useState<string | null>(null);
const dropTargetRef = useRef<string | null>(null);
const [dragOverDayId, setDragOverDayId] = useState<DragKey>(null);
// Stable, so the sidebar's listeners that call them are set up once.
const setDraggingId = useCallback((id: DragKey) => {
if (id != null) setHasDragged(true);
setDraggingIdState(id);
}, []);
const setDropTargetKey = useCallback((key: string | null) => {
dropTargetRef.current = key;
setDropTargetKeyState(key);
}, []);
useEffect(() => {
if (!draggingId && !dropTargetKey && !dragOverDayId) return;
const settle = (e: MouseEvent | PointerEvent) => {
if (e.type === 'mousemove' && e.buttons !== 0) return;
setDraggingIdState(null);
dropTargetRef.current = null;
setDropTargetKeyState(null);
setDragOverDayId(null);
dragDataRef.current = null;
window.__dragData = null;
};
document.addEventListener('mousemove', settle);
document.addEventListener('pointerdown', settle, true);
return () => {
document.removeEventListener('mousemove', settle);
document.removeEventListener('pointerdown', settle, true);
};
}, [draggingId, dropTargetKey, dragOverDayId, dragDataRef]);
return {
draggingId,
setDraggingId,
hasDragged,
dropTargetKey,
dropTargetRef,
setDropTargetKey,
dragOverDayId,
setDragOverDayId,
};
}
@@ -1,4 +1,4 @@
import { useNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems' import { useDockNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
export interface MobileNavValue { export interface MobileNavValue {
bar: string[] bar: string[]
@@ -36,7 +36,7 @@ export function useMobileNavEditor(
value: MobileNavValue, value: MobileNavValue,
onChange: (next: MobileNavValue) => void, onChange: (next: MobileNavValue) => void,
): MobileNavEditor { ): MobileNavEditor {
const items = useNavItems() const items = useDockNavItems()
const split = splitMobileNav(items, value) const split = splitMobileNav(items, value)
const dashboard = split.bar.find((i) => i.id === 'dashboard') const dashboard = split.bar.find((i) => i.id === 'dashboard')
const barItems = split.bar.filter((i) => i.id !== 'dashboard') const barItems = split.bar.filter((i) => i.id !== 'dashboard')
@@ -8,7 +8,7 @@ import {
Globe, Compass, Flag, Navigation, Map, Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross, Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi, Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap, Luggage, Backpack, Zap, Croissant, ShoppingBasket,
LucideIcon, LucideIcon,
} from 'lucide-react' } from 'lucide-react'
@@ -22,7 +22,7 @@ export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
Globe, Compass, Flag, Navigation, Map, Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross, Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi, Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap, Luggage, Backpack, Zap, Croissant, ShoppingBasket,
} }
export const ICON_LABELS: Record<string, string> = { export const ICON_LABELS: Record<string, string> = {
@@ -37,6 +37,7 @@ export const ICON_LABELS: Record<string, string> = {
Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine', Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet', Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure', Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
Croissant: 'Bakery', ShoppingBasket: 'Groceries',
} }
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon { export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
@@ -1,4 +1,4 @@
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008 // FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-011
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -23,11 +23,19 @@ import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import { useAssistantStore } from '../../store/assistantStore'; import { useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { AiStatusResponse } from '@trek/shared'; import type { AiStatusResponse } from '@trek/shared';
import MBottomNav from './MBottomNav'; import MBottomNav from './MBottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
const vacay = { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true };
const atlas = { id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true };
const assistantAddon = { id: 'ai_assistant', name: 'AI assistant', type: 'integration', icon: 'sparkles', enabled: true };
const withAssistant = () =>
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
const withCollections = () => const withCollections = () =>
seedStore(useAddonStore, { seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }], addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
@@ -40,6 +48,10 @@ beforeEach(() => {
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
}); });
afterEach(() => {
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
});
describe('MBottomNav', () => { describe('MBottomNav', () => {
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => { it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -100,14 +112,46 @@ describe('MBottomNav', () => {
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
}); });
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => { it('FE-COMP-MBOTTOMNAV-008: the assistant takes a free dock slot, so More does not hold it alone', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } }); withAssistant();
seedStore(useAddonStore, { addons: [atlas, assistantAddon] });
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-COMP-MBOTTOMNAV-009: with the dock full the assistant waits under More, and opens the home chat from there', async () => {
const user = userEvent.setup();
withAssistant();
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
render(<MBottomNav />, { initialEntries: ['/atlas'] }); render(<MBottomNav />, { initialEntries: ['/atlas'] });
await user.click(screen.getByRole('button', { name: 'More' })); await user.click(screen.getByRole('button', { name: 'More' }));
await user.click(screen.getByRole('button', { name: 'Assistant' })); await user.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false }); expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(mockNavigate).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled();
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } }); });
it('FE-COMP-MBOTTOMNAV-010: the assistant goes where the settings put it', () => {
withAssistant();
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, appearance: { mobileNav: { bar: ['assistant', 'atlas'], more: ['vacay'] } } },
});
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Atlas' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Vacay' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
});
it('FE-COMP-MBOTTOMNAV-011: no assistant entry while its addon is off', () => {
withAssistant();
seedStore(useAddonStore, { addons: [atlas, { ...assistantAddon, enabled: false }] });
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
}); });
}); });
+26 -26
View File
@@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, matchPath } from 'react-router' import { useNavigate, useLocation, matchPath } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react' import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared' import { normalizeAppearance } from '@trek/shared'
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems' import { useDockNavItems, splitMobileNav, ASSISTANT_NAV_ID, type NavItemDef } from '../../components/Layout/navItems'
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction' import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
import MFab from './MFab' import MFab from './MFab'
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome' import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
import MDockScrim from './MDockScrim' import MDockScrim from './MDockScrim'
interface NavItem { to: string; label: string; icon: LucideIcon }
/** /**
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the * Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon * legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
@@ -28,7 +25,8 @@ export default function MBottomNav() {
// the dock yields the slot instead (see screenFabSlot below, #1811). // the dock yields the slot instead (see screenFabSlot below, #1811).
const create = useCreateAction(true) const create = useCreateAction(true)
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's. // The home chat, an entry of the dock like the pages (useDockNavItems): sorted
// into the bar or under More in the settings, there only while it can run.
const assistant = useAssistantHomeLauncher() const assistant = useAssistantHomeLauncher()
// Close the popover when the route changes underneath it (browser back etc.). // Close the popover when the route changes underneath it (browser back etc.).
@@ -37,20 +35,21 @@ export default function MBottomNav() {
// The dock split + order is user-configurable (Settings → Appearance → Mobile); // The dock split + order is user-configurable (Settings → Appearance → Mobile);
// an un-customised account falls back to the built-in Dashboard + vacay/atlas // an un-customised account falls back to the built-in Dashboard + vacay/atlas
// dock with everything else under "More". // dock with everything else under "More".
const navItems = useNavItems() const navItems = useDockNavItems()
const appearance = useSettingsStore(s => s.settings.appearance) const appearance = useSettingsStore(s => s.settings.appearance)
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav) const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
const dockItems: NavItem[] = split.bar const dockItems = split.bar
const moreItems: NavItem[] = split.more const moreItems = split.more
const isActive = (to: string) => isNavItemActive(location.pathname, to) const isAssistant = (item: NavItemDef) => item.id === ASSISTANT_NAV_ID
const moreActive = moreItems.some(item => isActive(item.to)) const isActive = (item: NavItemDef) => !isAssistant(item) && isNavItemActive(location.pathname, item.to)
const moreRows = [ const open = (item: NavItemDef): void => {
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []), if (isAssistant(item)) assistant.open()
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })), else void navigate(item.to)
] }
const hasMore = moreRows.length > 0 const moreActive = moreItems.some(isActive)
const hasMore = moreItems.length > 0
// The FAB gives way to a decorative logo slot on screens without an add // The FAB gives way to a decorative logo slot on screens without an add
// action (settings/admin, demo Z. 1372/1429). // action (settings/admin, demo Z. 1372/1429).
@@ -82,16 +81,17 @@ export default function MBottomNav() {
// A map runs to the screen's edge on purpose; everything else fades out under the dock. // A map runs to the screen's edge on purpose; everything else fades out under the dock.
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
const renderItem = ({ to, label, icon: Icon }: NavItem) => { const renderItem = (item: NavItemDef) => {
const active = isActive(to) const { id, label, icon: Icon } = item
const active = isActive(item)
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other // Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
// slot 21/1.9 — independent of the active state. // slot 21/1.9 — independent of the active state.
const dash = to === '/dashboard' const dash = id === 'dashboard'
return ( return (
<button <button
key={to} key={id}
type="button" type="button"
onClick={() => navigate(to)} onClick={() => open(item)}
aria-label={label} aria-label={label}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={circleCls(active)} className={circleCls(active)}
@@ -111,17 +111,17 @@ export default function MBottomNav() {
role="presentation" role="presentation"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{moreRows.map(({ key, label, icon: Icon, go }) => ( {moreItems.map((item) => (
<button <button
key={key} key={item.id}
type="button" type="button"
onClick={() => { setMoreOpen(false); go() }} onClick={() => { setMoreOpen(false); open(item) }}
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start" className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
> >
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink"> <span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
<Icon size={20} strokeWidth={2} /> <item.icon size={20} strokeWidth={2} />
</span> </span>
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{label}</span> <span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{item.label}</span>
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" /> <ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
</button> </button>
))} ))}
+79 -12
View File
@@ -8,6 +8,7 @@ import { pagesFor } from './dayPagerPages';
vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() }));
const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[]; const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[];
const LONG = Array.from({ length: 10 }, (_, k) => ({ id: k + 1 })) as unknown as Day[];
function setup(selectedDayId: number | null, over: Partial<Parameters<typeof MDayPager>[0]> = {}) { function setup(selectedDayId: number | null, over: Partial<Parameters<typeof MDayPager>[0]> = {}) {
const onSelectDay = vi.fn(); const onSelectDay = vi.fn();
@@ -39,19 +40,37 @@ beforeEach(() => {
}); });
describe('pagesFor', () => { describe('pagesFor', () => {
it('windows the selection with two days on either side, room for a second swipe', () => { it('has a page for every day and draws the selection with two days on either side', () => {
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active])).toEqual([ expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active, p.near])).toEqual([
[1, false], [1, false, true],
[2, false], [2, false, true],
[3, true], [3, true, true],
[4, false], [4, false, true],
[5, false], [5, false, true],
[6, false, false],
]); ]);
}); });
it('has no page beyond the first and last day', () => { it('keeps the same pages whichever day is selected, so a run of swipes never meets an end', () => {
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2, 3]); expect(pagesFor(DAYS, 1).map((p) => p.key)).toEqual(pagesFor(DAYS, 6).map((p) => p.key));
expect(pagesFor(DAYS, 6).map((p) => p.dayId)).toEqual([4, 5, 6]); expect(
pagesFor(DAYS, 6)
.filter((p) => p.near)
.map((p) => p.dayId)
).toEqual([4, 5, 6]);
});
it('also draws the days around the page a swipe heads for, so a run of swipes never meets blank pages', () => {
const drawn = (focusDayId: number | null) =>
pagesFor(LONG, 1, focusDayId)
.filter((p) => p.near)
.map((p) => p.dayId);
expect(drawn(6)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
// Still only the selection's own window where nothing is previewed or the preview is gone.
expect(drawn(null)).toEqual([1, 2, 3]);
expect(drawn(99)).toEqual([1, 2, 3]);
// The focus never changes which page is current.
expect(pagesFor(LONG, 1, 6).find((p) => p.active)?.dayId).toBe(1);
}); });
it('keeps a page for a selection that points at no day, with the first day as the way back', () => { it('keeps a page for a selection that points at no day, with the first day as the way back', () => {
@@ -69,7 +88,9 @@ describe('MDayPager', () => {
expect(screen.getByText('page 2 live')).toBeInTheDocument(); expect(screen.getByText('page 2 live')).toBeInTheDocument();
const pages = Array.from(strip.children) as HTMLElement[]; const pages = Array.from(strip.children) as HTMLElement[];
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true]); expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true, true, true]);
// Day 6 is three away: its page is there to swipe onto, its content is not drawn yet.
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
expect(pages[0]).toHaveAttribute('aria-hidden', 'true'); expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
expect(pages[1]).not.toHaveAttribute('aria-hidden'); expect(pages[1]).not.toHaveAttribute('aria-hidden');
}); });
@@ -122,6 +143,19 @@ describe('MDayPager', () => {
expect(onPreview).toHaveBeenLastCalledWith(null); expect(onPreview).toHaveBeenLastCalledWith(null);
}); });
it('draws the days ahead of a swipe while the strip still moves, before the selection lands', () => {
const { strip, onSelectDay } = setup(1);
expect(screen.queryByText('page 5')).not.toBeInTheDocument();
place(strip, 3, true);
fireEvent.scroll(strip);
expect(onSelectDay).not.toHaveBeenCalled();
expect(screen.getByText('page 1 live')).toBeInTheDocument();
expect(screen.getByText('page 5')).toBeInTheDocument();
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
});
it('asks once, however many events report the same landing', () => { it('asks once, however many events report the same landing', () => {
const { strip, onSelectDay } = setup(2); const { strip, onSelectDay } = setup(2);
@@ -166,7 +200,7 @@ describe('MDayPager', () => {
} }
}); });
it('puts the strip back on the current page when the window moves', () => { it('puts the strip on the current page when the selection moves', () => {
const { strip, rerender, onSelectDay } = setup(2); const { strip, rerender, onSelectDay } = setup(2);
// jsdom keeps no scroll offset, so give the strip one to move. // jsdom keeps no scroll offset, so give the strip one to move.
let scrollLeft = 0; let scrollLeft = 0;
@@ -199,6 +233,39 @@ describe('MDayPager', () => {
expect(strip.scrollLeft).toBe(390); expect(strip.scrollLeft).toBe(390);
}); });
it('moves the strip to a day tapped inside the same window, as on a two-day trip', () => {
const twoDays = DAYS.slice(0, 2);
const { strip, rerender, onSelectDay } = setup(1, { days: twoDays });
let scrollLeft = 0;
Object.defineProperty(strip, 'scrollLeft', {
configurable: true,
get: () => scrollLeft,
set: (v) => {
scrollLeft = v;
},
});
// Both days are in the window either way; only the current page changes.
const realQuery = strip.querySelector.bind(strip);
vi.spyOn(strip, 'querySelector').mockImplementation((sel: string) => {
const el = realQuery(sel) as HTMLElement | null;
if (el && sel === '[data-page-active]') el.getBoundingClientRect = () => ({ left: 390 }) as DOMRect;
return el;
});
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
rerender(
<MDayPager
days={twoDays}
selectedDayId={2}
onSelectDay={onSelectDay}
describeDay={() => ''}
renderDay={(dayId) => <p>{`page ${dayId}`}</p>}
/>
);
expect(strip.scrollLeft).toBe(390);
});
it('holds still while locked', () => { it('holds still while locked', () => {
const { strip } = setup(2, { locked: true }); const { strip } = setup(2, { locked: true });
expect(strip).toHaveClass('overflow-x-hidden'); expect(strip).toHaveClass('overflow-x-hidden');
+24 -12
View File
@@ -4,11 +4,11 @@ import { tickHaptic } from '../../native/haptics';
import { pagesFor, type PagerItem } from './dayPagerPages'; import { pagesFor, type PagerItem } from './dayPagerPages';
/** /**
* Pages through the trip's days the way a native pager does: the current day * Pages through the trip's days the way a native pager does: every day is a
* and two on either side sit side by side in a horizontal scroll-snap strip * page of a horizontal scroll-snap strip (dayPagerPages.ts), the current one
* (dayPagerPages.ts), so the finger drags real paper, the neighbour slides in * and two on either side drawn, so the finger drags real paper, the neighbour
* with it, and a second swipe made while the first still snaps carries on to * slides in with it, and swipes made while the last one still snaps carry on
* the day after. The browser's compositor owns the gesture. Momentum, rubber-banding at the first * as far as the trip goes. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
* and last day, axis locking against the vertical scroll and the swallowed tap * and last day, axis locking against the vertical scroll and the swallowed tap
* after a swipe are all the platform's own, in the PWA and the app alike. * after a swipe are all the platform's own, in the PWA and the app alike.
* *
@@ -16,8 +16,10 @@ import { pagesFor, type PagerItem } from './dayPagerPages';
* the swipe is decided, not once the snap has settled, which on iOS comes * the swipe is decided, not once the snap has settled, which on iOS comes
* a good while later. The day only changes once the strip has come to rest on * a good while later. The day only changes once the strip has come to rest on
* another page. Then * another page. Then
* the selection moves, the window of three shifts by one and the strip is put * the selection moves and the days around it draw their content. The days
* back on the centre page before the next paint, so nothing visibly jumps. * around the page a swipe is heading for draw theirs as soon as it becomes the
* nearer one, so a run of quick swipes that never rests finds them drawn
* instead of blank until the snap lands (#3067).
* Keys follow the day ids, so the page that slid in keeps its state (scroll * Keys follow the day ids, so the page that slid in keeps its state (scroll
* position, fetched route and weather) as it becomes the current one. * position, fetched route and weather) as it becomes the current one.
* *
@@ -70,8 +72,14 @@ export default function MDayPager({
}: MDayPagerProps) { }: MDayPagerProps) {
const stripRef = useRef<HTMLDivElement>(null); const stripRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState(''); const [announcement, setAnnouncement] = useState('');
const pages = pagesFor(days, selectedDayId); /** The page a swipe last headed for. It stays after the landing, so swiping
const windowKey = pages.map((p) => p.key).join(','); * back over the same days finds them drawn too. */
const [focusDayId, setFocusDayId] = useState<number | null>(null);
const pages = pagesFor(days, selectedDayId, focusDayId);
// Which page is current belongs in the key: on a trip of up to five days a
// chip tap can keep the same pages, and the strip then stayed on the old day
// while the rail already showed the new one.
const windowKey = pages.map((p) => (p.active ? `*${p.key}` : p.key)).join(',');
// The handlers below are attached once, so they read the props through here. // The handlers below are attached once, so they read the props through here.
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview }); const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
@@ -91,7 +99,7 @@ export default function MDayPager({
if (Math.abs(offset) > 0.5) strip.scrollLeft += offset; if (Math.abs(offset) > 0.5) strip.scrollLeft += offset;
}, []); }, []);
// Before paint whenever the window moves: a landed swipe or a chip tap. // Before paint whenever the window or its current page moves: a landed swipe or a chip tap.
useLayoutEffect(() => { useLayoutEffect(() => {
requested.current = null; requested.current = null;
recentre(); recentre();
@@ -133,7 +141,11 @@ export default function MDayPager({
const next = dayId != null && Number.isFinite(dayId) ? dayId : null; const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
if (next === previewed) return; if (next === previewed) return;
previewed = next; previewed = next;
if (next !== null) tickHaptic(); if (next !== null) {
// Draw ahead of the finger: the days around this one, before it lands.
setFocusDayId(next);
tickHaptic();
}
latest.current.onPreview?.(next); latest.current.onPreview?.(next);
}; };
@@ -212,7 +224,7 @@ export default function MDayPager({
aria-hidden={page.active ? undefined : true} aria-hidden={page.active ? undefined : true}
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`} className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
> >
{renderDay(page.dayId, page.active)} {page.near ? renderDay(page.dayId, page.active) : null}
</div> </div>
))} ))}
</div> </div>
@@ -46,10 +46,12 @@ describe('MPushPrompt', () => {
it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => { it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => {
await renderAndWait(); await renderAndWait();
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument(); expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull(); // Asked once answered, not once shown.
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Turn on' })); fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
expect(env.toggle).toHaveBeenCalledTimes(1); expect(env.toggle).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument(); expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
}); });
@@ -77,6 +79,18 @@ describe('MPushPrompt', () => {
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull(); expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
}); });
it('FE-MOB-PUSHPROMPT-005: a prompt left unanswered asks again on the next launch', async () => {
const first = render(<MPushPrompt />);
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
});
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
first.unmount();
await renderAndWait();
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
});
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => { it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
useAuthStore.setState({ isAuthenticated: false } as never); useAuthStore.setState({ isAuthenticated: false } as never);
await renderAndWait(); await renderAndWait();
+13 -4
View File
@@ -2,6 +2,7 @@ import { BellRing } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useWebPush } from '../../hooks/useWebPush'; import { useWebPush } from '../../hooks/useWebPush';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { nativePushActive, nativePushEndpoint } from '../../native/push'; import { nativePushActive, nativePushEndpoint } from '../../native/push';
import { pushChannelOffered } from '../../push/webPush'; import { pushChannelOffered } from '../../push/webPush';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@@ -36,9 +37,14 @@ function rememberAsked(): void {
* server offers push, and only while this device is not signed up yet. Turning * server offers push, and only while this device is not signed up yet. Turning
* it on goes the way the settings' push card goes (useWebPush), which brings up * it on goes the way the settings' push card goes (useWebPush), which brings up
* the system's question. * the system's question.
*
* It counts as asked once the user answers it, not when it opens: a sheet that
* went under another one, or an app closed while it was up, asks again. And it
* waits for the app's capabilities, which can arrive after the sign-in.
*/ */
export default function MPushPrompt(): React.ReactElement | null { export default function MPushPrompt(): React.ReactElement | null {
const signedIn = useAuthStore((s) => s.isAuthenticated); const signedIn = useAuthStore((s) => s.isAuthenticated);
const { caps } = useNativeCapabilities();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -46,8 +52,7 @@ export default function MPushPrompt(): React.ReactElement | null {
let cancelled = false; let cancelled = false;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
void pushChannelOffered().then((offered) => { void pushChannelOffered().then((offered) => {
if (cancelled || !offered || nativePushEndpoint()) return; if (cancelled || !offered || nativePushEndpoint() || alreadyAsked()) return;
rememberAsked();
setOpen(true); setOpen(true);
}); });
}, PUSH_PROMPT_DELAY_MS); }, PUSH_PROMPT_DELAY_MS);
@@ -55,9 +60,13 @@ export default function MPushPrompt(): React.ReactElement | null {
cancelled = true; cancelled = true;
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [signedIn]); }, [signedIn, caps]);
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null; const answered = () => {
rememberAsked();
setOpen(false);
};
return open ? <PushPromptSheet onClose={answered} /> : null;
} }
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement { function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
+50 -9
View File
@@ -6,6 +6,11 @@ export interface MSection {
id: string; id: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
/**
* The heading the section stands under. Consecutive sections with the same
* group share one heading; a screen whose sections name none shows none.
*/
group?: string;
} }
interface MSectionRowProps { interface MSectionRowProps {
@@ -33,6 +38,50 @@ export function MSectionRow({ section, active, current = false, onPick }: MSecti
); );
} }
/**
* The sections in order, each run of one group under its heading: the same list
* in the dropdown of a phone and in the list beside the page.
*/
export function MSectionList({
sections,
activeId,
markCurrent = false,
onPick,
}: {
sections: MSection[];
activeId: string;
/** Marks the active row as the page on show (the list beside the page). */
markCurrent?: boolean;
onPick: (id: string) => void;
}) {
return (
<>
{sections.map((section, i) => {
const heading = section.group && section.group !== sections[i - 1]?.group ? section.group : null;
return (
<div key={section.id} role="presentation">
{heading && (
<div
className={`px-[10px] pb-1 font-geist text-caption font-bold uppercase tracking-[.09em] text-m-faint ${
i === 0 ? 'pt-1' : 'pt-3'
}`}
>
{heading}
</div>
)}
<MSectionRow
section={section}
active={section.id === activeId}
current={markCurrent && section.id === activeId}
onPick={() => onPick(section.id)}
/>
</div>
);
})}
</>
);
}
/** /**
* The frame of a settings-like screen (settings, administration), one rule for * The frame of a settings-like screen (settings, administration), one rule for
* both: the page column on a phone, a centred column on a wide window, and from * both: the page column on a phone, a centred column on a wide window, and from
@@ -88,15 +137,7 @@ export default function MSectionNav({
<span className="min-w-0 flex-1 truncate text-subtitle font-bold text-m-ink">{title}</span> <span className="min-w-0 flex-1 truncate text-subtitle font-bold text-m-ink">{title}</span>
</div> </div>
{action && <div className="mb-1 px-[4px]">{action}</div>} {action && <div className="mb-1 px-[4px]">{action}</div>}
{sections.map((section) => ( <MSectionList sections={sections} activeId={activeId} markCurrent onPick={onPick} />
<MSectionRow
key={section.id}
section={section}
active={section.id === activeId}
current={section.id === activeId}
onPick={() => onPick(section.id)}
/>
))}
</nav> </nav>
); );
} }
+9 -1
View File
@@ -154,6 +154,9 @@ const SHORT_FORM_SHAPE = 'm-form-sheet short:flex-1 short:border-b-0 short:[--m-
// behind read through as sharp ghost text: the frosting it counted on is // behind read through as sharp ghost text: the frosting it counted on is
// dropped whenever an ancestor animates its opacity (the scrim fading in), // dropped whenever an ancestor animates its opacity (the scrim fading in),
// which WebKit and Chromium both do, so the fields floated over the list behind. // which WebKit and Chromium both do, so the fields floated over the list behind.
// For the same reason the dim fades on a layer of its own, beside the sheet and
// not around it: opacity multiplies down the tree, so a fading parent made even
// the opaque sheet see-through for its first frames.
const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]' const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]'
// The docked sheet rises from below the screen edge like a native sheet; the // The docked sheet rises from below the screen edge like a native sheet; the
@@ -283,12 +286,17 @@ export default function MSheet({
// has to fall back to document.body (sheet mounted in the same commit as // has to fall back to document.body (sheet mounted in the same commit as
// the shell). // the shell).
<div <div
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`} className="m-root fixed inset-0 z-[60]"
role="presentation" role="presentation"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) requestClose() if (e.target === e.currentTarget) requestClose()
}} }}
> >
<div
aria-hidden="true"
data-m-scrim=""
className={`pointer-events-none absolute inset-0 ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
/>
<div className={regionClass(variant, wideAnchor, form)}> <div className={regionClass(variant, wideAnchor, form)}>
<div className={boxClass(variant, wideWidth, form)}> <div className={boxClass(variant, wideWidth, form)}>
<PopoverArrow variant={variant} anchor={wideAnchor} /> <PopoverArrow variant={variant} anchor={wideAnchor} />
@@ -35,8 +35,8 @@ function setup() {
describe('MSwipeRow', () => { describe('MSwipeRow', () => {
it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => { it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => {
setup(); setup();
expect(screen.queryByText('Delete')).toBeNull(); expect(screen.queryByLabelText('Delete')).toBeNull();
expect(screen.queryByText('Done')).toBeNull(); expect(screen.queryByLabelText('Done')).toBeNull();
}); });
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => { it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
@@ -45,7 +45,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchmove', 160); touch(row, 'touchmove', 160);
touch(row, 'touchmove', 120); touch(row, 'touchmove', 120);
touch(row, 'touchend'); touch(row, 'touchend');
fireEvent.click(screen.getByText('Delete')); fireEvent.click(screen.getByLabelText('Delete'));
expect(onDelete).toHaveBeenCalledOnce(); expect(onDelete).toHaveBeenCalledOnce();
}); });
@@ -54,7 +54,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 200, 100); touch(row, 'touchstart', 200, 100);
touch(row, 'touchmove', 190, 160); touch(row, 'touchmove', 190, 160);
touch(row, 'touchend'); touch(row, 'touchend');
expect(screen.queryByText('Delete')).toBeNull(); expect(screen.queryByLabelText('Delete')).toBeNull();
}); });
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => { it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
@@ -72,7 +72,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 10); touch(row, 'touchstart', 10);
touch(row, 'touchmove', 120); touch(row, 'touchmove', 120);
touch(row, 'touchend'); touch(row, 'touchend');
expect(screen.queryByText('Done')).toBeNull(); expect(screen.queryByLabelText('Done')).toBeNull();
}); });
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => { it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
+21 -6
View File
@@ -34,10 +34,18 @@ interface MSwipeRowProps {
className?: string; className?: string;
/** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */ /** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */
surface?: string; surface?: string;
/**
* How far the buttons sit in from the row's top and bottom edge. A row that is a
* card of its own leaves it at 0, so the buttons are as tall as the card; a row
* inside a grouped card insets them, so they do not touch its dividers.
*/
inset?: number;
children: ReactNode; children: ReactNode;
} }
const BUTTON_W = 76; /** One button, and the gap before it: the buttons are cards of their own beside the row. */
const BUTTON_W = 70;
const GAP = 6;
const EDGE_PX = 24; const EDGE_PX = 24;
const CLAIM_PX = 10; const CLAIM_PX = 10;
const AXIS_BIAS = 1.5; const AXIS_BIAS = 1.5;
@@ -59,6 +67,7 @@ export default function MSwipeRow({
disabled = false, disabled = false,
className = '', className = '',
surface = 'bg-[color:var(--m-card)]', surface = 'bg-[color:var(--m-card)]',
inset = 0,
children, children,
}: MSwipeRowProps) { }: MSwipeRowProps) {
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null);
@@ -91,7 +100,11 @@ export default function MSwipeRow({
// Positive offset reveals the leading side in LTR; RTL mirrors it. // Positive offset reveals the leading side in LTR; RTL mirrors it.
const leadSign = () => (rtl() ? -1 : 1); const leadSign = () => (rtl() ? -1 : 1);
const openWidth = (which: 'leading' | 'trailing') => const openWidth = (which: 'leading' | 'trailing') =>
which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W; which === 'leading'
? latest.current.leading
? BUTTON_W + GAP
: 0
: latest.current.trailing.length * (BUTTON_W + GAP);
const close = () => { const close = () => {
place(0, true); place(0, true);
@@ -194,17 +207,18 @@ export default function MSwipeRow({
action.onAction(); action.onAction();
}; };
const shown = open ?? side; const shown = open ?? side;
// The icon alone, as in Mail; the label names the button for a screen reader.
const button = (action: SwipeAction) => ( const button = (action: SwipeAction) => (
<button <button
key={action.label} key={action.label}
type="button" type="button"
data-swipe-action="" data-swipe-action=""
tabIndex={open ? 0 : -1} tabIndex={open ? 0 : -1}
aria-label={action.label}
onClick={() => run(action)} onClick={() => run(action)}
className={`flex h-full w-[76px] flex-none flex-col items-center justify-center gap-1 text-caption font-semibold ${TONE[action.tone]}`} className={`flex h-full w-[70px] flex-none items-center justify-center ${inset ? 'rounded-xl' : 'rounded-2xl'} ${TONE[action.tone]}`}
> >
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" /> <action.icon size={16} strokeWidth={2.2} aria-hidden="true" />
{action.label}
</button> </button>
); );
@@ -213,7 +227,8 @@ export default function MSwipeRow({
{shown && ( {shown && (
<div <div
aria-hidden={!open} aria-hidden={!open}
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`} className={`absolute inset-x-0 flex gap-[6px] ${shown === 'leading' ? 'justify-start pe-[6px]' : 'justify-end ps-[6px]'}`}
style={{ top: inset, bottom: inset }}
> >
{shown === 'leading' ? leading && button(leading) : trailing.map(button)} {shown === 'leading' ? leading && button(leading) : trailing.map(button)}
</div> </div>
+23 -15
View File
@@ -7,29 +7,37 @@ interface Page {
key: string; key: string;
dayId: number | null; dayId: number | null;
active: boolean; active: boolean;
/** Drawn with its content; the pages further out are empty paper of the same width. */
near: boolean;
} }
/** /** Where a day stands in the strip, -1 for none. */
* Pages kept either side of the selection. Two, not one: a second swipe that const indexOfDay = (days: PagerItem[], id: number | null): number =>
* starts while the first is still snapping has to find a page beyond the id == null ? -1 : days.findIndex((d) => d.id === id);
* neighbour, or it runs into the end of the strip and springs back instead of
* reaching the day after. /** Pages drawn with their content either side of the selection, and of the page a swipe heads for. */
*/
const REACH = 2; const REACH = 2;
/** The window of pages around the selection. A selection that points at no /**
* day (the whole-trip view, or a day deleted remotely) still gets its page, * A page for every day, so quick swipes in a row never run into the end of the
* with the first day next to it as a way back. */ * strip before the first has settled (they used to spring back after two days).
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] { * Only the selection and REACH days either side draw their content, and as
* many either side of `focusDayId`, the page a swipe is heading for: a run of
* quick swipes that has not landed yet then meets drawn pages instead of blank
* paper (#3067). Two windows at most, however long the trip. A selection
* that points at no day (the whole-trip view, or a day deleted remotely) still
* gets its page, with the first day next to it as a way back.
*/
export function pagesFor(days: PagerItem[], selectedDayId: number | null, focusDayId: number | null = null): Page[] {
const i = days.findIndex((d) => d.id === selectedDayId); const i = days.findIndex((d) => d.id === selectedDayId);
if (i < 0) { if (i < 0) {
const first = days[0]; const first = days[0];
return [ return [
{ key: 'none', dayId: selectedDayId, active: true }, { key: 'none', dayId: selectedDayId, active: true, near: true },
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []), ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []),
]; ];
} }
return days const f = indexOfDay(days, focusDayId);
.slice(Math.max(0, i - REACH), i + REACH + 1) const near = (j: number) => Math.abs(j - i) <= REACH || (f >= 0 && Math.abs(j - f) <= REACH);
.map((d) => ({ key: `d${d.id}`, dayId: d.id, active: d.id === days[i].id })); return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: near(j) }));
} }
+37 -8
View File
@@ -62,7 +62,14 @@
* 52px/26px offsets stand in for the phone status bar / home indicator, so * 52px/26px offsets stand in for the phone status bar / home indicator, so
* real devices derive them from the safe-area insets. */ * real devices derive them from the safe-area insets. */
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px)); --bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
--m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px); /* The safe area the iOS app measures (TrekBridgeViewController
* publishSafeArea) is a floor under env(): where a release lays the web view
* out below the status bar, WebKit reports 0 while a camera in a corner still
* reaches into the page. In a browser the variables are unset: env() alone. */
--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));
--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));
--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));
--m-safe-top: calc(var(--m-safe-t) + 12px);
/* The navigation rail's share of the leading edge (wide windows only), what /* The navigation rail's share of the leading edge (wide windows only), what
* every full-width fixed bar and the page column start after. */ * every full-width fixed bar and the page column start after. */
--m-rail-w: 0px; --m-rail-w: 0px;
@@ -75,10 +82,10 @@
* reads the same values as the shell. */ * reads the same values as the shell. */
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root { html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--bottom-nav-h: env(safe-area-inset-bottom, 0px); --bottom-nav-h: env(safe-area-inset-bottom, 0px);
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px); --m-rail-w: calc(var(--m-safe-l) + 100px);
} }
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root { html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px); --m-rail-w: calc(var(--m-safe-r) + 100px);
} }
.dark .m-root { .dark .m-root {
@@ -123,9 +130,11 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-
/* ── Keyframes ──────────────────────────────────────────────────────────── */ /* ── Keyframes ──────────────────────────────────────────────────────────── */
/* The floating card pops in place, the way a native alert does. */ /* The floating card pops in place, the way a native alert does. It is opaque
halfway through the pop, so the page behind never reads through its fields. */
@keyframes m-sheet-in { @keyframes m-sheet-in {
from { opacity: 0; transform: translateY(10px) scale(.96); } from { opacity: 0; transform: translateY(10px) scale(.96); }
45% { opacity: 1; }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
} }
@keyframes m-sheet-out { @keyframes m-sheet-out {
@@ -568,6 +577,17 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-
inset-inline-start: var(--m-rail-w, 0px); inset-inline-start: var(--m-rail-w, 0px);
inset-inline-end: 0; inset-inline-end: 0;
} }
/* Without the rail the floating bars keep clear of the sides' safe area too:
a camera in a corner can come as a side inset. The insets are the screen's,
not the text direction's, so right to left takes them the other way round.
On a wide window the rail takes the leading side and a bar may centre itself
(mx-auto). */
html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
margin-inline: var(--m-safe-l, 0px) var(--m-safe-r, 0px);
}
html[dir='rtl']:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {
margin-inline: var(--m-safe-r, 0px) var(--m-safe-l, 0px);
}
/* A screen whose background is a map reaches under the rail: the map runs to /* A screen whose background is a map reaches under the rail: the map runs to
the window's edge and the rail floats over it like the top controls do. Its the window's edge and the rail floats over it like the top controls do. Its
own bars then keep to the page column with m-bar-x / m-span-x. */ own bars then keep to the page column with m-bar-x / m-span-x. */
@@ -641,10 +661,10 @@ html:has(.m-shell) {
does; the fixed bars stay where they are. The rule above is for the browser, does; the fixed bars stay where they are. The rule above is for the browser,
where a pull at the top reloads an installed PWA, which the app cannot do. where a pull at the top reloads an installed PWA, which the app cannot do.
Not on Android, whose stretch scales the bars too (MainActivity turns it Not on Android, whose stretch scales the bars too (MainActivity turns it
off), not sideways, and not on a screen with pull-to-refresh, whose pull off), and not sideways. A screen with pull-to-refresh bounces too; its pull
WebKit's bounce would race (mobile/usePullToRefresh.ts). */ holds only the move down from the top (mobile/usePullToRefresh.ts). */
html[data-native-ios]:not([data-pull-refresh]), html[data-native-ios],
html[data-native-ios]:not([data-pull-refresh]):has(.m-shell) { html[data-native-ios]:has(.m-shell) {
overscroll-behavior-y: auto; overscroll-behavior-y: auto;
} }
html:has(.m-shell) body { html:has(.m-shell) body {
@@ -664,6 +684,15 @@ html:has(.m-shell) {
html:has(.m-shell) [data-desktop-navbar] { html:has(.m-shell) [data-desktop-navbar] {
display: none; display: none;
} }
/* A plugin's page inside the touch shell: below the status bar, above the dock,
after the rail, and exactly the visible height (100dvh), so the plugin's own
document is what scrolls instead of the app stretching past the screen. */
html:has(.m-shell) [data-plugin-page] {
--plugin-page-h: calc(100dvh - var(--bottom-nav-h, 0px));
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px);
padding-inline-start: var(--m-rail-w, 0px);
}
html:has(.m-shell) .leaflet-control-zoom { html:has(.m-shell) .leaflet-control-zoom {
display: none !important; display: none !important;
} }
@@ -12,6 +12,7 @@ import {
Puzzle, Puzzle,
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
Smartphone,
User, User,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from '../../../i18n' import { useTranslation } from '../../../i18n'
@@ -26,8 +27,11 @@ import MSettingsIntegrations from './MSettingsIntegrations'
import MSettingsAccount from './MSettingsAccount' import MSettingsAccount from './MSettingsAccount'
import MSettingsAbout from './MSettingsAbout' import MSettingsAbout from './MSettingsAbout'
import MSettingsServer from './MSettingsServer' import MSettingsServer from './MSettingsServer'
import MAppLockCard from './MAppLockCard'
import MClientCertCard from './MClientCertCard'
import { isNativeApp } from '../../../native/platform'
import { useIsSplit } from '../../../hooks/sizeClass' import { useIsSplit } from '../../../hooks/sizeClass'
import MSectionNav, { MSectionRow, SECTION_SCREEN, type MSection } from '../../components/MSectionNav' import MSectionNav, { MSectionList, SECTION_SCREEN, type MSection } from '../../components/MSectionNav'
/** /**
* Mobile settings screen: back + section-switcher pill on top, the section * Mobile settings screen: back + section-switcher pill on top, the section
@@ -44,21 +48,33 @@ export default function MSettings() {
// the page, the way Settings on an iPad does, instead of behind a dropdown. // the page, the way Settings on an iPad does, instead of behind a dropdown.
const split = useIsSplit() const split = useIsSplit()
// Two groups, so it is clear what a setting reaches: the account's settings
// follow the user to every device; this device's stay on it (the server the
// app opens, the app lock, a client certificate, the offline copy).
const accountGroup = t('settings.groups.account')
const deviceGroup = t('settings.groups.device')
const app = isNativeApp()
const tabs: MSection[] = [ const tabs: MSection[] = [
{ id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal }, { id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal, group: accountGroup },
{ id: 'appearance', label: t('settings.tabs.appearance'), icon: Palette }, { id: 'appearance', label: t('settings.tabs.appearance'), icon: Palette, group: accountGroup },
{ id: 'map', label: t('settings.tabs.map'), icon: Map }, { id: 'map', label: t('settings.tabs.map'), icon: Map, group: accountGroup },
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell }, { id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell, group: accountGroup },
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []), ...(hasIntegrations
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []), ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug, group: accountGroup }]
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff }, : []),
{ id: 'account', label: t('settings.tabs.account'), icon: User }, ...(hasPlugins && !managed
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle, group: accountGroup }]
: []),
{ id: 'account', label: t('settings.tabs.account'), icon: User, group: accountGroup },
...(app ? [{ id: 'device', label: t('settings.tabs.device'), icon: Smartphone, group: deviceGroup }] : []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff, group: deviceGroup },
// Same call as the desktop page: About is about the project — what TREK is, // Same call as the desktop page: About is about the project — what TREK is,
// where to file a bug, where to support it — and a customer of a hosted // where to file a bug, where to support it — and a customer of a hosted
// instance is the audience for none of it. The version and the source link // instance is the audience for none of it. The version and the source link
// move to the footer below, because AGPL §13 wants those offered wherever // move to the footer below, because AGPL §13 wants those offered wherever
// people reach the software over a network. // people reach the software over a network. Under the product's name, not
...(appVersion && !managed ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }] : []), // under either group.
...(appVersion && !managed ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info, group: 'TREK' }] : []),
] ]
const active = tabs.find((tab) => tab.id === activeTab) || tabs[0] const active = tabs.find((tab) => tab.id === activeTab) || tabs[0]
@@ -105,17 +121,14 @@ export default function MSettings() {
{/* Section dropdown, in flow (pushes the content down like the demo) */} {/* Section dropdown, in flow (pushes the content down like the demo) */}
{dropOpen && !split && ( {dropOpen && !split && (
<div className="m-pop-in -mt-[6px] mb-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]"> <div className="m-pop-in -mt-[6px] mb-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
{tabs.map((tab) => ( <MSectionList
<MSectionRow sections={tabs}
key={tab.id} activeId={active.id}
section={tab} onPick={(id) => {
active={tab.id === active.id} setActiveTab(id)
onPick={() => { setDropOpen(false)
setActiveTab(tab.id) }}
setDropOpen(false) />
}}
/>
))}
</div> </div>
)} )}
@@ -126,8 +139,15 @@ export default function MSettings() {
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />} {active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
{/* Per-plugin settings still reuse the existing responsive tab. */} {/* Per-plugin settings still reuse the existing responsive tab. */}
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />} {active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
{/* Each card hides itself where it has nothing to offer; their own margins give way to one gap. */}
{active.id === 'device' && app && (
<div className="flex flex-col gap-3 [&>*]:m-0">
<MSettingsServer />
<MAppLockCard />
<MClientCertCard />
</div>
)}
{active.id === 'offline' && <MSettingsOffline />} {active.id === 'offline' && <MSettingsOffline />}
{active.id === 'account' && <MSettingsServer />}
{active.id === 'account' && <MSettingsAccount />} {active.id === 'account' && <MSettingsAccount />}
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />} {active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
@@ -19,8 +19,6 @@ import {
import { useTranslation } from '../../../i18n' import { useTranslation } from '../../../i18n'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet' import MConfirmSheet from './MConfirmSheet'
import MAppLockCard from './MAppLockCard'
import MClientCertCard from './MClientCertCard'
import PasswordChecklist from '../../../components/shared/PasswordChecklist' import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys' import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings' import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
@@ -181,11 +179,7 @@ export default function MSettingsAccount() {
)} )}
{/* ── Two-factor authentication ───────────────────────────── */} {/* ── Two-factor authentication ───────────────────────────── */}
{/* The app lock, inside the app only (Face ID, Touch ID or the passcode). */} {/* The app lock and the client certificate belong to this device: MSettings, "This device". */}
<MAppLockCard />
{/* The client certificate for a server behind mutual TLS, once one is stored. */}
<MClientCertCard />
<MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3"> <MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3">
{mfaRequiredByPolicy && ( {mfaRequiredByPolicy && (
<div className="mb-3 flex gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3"> <div className="mb-3 flex gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
@@ -8,7 +8,7 @@ import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, Ca
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta, timeRange } from './transportsModel' import { cardWhen, parseTransportMeta, timeRange } from './transportsModel'
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel' import { BOOKING_TYPE_COLOR, hotelStayTimes } from '../../../../components/Planner/bookings/bookingsModel'
import { useReservationListFilter } from '../../../../components/Planner/bookings/useReservationListFilter' import { useReservationListFilter } from '../../../../components/Planner/bookings/useReservationListFilter'
import { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard' import { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'
import { orderedEndpoints } from '../../../../utils/flightLegs' import { orderedEndpoints } from '../../../../utils/flightLegs'
@@ -119,13 +119,18 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
if (meta.price != null && meta.price !== '') { if (meta.price != null && meta.price !== '') {
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) }) metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
} }
if (meta.check_in_time) { const stay = hotelStayTimes(res, meta)
metaCells.push({ // A hotel's times are its check-in and check-out; they come first, so a price
// or a seat cannot push them off the card.
const stayCells: { label: string; value: string }[] = []
if (stay.checkIn) {
stayCells.push({
label: t('reservations.meta.checkIn'), label: t('reservations.meta.checkIn'),
value: timeRange(meta.check_in_time, meta.check_in_end_time ?? null, locale, timeFormat), value: timeRange(stay.checkIn, stay.checkInEnd, locale, timeFormat),
}) })
} }
if (meta.check_out_time) metaCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (stay.checkOut) stayCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
metaCells.unshift(...stayCells)
const files = filesFor(res, planner.files || []) const files = filesFor(res, planner.files || [])

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