Compare commits

...

65 Commits

Author SHA1 Message Date
Maurice 5e7d778740 fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016) 2026-11-08 21:16:09 +01:00
Maurice 3be87343b8 fix: the gate texts formatted as the locales are, gate detection as a table of rules, and no non-null assertions in its tests 2026-11-08 20:58:49 +01:00
Maurice 517efabbca feat(native): the address screen recognises a sign-in proxy in front of TREK and signs in to it inside the app, so its session works as in a browser 2026-11-08 20:44:26 +01:00
Maurice 1866dca818 test(dashboard): the pair-change test stubs fetch with its own mock instead of a promise-returning mockImplementation 2026-11-08 20:10:22 +01:00
Maurice 1400b5d308 fix: the iOS tile trim handles its own failure instead of an empty catch, and the weight test's slow row saves without an async prop 2026-11-08 19:57:43 +01:00
Maurice b529cf4e57 fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load 2026-11-08 19:44:50 +01:00
Maurice 6cc3d235b1 fix(offline): on an iPhone or iPad the tile prefetch keeps to 2,048 tiles and a fuller cache is trimmed once a day, so the home screen app no longer starts on a black screen (#3008) 2026-11-08 19:33:13 +01:00
Maurice a5b1156612 fix(dashboard): the hero, the atlas cards and the collections widget hold their size while they load, and the currency widget shows only the saved pair and its own rates (#3009) 2026-11-08 19:26:18 +01:00
Maurice c6ffd48e26 fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005) 2026-11-08 19:19:04 +01:00
Maurice afb6eae4c8 fix(native): the handoff's passkey sign-in lives in native/browserSignIn, so the page hook reaches no API on its own 2026-11-08 18:33:00 +01:00
Maurice cd1f39377d test(native): the handoff page's fixture knows the passkey step, and the hook test keeps the real API client around its passkey mocks 2026-11-08 18:10:55 +01:00
Maurice 3a99e8576d fix(android): the sign-in browser opens from the UI thread, falls back to the plain browser where no Custom Tab comes up, and says so when none opens 2026-11-08 17:54:43 +01:00
Maurice 36c8c5bc48 fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time 2026-11-08 17:50:50 +01:00
Maurice 4ba7db90e6 fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows 2026-11-08 17:43:28 +01:00
Maurice ab6cd7009e fix(login): the language picker's safe-area offset as classes, within the file's size baseline 2026-11-08 17:14:25 +01:00
Maurice b16ac683cf fix(native): Android's back gesture steps back through the first screens, the login's language picker clears the status bar, and a slow trip open shows its splash sooner 2026-11-08 16:51:28 +01:00
github-actions[bot] 95d62b7a02 chore: bump version to 5.1.1 [skip ci] 2026-11-08 14:52:31 +00:00
Maurice e444e855c0 fix(ci): the release counts only plain vX.Y.Z tags, not the apps' TestFlight tags [skip ci] 2026-11-08 15:48:30 +01:00
Maurice 4ab42c06c9 fix(native): spell out the welcome screen's translucent mixes, which older Android web views drew opaque as halos and rings 2026-11-08 15:07:41 +01:00
Maurice 9f6ab11839 fix(native): no logo shadow on the Android welcome screen, where the web view drew it as a dark halo 2026-11-08 14:36:12 +01:00
Maurice 5f711d5374 fix: format the floating panel stylesheet and keep the locate-button room private 2026-11-08 13:33:01 +01:00
Maurice 4d550112a1 fix(mobile): Studio's inspector stays docked on an iPad in landscape, the linked journal's buttons sit beside its switch, and the iOS app draws Leaflet's basemap as raster tiles 2026-11-08 13:12:53 +01:00
Maurice 26dc88181d fix: the file size, theme and reading direction ratchets the last push tipped; widget previews and watch screens run by hand only 2026-11-08 13:09:53 +01:00
Maurice 3d4f8bfe12 fix(docker): take the base image's pending Debian security fixes, so the image scan passes 2026-11-08 12:48:19 +01:00
Maurice 5fb1838c7a fix(mobile): switching collection lists changes the content in place instead of sliding a new page in 2026-11-08 12:41:17 +01:00
Maurice 38e5d76ad2 fix(mobile): Studio's floating panels are opaque over the page, the journal header lays out in one row on a tablet, and the journal a trip links opens Studio too 2026-11-08 12:38:39 +01:00
Maurice a9af19f02a fix(mobile): the journal as a trip tab keeps its switch and menu in the shell's top row, like the tour tab 2026-11-08 12:34:41 +01:00
Maurice 305b00ae01 fix(mobile): on a narrow 3D map the flyover controls and grade legend stand as a column of icons on the side, the layer pill keeps the bottom row; and the lint ratchets the last push tipped 2026-11-08 12:30:12 +01:00
Maurice ed6724035e fix(offline): a Cache Storage open that never settles is given up on, so preparing for offline and the offline map no longer wait on it forever 2026-11-08 12:26:07 +01:00
Maurice eea83bb1c9 fix: GL maps read the stored copy again while offline, and a switch that is on keeps a visible knob in the dark scheme 2026-11-08 12:21:52 +01:00
Maurice 06c03cbded fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it 2026-11-08 12:15:34 +01:00
Maurice b73f749880 fix(mobile): no how-to line on the 3D map on a tablet 2026-11-08 12:13:20 +01:00
Maurice 53c18b861a fix(native): GL maps load straight from their servers in the app, past the schemes they drew nothing through on iPadOS 26 2026-11-08 12:11:23 +01:00
Maurice 9b1bf89382 fix(native): a field low on a screen scrolls above the keyboard when it comes up 2026-11-08 12:07:57 +01:00
Maurice c7f144d3ff fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke 2026-11-08 12:05:59 +01:00
Maurice cb41f08199 fix(mobile): the tour tab's rail heads are rounded bands like the cards under them 2026-11-08 11:50:25 +01:00
Maurice eafb30555c fix(mobile): the iPad's tour tab opens saved tours, keeps its switch in the top row, and a tap shows no tooltip 2026-11-08 11:43:45 +01:00
Maurice 4f1f48eec5 fix: watch sync reads the roster through trip membership, image viewer on theme tokens, retry flaky release downloads in CI 2026-11-08 11:30:45 +01:00
Maurice 42341c2c76 fix(mobile): iPad feedback: columns start level, map controls sit low, no day-rail chevrons, no bottom-bar settings on a tablet, and a planned place on the journey map can be written up 2026-11-08 11:30:45 +01:00
Maurice 6a60fc183a feat(native): the app asks for notifications by itself, once
A moment after signing in, the app asks whether to turn notifications on, once per device and only where the server offers push and the device is not signed up yet. Turning them on goes the way of the settings card, which brings up the system's question. iOS asked only when someone found that card.
2026-11-08 11:05:07 +01:00
Maurice 236b4c34ac fix(mobile): the chat's composer stays above the keyboard, and its pictures open large
In the app the keyboard resizes only the body, which leaves the trip shell's fixed layers as they were, so the composer sat under the keys. The keyboard inset now also follows the height the app reports, and the chat lifts its composer by it. A picture in the chat opens full screen on a tap and zooms on a second; it sits below its bubble, keeps the long press for the message's actions, and a picture sent alone has no empty bubble beside it.
2026-11-08 11:05:07 +01:00
Maurice c6cb3dc64a feat(native): a silent push keeps every watch on a trip current
A change to a trip's packing lists or to-dos (any writer: REST, MCP, plugin RPC) is noted from the realtime broadcast and, a couple of seconds later, each member with the iPhone app gets a silent push through the app relay, marked as a background push. The push carries no content: the woken app reads both lists of the trip its watch shows from the server with its own session and hands them to the watch. The app gains the remote-notification background mode for it.
2026-11-08 11:05:06 +01:00
Maurice 3fc7487b58 fix(ci): the lint ratchet, and SwiftPM's half-downloaded artifacts on the iOS runners
The import sheet no longer sets state from its preset effect and is below the complexity limit; the trip shell's splash tests await their timers. The iOS and watch builds resolve their Swift packages first on a clean artifact cache, up to three times: a failed download from GitHub left a partial Capacitor xcframework behind and every later attempt stopped on it.
2026-11-08 11:05:06 +01:00
Maurice ac055d531f fix: the checks the tour tab, the shared links and the watch sync broke on dev
The tour tab keeps desktopOnly as a plain property, so the tab list's type still has it. The wiring test follows the tab's new gate. The import sheet keeps the choice and the save with the item they were made for instead of resetting them in an effect, takes the share sheet's trip through an effect event instead of a ref read while rendering, and is split into its parts. The tours tab sets its pane while rendering, as React has it for state that follows a prop. The sheet's tests look for the texts it shows. The Android link test reads the host with java.net.URI, which a JVM unit test can run. Two locale files take Prettier's quotes.
2026-11-08 10:37:41 +01:00
Maurice 0ef3cbf520 feat(mobile): a trip opens without its loading screen on the phone and the tablet
The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening.
2026-11-08 10:33:49 +01:00
Maurice 3458238db0 feat(native): a link shared to TREK can go to the trip's links
A page or a link shared from another app now carries its address. The app asks whether to keep it among the trip's links (Collab, Links) or to read a booking from it. A link whose page the phone cannot read is no longer refused: it is handed over as the link alone, which can be kept. With Collab off a page is read as a booking, as before.
2026-11-08 10:15:26 +01:00
Maurice afaacec61a fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
2026-11-08 10:15:20 +01:00
Maurice 5f4fb5dae7 feat(mobile): the tour editor as a tab of its own on an iPad and an unfolded foldable
On the wide and split size classes the touch shell no longer sends the tours tab back to the plan. It gets the desktop's tour editor, wired over the shell's own planner (useTripPlannerPageOf): on split the trip's tours and the editor share a pane beside the map, on wide the pane and the map take turns. A phone keeps its tours in the places browser.
2026-11-08 09:58:33 +01:00
Maurice 788b1f63d0 fix(mobile): the trip rail's More wears the dock's ring while its sheet is open 2026-11-08 03:07:24 +00:00
Maurice bfb1b04f8d feat(mobile): TREK Studio opens over the journey on an iPad on its side and an unfolded Duo
Studio is a child route of the journey, opened on top of it. The touch
shell's journey now renders that child in the split size class, where the
header offers Studio beside the menu, as the desktop's bar does. The editor
is the desktop's: tapping a panel, adding an element and dragging it with a
finger work through its pointer events. On the phone and in a single pane
the journey stays alone, whatever the address says, and offers no Studio.
2026-11-08 03:05:56 +00:00
Maurice a77ec6e225 fix(ci): the track sheet reads the distance unit as a DistanceUnit, and knip's baseline drops the type useFollowTrack no longer leaves unused 2026-11-08 03:00:39 +00:00
Maurice 2851eb7afe feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into
useGoogleRouteImport, unchanged: the link previewed, its stops in order with
the unresolved ones named, and the resolved ones appended to the day picked,
at least two, under one request id per opening and day. The large screens
get the same flow as a sheet, offered on the stage card under the track
offer to a member who may add places to days. The phone has no entry to it.
2026-11-08 02:57:43 +00:00
Maurice 6c7605b4a6 fix(mobile): the driving form's vehicle card says what you drive, not a second "Vehicle" under the range card 2026-11-08 02:54:39 +00:00
Maurice 3b286d04c3 feat(mobile): the tour detail edits on an iPad and an unfolded Duo
The phone's tour sheet reads the tour and plans it onto a day. In the wide
and split size classes it now edits what the desktop's tour card edits,
through the same useTourDetailActions: the name and the colour, a copy saved
as a new tour, the recorded tracks, the packing list it points at and its
files, for a member who may edit places. The phone is unchanged.
2026-11-08 02:45:34 +00:00
Maurice 21afe89b13 feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo
The desktop's track dialog comes to the large screens as a sheet over the
planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks
nearest to the day first, the drive fitted to the one picked with via points,
and those vias dropped again. The stage card offers it in the wide and split
size classes, to an editor of days, on a trip that holds a track, and names
the track the day follows. The phone and the folded Duo have no entry to it.
2026-11-08 02:42:37 +00:00
Maurice 060a43ba64 feat(mobile): the driving figures are a form on an iPad and an unfolded Duo
On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
2026-11-08 02:37:26 +00:00
Maurice dedbf16f03 fix(native): Android offers TREK Cloud while orders are closed and says a refused purchase is refunded
The licence testers of the internal test buy from a release build, so the
plan screen no longer hides the purchase on Android before the opening.
Their purchases are Play test purchases, which the cloud serves. A real
purchase made before the opening is refused with ordering-closed and never
acknowledged, so Google Play refunds it after three days; the claim and the
restore now say that instead of a general error. The app still acknowledges
a purchase only after a redeem that returned an instance. iOS is unchanged.
2026-11-08 02:31:13 +00:00
Maurice 796f39fe1f fix(rtl): write the isolate marks of flowText as escapes, not as raw characters
A raw FSI or PDI in the source reads as nothing and is what Sonar's
bidirectional character rule (S6389) flags. The strings are the same.
2026-11-08 01:40:30 +00:00
Maurice ee9cbce23d fix(journey): the feed's entries are gone under the header pills, not half seen between them
Scrolled up, a day's head showed at a third of its strength between the
back button and the segment, because the feed's mask let content through
from 22px below the safe area while the pills reach 38px. It is now
transparent down to 40px and whole from 60px, where the first day's head
starts.
2026-11-08 01:11:01 +00:00
Maurice 82fcdcd0fc test(rtl): read rtl.css selectors with their white space folded
Prettier breaks the arrow rule's long selector over lines, so the literal
look-up found no rule and FE-RTL-CSS-003 failed after the file was
formatted.
2026-11-08 00:38:51 +00:00
Maurice 3b7cdf516a fix(mobile): the rail fades where its entries scroll on a short window; the watch capture says why it retries
On a wide window too short for every entry (a folded Duo on its side) the
rail's list scrolls with its scrollbar hidden, and settings and
administration at its foot were cut off without a sign. The list now fades
at the edge with more behind it, as other scrolling lists do.

watch-screens.sh names the step that failed (launch, not ready,
screenshot) and tries once more after 20 seconds before it gives up.
2026-11-08 00:33:07 +00:00
Maurice 41752887dc fix(journey): a folded Duo on its side gets the compact cards in the wide size class too
The folded Duo on its side is over 500px high and so in the wide size
class, whose large cards won over the short window's compact ones. Short
is now asked first.
2026-11-08 00:10:57 +00:00
Maurice b9d0d3397e style: format the six files the formatting check names
They are outside scripts/format-baseline.json, so new code there must be
formatted; Prettier only, no change in behaviour.
2026-11-08 00:10:57 +00:00
Maurice 2a2dfec247 fix(mobile): large screen polish for the dashboard, the trip dock, transports, the stay chip and the journey strip
- Dashboard: crossing the split size keeps every widget in the same parent
  (the columns dissolve into one ordered stack below it), so the currency
  converter is not remounted and keeps its amount and rates.
- Trip dock: More is marked with the main dock's ring while one of its
  sections (chat, notes, links, polls, files) is on show or it is open,
  instead of the filled circle of a tab.
- Transport cards: the status button is a flex box, so its dot shows when
  the card may be edited.
- Day header: the stay chip's name keeps its own direction, so a Latin name
  in an Arabic header is cut at its end.
- Journey: a folded Duo on its side (about 540px high) gets the compact
  cards, so the map keeps its room.
- Rail: a test pins settings and administration marking their own entry.
2026-11-07 23:31:54 +00:00
Maurice 21e07e20a1 fix(ci): keep index.css and useAtlas.ts within their size baselines
The opening pan now measures the north edge through a function chromePan
calls only where there is chrome to clear, so useAtlas needs no branch of
its own. index.css loses the long note on the dark field rule to one line,
and a .dark .bg-slate-100 rule that two later rules always overrode.
2026-11-07 23:31:54 +00:00
243 changed files with 7638 additions and 972 deletions
+3 -1
View File
@@ -140,7 +140,9 @@ jobs:
set -eu
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
+35
View File
@@ -121,6 +121,21 @@ jobs:
working-directory: client
run: npm run cap:sync -- ios
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
# No -sdk: the scheme builds the embedded watch app and its complications
# against the watchOS simulator SDK and the rest against the iOS one.
- name: Build for the simulator
@@ -136,7 +151,10 @@ jobs:
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
# dark, for a trip ahead, a trip under way and none, plus a home screen and
# a lock screen of them. Nobody has to install a build to see a widget change.
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
# where a change is looked at on a device; on every PR it only held the queue.
widget-previews:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 20
steps:
@@ -193,7 +211,9 @@ jobs:
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
# every screen on the largest and the smallest watch, the main ones in every
# language, and the complications, as PNGs to look at without a watch.
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
watch-screens:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 90
steps:
@@ -220,6 +240,21 @@ jobs:
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
xcrun simctl list runtimes
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
- name: Build the watch app for the simulator
working-directory: client/ios/App
run: |
+4 -2
View File
@@ -1236,10 +1236,12 @@ jobs:
bin="$RUNNER_TEMP/bin"
mkdir -p "$bin"
cd "$RUNNER_TEMP"
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
tar -xzf kubeconform.tgz -C "$bin" kubeconform
echo "$bin" >> "$GITHUB_PATH"
+3
View File
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
# The upgrade takes the Debian security fixes the base image was built before
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.0
version: 5.1.1
description: Minimal Helm chart for TREK app
appVersion: "5.1.0"
appVersion: "5.1.1"
@@ -36,7 +36,7 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this);
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.json.JSONException;
import org.json.JSONObject;
@@ -18,6 +22,7 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
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_RESTART = "restart";
private static final String CONFIG_DIR = "trek-server-config";
@@ -28,16 +33,39 @@ final class TrekServerStore {
return prefs(context).getString(KEY_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) {
setServer(context, url, null);
}
static void setServer(Context context, String url, Set<String> signInHosts) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
} else {
Set<String> lower = new HashSet<>();
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
}
editor.commit();
}
/**
* The hosts of a sign-in page that stands in front of the server (a proxy
* such as Cloudflare Access or Pangolin), as the address screen found them.
* Only these may load inside the web view besides the server itself, so
* the proxy's session lands where the app can use it (TrekWebViewClient).
*/
static Set<String> getSignInHosts(Context context) {
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
}
/** Start the next launch on the bundled address screen, even with a server remembered. */
static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
// A page or a link: where it came from, so the UI can keep it among the trip's links.
String url = meta.optString("url", "");
if (url.startsWith("https://") || url.startsWith("http://")) {
file.put("url", url);
file.put("title", meta.optString("title", url));
}
pending = file;
if (listener != null) listener.onFile(file);
}
@@ -6,8 +6,10 @@ import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
@@ -17,6 +19,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@@ -29,6 +33,8 @@ import org.json.JSONObject;
)
public class TrekShellPlugin extends Plugin {
private static final String TAG = "TrekShell";
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
@@ -272,11 +278,23 @@ public class TrekShellPlugin extends Plugin {
call.reject("A http(s) URL is required");
return;
}
TrekServerStore.setUrl(getContext(), url);
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
call.resolve();
restart();
}
/** The sign-in hosts the address screen passes along, plain host names only. */
private static Set<String> signInHostsOf(PluginCall call) {
Set<String> hosts = new HashSet<>();
JSArray list = call.getArray("signInHosts");
if (list == null) return hosts;
for (int i = 0; i < list.length(); i++) {
String host = list.optString(i, "");
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
}
return hosts;
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
@@ -315,7 +333,32 @@ public class TrekShellPlugin extends Plugin {
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
Uri target = Uri.parse(url);
getActivity().runOnUiThread(() -> openSignIn(target));
}
/**
* Opens the sign-in in a Custom Tab, or in the browser itself where no
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
* launch from the bridge's thread without a word, and the sign-in then
* looked cancelled. A browser that cannot open at all is reported as such.
*/
private void openSignIn(Uri target) {
try {
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
return;
} catch (RuntimeException tabFailed) {
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
} catch (RuntimeException browserFailed) {
Log.w(TAG, "No browser for the sign-in", browserFailed);
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("No browser could open the sign-in", "NO_BROWSER");
finishAuth(call);
}
}
@Override
@@ -1,11 +1,14 @@
package com.liketrek.trek;
import android.net.Uri;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import java.util.Locale;
import java.util.Set;
/**
* Used while the WebView shows the user's server. When that server cannot be
@@ -18,10 +21,35 @@ import com.getcapacitor.BridgeWebViewClient;
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
private final Set<String> signInHosts;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
super(bridge);
this.onUnreachable = onUnreachable;
this.signInHosts = signInHosts;
}
/**
* The sign-in page of a proxy in front of the server loads here rather than
* in the browser, so its session cookie is the web view's and every request
* of the app carries it, as in a browser. Only the hosts the address screen
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
* page: Capacitor injects its bridge into the server's origin alone, so the
* proxy's page gets no access to the app. Everything else goes on to
* Capacitor, which opens a foreign page in the browser.
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
return super.shouldOverrideUrlLoading(view, request);
}
private boolean isSignInHost(Uri url) {
String scheme = url.getScheme();
String host = url.getHost();
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
String key = host.toLowerCase(Locale.ROOT);
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
}
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
@@ -46,6 +46,7 @@ object ShareHandoff {
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
if (tripId != null) meta.put("tripId", tripId)
if (item.url != null) meta.put("url", item.url).put("title", item.title)
File(dir(context), "pending.data").writeBytes(item.data)
File(dir(context), "pending.json").writeText(meta.toString())
true
@@ -26,6 +26,11 @@ data class ShareItem(
val title: String,
/** A host, for a page. */
val source: String? = null,
/**
* The web address it came from, when it is a page or a link: the app offers to
* keep it among the trip's links as well as to read a booking out of it.
*/
val url: String? = null,
) {
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
@@ -90,11 +95,16 @@ object ShareLoader {
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
}
/** A link becomes the page it points at; anything else, a text file the import reads. */
/**
* A link becomes the page it points at, or the bare link when the page cannot be
* read (a sign-in wall, an app-only page, no network), which the app can still
* keep among the trip's links. Anything else, a text file the import reads.
*/
internal fun fromText(text: String, subject: String?): ShareItem {
val link = firstLink(text)
if (link != null) {
runCatching { return page(link, subject) }
return linkOnly(link, subject)
}
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
@@ -121,12 +131,19 @@ object ShareLoader {
val html = String(data, Charsets.UTF_8)
val host = runCatching { Uri.parse(link).host }.getOrNull()
val title = pageTitle(html) ?: subject ?: host ?: "Page"
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host))
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
} finally {
connection.disconnect()
}
}
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
internal fun linkOnly(link: String, subject: String?): ShareItem {
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
val host = runCatching { java.net.URI(link).host }.getOrNull()
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
}
/** The kind and the MIME type the import should see, from the name when the type is vague. */
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
val ext = name.substringAfterLast('.', "").lowercase()
@@ -49,6 +49,16 @@ class NativeLogicTest {
assertEquals("page", ShareLoader.slug("!!!"))
}
@Test
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
assertEquals("text/uri-list", item.mimeType)
assertEquals("https://www.instagram.com/p/abc", item.url)
assertEquals("https://www.instagram.com/p/abc", String(item.data))
assertEquals("www.instagram.com", item.title)
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
}
@Test
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
val today = LocalDate.of(2026, 11, 7)
+8
View File
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
TrekPush.shared.didFail(error)
}
/// A silent push from the TREK server: a list of a trip changed, so the Apple
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
TrekWatchRefresher.refresh(completion: completionHandler)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .list, .sound])
+4
View File
@@ -120,6 +120,10 @@
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+5
View File
@@ -110,6 +110,11 @@ enum TrekSharedFiles {
"data": data.base64EncodedString(),
]
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
// A page or a link: where it came from, so the UI can keep it among the trip's links.
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
file["url"] = url
if let title = info["title"] as? String { file["title"] = title }
}
pending = file
onReceive?(file)
}
@@ -43,6 +43,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
navigationProxy = proxy
webView.navigationDelegate = proxy
}
guardBridgeMessages()
bridge?.registerPluginInstance(TrekShellPlugin())
// 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
@@ -51,6 +52,22 @@ class TrekBridgeViewController: CAPBridgeViewController {
webView?.allowsBackForwardNavigationGestures = false
}
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
private var messageGuard: TrekBridgeMessageGuard?
/// 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
/// reach the plugins.
private func guardBridgeMessages() {
guard !TrekServerStore.signInHosts.isEmpty,
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let controller = webView?.configuration.userContentController else { return }
let guarded = TrekBridgeMessageGuard(inner: handler)
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
controller.add(guarded, name: TrekBridgeMessageGuard.name)
messageGuard = guarded
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@@ -137,14 +154,43 @@ enum TrekKeyCommands {
enum TrekServerStore {
private static let key = "TrekServerURL"
private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it.
static var url: String? {
get { UserDefaults.standard.string(forKey: key) }
set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL)
}
}
/// The hosts of a sign-in page in front of the server (a proxy such as
/// Cloudflare Access or Pangolin), as the address screen found them. Only
/// these load in the web view besides the server, so the proxy's session
/// cookie is the web view's (TrekNavigationProxy), and none of them may
/// talk to the app (TrekBridgeMessageGuard).
static var signInHosts: Set<String> {
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
set {
if newValue.isEmpty {
UserDefaults.standard.removeObject(forKey: signInHostsKey)
} else {
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
}
}
}
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
static func isSignInHost(_ url: URL?) -> Bool {
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
let hosts = signInHosts
if hosts.contains(host) { return true }
if let port = url.port { return hosts.contains("\(host):\(port)") }
return false
}
static var offlineCopyURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return support.appendingPathComponent("offline-start-page.html")
@@ -391,6 +437,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
/// The sign-in page of a proxy in front of the server loads here instead of
/// in Safari, so its session is the web view's and every request carries it.
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
/// only in the main frame; everything else is Capacitor's decision as before.
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
decisionHandler(.allow)
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
@@ -401,3 +464,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
/// Stands in front of Capacitor's bridge message handler and drops what a
/// proxy's sign-in page posts: that page loads in the web view (see
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
/// plugins whatever page they come from.
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
static let name = "bridge"
private let inner: WKScriptMessageHandler
init(inner: WKScriptMessageHandler) {
self.inner = inner
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let origin = message.frameInfo.securityOrigin
var components = URLComponents()
components.scheme = origin.protocol
components.host = origin.host
if origin.port != 0 { components.port = origin.port }
if TrekServerStore.isSignInHost(components.url) { return }
inner.userContentController(userContentController, didReceive: message)
}
}
+3
View File
@@ -324,6 +324,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return
}
TrekServerStore.url = raw
// 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 }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
call.resolve()
restartBridge()
}
+240 -2
View File
@@ -2,6 +2,7 @@ import CryptoKit
import Foundation
import UIKit
import WatchConnectivity
import WebKit
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
return result
}
/// The snapshot the watch has now, as last published.
func storedSnapshot() -> Data? {
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
}
// MARK: Ticks from the watch
/// The ticks queued so far, oldest first, and the queue emptied.
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
return list
}
/// While the app is in front, its UI writes the tick, through its offline queue
/// as a tap would. In the background the WebView is asleep, so the tick is
/// written to the server from here at once (TrekWatchWriter), and the trip's
/// other devices hear of it over their WebSocket; only a write that fails is
/// queued for the UI, which then tries again once it runs.
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
DispatchQueue.main.async {
guard UIApplication.shared.applicationState != .active else {
self.enqueue(action)
done()
return
}
TrekWatchWriter.write(action) { written in
if !written { self.enqueue(action) }
done()
}
}
}
private func enqueue(_ action: [String: Any]) {
queue.async {
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
@@ -145,11 +170,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
}
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
/// The watch asks for the latest snapshot when it opens while the phone is in
/// reach, and sends its ticks this way while it is (WatchLink.send).
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
if let action = message["action"] as? [String: Any] {
receive(action) { replyHandler(["queued": true]) }
return
}
queue.async {
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
replyHandler(["snapshot": data])
@@ -159,3 +189,211 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
}
}
}
/// Writes a tick from the watch straight to the TREK server, for when the app is
/// in the background and its UI cannot. The same request the UI makes for a tap
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
/// session the WebView holds (its cookies) and the tick's id as the idempotency
/// key, so a tick that also reaches the server another way is taken once.
enum TrekWatchWriter {
private final class BackgroundTask {
var id = UIBackgroundTaskIdentifier.invalid
func end() {
guard id != .invalid else { return }
UIApplication.shared.endBackgroundTask(id)
id = .invalid
}
}
/// Called on the main queue, as is `completion`.
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
guard let request = Self.request(for: action) else {
completion(false)
return
}
let task = BackgroundTask()
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
let finish: (Bool) -> Void = { written in
DispatchQueue.main.async {
completion(written)
task.end()
}
}
perform(request) { _, ok in finish(ok) }
}
/// Sends a request with the WebView's session (its cookies for the server).
/// Called on the main queue; `completion` runs on a background queue.
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
guard let host = request.url?.host else { return completion(nil, false) }
let session = cookies.filter { cookie in
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
return host == domain || host.hasSuffix("." + domain)
}
guard !session.isEmpty else { return completion(nil, false) }
var signed = request
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
signed.setValue(value, forHTTPHeaderField: field)
}
URLSession.shared.dataTask(with: signed) { data, response, error in
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
completion(data, error == nil && (200..<300).contains(status))
}.resume()
}
}
/// The server's address for an API path, or nil without a server.
static func url(_ path: [String]) -> URL? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http" else { return nil }
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
}
/// The write for a tick as the watch sends it, or nil for anything else.
static func request(for action: [String: Any]) -> URLRequest? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http",
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
let list = action["list"] as? String, list == "packing" || list == "todo",
let done = action["done"] as? Bool,
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpMethod = "PUT"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
request.httpBody = body
return request
}
}
/// Brings the watch's packing list and to-dos up to date while the app is in the
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
/// reads both lists of the trip the watch shows from the server and puts them
/// into the snapshot the watch has, built the way the UI builds them
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
/// in the snapshot stays as the UI last made it. In front, the UI does this
/// itself as soon as the lists change, so nothing is done here then.
enum TrekWatchRefresher {
private static let maxName = 80
private static let maxGroup = 40
/// Below what WatchConnectivity carries in one application context.
private static let maxBytes = 55_000
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
let stored = TrekWatchLink.shared.storedSnapshot(),
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
return completion(.noData)
}
let group = DispatchGroup()
var packing: [[String: Any]]?
var todos: [[String: Any]]?
group.enter()
items(at: packingURL) { list in
packing = list
group.leave()
}
group.enter()
items(at: todoURL) { list in
todos = list
group.leave()
}
group.notify(queue: .main) {
guard let packing, let todos else { return completion(.failed) }
snapshot["packing"] = packingList(packing)
snapshot["todos"] = todoList(todos)
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
guard let data = fitted(snapshot) else { return completion(.failed) }
TrekWatchLink.shared.publish(data)
completion(.newData)
}
}
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Accept")
TrekWatchWriter.perform(request) { data, ok in
var list: [[String: Any]]?
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
list = body["items"] as? [[String: Any]]
}
DispatchQueue.main.async { completion(list) }
}
}
private static func done(_ item: [String: Any]) -> Bool {
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
}
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
return String(text.prefix(max))
}
private static func entry(_ item: [String: Any]) -> [String: Any]? {
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
return out
}
/// Unpacked first, then packed, each in the list's own order.
static func packingList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let sorted = items.sorted {
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
}
let packed = items.filter { done($0) }.count
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
}
/// Open ones first by due date (undated last) and priority, then the done ones.
static func todoList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
let sorted = items.sorted { a, b in
if done(a) != done(b) { return !done(a) }
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
if let da = due(a), let db = due(b), da != db { return da < db }
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
if pa != pb { return pa > pb }
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
}
let rows = sorted.compactMap { item -> [String: Any]? in
guard var row = entry(item) else { return nil }
if let date = due(item) { row["due"] = date }
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
return row
}
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
}
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
private static func fitted(_ snapshot: [String: Any]) -> Data? {
var object = snapshot
// A list that is now empty leaves the snapshot, as the UI leaves it out.
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
if data.count > maxBytes {
for key in ["packing", "todos"] {
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
list["items"] = items.filter { ($0["done"] as? Bool) != true }
object[key] = list
}
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
}
return data
}
}
@@ -54,6 +54,10 @@ enum ShareHandoff {
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
if let tripId { meta["tripId"] = tripId }
if let url = item.url {
meta["url"] = url
meta["title"] = item.title
}
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
return write(dataAccount, item.data) && write(metaAccount, metaData)
}
+35 -10
View File
@@ -32,6 +32,9 @@ struct ShareItem {
var title: String
/// A host, for a page.
var source: String?
/// The web address it came from, when it is a page or a link: the app offers to
/// keep it among the trip's links as well as to read a booking out of it.
var url: String? = nil
var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
@@ -88,6 +91,10 @@ enum ShareLoader {
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
// A text that is just a link (what many apps share) is that link.
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
return try await page(at: url)
}
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
@@ -133,21 +140,39 @@ enum ShareLoader {
let url = (results["url"] as? String).flatMap(URL.init(string:))
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: Data(html.utf8), title: title, source: url?.host))
data: Data(html.utf8), title: title, source: url?.host,
url: url.flatMap(webAddress)))
}
/// A link shared from an app other than Safari: the page as the server sends it.
/// A link shared from an app other than Safari: the page as the server sends it,
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
/// page, no network), which the app can still keep among the trip's links.
private static func page(at url: URL) async throws -> ShareItem {
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
var request = URLRequest(url: url, timeoutInterval: 10)
request.setValue("text/html", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
let html = String(decoding: data, as: UTF8.self)
let title = pageTitle(html) ?? url.host ?? "Page"
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: data, title: title, source: url.host))
if let fetched = try? await URLSession.shared.data(for: request),
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
let data = fetched.0
let html = String(decoding: data, as: UTF8.self)
let title = pageTitle(html) ?? url.host ?? "Page"
return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: data, title: title, source: url.host, url: address)
}
return link(url, address)
}
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
private static func link(_ url: URL, _ address: String) -> ShareItem {
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
title: url.host ?? address, source: url.host, url: address)
}
/// An http(s) address, the only kind a trip's links take.
private static func webAddress(_ url: URL) -> String? {
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
return url.absoluteString
}
private static func fromFile(_ url: URL) throws -> ShareItem {
+20 -2
View File
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
try? await Task.sleep(nanoseconds: 300_000_000)
}
/// A tick to the phone. In reach it goes as a message, which arrives at once and
/// wakes the phone app for it, so the trip's other devices see it within seconds.
/// Out of reach, or when the message fails or a phone app too old to take it
/// answers without "queued", it goes as user info, which the system delivers later.
func send(_ action: PendingAction) {
guard WCSession.default.activationState == .activated else { return }
WCSession.default.transferUserInfo(["action": action.message])
let session = WCSession.default
guard session.activationState == .activated else { return }
let message: [String: Any] = ["action": action.message]
guard session.isReachable else {
session.transferUserInfo(message)
return
}
session.sendMessage(message, replyHandler: { reply in
if reply["queued"] as? Bool == true {
Task { @MainActor in WatchModel.shared.markSent(action.id) }
} else {
session.transferUserInfo(message)
}
}, errorHandler: { _ in
session.transferUserInfo(message)
})
}
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
+7 -6
View File
@@ -66,12 +66,12 @@ wait_ready() {
TOKEN=0
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
# after 2, 5 and 10 seconds when the simulator refuses it.
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
shot() {
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
local locale=${lang//-/_} attempt
for attempt in 0 2 5 10; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
local locale=${lang//-/_} attempt failed=
for attempt in 0 2 5 10 20; do
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
TOKEN=$((TOKEN + 1))
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
sleep 0.5
@@ -79,11 +79,12 @@ shot() {
[ "$clock" = - ] || extra=(-TrekNow "$clock")
xcrun simctl launch "$udid" "$BUNDLE" \
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
wait_ready "$udid" "$TOKEN" || continue
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
# The map's tiles come over the network after the screen is up.
[ "$screen" = place ] && sleep 6
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
failed=screenshot
done
echo "gave up on $(basename "$file")"
}
+96 -15
View File
@@ -1,3 +1,4 @@
import { App } from '@capacitor/app';
import { Capacitor, CapacitorHttp } from '@capacitor/core';
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
import { SplashScreen } from '@capacitor/splash-screen';
@@ -5,8 +6,17 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import logoUrl from '../public/icons/icon-white.svg';
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
import { AUTH_GATE_NAMES, canSignInInApp, type AuthGate } from '../src/native/authGate';
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import {
AUTH_PROXY_DOCS_URL,
CLOUD_TERMS_URL,
CLOUD_URL,
PRIVACY_URL,
SERVER_DOCS_URL,
STORE_TERMS_URL,
} from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { directionOf, pickLocale, translator } from './i18n';
@@ -50,7 +60,7 @@ function cloudSuffix(): string {
// choosing, which has no reason to answer this page's origin with CORS.
const get: ProbeGet = async (url) => {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
return { status: response.status, data: response.data, url: response.url };
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
};
function failureMessage(reason: ProbeFailure): string {
@@ -61,6 +71,8 @@ function failureMessage(reason: ProbeFailure): string {
return t('native.connect.errorUnreachable');
case 'notTrek':
return t('native.connect.errorNotTrek');
case 'authGate':
return t('native.gate.titleUnknown');
case 'tooOld':
return t('native.connect.errorTooOld');
}
@@ -250,13 +262,19 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
const label = el('span', {}, t('native.connect.submit'));
const submit = el('button', { type: 'submit', className: 'btn' }, label);
const error = el('p', { className: 'error', role: 'alert' });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error);
const gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
const fail = (reason: ProbeFailure): void => {
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string): void => {
void notify(NotificationType.Error);
field.classList.add('bad');
setTimeout(() => field.classList.remove('bad'), 350);
error.textContent = failureMessage(reason);
if (reason === 'authGate' && gate && server) {
error.textContent = '';
fillGatePanel(gatePanel, gate, server);
} else {
error.textContent = failureMessage(reason);
}
input.disabled = false;
submit.disabled = false;
label.textContent = t('native.connect.submit');
@@ -268,6 +286,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
event.preventDefault();
void tap(ImpactStyle.Medium);
error.textContent = '';
gatePanel.hidden = true;
gatePanel.replaceChildren();
input.disabled = true;
submit.disabled = true;
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
@@ -276,7 +296,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
await connected(result.url);
return;
}
fail(result.reason);
fail(result.reason, result.gate, result.url);
});
show(
@@ -297,6 +317,43 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
requestAnimationFrame(() => input.focus());
}
/**
* A sign-in page of the user's own stands in front of the server (authGate.ts):
* say which one, offer to sign in to it inside the app, which keeps its session
* as a browser would, and how to let the app through instead.
*/
function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void {
const name = gate.kind === 'unknown' || gate.kind === 'basic' ? null : AUTH_GATE_NAMES[gate.kind];
const title =
gate.kind === 'basic'
? t('native.gate.titleBasic')
: name
? t('native.gate.title', { name })
: t('native.gate.titleUnknown');
const children: Node[] = [el('p', { className: 'gate-title' }, title)];
if (canSignInInApp(gate)) {
const signIn = el(
'button',
{ type: 'button', className: 'btn' },
name ? t('native.gate.signIn', { name }) : t('native.gate.signInGeneric')
);
signIn.addEventListener('click', () => {
void tap(ImpactStyle.Medium);
void connected(server, gate.loginHost ? [gate.loginHost] : []);
});
children.push(
el('p', { className: 'gate-text' }, t('native.gate.hint')),
signIn,
el('p', { className: 'gate-note' }, t('native.gate.googleNote'))
);
} else {
children.push(el('p', { className: 'gate-text' }, t('native.gate.basicHint')));
}
children.push(externalLink(t('native.gate.help'), AUTH_PROXY_DOCS_URL));
panel.replaceChildren(...children);
panel.hidden = false;
}
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
function feature(path: string, text: string): HTMLElement {
return el(
@@ -429,12 +486,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
async function loadOffer(list: CloudPlans): Promise<Offer> {
const available = await iapReady;
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
const canBuy = available && !opensSoon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
// Android keeps the button for the licence testers (see cloudOrders.ts).
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
const canBuy = available && !soon;
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
}
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
@@ -526,7 +585,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
void notify(NotificationType.Error);
restore.disabled = false;
status.className = 'hint bad';
status.textContent = t('native.plans.restoreFailed');
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.plans.restoreFailed');
});
// A payment that was pending (cash, some carrier billing) and has cleared since:
@@ -838,7 +900,9 @@ function showClaim(
slugNote.textContent = t('native.claim.taken');
slugNote.className = 'hint bad';
} else {
error.textContent = t('native.claim.redeemFailed');
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
? t('native.claim.closedRefund')
: t('native.claim.redeemFailed');
}
});
@@ -934,7 +998,7 @@ async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<
}
/** The tick, then the server takes over. A beat so the success registers. */
async function connected(url: string): Promise<void> {
async function connected(url: string, signInHosts: string[] = []): Promise<void> {
void notify(NotificationType.Success);
const host = (() => {
try {
@@ -959,7 +1023,7 @@ async function connected(url: string): Promise<void> {
el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host })))
);
await new Promise((r) => setTimeout(r, 700));
await TrekShell.setServer({ url });
await TrekShell.setServer(signInHosts.length ? { url, signInHosts } : { url });
}
function showUnreachable(server: string): void {
@@ -1005,8 +1069,25 @@ async function paintStatusBar(): Promise<void> {
}
}
/**
* Android's back gesture steps back through the first screens as their own
* back button does, and only leaves the app from a screen that has none.
*/
function followBackGesture(): void {
try {
void App.addListener('backButton', () => {
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
if (back) back.click();
else void App.minimizeApp();
});
} catch {
/* web / unavailable: no system back gesture */
}
}
async function start(): Promise<void> {
void paintStatusBar();
followBackGesture();
let url: string | null;
try {
({ url } = await TrekShell.getServer());
+78 -17
View File
@@ -103,6 +103,19 @@
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
--grid: rgba(255, 255, 255, 0.055);
--wash-1: rgba(129, 140, 248, 0.12);
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
--accent-ink-a16: rgba(9, 9, 11, 0.16);
--accent-ink-a35: rgba(9, 9, 11, 0.35);
--danger-a16: rgba(248, 113, 113, 0.16);
--ink-a22: rgba(250, 250, 250, 0.22);
--ink-a3: rgba(250, 250, 250, 0.03);
--ink-a4: rgba(250, 250, 250, 0.04);
--ink-a9: rgba(250, 250, 250, 0.09);
--ok-a16: rgba(74, 222, 128, 0.16);
--ok-a22: rgba(74, 222, 128, 0.22);
--wash-1-a85: rgba(129, 140, 248, 0.102);
--wash-1-a90: rgba(129, 140, 248, 0.108);
--wash-2: rgba(244, 114, 182, 0.08);
--veil-top: rgba(9, 9, 11, 0.4);
--veil-mid: rgba(9, 9, 11, 0.72);
@@ -135,6 +148,17 @@
--ok: #16a34a;
--grid: rgba(17, 24, 39, 0.075);
--wash-1: rgba(79, 70, 229, 0.1);
--accent-ink-a16: rgba(255, 255, 255, 0.16);
--accent-ink-a35: rgba(255, 255, 255, 0.35);
--danger-a16: rgba(220, 38, 38, 0.16);
--ink-a22: rgba(17, 24, 39, 0.22);
--ink-a3: rgba(17, 24, 39, 0.03);
--ink-a4: rgba(17, 24, 39, 0.04);
--ink-a9: rgba(17, 24, 39, 0.09);
--ok-a16: rgba(22, 163, 74, 0.16);
--ok-a22: rgba(22, 163, 74, 0.22);
--wash-1-a85: rgba(79, 70, 229, 0.085);
--wash-1-a90: rgba(79, 70, 229, 0.09);
--wash-2: rgba(219, 39, 119, 0.07);
--veil-top: rgba(255, 255, 255, 0.12);
--veil-mid: rgba(255, 255, 255, 0.74);
@@ -398,16 +422,16 @@ body::after {
height: 80px;
margin-bottom: 20px;
animation: floaty 6s ease-in-out infinite;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
}
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
.hero-logo {
display: block;
margin-bottom: 22px;
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
drop-shadow(0 6px 16px var(--ink-a22));
animation: logoRise 0.7s var(--ease) both;
}
.hero-logo img {
@@ -564,7 +588,7 @@ body::after {
border-color: var(--edge);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 0 0 1px var(--ink-a4);
}
.btn-ghost:active {
background: var(--surface-2);
@@ -689,7 +713,7 @@ a.linkbtn {
outline: none;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -701,11 +725,11 @@ a.linkbtn {
}
.field input:focus {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.field.bad input {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
@keyframes shake {
@@ -729,11 +753,48 @@ a.linkbtn {
transition: opacity 0.2s;
}
/* A sign-in proxy in front of the server: which one, sign in to it here, or let the app through. */
.gate {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: 22px;
background: var(--surface);
border: 1px solid var(--edge);
text-align: start;
}
.gate[hidden] {
display: none;
}
.gate-title {
margin: 0;
font-weight: 600;
color: var(--ink);
line-height: 1.4;
}
.gate-text {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.gate-note {
margin: 0;
color: var(--faint);
font-size: 0.82rem;
line-height: 1.45;
}
.gate a {
color: var(--ink);
font-size: 0.9rem;
}
/* Spinner inside the connect button while probing. */
.spinner {
width: 18px;
height: 18px;
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
border: 2px solid var(--accent-ink-a35);
border-top-color: var(--accent-ink);
border-radius: 50%;
animation: spin 0.7s linear infinite;
@@ -788,7 +849,7 @@ a.linkbtn {
width: 72px;
height: 72px;
border-radius: 50%;
background: color-mix(in srgb, var(--ok) 16%, transparent);
background: var(--ok-a16);
display: grid;
place-items: center;
margin-bottom: 8px;
@@ -889,11 +950,11 @@ a.linkbtn {
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--ok) 22%, transparent);
background: var(--ok-a22);
color: var(--ok);
}
.segmented button[aria-pressed='true'] .save {
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
background: var(--accent-ink-a16);
color: var(--accent-ink);
}
@@ -909,7 +970,7 @@ a.linkbtn {
background: var(--surface);
box-shadow:
0 2px 10px -4px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
inset 0 0 0 1px var(--ink-a3);
transition:
border-color 0.2s,
box-shadow 0.2s,
@@ -917,7 +978,7 @@ a.linkbtn {
}
.plan.featured {
box-shadow:
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
0 10px 30px -10px var(--wash-1-a85),
0 2px 8px -3px rgba(0, 0, 0, 0.35);
}
.plan:active {
@@ -1025,7 +1086,7 @@ a.linkbtn {
background: var(--surface);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
inset 0 1px 0 var(--ink-a4);
transition:
border-color 0.2s,
box-shadow 0.25s;
@@ -1046,11 +1107,11 @@ a.linkbtn {
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
box-shadow: 0 0 0 4px var(--ink-a9);
}
.slug-field.bad {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
box-shadow: 0 0 0 4px var(--danger-a16);
animation: shake 0.3s;
}
.slug-suffix {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.1.0",
"version": "5.1.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 845,
"@typescript-eslint/no-misused-promises": 843,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
+15
View File
@@ -669,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
},
{
"feature": "Road-trip Google Maps route import",
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
},
{
"feature": "Road-trip stay length draft",
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
@@ -828,6 +834,12 @@
"desktop": ["src/components/shared/Toast.tsx"],
"mobile": ["src/mobile/components/MToastHost.tsx"]
},
{
"feature": "Tour editor workspace",
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
},
{
"feature": "Tours list filter",
"hooks": ["src/components/Tours/tourPresentation.ts"],
@@ -1026,6 +1038,7 @@
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
@@ -1045,8 +1058,10 @@
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
+3 -2
View File
@@ -24,8 +24,8 @@
"components/Map/DawarichTrailPill.tsx": 1,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 6,
"components/Map/MapViewGL.tsx": 8,
"components/Map/MapView.tsx": 5,
"components/Map/MapViewGL.tsx": 7,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/PoiCategoryPill.tsx": 1,
@@ -34,6 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/Map/switcherRow.ts": 1,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
+2 -2
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1201,
"src/components/Map/MapView.tsx": 1200,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2127,
"src/components/Map/MapViewGL.tsx": 2126,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
+3 -2
View File
@@ -60,8 +60,8 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/MapView.tsx": 7,
"components/Map/MapViewGL.tsx": 10,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/ReservationOverlay.tsx": 5,
@@ -71,6 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Map/switcherRow.ts": 1,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
+2
View File
@@ -36,6 +36,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import NativeImportSheet from './mobile/components/NativeImportSheet'
import MPushPrompt from './mobile/components/MPushPrompt'
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
@@ -453,6 +454,7 @@ export default function App() {
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
render(<CollectionsWidget onOpen={() => {}} />);
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
localStorage.setItem('trek_dash_collections_shown', '3');
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
expect(await screen.findByText('List 1')).toBeInTheDocument();
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
localStorage.clear();
});
});
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useTranslation } from '../../i18n'
import { entityGradient } from '../../utils/gradients'
import { useCollectionLists } from './useCollectionLists'
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
/**
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
const { t } = useTranslation()
const navigate = useNavigate()
const { lists, loading } = useCollectionLists()
const [expected] = useState(readShownCollections)
useEffect(() => {
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
}, [loading, lists.length])
return (
<div className="tool">
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
<ArrowRight size={14} />
</button>
</div>
{loading ? null : lists.length === 0 ? (
{loading ? (
expected ? (
<div className="col-badges" aria-hidden>
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
</div>
) : (
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
)
) : lists.length === 0 ? (
<div className="col-empty">{t('collections.widget.empty')}</div>
) : (
<div className="col-badges">
@@ -0,0 +1,21 @@
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
afterEach(() => localStorage.clear());
describe('collectionsWidgetMemory', () => {
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
expect(readShownCollections()).toBeNull();
rememberShownCollections(4);
expect(readShownCollections()).toBe(4);
rememberShownCollections(9);
expect(readShownCollections()).toBe(6);
});
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
localStorage.setItem('trek_dash_collections_shown', 'many');
expect(readShownCollections()).toBeNull();
localStorage.setItem('trek_dash_collections_shown', '42');
expect(readShownCollections()).toBeNull();
});
});
@@ -0,0 +1,26 @@
const KEY = 'trek_dash_collections_shown';
/**
* How many lists the dashboard's collections widget showed last time. While the
* lists load, the widget stands in with that many placeholder badges, so it
* opens at the height it will have and the widgets under it stay put (#3009).
* Null when nothing is remembered yet, or storage is unavailable.
*/
export function readShownCollections(): number | null {
try {
const raw = localStorage.getItem(KEY);
if (raw == null) return null;
const n = Number.parseInt(raw, 10);
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
} catch {
return null;
}
}
export function rememberShownCollections(count: number): void {
try {
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
} catch {
/* storage blocked: the widget just opens at its empty height next time */
}
}
@@ -1,4 +1,4 @@
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { useSettingsStore } from '../../store/settingsStore';
@@ -13,7 +13,7 @@ const RATES = [
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
let fetchMock: ReturnType<typeof vi.fn>;
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
useSettingsStore.setState((s) => ({
isLoaded,
updateSetting: updateSetting as unknown as typeof s.updateSetting,
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
});
expect(result.current.rate).toBe(1.3);
});
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
seedSettings({}, false);
const { result } = renderHook(() => useCurrencyConverter());
expect(result.current.pairPending).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
expect(result.current.pairPending).toBe(false);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
});
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
fetchMock = vi.fn((url: string) =>
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
);
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCurrencyConverter());
await waitFor(() => expect(result.current.rate).toBe(1.1));
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
expect(result.current.rate).toBeNull();
expect(result.current.ratePending).toBe(true);
await waitFor(() => expect(result.current.rate).toBe(0.0056));
expect(result.current.ratePending).toBe(false);
});
});
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
updateSetting('dashboard_fx_to', v).catch(() => {});
};
const [amount, setAmount] = useState('100');
const [rates, setRates] = useState<Record<string, number> | null>(null);
// The rates carry the base they were fetched for: the pair can change before
// the new base's rates arrive, and the old map priced the new pair wrongly
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
// The base whose rates could not be had, so a failure belongs to its pair only.
const [failedBase, setFailedBase] = useState<string | null>(null);
const inFlight = useRef<AbortController | null>(null);
const fetchRates = useCallback(() => {
// Not before the user's pair is known: the defaults' rates were fetched and
// shown first, then replaced by the saved pair (#3009).
if (!isLoaded) return;
inFlight.current?.abort();
const controller = new AbortController();
inFlight.current = controller;
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
if (signal.aborted) return;
if (!Array.isArray(d)) {
setRates(null);
setFailedBase(from);
return;
}
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
const map: Record<string, number> = { [from]: 1 };
for (const r of d) map[r.quote] = r.rate;
setRates(map);
setFailedBase(null);
setRates({ base: from, map });
})
.catch(() => {
// An abort is not a failure: it means nobody is waiting for the answer.
if (!signal.aborted) setRates(null);
if (signal.aborted) return;
setRates(null);
setFailedBase(from);
});
}, [from]);
}, [from, isLoaded]);
useEffect(() => {
fetchRates();
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
});
}, [isLoaded, updateSetting]);
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
const rate = rates?.[to] ?? null;
const current = rates?.base === from ? rates.map : null;
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
const rate = current?.[to] ?? null;
// The pair is not known yet, or its rates are still on their way: neither is
// "rate unavailable", and neither may show a rate.
const pairPending = !isLoaded;
const ratePending = isLoaded && !current && failedBase !== from;
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
const swap = () => {
setFrom(to);
setTo(from);
};
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
return {
from,
to,
setFrom,
setTo,
amount,
setAmount,
currencies,
rate,
converted,
swap,
fetchRates,
pairPending,
ratePending,
};
}
@@ -1,3 +1,4 @@
import type { JourneyMapPlace } from '@trek/shared';
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
import JourneyMapAuto from './JourneyMapAuto';
@@ -27,6 +28,13 @@ interface Props extends MapProps {
/** Leads from a picked place or stay to its entry. */
onOpenEntry?: (entryId: string) => void;
entryTitleOf?: (entryId: number) => string | null;
/** Starts an entry about a picked planned place nothing was written about; absent for a reader who cannot. */
onWriteAboutPlace?: (place: JourneyMapPlace) => void;
}
/** A card action, only where the screen offers one. */
function offeredIf<T>(offered: unknown, action: T): T | undefined {
return offered ? action : undefined;
}
/**
@@ -44,6 +52,7 @@ export default function JourneyLayeredMap({
controlsTop = 12,
onOpenEntry,
entryTitleOf,
onWriteAboutPlace,
photos,
tracks,
height = 220,
@@ -55,6 +64,13 @@ export default function JourneyLayeredMap({
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
}, []);
const close = useCallback(() => setPicked(null), []);
const writeAbout = useCallback(
(place: JourneyMapPlace) => {
setPicked(null);
onWriteAboutPlace?.(place);
},
[onWriteAboutPlace]
);
const openEntry = useCallback(
(entryId: string) => {
setPicked(null);
@@ -99,6 +115,7 @@ export default function JourneyLayeredMap({
marks={card.marks}
onClose={close}
onOpenEntry={onOpenEntry ? openEntry : undefined}
onWriteAbout={offeredIf(onWriteAboutPlace, writeAbout)}
entryTitleOf={entryTitleOf}
/>
)}
@@ -86,4 +86,23 @@ describe('JourneyMapPickCard', () => {
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => {
const onWriteAbout = vi.fn();
const { unmount } = render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
fireEvent.click(screen.getByRole('button', { name: 'New Entry' }));
expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' }));
unmount();
// A reader who cannot write, or a place already written about, gets no such button.
render(
<JourneyMapPickCard
marks={[plan({ entry_id: 7 })]}
onClose={vi.fn()}
onOpenEntry={vi.fn()}
onWriteAbout={onWriteAbout}
/>
);
expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull();
});
});
@@ -1,5 +1,5 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, PenLine, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { SoftPill, WhiteBadge } from '../Planner/planParts';
@@ -14,6 +14,8 @@ interface Props {
onOpenEntry?: (entryId: string) => void;
/** The title of an entry the reader can see, for the stay that belongs to it. */
entryTitleOf?: (entryId: number) => string | null;
/** Starts an entry about a planned place nothing was written about; absent where the reader cannot write. */
onWriteAbout?: (place: JourneyMapPlace) => void;
}
function dayLabel(date: string, locale: string): string {
@@ -41,7 +43,15 @@ function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (e
);
}
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
function PlaceDetail({
place,
onOpenEntry,
onWriteAbout,
}: {
place: JourneyMapPlace;
onOpenEntry?: (entryId: string) => void;
onWriteAbout?: (place: JourneyMapPlace) => void;
}) {
const { t, locale } = useTranslation();
return (
<div className="flex flex-col gap-2">
@@ -56,9 +66,22 @@ function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEnt
{place.entry_id !== null ? (
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
) : (
<p className="text-content-muted" style={fs(11.5, 'body')}>
{t('journey.map.pick.notWritten')}
</p>
<>
<p className="text-content-muted" style={fs(11.5, 'body')}>
{t('journey.map.pick.notWritten')}
</p>
{onWriteAbout && (
<button
type="button"
onClick={() => onWriteAbout(place)}
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
style={fs(11.5, 'body')}
>
<PenLine size={12} strokeWidth={2.2} aria-hidden />
{t('journey.detail.newEntry')}
</button>
)}
</>
)}
</div>
);
@@ -106,7 +129,7 @@ function markName(mark: JourneyMapMark): string {
* one spot come as a list first, each with what kind it is, so overlapping
* hits stay reachable one by one.
*/
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf, onWriteAbout }: Props) {
const { t, locale } = useTranslation();
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
@@ -150,7 +173,9 @@ export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryT
<X size={14} strokeWidth={2.2} />
</button>
</div>
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
{picked?.kind === 'plan' && (
<PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} onWriteAbout={onWriteAbout} />
)}
{picked?.kind === 'stay' && (
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
)}
@@ -8,7 +8,7 @@ import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf } from './journeyCard'
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
import type { JourneyEntry } from '../../store/journeyStore'
import type { JourneyTrack } from '@trek/shared'
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared'
interface MapEntry {
id: string
@@ -40,6 +40,8 @@ interface Props {
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
layers?: JourneyMapLayersView | null
entryTitleOf?: (entryId: number) => string | null
/** Starts an entry about a picked planned place nothing was written about. */
onWriteAboutPlace?: (place: JourneyMapPlace) => void
/** Geotagged photos on the map, a tap opening them. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
@@ -64,6 +66,7 @@ export default function MobileMapTimeline({
initialEntryId,
layers,
entryTitleOf,
onWriteAboutPlace,
photos,
onPhotoClick,
controlsTop,
@@ -118,6 +121,7 @@ export default function MobileMapTimeline({
hideLayerControl
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
onWriteAboutPlace={onWriteAboutPlace}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
@@ -160,6 +164,7 @@ export default function MobileMapTimeline({
hideLayerControl
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
onWriteAboutPlace={onWriteAboutPlace}
entryTitleOf={entryTitleOf}
entries={mapEntries}
checkins={[]}
@@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { TrekShell } from '../../native/trekShell';
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
import { onWatchListsChange } from '../../native/watchListChanges';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
@@ -229,8 +230,11 @@ export default function NativeWidgetsSync(): null {
if (isActive) void sync().then(drainWatch);
});
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
return () => {
cancelled = true;
stopListWatch?.();
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
void watchListener?.then((handle) => handle.remove());
@@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
expect(screen.getByRole('status')).toHaveTextContent('')
})
})
describe('TourMapLayerSwitcher fitting its map', () => {
/** A parent of `room` px, and the pill reporting `withWords` px while its words show. */
function renderIn(room: { width: number }, withWords: number) {
const host = document.createElement('div')
Object.defineProperty(host, 'clientWidth', { get: () => room.width })
document.body.appendChild(host)
const view = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />, { container: host })
const pill = () => screen.getByRole('group')
Object.defineProperty(pill(), 'scrollWidth', {
get: () => (pill().dataset.layout === 'labels' ? withWords : 200),
})
return { ...view, pill }
}
it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => {
const room = { width: 900 }
const { pill, rerender } = renderIn(room, 600)
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('labels')
expect(screen.getByText('Topo')).toBeInTheDocument()
room.width = 500
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('icons')
expect(screen.queryByText('Topo')).toBeNull()
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
room.width = 599
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('icons')
room.width = 600
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('labels')
})
it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => {
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
expect(screen.getByRole('group').style.flexDirection).toBe('column')
expect(screen.queryByText('Topo')).toBeNull()
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
expect(screen.getByRole('group').dataset.layout).toBe('labels')
})
})
+36 -5
View File
@@ -1,7 +1,7 @@
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import { PisteKeyButton } from '../Tours/winter/PisteKey'
import type { ComponentType } from 'react'
import type { ComponentType, CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
import { useLabelFit } from './useLabelFit'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | TourRasterBase
@@ -31,6 +32,7 @@ export interface TourLayerOffer {
/** Distance from the map's left edge (plus any side panel) to the switcher. */
export const MAP_LAYER_SWITCHER_INSET = 20
/**
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
@@ -257,11 +259,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
)
}
/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */
function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) {
const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical)
const compact = vertical || (compactProp ?? iconsOnly)
const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels'
return { attachFit: attach, compact, layout }
}
function shellStyle(vertical: boolean): CSSProperties {
return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 }
}
function Divider({ vertical }: { vertical: boolean }) {
return <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
}
/**
* Per-view Tours control. It never reads or writes the global map setting.
* `compact` shows the base layers and the via ferrata switch by their icons
* alone, each named by its tooltip, for a bottom row that has to share its
* width (the 3D map's, beside the flyover dock).
* width (the 3D map's, beside the flyover dock). Left out, the control goes to
* its icons by itself once its words no longer fit the room its parent gives
* it (a narrow map, such as the iPad's beside the tour pane).
* `vertical` stands it on the map's side, icons only (the 3D map, where its
* bottom row has no room left for it).
*/
export function TourMapLayerSwitcher({
active,
@@ -271,7 +293,8 @@ export function TourMapLayerSwitcher({
ferrataLayer,
placeTool,
layers,
compact = false,
compact: compactProp,
vertical = false,
}: {
active: TourBaseLayer
onChange: (layer: TourBaseLayer) => void
@@ -285,15 +308,23 @@ export function TourMapLayerSwitcher({
/** Offers the "set points" switch, while a tour is being edited. */
placeTool?: MapPlaceTool
compact?: boolean
vertical?: boolean
}) {
const { t } = useTranslation()
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
const bases = layers?.bases
const overlays = layers?.overlays
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
const health = layers?.baseHealth
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
<div
ref={attachFit}
style={shellStyle(vertical)}
role="group"
aria-label={t('settings.mapStyle')}
data-layout={layout}
>
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = t(labelKey)
@@ -321,7 +352,7 @@ export function TourMapLayerSwitcher({
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
</span>
)}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
+3 -2
View File
@@ -73,7 +73,8 @@ import VectorBasemap from './VectorBasemap'
import { nativeRasterTemplate } from '../../native/tileCache'
import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, type TourBaseLayer } from './MapLayerSwitcher'
import { switcherRowStyle } from './switcherRow'
import { MapLockPill } from './MapLockPill'
import type { ViewportPadding } from '../../utils/mapViewport'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
@@ -1126,7 +1127,7 @@ export const MapView = memo(function MapView({
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...switcherRowStyle(leftWidth, rightWidth), bottom: switcherBottom }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
@@ -40,7 +40,10 @@ vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
}));
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
vi.mock('../../native/tileCache', () => ({
tileCacheActive: () => env.tileStore,
glTileStoreActive: () => env.tileStore,
}));
vi.mock('../../native/platform', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/platform')>()),
isNativeIos: () => false,
+2 -2
View File
@@ -13,7 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useIsDark } from '../../hooks/useIsDark'
import { OFM_POSITRON } from '../../constants/mapDefaults'
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
import { tileCacheActive } from '../../native/tileCache'
import { glTileStoreActive, tileCacheActive } from '../../native/tileCache'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
@@ -50,7 +50,7 @@ export function MapViewAuto(props: MapViewAutoProps) {
// nothing stored is swapped for the stored one straight away.
const nativeStore = tileCacheActive()
const [preferStored, setPreferStored] = useState(false)
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
const mapboxKept = configuredGl === 'mapbox-gl' && glTileStoreActive()
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
+3 -3
View File
@@ -12,7 +12,8 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { applyBaseLayer } from './glRasterLayers'
import { reportGlViewport } from './glViewport'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { switcherRowStyle } from './switcherRow'
import { MapLockPill } from './MapLockPill'
import { useMarkerThumbs } from './useMarkerThumbs'
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
@@ -2060,8 +2061,7 @@ export function MapViewGL({
card too, and used to send the pill to the top of the map for a corner it never
covers. */}
<div style={{
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
display: 'flex', flexDirection: 'column', gap: 8,
...switcherRowStyle(leftWidth, rightWidth),
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
@@ -7,7 +7,7 @@
* change, and what is left behind on teardown.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { act, render, waitFor } from '@testing-library/react'
import type * as L from 'leaflet'
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
import { hasWebGL } from '../../utils/webgl'
@@ -39,6 +39,8 @@ vi.mock('react-leaflet', () => ({
// jsdom has no WebGL, so without this every case below would take the raster
// branch. The suite is about the GL wiring; the raster branch has its own cases.
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
const platform = vi.hoisted(() => ({ ios: false }))
vi.mock('../../native/platform', () => ({ isNativeIos: () => platform.ios, isNativeApp: () => platform.ios }))
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const maplibreGL = vi.fn(() => {
@@ -47,6 +49,8 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const gl = {
setStyle: vi.fn(),
on: vi.fn(),
once: vi.fn(),
off: vi.fn(),
isStyleLoaded: vi.fn(() => false),
getStyle: vi.fn(() => ({
layers: [
@@ -73,6 +77,8 @@ type Spy = ReturnType<typeof vi.fn>
interface MockGl {
setStyle: Spy
on: Spy
once: Spy
off: Spy
isStyleLoaded: Spy
getStyle: Spy
setLayoutProperty: Spy
@@ -124,6 +130,42 @@ describe('VectorBasemap', () => {
expect(maplibreGL).toHaveBeenCalledTimes(1)
})
it('FE-COMP-VECBM-020: a style that errors before it loads gives way to raster tiles; later errors do not', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { findByTestId, queryByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
const { layer, gl } = lastLayer()
const onError = gl.on.mock.calls.find(([name]) => name === 'error')?.[1] as (e: { error?: unknown }) => void
expect(gl.once).toHaveBeenCalledWith('style.load', expect.any(Function))
expect(queryByTestId('raster-basemap')).toBeNull()
act(() => onError({ error: new Error('trek-offline failed') }))
expect(await findByTestId('raster-basemap')).toBeInTheDocument()
expect(layer.remove).toHaveBeenCalled()
expect(gl.off).toHaveBeenCalledWith('error', onError)
// A second map whose style did load keeps its vector basemap through a tile error.
render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(2))
const second = lastLayer()
const secondError = second.gl.on.mock.calls.find(([name]) => name === 'error')?.[1] as (e: { error?: unknown }) => void
;(second.gl.once.mock.calls.find(([name]) => name === 'style.load')?.[1] as () => void)()
act(() => secondError({ error: new Error('one tile') }))
expect(second.layer.remove).not.toHaveBeenCalled()
warn.mockRestore()
})
it('FE-COMP-VECBM-021: in the iOS app the basemap is raster tiles, and no GL layer is built', async () => {
platform.ios = true
try {
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
expect(await findByTestId('raster-basemap')).toBeInTheDocument()
expect(maplibreGL).not.toHaveBeenCalled()
} finally {
platform.ios = false
}
})
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
+44 -4
View File
@@ -11,6 +11,8 @@ import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
import { isGpuInitFailure } from './glSupport'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
import { offlineStyle } from '../../native/offlineTiles'
import { isNativeIos } from '../../native/platform'
import { nativeRasterTemplate } from '../../native/tileCache'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
@@ -107,13 +109,43 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
return true
}
/*
* Whether the vector basemap can draw here. In the iOS app the MapLibre layer
* inside Leaflet drew nothing at all, with no error to fall back on (iPhone and
* iPad on iOS 26 and 27, 11/2026), while raster tiles and the 3D map drew; until
* that is traced on a device, the app draws Leaflet's basemap as raster tiles,
* through its tile store so they stay offline too.
*/
function vectorBasemapDraws(): boolean {
return hasWebGL() && !isNativeIos()
}
/**
* Calls `fail` once when the layer's style errors before it ever loaded. Errors
* after that (a tile here and there) are the map's ordinary weather and leave it be.
*/
function onStyleFailure(layer: GlLeafletLayer, fail: () => void): void {
const gl = layer.getMaplibreMap?.()
if (!gl) return
let loaded = false
gl.once('style.load', () => {
loaded = true
})
const onError = () => {
if (loaded) return
gl.off('error', onError)
fail()
}
gl.on('error', onError)
}
/**
* The raster stand-in, attached imperatively for the maps that build Leaflet
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
* below instead, the way they already draw a user's own raster template.
*/
function attachRasterFallback(map: L.Map): L.TileLayer {
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
const layer = L.tileLayer(nativeRasterTemplate(RASTER_FALLBACK_TILE_URL), {
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
referrerPolicy: 'strict-origin-when-cross-origin',
} as L.TileLayerOptions)
@@ -174,7 +206,7 @@ export function VectorBasemap({ style }: { style: string }) {
// State rather than a plain constant: the probe can say yes and the attach can
// still fail once the page has spent its context budget, and that has to reach
// the render or the raster tiles never appear.
const [glUsable, setGlUsable] = useState(hasWebGL)
const [glUsable, setGlUsable] = useState(vectorBasemapDraws)
useEffect(() => {
if (!glUsable) return
@@ -201,6 +233,14 @@ export function VectorBasemap({ style }: { style: string }) {
return
}
layerRef.current = layer
// A style that never loads (its server, or the iOS app's offline protocol
// failing) would leave the map white under its markers: raster instead.
onStyleFailure(layer, () => {
if (cancelled || layerRef.current !== layer) return
detachBasemapLayer(layer)
layerRef.current = null
setGlUsable(false)
})
})()
return () => {
@@ -222,7 +262,7 @@ export function VectorBasemap({ style }: { style: string }) {
return (
<TileLayer
key="webgl-fallback"
url={RASTER_FALLBACK_TILE_URL}
url={nativeRasterTemplate(RASTER_FALLBACK_TILE_URL)}
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
keepBuffer={8}
@@ -256,7 +296,7 @@ export async function attachVectorBasemap(
): Promise<void> {
// Same gate as the component: a browser without WebGL never pays for the
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
if (!hasWebGL()) {
if (!vectorBasemapDraws()) {
if (!cancelled()) ref.current = attachRasterFallback(map)
return
}
+24
View File
@@ -0,0 +1,24 @@
import type { CSSProperties } from 'react';
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher';
/** What the switcher row leaves free at the map's trailing edge: the locate button (20px off the edge, 44px wide) and a gap. */
const LOCATE_BUTTON_ROOM = 76;
/**
* The column along a map's bottom edge that holds the layer switcher, both
* renderers alike. Its width ends before the locate button, so the switcher can
* tell when its words no longer fit (useLabelFit) and go to its icons.
*/
export function switcherRowStyle(leftWidth: number, rightWidth: number): CSSProperties {
return {
position: 'absolute',
left: leftWidth + MAP_LAYER_SWITCHER_INSET,
width: `calc(100% - ${leftWidth + MAP_LAYER_SWITCHER_INSET + rightWidth + LOCATE_BUTTON_ROOM}px)`,
zIndex: 1000,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 8,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
/**
* Whether a control row keeps its words or goes to its icons: it keeps them
* while it fits the room its parent gives it (a map's bottom edge, between the
* panels and the locate button), and goes back to them once the room has grown
* to what the words needed, so it never flips back and forth at one width.
*
* `enabled` false leaves the words on and measures nothing.
*/
export function useLabelFit(enabled: boolean) {
const [node, setNode] = useState<HTMLElement | null>(null);
const [iconsOnly, setIconsOnly] = useState(false);
// The width the row needed with its words, once it had to give them up.
const needed = useRef<number | null>(null);
const check = useCallback(() => {
const room = node?.parentElement?.clientWidth;
if (!node || !room) return;
setIconsOnly((current) => {
if (current) return needed.current !== null && room >= needed.current ? false : current;
if (node.scrollWidth > room + 1) {
needed.current = node.scrollWidth;
return true;
}
return false;
});
}, [node]);
useLayoutEffect(() => {
if (enabled) check();
});
useLayoutEffect(() => {
const parent = node?.parentElement;
if (!enabled || !parent || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => check());
observer.observe(parent);
return () => observer.disconnect();
}, [enabled, node, check]);
return { attach: setNode, iconsOnly: enabled && iconsOnly };
}
@@ -103,7 +103,10 @@ describe('localizedParams', () => {
});
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({
trip: '2026-11-05 at noon',
n: '3',
});
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
});
});
@@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => {
)
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '450' } })
fireEvent.blur(weightField)
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
})
@@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => {
bagTrackingEnabled: true,
})
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '' } })
fireEvent.blur(weightField)
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
})
@@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
const { container } = setup({ bagTrackingEnabled: true })
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
const weightField = container.querySelector('input[placeholder="—"]')!
fireEvent.change(weightField, { target: { value: '450' } })
fireEvent.blur(weightField)
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
})
@@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => {
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
expect(weight.readOnly).toBe(true)
fireEvent.change(weight, { target: { value: '450' } })
fireEvent.blur(weight)
fireEvent.click(bagButton(container))
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
@@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => {
const weight = fieldOf(container, 'Total weight')
fireEvent.change(weight, { target: { value: '900' } })
fireEvent.blur(weight)
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
// The first entry mirrors the current bag and clears it when tapped.
@@ -945,6 +953,7 @@ describe('ArtikelZeile — overflow menu', () => {
fireEvent.click(overflowTrigger(container))
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
fireEvent.blur(fieldOf(container, 'Total weight'))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
@@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => {
fireEvent.click(overflowTrigger(container))
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
fireEvent.blur(fieldOf(container, 'Total weight'))
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
})
@@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
import { WeightInput } from './PackingListPanelWeightInput'
import { bringersOf, packingBringerNames } from './packingBringers'
import { Tooltip } from '../shared/Tooltip'
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
@@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
catch { toast.error(t('common.error')) }
}
const saveWeight = async (grams: number | null) => {
try { await updatePackingItem(tripId, item.id, { weight_grams: grams }) } catch { toast.error(t('packing.toast.saveError')) }
}
const canDrag = canEdit && !isPlaceholder && !!drag
const selectedBag = bags.find(b => b.id === item.bag_id)
const quantity = item.quantity || 1
@@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
{bagTrackingEnabled && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
<NumericInput
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onValueChange={async raw => {
if (!canEdit) return
const v = raw === '' ? null : Number.parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
}}
placeholder="—"
style={{ width: 36, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
/>
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
</div>
<div style={{ position: 'relative' }}>
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
<NumericInput
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onValueChange={async raw => {
const v = raw === '' ? null : Number.parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
}}
placeholder="—"
style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
/>
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
</div>
</div>
@@ -0,0 +1,77 @@
// FE-COMP-PACKING-095 to FE-COMP-PACKING-098
import { useState } from 'react';
import { vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { WeightInput } from './PackingListPanelWeightInput';
/** A row whose stored weight changes only once a save answers, as the store does. */
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
const [stored, setStored] = useState<number | null>(null);
return (
<WeightInput
value={stored}
width={36}
onSave={(grams) => {
onSave(grams);
setTimeout(() => setStored(grams), 300);
}}
/>
);
}
describe('WeightInput', () => {
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
const onSave = vi.fn();
render(<SlowRow onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.focus(input);
for (const typed of ['2', '25', '250']) {
fireEvent.change(input, { target: { value: typed } });
expect(input.value).toBe(typed);
}
expect(onSave).not.toHaveBeenCalled();
fireEvent.blur(input);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(250);
await waitFor(() => expect(input.value).toBe('250'));
});
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
const onSave = vi.fn();
render(<WeightInput value={300} width={36} onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
input.focus();
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSave).toHaveBeenCalledWith(null);
});
it('FE-COMP-PACKING-097: leaves an unchanged weight alone and follows a change from elsewhere', () => {
const onSave = vi.fn();
const { rerender } = render(<WeightInput value={300} width={36} onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
rerender(<WeightInput value={450} width={36} onSave={onSave} />);
expect(input.value).toBe('450');
});
it('FE-COMP-PACKING-098: a read-only field never saves', () => {
const onSave = vi.fn();
render(<WeightInput value={300} width={36} readOnly onSave={onSave} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '9' } });
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { useState, type CSSProperties } from 'react';
import { NumericInput } from '../shared/NumericInput';
interface Props {
value: number | null | undefined;
onSave: (grams: number | null) => void | Promise<void>;
readOnly?: boolean;
width: number;
}
/**
* The weight of a packing item, in grams. It keeps what is typed in a draft of
* its own and saves once, on blur or Enter, the way the quantity does: saving
* every keystroke let a slow reply put the stored value back under the cursor,
* so the next digit went onto the old number and a fragment was saved.
*/
export function WeightInput({ value, onSave, readOnly, width }: Props) {
const stored = value == null ? '' : String(value);
// What is being typed, while the field has it; null shows the stored weight,
// so a change from elsewhere (another member) shows whenever nobody types.
const [draft, setDraft] = useState<string | null>(null);
// A saved weight until the store has it, so the field does not fall back to
// the old number for the length of the request.
const [pending, setPending] = useState<string | null>(null);
const shown = draft ?? pending ?? stored;
const commit = () => {
if (draft === null) return;
const typed = draft;
setDraft(null);
if (readOnly) return;
const grams = typed.trim() === '' ? null : Math.max(0, Number.parseInt(typed, 10) || 0);
const next = grams == null ? '' : String(grams);
if (next === stored) return;
setPending(next);
void Promise.resolve(onSave(grams)).finally(() => setPending(null));
};
const style: CSSProperties = {
width,
border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
textAlign: 'end',
fontFamily: 'inherit',
outline: 'none',
color: 'var(--text-secondary)',
background: 'transparent',
padding: 0,
};
return (
<NumericInput
value={shown}
readOnly={readOnly}
onFocus={() => setDraft(shown)}
onValueChange={setDraft}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}}
placeholder="—"
style={style}
/>
);
}
@@ -1,60 +1,17 @@
import { useEffect, useId, useRef, useState } from 'react'
import { useId } from 'react'
import { MoreHorizontal, Link2 } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useTripStore } from '../../store/tripStore'
import { googleRouteRepo } from '../../repo/googleRouteRepo'
import { generateUUID } from '../../sync/mutationQueue'
import { getApiErrorMessage } from '../../types'
import type { GoogleRoutePreview } from '@trek/shared'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import CustomSelect from '../shared/CustomSelect'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { Tooltip } from '../shared/Tooltip'
import { useGoogleRouteImport } from './useGoogleRouteImport'
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
const { t } = useTranslation()
const days = useTripStore(s => s.days)
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null)
const [target, setTarget] = useState('')
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState('')
const [saved, setSaved] = useState(false)
const controller = useRef<AbortController | null>(null)
const requestId = useRef('')
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId)
const menu = useContextMenu()
const labelId = useId()
useEffect(() => () => controller.current?.abort(), [])
const inspect = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setFailure(''); setPreview(null)
try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) }
catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) }
finally { if (!abort.signal.aborted) setBusy(false) }
}
const save = async () => {
if (!preview || !target) return
setBusy(true); setFailure('')
try {
const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! }))
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current)
setSaved(true)
await useTripStore.getState().loadTrip(tripId)
setOpen(false)
} catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) }
finally { setBusy(false) }
}
const show = () => {
setUrl(''); setPreview(null); setFailure(''); setSaved(false)
setTarget(String(dayId ?? days[0]?.id ?? ''))
requestId.current = generateUUID(); setOpen(true)
}
// Every way out is held while a preview or an import is in flight, as before.
const close = () => { if (!busy) setOpen(false) }
const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? []
return <>
<Tooltip label={t('roadtrip.import.title')}>
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
@@ -84,7 +41,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
<DialogFooter>
<FooterSpacer />
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}>
<DialogButton variant="primary" disabled={!canSubmit} onClick={submit}>
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
</DialogButton>
</DialogFooter>
@@ -94,7 +51,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50"
style={fs(13, 'body')} /> : <>
<DialogSection label={t('roadtrip.import.day')}>
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
<CustomSelect value={target} onChange={value => pickDay(String(value))}
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
</DialogSection>
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
@@ -0,0 +1,92 @@
import type { GoogleRoutePreview } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n/TranslationContext';
import { googleRouteRepo } from '../../repo/googleRouteRepo';
import { useTripStore } from '../../store/tripStore';
import { generateUUID } from '../../sync/mutationQueue';
import { getApiErrorMessage } from '../../types';
/**
* A route shared from Google Maps, read and appended to a day of the trip: the logic
* behind the desktop's import dialog (GoogleRouteImport) and the large screens' sheet
* (MRtRouteImportSheet), so the two cannot drift.
*
* A link is previewed first, the stops it names in order, and only the ones that resolved
* to a place are appended, at least two of them. One request id per opening and per day
* picked, so a retried append lands once. Every way out is held while a preview or an
* import is in flight.
*/
export function useGoogleRouteImport(tripId: number, dayId?: number) {
const { t } = useTranslation();
const days = useTripStore((s) => s.days);
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null);
const [target, setTarget] = useState('');
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState('');
const [saved, setSaved] = useState(false);
const controller = useRef<AbortController | null>(null);
const requestId = useRef('');
useEffect(() => () => controller.current?.abort(), []);
const inspect = async () => {
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setBusy(true);
setFailure('');
setPreview(null);
try {
const parsed = await googleRouteRepo.preview(url, abort.signal);
if (!abort.signal.aborted) setPreview(parsed);
} catch (error) {
if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error')));
} finally {
if (!abort.signal.aborted) setBusy(false);
}
};
const save = async () => {
if (!preview || !target) return;
setBusy(true);
setFailure('');
try {
const stops = preview.stops
.filter((s) => s.lat != null && s.lng != null)
.map((s) => ({ name: s.name, lat: s.lat!, lng: s.lng! }));
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current);
setSaved(true);
await useTripStore.getState().loadTrip(tripId);
setOpen(false);
} catch (error) {
setFailure(getApiErrorMessage(error, t('common.error')));
} finally {
setBusy(false);
}
};
const show = () => {
setUrl('');
setPreview(null);
setFailure('');
setSaved(false);
setTarget(String(dayId ?? days[0]?.id ?? ''));
requestId.current = generateUUID();
setOpen(true);
};
const close = () => {
if (!busy) setOpen(false);
};
/** Another day is another append, so it gets a request id of its own. */
const pickDay = (value: string) => {
setTarget(value);
requestId.current = generateUUID();
};
const resolved = preview?.stops.filter((s) => s.lat != null && s.lng != null) ?? [];
const canSubmit = !busy && !saved && (!preview ? !!url.trim() : resolved.length >= 2 && !!target);
/** The one button: the preview while there is none, the import once there is. */
const submit = () => {
void (preview ? save() : inspect());
};
return { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit };
}
@@ -21,6 +21,7 @@ import { NewerBookOverlay } from './SaveIndicator'
import { StudioNotices } from './StudioNotices'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import '../../styles/studio-floating.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
@@ -206,7 +206,7 @@ describe('the rail', () => {
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
expect(columns).toEqual([
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
'minmax(64px, min-content) 236px minmax(0, 1fr)',
'minmax(64px, min-content) 236px minmax(0, 1fr) 280px',
'minmax(64px, min-content) minmax(0, 1fr)',
]);
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../../store/settingsStore'
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
import { formatDistance } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import { fs } from '../../shared/DialogShell'
import { useRailHeadProps } from './railHead'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { Tooltip } from '../../shared/Tooltip'
@@ -74,7 +75,6 @@ import { SkiPlannerSummary } from '../ski/SkiRouteSummary'
import { walkTimeFields } from '../walk/walkTimeFields'
/** The tinted head band both rails open with, like every panel head in the planner. */
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5'
/** The title of a rail's head band, with an optional line under it. */
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
@@ -136,10 +136,11 @@ function NeutralRail({
canEdit: boolean
onOpenWanderer?: () => void
}) {
const railHead = useRailHeadProps({ bare: true })
const { t } = useTranslation()
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<button
type="button"
disabled={!canEdit}
@@ -207,6 +208,7 @@ function GpxViewRail({
paddleChecklist?: PaddleChecklist
skiClassification?: SkiClassification
}) {
const railHead = useRailHeadProps()
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
const { readOnlyGpxTour } = planner
@@ -226,7 +228,7 @@ function GpxViewRail({
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton
label={t('tours.planner.backToTours')}
onClick={() => planner.returnToNeutral({ keepView: true })}
@@ -328,6 +330,7 @@ export function TourPlannerRail({ planner,
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>
} & TourPermissionProps) {
const railHead = useRailHeadProps()
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const [confirmDiscard, setConfirmDiscard] = useState(false)
@@ -364,7 +367,7 @@ export function TourPlannerRail({ planner,
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
@@ -673,6 +676,7 @@ function stagesFold(tour: TourListItem, rail: ReturnType<typeof useTourStageRail
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const railHead = useRailHeadProps()
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const toast = useToast()
@@ -734,7 +738,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
</div>
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { FloatingRailHeads, RAIL_HEAD, useRailHeadProps } from './railHead';
function Head() {
return <div data-testid="head" {...useRailHeadProps()} />;
}
describe('rail heads', () => {
it('FE-TOUR-RAILHEAD-001: a ruled, tinted strip on the desktop, a rounded card-like band in the touch tab', () => {
const { unmount } = render(<Head />);
expect(screen.getByTestId('head').className).toBe(RAIL_HEAD);
expect(screen.getByTestId('head').getAttribute('style')).toContain('background');
unmount();
render(
<FloatingRailHeads value>
<Head />
</FloatingRailHeads>
);
const head = screen.getByTestId('head');
expect(head.className).toContain('rounded-2xl');
expect(head.className).not.toContain('border-b');
expect(head.getAttribute('style')).toBeNull();
});
it('FE-TOUR-RAILHEAD-002: a head of buttons only stands without a band in the touch tab, and keeps its strip on the desktop', () => {
function Bare() {
return <div data-testid="bare" {...useRailHeadProps({ bare: true })} />;
}
const { unmount } = render(<Bare />);
expect(screen.getByTestId('bare').className).toBe(RAIL_HEAD);
unmount();
render(
<FloatingRailHeads value>
<Bare />
</FloatingRailHeads>
);
const bare = screen.getByTestId('bare');
expect(bare.className).not.toMatch(/border|rounded|bg-/);
expect(bare.getAttribute('style')).toBeNull();
});
});
@@ -0,0 +1,31 @@
import { createContext, use } from 'react';
import { NEUTRAL_TINT } from '../../shared/DialogShell';
/** The head band of a tours rail: ruled off from the rail under it, as the desktop planner shows it. */
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5';
/** The head as a rounded band of its own, inset and shaped like the cards under it. */
const FLOATING_RAIL_HEAD =
'mx-3 mt-3 flex flex-none items-start gap-2 rounded-2xl border border-edge-faint bg-surface-secondary px-3 py-2';
/**
* Set by the touch shell's tours tab: there each rail's head (the trip's tours,
* a new or saved tour, a GPX track, the wanderer trails) is a rounded band like
* the cards below it rather than a ruled strip across the pane.
*/
export const FloatingRailHeads = createContext(false);
/** A head that holds only buttons: in the touch tab they stand on their own, without a band. */
const BARE_RAIL_HEAD = 'mx-3 mt-3 flex flex-none items-start gap-2';
/**
* The class and style of a rail's head band, as the surrounding tab wants it.
* `bare`: the head holds only buttons (the editor with nothing open).
*/
export function useRailHeadProps({ bare = false }: { bare?: boolean } = {}): {
className: string;
style?: { background: string };
} {
if (!use(FloatingRailHeads)) return { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } };
return { className: bare ? BARE_RAIL_HEAD : FLOATING_RAIL_HEAD };
}
@@ -1,6 +1,7 @@
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-018
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetSizeClass, setSizeClass } from '../../../../tests/helpers/sizeClass';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
import TourTerrainMap, { type TourTerrainMapProps } from './TourTerrainMap';
@@ -257,6 +258,17 @@ describe('TourTerrainMap', () => {
expect(screen.queryByRole('button', { name: 'Play flyover' })).toBeNull();
});
it('FE-COMP-TERRAIN-002b: on an iPad or an unfolded foldable the how-to line stays out of the map', async () => {
setSizeClass('split');
try {
await renderTerrain();
expect(screen.queryByText('Open a tour on the right to fly it in 3D and colour it by grade.')).toBeNull();
expect(screen.queryByText(/Click the terrain to set points/)).toBeNull();
} finally {
resetSizeClass();
}
});
it('FE-COMP-TERRAIN-003: a click on the terrain adds a control point only while editing', async () => {
const onAdd = vi.fn();
const { rerender } = await renderTerrain({ waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd } });
@@ -1,6 +1,7 @@
import type { FerrataBbox } from '@trek/shared';
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useIsWide } from '../../../hooks/sizeClass';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
@@ -84,11 +85,25 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
const stats = useMemo(() => terrainStats(samples), [samples]);
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
const touchShell = useIsWide();
const legend =
terrain.canReplay && !props.water ? (
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} compact={fit === 'side'} />
) : null;
const side = fit === 'side';
const legendAbove = fit === 'stacked';
const layerSwitcher = (
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
layers={props.tourLayers}
compact={fit !== 'full'}
/>
);
useEffect(() => {
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
@@ -112,35 +127,38 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
}}
>
{/* Too narrow for one row (an iPad beside the tour pane): the flyover controls
and the grade legend stand on the trailing edge as one column of icons, and
the bottom row keeps the layer pill alone. */}
{side && (
<div className="flex flex-col items-center gap-2" data-testid="terrain-side-column">
{legend}
{terrain.canReplay && <TerrainDock terrain={terrain} water={props.water} vertical />}
</div>
)}
<BaseMapCredit layer={baseLayer} />
{legendAbove && legend}
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
<div className="flex-none">
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
layers={props.tourLayers}
compact={fit !== 'full'}
/>
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span />
{terrain.canReplay ? (
<TerrainDock terrain={terrain} water={props.water} />
) : (
<div
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
</div>
)}
<div className="flex justify-end">{!legendAbove && legend}</div>
</div>
<div className="flex-none">{layerSwitcher}</div>
{!side && (
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span />
{terrain.canReplay ? (
<TerrainDock terrain={terrain} water={props.water} />
) : touchShell ? (
// A tablet's map is short on room, and its user learns the gestures by touch: no how-to line.
<span />
) : (
<div
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
</div>
)}
<div className="flex justify-end">{!legendAbove && legend}</div>
</div>
)}
</div>
</div>
</div>
@@ -206,21 +224,89 @@ function IconButton({
);
}
function Divider() {
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
function Divider({ vertical = false }: { vertical?: boolean }) {
return <span aria-hidden className={`flex-none bg-edge ${vertical ? 'my-0.5 h-px w-5' : 'mx-0.5 h-5 w-px'}`} />;
}
type TerrainControls = Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
/** The relief steps the column's relief button goes through, a tap each. */
const RELIEF_STEPS = [1, 1.5, 2, 3] as const;
/** The next step after `value` in `steps`, round again after the last. */
function nextStep<T extends number>(steps: readonly T[], value: number): T {
const at = steps.findIndex((step) => step >= value - 0.001);
return steps[at < 0 || at === steps.length - 1 ? 0 : at + 1];
}
/** The column's half after play, restart and overview: speed and relief a tap each, the grade switch as its icon. */
function DockColumnTail({ terrain, water }: { terrain: TerrainControls; water?: boolean }) {
const { t } = useTranslation();
const grade = terrain.colorMode === 'grade';
return (
<>
<Divider vertical />
<Tooltip label={t('tours.terrain.speed')} placement="left">
<button
type="button"
aria-label={`${t('tours.terrain.speed')}: ${terrain.speed}×`}
onClick={() => terrain.setSpeed(nextStep(SPEEDS, terrain.speed))}
className="h-[34px] w-[34px] rounded-full font-semibold tabular-nums text-content-muted hover:bg-surface-hover"
style={fs(11)}
>
{terrain.speed}×
</button>
</Tooltip>
<Tooltip label={t('tours.terrain.exaggeration')} placement="left">
<button
type="button"
aria-label={`${t('tours.terrain.exaggeration')}: ${terrain.exaggeration.toFixed(1)}×`}
onClick={() => terrain.setExaggeration(nextStep(RELIEF_STEPS, terrain.exaggeration))}
className="flex h-[34px] w-[34px] flex-col items-center justify-center rounded-full font-semibold tabular-nums text-content-muted hover:bg-surface-hover"
style={fs(9)}
>
<TrendingUp size={13} strokeWidth={2} aria-hidden />
{terrain.exaggeration.toFixed(1)}
</button>
</Tooltip>
{!water && (
<>
<Divider vertical />
<Tooltip label={t('tours.terrain.colorByGrade')} placement="left">
<button
type="button"
aria-label={t('tours.terrain.colorByGrade')}
aria-pressed={grade}
onClick={() => terrain.setColorMode(grade ? 'tour' : 'grade')}
className={`flex h-[34px] w-[34px] items-center justify-center rounded-full ${grade ? 'bg-accent-soft text-accent' : 'text-content-muted hover:bg-surface-hover'}`}
>
<Palette size={15} strokeWidth={2} />
</button>
</Tooltip>
</>
)}
</>
);
}
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
function TerrainDock({
terrain,
water,
vertical = false,
}: {
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
terrain: TerrainControls;
water?: boolean;
/** A column of icons on the map's trailing edge (the `side` fit). */
vertical?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
<div
className={`pointer-events-auto flex items-center gap-0.5 rounded-full p-1 ${vertical ? 'flex-col' : ''}`}
style={GLASS}
data-testid="terrain-dock"
>
{terrain.preloaded < 1 ? (
<PreloadRing
share={terrain.preloaded}
@@ -241,6 +327,16 @@ function TerrainDock({
<IconButton label={t('tours.terrain.overview')} onClick={terrain.overview}>
<Scan size={15} strokeWidth={2} />
</IconButton>
{vertical ? <DockColumnTail terrain={terrain} water={water} /> : <DockRowTail terrain={terrain} water={water} />}
</div>
);
}
/** The row's half after play, restart and overview: the speeds, the relief slider and the grade switch. */
function DockRowTail({ terrain, water }: { terrain: TerrainControls; water?: boolean }) {
const { t } = useTranslation();
return (
<>
<Divider />
<div className="flex items-center" role="group" aria-label={t('tours.terrain.speed')}>
{SPEEDS.map((speed) => (
@@ -275,7 +371,7 @@ function TerrainDock({
</label>
</Tooltip>
{!water && <GradeSwitch terrain={terrain} />}
</div>
</>
);
}
@@ -336,14 +432,42 @@ function PreloadRing({ share, label }: { share: number; label: string }) {
}
/** The grade mix as a pill like the dock; it unfolds upwards into the bands. */
/** The highest point and the steepest grade, as the legend's soft pills. */
function LegendFacts({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) {
const { t } = useTranslation();
return (
<>
{stats.summit && (
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
{formatElevation(stats.summit.elevationMeters, unit)}
</SoftPill>
)}
{stats.steepest && (
<SoftPill
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
>
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
</SoftPill>
)}
</>
);
}
/**
* `compact` (the map's side, on a narrow 3D map): the bar and the chevron
* only; the highest point and the steepest grade move into the opened card.
*/
function GradeLegend({
stats,
unit,
onLayout,
compact = false,
}: {
stats: TerrainStats;
unit: DistanceUnit;
onLayout?: (layout: TerrainLegendLayout | null) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -373,9 +497,14 @@ function GradeLegend({
<div
ref={panelRef}
id="tour-terrain-grades"
className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3"
className={`absolute rounded-2xl p-3 ${compact ? 'bottom-0 end-full me-2 w-[240px]' : 'inset-x-0 bottom-full mb-2'}`}
style={GLASS}
>
{compact && (
<div className="mb-2 flex flex-wrap gap-1.5">
<LegendFacts stats={stats} unit={unit} />
</div>
)}
<ul className="flex flex-col gap-1">
{GRADE_BANDS.map((band) => (
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
@@ -398,27 +527,29 @@ function GradeLegend({
aria-expanded={open}
aria-controls="tour-terrain-grades"
aria-label={t('tours.terrain.gradeMix')}
className="flex h-[42px] items-center gap-2 rounded-full pe-2 ps-3"
className={
compact
? 'flex w-[42px] flex-col items-center gap-2 rounded-full pb-1 pt-3'
: 'flex h-[42px] items-center gap-2 rounded-full pe-2 ps-3'
}
style={GLASS}
>
<span className="flex h-1.5 w-14 overflow-hidden rounded-full bg-surface-hover" aria-hidden>
<span
className={`flex overflow-hidden rounded-full bg-surface-hover ${compact ? 'h-14 w-1.5 flex-col' : 'h-1.5 w-14'}`}
aria-hidden
>
{GRADE_BANDS.map((band) => (
<span key={band.key} style={{ width: width(band.key), background: band.paint }} />
<span
key={band.key}
style={
compact
? { height: width(band.key), background: band.paint }
: { width: width(band.key), background: band.paint }
}
/>
))}
</span>
{stats.summit && (
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
{formatElevation(stats.summit.elevationMeters, unit)}
</SoftPill>
)}
{stats.steepest && (
<SoftPill
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
>
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
</SoftPill>
)}
{!compact && <LegendFacts stats={stats} unit={unit} />}
<span className="flex h-[26px] w-[26px] items-center justify-center rounded-full text-content-muted">
{open ? <ChevronDown size={14} strokeWidth={2.2} /> : <ChevronUp size={14} strokeWidth={2.2} />}
</span>
@@ -1,11 +1,11 @@
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-002: the 3D map's bottom row steps down to fit between the panels.
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-003: the 3D map's bottom row steps down to fit between the panels.
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
/** What the row's content needs at each step, in pixels. */
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400 };
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400, side: 260 };
function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement {
const row = document.createElement('div');
@@ -49,4 +49,21 @@ describe('useControlRowFit', () => {
rerender();
expect(result.current.fit).toBe('full');
});
it('FE-TERRAIN-FIT-003: where even the stacked row overflows, the layer pill goes to the side, and comes back with the room', () => {
const room = { width: 300 };
const { result, rerender } = renderHook(() => useControlRowFit());
const row = fakeRow(room, () => result.current.fit);
act(() => result.current.attachRow(row));
rerender();
rerender();
expect(result.current.fit).toBe('side');
room.width = 399;
rerender();
expect(result.current.fit).toBe('side');
room.width = 400;
rerender();
expect(result.current.fit).toBe('stacked');
});
});
@@ -2,12 +2,14 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
/**
* How the 3D map's bottom row fits between the side panels: `full` with every
* label, `compact` with the layer pill on its icons, and `stacked` with the
* grade legend on a row of its own above the dock as well.
* label, `compact` with the layer pill on its icons, `stacked` with the grade
* legend on a row of its own above the dock as well, and `side` with the layer
* pill standing on the map's trailing edge, leaving the row to the dock (a map
* as narrow as the iPad's beside the tour pane).
*/
export type ControlRowFit = 'full' | 'compact' | 'stacked';
export type ControlRowFit = 'full' | 'compact' | 'stacked' | 'side';
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked'];
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked', 'side'];
/**
* Steps the row down while its content is wider than the room between the
@@ -24,10 +24,11 @@ import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
import { DialogButton, fs } from '../../shared/DialogShell';
import EmptyState from '../../shared/EmptyState';
import { Tooltip } from '../../shared/Tooltip';
import { RAIL_HEAD, RailTitle } from '../planner/TourPlannerPanels';
import { useRailHeadProps } from '../planner/railHead';
import { RailTitle } from '../planner/TourPlannerPanels';
import { TourDayRowAvatar } from '../TourDayRowFacts';
import { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
@@ -40,11 +41,12 @@ import WandererMark from './WandererMark';
* The trail itself shows on the map between the rails.
*/
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
const railHead = useRailHeadProps();
const { t } = useTranslation();
const inPreview = browser.selected !== null;
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('wanderer.browser.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
onClick={inPreview ? browser.backToList : browser.close}
@@ -44,11 +44,11 @@ describe('Skeleton', () => {
})
describe('composed skeletons', () => {
it('FE-W4SKEL-006: SpotlightSkeleton renders the hero backdrop plus two bars', () => {
it('FE-W4SKEL-006: SpotlightSkeleton takes the box of the hero and renders the backdrop plus two bars', () => {
const { container } = render(<SpotlightSkeleton />)
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
expect((container.firstElementChild as HTMLElement).style.minHeight).toBe('340px')
expect(container.firstElementChild).toHaveClass('hero-trip', 'hero-trip-pending')
})
it('FE-W4SKEL-007: TripCardSkeleton renders a cover plus two text bars', () => {
+6 -6
View File
@@ -26,13 +26,13 @@ export function Skeleton({
// Trip card skeleton matching SpotlightCard layout
export function SpotlightSkeleton(): React.ReactElement {
// The dashboard hero's own box (.hero-trip: 520px on desktop, 340px on a
// phone, and its margin), so the hero takes the place it held while loading
// and nothing under it moves when it arrives (#3009).
return (
<div
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
style={{ minHeight: 340 }}
>
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
<div className="hero-trip hero-trip-pending" aria-hidden>
<div className="trek-skeleton absolute inset-0" />
<div className="relative flex h-full flex-col justify-end p-6">
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
<Skeleton width={220} height={16} radius={4} />
</div>
@@ -223,4 +223,20 @@ describe('Tooltip — with content', () => {
expect(tip.querySelectorAll('li')).toHaveLength(2)
expect(tip).not.toHaveTextContent('brought by')
})
it('FE-W4TIP-017: a tap opens nothing, a pointer that comes along later still does', () => {
render(<Tooltip label="Add a tour" delay={0}><button>+</button></Tooltip>)
const button = screen.getByRole('button')
// The browser follows a tap with a mouseenter and never sends the mouseleave.
fireEvent.touchStart(button)
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByRole('tooltip')).toBeNull()
act(() => { vi.advanceTimersByTime(2000) })
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(10) })
expect(screen.getByRole('tooltip')).toHaveTextContent('Add a tour')
})
})
+14 -1
View File
@@ -19,12 +19,22 @@ interface TooltipProps {
children: React.ReactElement
}
/** The emulated mouse events of a tap follow it within a few hundred milliseconds. */
const TAP_MOUSE_WINDOW_MS = 1000
function touchedRecently(touchedAt: number, now: number): boolean {
return touchedAt > 0 && now - touchedAt < TAP_MOUSE_WINDOW_MS
}
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const triggerRef = useRef<HTMLElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<number | null>(null)
// When a finger last touched the trigger. A tap makes the browser send a mouseenter
// after it and never the mouseleave, so the tip would open on a tap and stay.
const touchedAtRef = useRef(0)
const show = () => {
if (disabled || !label) return
@@ -60,6 +70,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
type TriggerProps = {
ref?: React.Ref<HTMLElement>
onMouseEnter?: (e: React.MouseEvent) => void
onTouchStart?: (e: React.TouchEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
onFocus?: (e: React.FocusEvent) => void
onBlur?: (e: React.FocusEvent) => void
@@ -73,7 +84,9 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
if (typeof r === 'function') r(node)
else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
},
onMouseEnter: (e: React.MouseEvent) => { show(); childProps.onMouseEnter?.(e) },
onTouchStart: (e: React.TouchEvent) => { touchedAtRef.current = Date.now(); hide(); childProps.onTouchStart?.(e) },
// A touch screen has no hover to hint at: only a real pointer opens the tip.
onMouseEnter: (e: React.MouseEvent) => { if (!touchedRecently(touchedAtRef.current, Date.now())) show(); childProps.onMouseEnter?.(e) },
onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
// Keyboard focus only. A dialog hands focus back to the button that opened it
// when it closes, and a tooltip over whatever comes next is noise, not help.
+33 -2
View File
@@ -1,7 +1,21 @@
// FE-HOOK-KBINSET-001 to FE-HOOK-KBINSET-004
// FE-HOOK-KBINSET-001 to FE-HOOK-KBINSET-006
import { act, renderHook } from '@testing-library/react';
import { useKeyboardInset } from './useKeyboardInset';
import { _resetNativeKeyboard, useKeyboardInset } from './useKeyboardInset';
const native = vi.hoisted(() => ({
app: false,
listeners: new Map<string, (info: { keyboardHeight: number }) => void>(),
}));
vi.mock('../native/platform', () => ({ isNativeApp: () => native.app }));
vi.mock('@capacitor/keyboard', () => ({
Keyboard: {
addListener: (event: string, listener: (info: { keyboardHeight: number }) => void) => {
native.listeners.set(event, listener);
return Promise.resolve({ remove: () => Promise.resolve() });
},
},
}));
class FakeVisualViewport extends EventTarget {
height = 800;
@@ -18,6 +32,9 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
_resetNativeKeyboard();
native.app = false;
native.listeners.clear();
});
function keyboard(height: number, offsetTop = 0) {
@@ -57,4 +74,18 @@ describe('useKeyboardInset', () => {
const { result } = renderHook(() => useKeyboardInset());
expect(result.current).toBe(0);
});
it('FE-HOOK-KBINSET-005: in the app, the keyboard the app reports counts, though the viewport did not shrink', () => {
native.app = true;
const { result } = renderHook(() => useKeyboardInset());
act(() => native.listeners.get('keyboardWillShow')?.({ keyboardHeight: 336.4 }));
expect(result.current).toBe(336);
act(() => native.listeners.get('keyboardWillHide')?.({ keyboardHeight: 0 }));
expect(result.current).toBe(0);
});
it('FE-HOOK-KBINSET-006: in a browser nothing listens for the app keyboard', () => {
renderHook(() => useKeyboardInset());
expect(native.listeners.size).toBe(0);
});
});
+46 -10
View File
@@ -1,29 +1,65 @@
import { Keyboard } from '@capacitor/keyboard';
import { useSyncExternalStore } from 'react';
import { isNativeApp } from '../native/platform';
/**
* The keyboard's height as the app reports it (@capacitor/keyboard). The app
* resizes the body for the keyboard (KeyboardResize.Body in capacitor.config),
* which leaves the visual viewport as it was and does nothing for a screen built
* of fixed layers, as the trip shell is, so there only the app can say how much
* the keyboard covers.
*/
let nativeHeight = 0;
let followingNative = false;
const nativeListeners = new Set<() => void>();
function setNativeHeight(height: number): void {
nativeHeight = Math.max(0, Math.round(height));
for (const listener of nativeListeners) listener();
}
function followNativeKeyboard(): void {
if (followingNative || !isNativeApp()) return;
followingNative = true;
void Keyboard.addListener('keyboardWillShow', ({ keyboardHeight }) => setNativeHeight(keyboardHeight));
void Keyboard.addListener('keyboardWillHide', () => setNativeHeight(0));
}
/** How far the on-screen keyboard reaches into the layout viewport, in px; 0 when none is up. */
function readInset(): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
const fromViewport = viewport
? Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop))
: 0;
return Math.max(fromViewport, nativeHeight);
}
function subscribe(onChange: () => void): () => void {
followNativeKeyboard();
nativeListeners.add(onChange);
const viewport = window.visualViewport;
if (!viewport) return () => undefined;
viewport.addEventListener('resize', onChange);
viewport.addEventListener('scroll', onChange);
viewport?.addEventListener('resize', onChange);
viewport?.addEventListener('scroll', onChange);
return () => {
viewport.removeEventListener('resize', onChange);
viewport.removeEventListener('scroll', onChange);
nativeListeners.delete(onChange);
viewport?.removeEventListener('resize', onChange);
viewport?.removeEventListener('scroll', onChange);
};
}
/**
* The height the phone's keyboard covers at the bottom of the screen, read
* off the visual viewport. A full-screen view pads its bottom by it, so a
* composer at the foot stays above the keys instead of under them: iOS
* Safari lays the keyboard over the page without resizing it.
* off the visual viewport, and in the app off the keyboard itself. A
* full-screen view pads its bottom by it, so a composer at the foot stays above
* the keys instead of under them: iOS Safari lays the keyboard over the page
* without resizing it, and the app resizes only the body.
*/
export function useKeyboardInset(): number {
return useSyncExternalStore(subscribe, readInset, () => 0);
}
/** Test helper: forgets the keyboard the app reported and lets it be followed afresh. */
export function _resetNativeKeyboard(): void {
nativeHeight = 0;
followingNative = false;
}
+1 -5
View File
@@ -1290,7 +1290,6 @@ img[alt="TREK"] {
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
.dark .bg-slate-50 { background-color: #131316 !important; }
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
.dark .bg-white { background-color: #09090b !important; }
.dark .bg-gray-50 { background-color: #131316 !important; }
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
@@ -1307,10 +1306,7 @@ img[alt="TREK"] {
.dark .bg-slate-100 { background-color: #27272a !important; }
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
/* Not inside the touch shell (.m-root): its fields state their own fill, a
transparent input inside a rounded wrapper above all, and this rule outweighs
every utility class, so it painted a dark box inside each of them. The
:where() keeps the desktop's weight exactly as it was. */
/* Not in the touch shell (.m-root), whose fields set their own fill; :where() keeps the desktop's weight. */
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
background-color: #1c1c21;
color: #f4f4f5;
+4
View File
@@ -42,6 +42,7 @@ import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
import { startConnectivityProbe } from './sync/connectivity'
import { startNativeApp } from './native/nativeApp'
import { requestPersistentStorage } from './sync/persistentStorage'
import { isIosDevice } from './utils/iosDevice'
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
@@ -52,6 +53,9 @@ startConnectivityProbe()
startNativeApp()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
void requestPersistentStorage()
// A tile cache past the iOS budget slows every cold start of the home screen app (#3008).
// Loaded on iOS only, so the prefetcher stays out of everyone's first chunk.
if (isIosDevice()) void import('./sync/tilePrefetcher').then((m) => m.scheduleTileCacheTrim())
// Event handlers and async code never reach a boundary; this is where they land.
installGlobalErrorHandlers()
// A page the precache keeps under a stylesheet's or a script's name would be
@@ -0,0 +1,73 @@
import { X } from 'lucide-react';
import { useEffect, useState, type ReactElement } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { lockBodyScroll } from '../../utils/bodyScrollLock';
function sheetRoot(): HTMLElement {
return document.getElementById('m-sheet-root') ?? document.body;
}
/**
* One image full screen, for a picture shown small in a list (a chat
* attachment). A tap on the image zooms in, and the zoomed image pans with the
* finger as any scrolled page does; a tap beside it or the X closes the view,
* as Escape does with a keyboard. The page's own pinch zoom is off in the app,
* so the zoom is the viewer's.
*/
export default function MImageViewer({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}): ReactElement {
const { t } = useTranslation();
const [zoomed, setZoomed] = useState(false);
useEffect(() => lockBodyScroll(), []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={alt}
className="m-root fixed inset-0 z-[var(--z-overlay)] bg-[color:var(--m-viewer-bg)]"
onClick={onClose}
>
<button
type="button"
onClick={onClose}
aria-label={t('collab.chat.closeImage')}
className="absolute end-4 top-[calc(var(--m-safe-top,12px)+4px)] z-10 flex h-10 w-10 items-center justify-center rounded-full bg-[color:var(--m-viewer-btn)] text-[color:var(--m-viewer-ink)]"
>
<X size={20} strokeWidth={2} />
</button>
<div
className={`h-full w-full ${zoomed ? 'overflow-auto' : 'flex items-center justify-center overflow-hidden p-4'}`}
>
<img
src={src}
alt={alt}
onClick={(e) => {
e.stopPropagation();
setZoomed((z) => !z);
}}
className={
zoomed ? 'block w-[250%] max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain'
}
/>
</div>
</div>,
sheetRoot()
);
}
@@ -0,0 +1,85 @@
import { act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useAuthStore } from '../../store/authStore';
import MPushPrompt, { PUSH_PROMPT_DELAY_MS, PUSH_PROMPT_KEY } from './MPushPrompt';
// FE-MOB-PUSHPROMPT-001 onwards: the app asks for notifications by itself, once.
const env = vi.hoisted(() => ({
native: true,
endpoint: null as string | null,
offered: true,
on: false,
canSwitch: true,
toggle: vi.fn(),
}));
vi.mock('../../native/push', () => ({
nativePushActive: () => env.native,
nativePushEndpoint: () => env.endpoint,
}));
vi.mock('../../push/webPush', () => ({ pushChannelOffered: () => Promise.resolve(env.offered) }));
vi.mock('../../hooks/useWebPush', () => ({
useWebPush: () => ({ on: env.on, canSwitch: env.canSwitch, busy: false, toggle: env.toggle }),
}));
async function renderAndWait() {
render(<MPushPrompt />);
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
});
}
describe('MPushPrompt', () => {
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.removeItem(PUSH_PROMPT_KEY);
Object.assign(env, { native: true, endpoint: null, offered: true, on: false, canSwitch: true });
env.toggle.mockClear();
useAuthStore.setState({ isAuthenticated: true } as never);
});
afterEach(() => {
vi.useRealTimers();
});
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();
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
expect(env.toggle).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
});
it('FE-MOB-PUSHPROMPT-002: later closes it, and it is not asked again', async () => {
await renderAndWait();
fireEvent.click(screen.getByRole('button', { name: 'Not now' }));
expect(env.toggle).not.toHaveBeenCalled();
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
render(<MPushPrompt />);
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
});
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
});
it.each([
['in a browser', { native: false }],
['where the server offers no push', { offered: false }],
['on a device that is signed up already', { endpoint: 'https://push.liketrek.com/v1/apns/x' }],
])('FE-MOB-PUSHPROMPT-003: it does not ask %s', async (_case, over) => {
Object.assign(env, over);
await renderAndWait();
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
});
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
useAuthStore.setState({ isAuthenticated: false } as never);
await renderAndWait();
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import { BellRing } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useWebPush } from '../../hooks/useWebPush';
import { useTranslation } from '../../i18n';
import { nativePushActive, nativePushEndpoint } from '../../native/push';
import { pushChannelOffered } from '../../push/webPush';
import { useAuthStore } from '../../store/authStore';
import MSheet from './MSheet';
/** Asked once per device; the settings' push card is where it is changed after. */
export const PUSH_PROMPT_KEY = 'trek.pushPromptShown';
/** Long enough that the app has opened and the user sees where they are first. */
export const PUSH_PROMPT_DELAY_MS = 2500;
function alreadyAsked(): boolean {
try {
return window.localStorage.getItem(PUSH_PROMPT_KEY) != null;
} catch {
// Without storage it would ask on every launch: better not at all.
return true;
}
}
function rememberAsked(): void {
try {
window.localStorage.setItem(PUSH_PROMPT_KEY, new Date().toISOString());
} catch {
// Nothing to keep it in; alreadyAsked says yes then anyway.
}
}
/**
* The app asks for notifications by itself, once, a moment after the user is
* signed in: iOS shows its own question only when an app asks, and nobody went
* looking for the switch in the settings. Only in the app, only where this
* 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
* the system's question.
*/
export default function MPushPrompt(): React.ReactElement | null {
const signedIn = useAuthStore((s) => s.isAuthenticated);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!signedIn || !nativePushActive() || alreadyAsked() || nativePushEndpoint()) return;
let cancelled = false;
const timer = window.setTimeout(() => {
void pushChannelOffered().then((offered) => {
if (cancelled || !offered || nativePushEndpoint()) return;
rememberAsked();
setOpen(true);
});
}, PUSH_PROMPT_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [signedIn]);
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null;
}
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
const { t } = useTranslation();
const push = useWebPush();
const turnOn = () => {
if (!push.on) push.toggle();
onClose();
};
return (
<MSheet open onClose={onClose} variant="bottom" ariaLabel={t('native.pushPrompt.title')}>
<div className="flex flex-col items-center px-[22px] pb-5 pt-6 text-center">
<span
aria-hidden="true"
className="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
>
<BellRing size={26} strokeWidth={1.9} />
</span>
<h2 className="mb-2 text-[length:calc(18px*var(--fs-scale-title,1))] font-bold text-m-ink">
{t('native.pushPrompt.title')}
</h2>
<p className="mb-5 text-[length:calc(14px*var(--fs-scale-body,1))] text-m-muted">
{t('native.pushPrompt.body')}
</p>
<button
type="button"
onClick={turnOn}
disabled={!push.canSwitch || push.busy}
className="mb-2 w-full rounded-full bg-m-act py-3 text-[length:calc(15px*var(--fs-scale-body,1))] font-semibold text-m-actfg transition-transform active:scale-[.98] disabled:opacity-50"
>
{t('native.pushPrompt.enable')}
</button>
<button
type="button"
onClick={onClose}
className="w-full rounded-full py-3 text-[length:calc(15px*var(--fs-scale-body,1))] font-medium text-m-muted"
>
{t('native.pushPrompt.later')}
</button>
</div>
</MSheet>
);
}
+22 -5
View File
@@ -1,4 +1,5 @@
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
/**
* The rail on the leading edge that takes the dock's place on a wide window
@@ -27,6 +28,9 @@ export function MRail({
}) {
const listRef = useRef<HTMLDivElement>(null);
useEvenLines(listRef);
// On a short window the entries scroll; the cut edge fades, so the foot's entries read as more below.
const [list, setList] = useState<HTMLDivElement | null>(null);
const fade = useEdgeFade(list, 'y');
return (
<nav
aria-label={label}
@@ -34,8 +38,11 @@ export function MRail({
>
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
<div
ref={listRef}
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
ref={(node) => {
listRef.current = node;
setList(node);
}}
className={`flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none] ${edgeFadeClass(fade, 'y')}`}
>
{children}
</div>
@@ -90,6 +97,11 @@ export function MRailSpacer() {
interface MRailButtonProps {
label: string;
active: boolean;
/**
* Opens a sheet rather than a section (More): while that sheet is open the button wears
* the ring the phone dock's More wears, so the rail says where the sheet came from.
*/
expanded?: boolean;
onClick: () => void;
/** The icon, drawn at 20px. */
children: ReactNode;
@@ -101,19 +113,24 @@ interface MRailButtonProps {
* and never shrunk: it wraps into a second, balanced line where its language
* needs one, and only a word longer than the rail is hyphenated.
*/
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
export function MRailButton({ label, active, expanded, onClick, children }: MRailButtonProps) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-current={active ? 'page' : undefined}
aria-expanded={expanded}
className="group flex w-full flex-none flex-col items-center gap-[3px] rounded-[18px] py-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
>
<span
aria-hidden="true"
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors duration-150 ${
active ? 'bg-m-act text-m-actfg' : 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
active
? 'bg-m-act text-m-actfg'
: expanded
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
: 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
}`}
>
{children}
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import MToggle from './MToggle';
vi.mock('../../native/haptics', () => ({ selectionHaptic: vi.fn() }));
describe('MToggle', () => {
it('FE-MOB-TOGGLE-001: on, the knob takes the track ink so it stays visible on the near-white dark track', () => {
const onChange = vi.fn();
const { rerender } = render(<MToggle checked onChange={onChange} ariaLabel="Offline" />);
const knob = screen.getByRole('switch').firstElementChild as HTMLElement;
expect(knob.className).toContain('bg-[color:var(--m-actfg)]');
expect(knob.className).not.toContain('--m-knob');
rerender(<MToggle checked={false} onChange={onChange} ariaLabel="Offline" />);
expect((screen.getByRole('switch').firstElementChild as HTMLElement).className).toContain(
'bg-[color:var(--m-knob)]'
);
fireEvent.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenCalledWith(true);
});
});
+9 -3
View File
@@ -7,7 +7,11 @@ interface MToggleProps {
className?: string
}
/** iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. */
/**
* iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. The knob on
* takes the track's ink (--m-actfg): in the dark scheme the track is near white, and a
* white knob on it left the whole switch one white pill.
*/
export default function MToggle({ checked, onChange, ariaLabel, disabled = false, className = '' }: MToggleProps) {
return (
<button
@@ -27,8 +31,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
} ${className}`}
>
<span
className={`block h-[18px] w-[18px] rounded-full bg-[color:var(--m-knob)] transition-transform duration-[180ms] ${
checked ? 'translate-x-[18px] rtl:-translate-x-[18px]' : 'translate-x-0'
className={`block h-[18px] w-[18px] rounded-full transition-transform duration-[180ms] ${
checked
? 'translate-x-[18px] bg-[color:var(--m-actfg)] rtl:-translate-x-[18px]'
: 'translate-x-0 bg-[color:var(--m-knob)]'
}`}
/>
</button>
@@ -1,10 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { useSharedImportStore } from '../../native/sharedFiles';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { saveSharedLink, useSharedImportStore } from '../../native/sharedFiles';
import { tripRepo } from '../../repo/tripRepo';
import { useAddonStore } from '../../store/addonStore';
import NativeImportSheet from './NativeImportSheet';
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../native/sharedFiles', async (importOriginal) => ({
...(await importOriginal<typeof import('../../native/sharedFiles')>()),
saveSharedLink: vi.fn(),
}));
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => navigate,
@@ -14,7 +19,9 @@ beforeEach(() => {
// A second spyOn returns the first spy with its calls; start every test clean.
vi.restoreAllMocks();
navigate.mockReset();
useSharedImportStore.setState({ file: null, tripId: null });
useSharedImportStore.setState({ file: null, tripId: null, link: null });
useAddonStore.setState({ addons: [{ id: 'collab', enabled: true }] as never, loaded: true });
vi.mocked(saveSharedLink).mockReset().mockResolvedValue();
vi.spyOn(tripRepo, 'list').mockResolvedValue({
trips: [
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
@@ -24,6 +31,14 @@ beforeEach(() => {
} as unknown as Awaited<ReturnType<typeof tripRepo.list>>);
});
const page = () => new File(['<html></html>'], 'hotel.html', { type: 'text/html' });
const bareLink = () => new File(['https://hotel.example/stay'], 'link.url', { type: 'text/uri-list' });
const link = (presetTripId: number | null = null) => ({
url: 'https://hotel.example/stay',
title: 'Hotel Kanra',
presetTripId,
});
describe('NativeImportSheet', () => {
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
render(<NativeImportSheet />);
@@ -49,6 +64,58 @@ describe('NativeImportSheet', () => {
expect(navigate).toHaveBeenCalledTimes(1);
});
it('asks what a shared page is for, and keeps it among the links of the trip picked', async () => {
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(page(), link()));
expect(screen.getByText('What should TREK do with this link?')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Save to the trip's links/ }));
expect(screen.getByText('Save the link to which trip?')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Japan/ }));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab'));
expect(saveSharedLink).toHaveBeenCalledWith(2, link());
expect(useSharedImportStore.getState().file).toBeNull();
});
it('reads a booking from the page when that is asked for', async () => {
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(page(), link()));
fireEvent.click(screen.getByRole('button', { name: /Read a booking from it/ }));
fireEvent.click(await screen.findByRole('button', { name: /Japan/ }));
expect(useSharedImportStore.getState().tripId).toBe(2);
expect(navigate).toHaveBeenCalledWith('/trips/2');
expect(saveSharedLink).not.toHaveBeenCalled();
});
it('keeps a bare link in the trip the share sheet picked, with nothing to ask', async () => {
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(bareLink(), link(2)));
await waitFor(() => expect(saveSharedLink).toHaveBeenCalledWith(2, link(2)));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab'));
});
it('with Collab off, a page is read as a booking and a bare link is explained', async () => {
useAddonStore.setState({ addons: [], loaded: true });
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(page(), link()));
expect(screen.queryByText('What should TREK do with this link?')).not.toBeInTheDocument();
expect(await screen.findAllByRole('button', { name: /Japan|Past trip/ })).toHaveLength(2);
act(() => useSharedImportStore.getState().setFile(bareLink(), link()));
expect(screen.getByText('Turn on Collab to keep links in a trip.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument();
});
it('says so when the link cannot be saved, and offers the trips again', async () => {
vi.mocked(saveSharedLink).mockRejectedValue(new Error('offline'));
render(<NativeImportSheet />);
act(() => useSharedImportStore.getState().setFile(bareLink(), link(2)));
expect(await screen.findByRole('button', { name: /Japan/ })).toBeEnabled();
expect(navigate).not.toHaveBeenCalled();
expect(useSharedImportStore.getState().file).not.toBeNull();
});
it('stays closed with nothing shared', () => {
render(<NativeImportSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
@@ -1,14 +1,21 @@
/**
* NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts),
* so this asks which trip it belongs to and opens that trip's booking import with
* the file already chosen. Trips still ahead come first, the ones that ended after.
* NativeImportSheet: something was shared to TREK (native/sharedFiles.ts), so this
* asks which trip it belongs to and opens that trip's booking import with the file
* already chosen. Trips still ahead come first, the ones that ended after.
*
* A web page or a link is asked about first: kept among the trip's links
* (Collaboration, Links), or read as a booking. A bare link, whose page the phone
* could not read, can only be kept; with Collaboration off, a page can only be read.
*/
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { ChevronRight, FileText, Link2, ScanText } from 'lucide-react';
import React, { useEffect, useEffectEvent, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useToast } from '../../components/shared/Toast';
import { useTranslation } from '../../i18n';
import { useSharedImportStore } from '../../native/sharedFiles';
import { saveSharedLink, useSharedImportStore, type SharedLink } from '../../native/sharedFiles';
import { tripRepo } from '../../repo/tripRepo';
import { useAddonStore } from '../../store/addonStore';
import { offlineOr } from '../../utils/needsConnection';
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
import MSheet from './MSheet';
@@ -19,18 +26,45 @@ interface TripRow {
end_date?: string | null;
}
export default function NativeImportSheet(): React.ReactElement | null {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
const file = useSharedImportStore((s) => s.file);
const tripId = useSharedImportStore((s) => s.tripId);
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
const setFile = useSharedImportStore((s) => s.setFile);
type LinkChoice = 'link' | 'booking';
/** What to offer for a shared link: the links when Collaboration is on, the import when there is a page to read. */
function linkChoicesOf(file: File, collabOn: boolean): LinkChoice[] {
const choices: LinkChoice[] = [];
if (collabOn) choices.push('link');
if (file.type !== 'text/uri-list') choices.push('booking');
return choices;
}
interface StageInput {
file: File | null;
link: SharedLink | null;
collabOn: boolean;
picked: LinkChoice | null;
/** The share sheet's trip could not take the link: the trips are offered after all. */
presetFailed: boolean;
}
/** Where the sheet stands for the shared item: what it asks, and what a tap on a trip does. */
function stageOf({ file, link, collabOn, picked, presetFailed }: StageInput) {
if (!file || !link) return { choice: 'booking' as LinkChoice | null, asking: false, nowhere: false, showTrips: true };
const choices = linkChoicesOf(file, collabOn);
const choice = picked ?? (choices.length === 1 ? choices[0] : null);
const asking = choice == null && choices.length > 1;
const nowhere = choices.length === 0;
// While the share sheet's trip takes the link there is nothing to choose.
const presetTakes = link.presetTripId != null && choice === 'link' && !presetFailed;
return { choice, asking, nowhere, showTrips: !asking && !nowhere && !presetTakes };
}
function sheetTitle(t: (key: string) => string, asking: boolean, choice: LinkChoice | null): string {
if (asking) return t('native.share.linkTitle');
return choice === 'link' ? t('native.share.linkTrip') : t('native.share.title');
}
/** The trips to choose from, those still ahead first. */
function useTripRows(open: boolean): TripRow[] {
const [trips, setTrips] = useState<TripRow[]>([]);
const open = !!file && tripId == null;
useEffect(() => {
if (!open) return;
tripRepo.list().then(
@@ -46,6 +80,35 @@ export default function NativeImportSheet(): React.ReactElement | null {
() => setTrips([])
);
}, [open]);
return trips;
}
export default function NativeImportSheet(): React.ReactElement | null {
const { t } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const { pathname } = useLocation();
const file = useSharedImportStore((s) => s.file);
const tripId = useSharedImportStore((s) => s.tripId);
const link = useSharedImportStore((s) => s.link);
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
const setFile = useSharedImportStore((s) => s.setFile);
const collabOn = useAddonStore((s) => s.isEnabled('collab'));
// Both belong to the item they were made for, so a newly shared one starts afresh.
const [pick, setPick] = useState<{ file: File; choice: LinkChoice } | null>(null);
const [failedFile, setFailedFile] = useState<File | null>(null);
// The save in flight, kept off the state: it guards a second tap, it is not shown.
const savingRef = useRef<File | null>(null);
const picked = pick && pick.file === file ? pick.choice : null;
const { choice, asking, nowhere, showTrips } = stageOf({
file,
link,
collabOn,
picked,
presetFailed: failedFile != null && failedFile === file,
});
const open = !!file && tripId == null;
const trips = useTripRows(open);
// Once a trip is picked (here, or in the share sheet before the app opened), go to
// it; its planner opens the booking import with the file (MTripShell).
@@ -54,41 +117,133 @@ export default function NativeImportSheet(): React.ReactElement | null {
void navigate(`/trips/${tripId}`);
}, [file, tripId, pathname, navigate]);
const pick = (id: number) => chooseTrip(id);
const saveLink = (id: number) => {
if (!link || !file || savingRef.current === file) return;
savingRef.current = file;
saveSharedLink(id, link).then(
() => {
toast.success(t('native.share.linkSaved'));
setFile(null);
void navigate(`/trips/${id}?tab=collab`);
},
() => {
toast.error(offlineOr(t, t('common.error')));
savingRef.current = null;
setFailedFile(file);
}
);
};
// The trip the share sheet picked, used as soon as it is clear what the link is for.
const preset = link?.presetTripId ?? null;
const applyPreset = useEffectEvent((id: number, use: LinkChoice) => {
if (use === 'booking') chooseTrip(id);
else saveLink(id);
});
useEffect(() => {
if (preset != null && choice != null && file) applyPreset(preset, choice);
}, [preset, choice, file]);
const pickTrip = (id: number) => (choice === 'link' ? saveLink(id) : chooseTrip(id));
return (
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
<div className="px-[18px] pb-4 pt-4">
<div className="mb-3">
<TileHeader
icon={<FileText size={19} strokeWidth={1.8} />}
title={t('native.share.title')}
sub={file?.name}
icon={link ? <Link2 size={19} strokeWidth={1.8} /> : <FileText size={19} strokeWidth={1.8} />}
title={sheetTitle(t, asking, choice)}
sub={link ? link.title : file?.name}
onClose={() => setFile(null)}
closeLabel={t('common.close')}
/>
</div>
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
key={trip.id}
type="button"
onClick={() => pick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.noTrips')}
</p>
)}
</div>
{asking && file && <LinkQuestion onPick={(use) => setPick({ file, choice: use })} />}
{nowhere && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.linkUnsupported')}
</p>
)}
{showTrips && <TripPicker trips={trips} onPick={pickTrip} />}
</div>
</MSheet>
);
}
function LinkQuestion({ onPick }: { onPick: (choice: LinkChoice) => void }): React.ReactElement {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<ChoiceRow
icon={<Link2 size={17} strokeWidth={2} />}
label={t('native.share.saveLink')}
hint={t('native.share.saveLinkHint')}
onClick={() => onPick('link')}
/>
<ChoiceRow
icon={<ScanText size={17} strokeWidth={2} />}
label={t('native.share.readBooking')}
hint={t('native.share.readBookingHint')}
onClick={() => onPick('booking')}
/>
</div>
);
}
function TripPicker({ trips, onPick }: { trips: TripRow[]; onPick: (id: number) => void }): React.ReactElement {
const { t } = useTranslation();
return (
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
key={trip.id}
type="button"
onClick={() => onPick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98] disabled:opacity-50"
>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">{t('native.share.noTrips')}</p>
)}
</div>
);
}
function ChoiceRow({
icon,
label,
hint,
onClick,
}: {
icon: React.ReactNode;
label: string;
hint: string;
onClick: () => void;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span
className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
aria-hidden="true"
>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{label}
</span>
<span className="block truncate text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">{hint}</span>
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
);
}
+4
View File
@@ -21,6 +21,10 @@
--m-act: #101013;
--m-actfg: #fff;
--m-dim: rgba(16,16,19,.4);
/* A picture shown full screen (MImageViewer): dark in either scheme, as a photo viewer is. */
--m-viewer-bg: rgba(0,0,0,.92);
--m-viewer-ink: #fff;
--m-viewer-btn: rgba(255,255,255,.15);
--m-sheet: rgba(250,250,248,.94);
--m-sheetop: #FAFAF8;
--m-shbr: rgba(255,255,255,.95);
+3 -1
View File
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
*/
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
const SETTLE_MS = 800;
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
// page, so on a slow connection a longer wait reads as the app hanging.
const SETTLE_MS = 350;
let skipNext = false;
let current: ViewTransition | null = null;
@@ -26,6 +26,7 @@ import { STATUS_SPEC } from './collectionsMobileModel'
import MConfirmSheet from '../settings/MConfirmSheet'
import MCollListNav from './MCollListNav'
import { useIsSplit } from '../../../hooks/sizeClass'
import { skipNextRouteTransition } from '../../routeTransition'
const HEADER_CIRCLE =
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
@@ -133,6 +134,9 @@ export default function MCollections() {
const selectList = (id: number | typeof ALL_SAVED) => {
setDrop(false)
// Another list is the same screen with other content, not a page opened
// over it: the URL changes, but the page must not slide in.
skipNextRouteTransition()
c.handleSelectList(id)
}
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import { useIsSplit } from '../../../hooks/sizeClass'
import {
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
@@ -60,7 +59,6 @@ export default function MDashboard(): React.ReactElement {
const unread = useInAppNotificationStore(s => s.unreadCount)
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
const [menuOpen, setMenuOpen] = useState(false)
const split = useIsSplit()
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
const [subOpen, setSubOpen] = useState(false)
// The filter chips scroll when a language's words do not fit beside the two
@@ -281,9 +279,12 @@ export default function MDashboard(): React.ReactElement {
{/* Beside each other from the split size class on, the way the desktop
dashboard holds them: the trips in the main column, the widgets in a
column of their own, each in the order the user set. */}
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
<div className="min-w-0">
column of their own, each in the order the user set. Narrower, both
columns dissolve (display: contents) into one stack ordered the same
way, so crossing the split size never moves a widget to another
parent and its state (the converter's amount, its rates) stays. */}
<div className="flex flex-col split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
<div className="contents min-w-0 split:block">
{spotlight && (
<MSpotlightCard
trip={spotlight}
@@ -292,22 +293,18 @@ export default function MDashboard(): React.ReactElement {
actions={actionsFor(spotlight, 'list')}
/>
)}
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
</div>
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+70px)] split:-mt-3 split:block split:max-h-[calc(100dvh-var(--m-safe-top,12px)-70px)] split:overflow-y-auto split:overscroll-contain split:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] split:[scrollbar-width:none]">
{dashOrder.filter(id => id !== 'trips').map(id => (
<div key={id} style={{ order: dashOrder.indexOf(id) }}>{renderBlock(id)}</div>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3" style={{ order: dashOrder.length }}>
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</div>
)}
</div>
{(split || widgetPlugins.length > 0) && (
<div className="min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px]">
{split && dashOrder.filter(id => id !== 'trips').map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3">
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</div>
)}
</div>
)}
</div>
</div>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
import { Outlet, useSearchParams } from 'react-router'
import { BookOpen, ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
@@ -32,6 +32,8 @@ import MJourneyFeed from './MJourneyFeed'
import MJourneySettingsSheet from './MJourneySettingsSheet'
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
import { createPortal } from 'react-dom'
import { useTabHeadSlot } from '../trip/tabHeadSlot'
/**
* Journey detail — integrated map with the horizontal 280px card timeline
@@ -42,8 +44,12 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/
/** A phone on its side: too short for the regular strip of cards and the map above it. */
const SHORT_WINDOW = '(max-height: 500px)'
/**
* A phone on its side, or a folded Duo on its side (about 540px high): too short
* for the regular strip of cards and the map above it. Higher than the shell's
* 500px for that Duo, whose regular cards left the map a band under the header.
*/
const SHORT_WINDOW = '(max-height: 600px)'
function subscribeShort(onChange: () => void) {
const mq = window.matchMedia(SHORT_WINDOW)
mq.addEventListener('change', onChange)
@@ -80,8 +86,11 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
sidebarMapItems, tracks, mapLayers, entryTitleOf, mapPhotos, openMapPhotos, recordings, canTakeOverRecording, dayImport, dayImportDates, dawarichByDate, recordingPrep,
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
addPickedProviderPhotos, addEntryProviderPhotos, writeAboutPlace,
} = useJourneyDetail({ journeyId, embedded })
// A trip tab: the header goes into the trip shell's top row (tabHeadSlot.ts).
const shellSlot = useTabHeadSlot()
const headSlot = embedded ? shellSlot : null
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing
@@ -320,8 +329,9 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
// keeps a single "more" affordance.
//
// There is no book export here any more: the book is a Studio document now,
// and Studio is a desktop editor. A phone-sized PDF of a book laid out for
// print was the old export's answer to a question nobody was asking.
// and Studio needs the room of a desktop, an iPad on its side or an unfolded
// Duo (split), where the header offers it. A phone-sized PDF of a book laid
// out for print was the old export's answer to a question nobody was asking.
const toggleSkeletons = async (next: boolean) => {
setHideSkeletons(next)
try {
@@ -334,7 +344,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
return (
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
// and a map on a percentage of an auto-height parent collapses to zero.
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden" style={headSlot ? IN_SHELL_ROW : undefined}>
{/* Integrated map — always mounted, the gallery overlays it. Beside the feed
it begins where the feed ends, so its controls and its framing stay in
the part that can be seen. */}
@@ -348,6 +358,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
hideLayerControl
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
onOpenEntry={handleMarkerClick}
onWriteAboutPlace={writeAboutPlace}
entryTitleOf={entryTitleOf}
checkins={[]}
entries={sidebarMapItems}
@@ -418,11 +429,12 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
{feed && (
<div
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
// Entries scrolled up fade out under the header's pills instead of showing through between them.
// Entries scrolled up fade out under the header's pills instead of showing through between them:
// gone down to the pills' lower edge (38px tall), whole again before the first day's head.
style={{
insetInlineStart: 'var(--m-rail-w, 0px)',
width: 'var(--m-pane-aside-w)',
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 22px), #000 calc(var(--m-safe-top, 12px) + 58px))',
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 40px), #000 calc(var(--m-safe-top, 12px) + 60px))',
}}
>
<MJourneyFeed
@@ -441,10 +453,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
{/* Header: back — segment — upload / overflow menu. Beside the feed it keeps
to the feed, as the trip's controls keep to its pane. */}
<div
className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"
style={feed ? { insetInlineEnd: 'calc(100% - var(--m-rail-w, 0px) - var(--m-pane-aside-w) + 16px)' } : undefined}
>
<HeaderFrame slot={headSlot} feed={!!feed}>
{embedded ? (
<span className="w-[38px] flex-none" aria-hidden />
) : (
@@ -457,7 +466,10 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
</button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
<div
data-view-switch
className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full wide:static wide:translate-x-0 bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<button
type="button"
onClick={() => setView('timeline')}
@@ -493,6 +505,16 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</span>
)}
{split && (
<button
type="button"
onClick={() => void navigate(`/journey/${current.id}/studio`)}
aria-label={t('journey.studio.openAria')}
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<BookOpen size={17} strokeWidth={2} />
</button>
)}
<button
type="button"
onClick={() => setShowActionMenu(true)}
@@ -502,7 +524,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<MoreHorizontal size={17} strokeWidth={2} />
</button>
</span>
</div>
</HeaderFrame>
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
the map rather than in the header: it belongs to what is on the map,
@@ -589,7 +611,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
tone="mobile"
size={wide ? 'large' : short ? 'compact' : 'regular'}
// Short before wide: a folded Duo on its side is wide (over 500px high) and short.
size={short ? 'compact' : wide ? 'large' : 'regular'}
/>
</div>
))}
@@ -799,6 +822,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
onClose={() => setLightbox(null)}
/>
)}
{/* TREK Studio, the book designer, opens over the journey on a screen with room for
two panes, as on the desktop (its route is a child of this one). The phone keeps
the journey alone, whatever the address says. */}
{split && !embedded && <Outlet />}
</div>
)
}
/**
* With the header in the shell's row, what the screen measures from its top
* starts right under that row instead of under a row of its own.
*/
const IN_SHELL_ROW = { '--m-safe-top': 'calc(env(safe-area-inset-top, 0px) + 6px)' } as CSSProperties
/** The header row: on the screen, or in the trip shell's top row when the journal is a trip tab. */
function HeaderFrame({ slot, feed, children }: { slot: HTMLElement | null; feed: boolean; children: ReactNode }) {
if (slot) {
return createPortal(
<div className="flex min-w-0 flex-1 items-center gap-2 [&>span:first-child]:hidden [&>span:last-child]:ms-0 [&_[data-view-switch]]:static [&_[data-view-switch]]:translate-x-0">
{children}
</div>,
slot
)
}
return (
<div
className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"
style={feed ? { insetInlineEnd: 'calc(100% - var(--m-rail-w, 0px) - var(--m-pane-aside-w) + 16px)' } : undefined}
>
{children}
</div>
)
}
@@ -137,12 +137,19 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
) : (
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
)}
<span dir="auto" className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
<span
dir="auto"
className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold"
>
{place}
</span>
</span>
)}
{story && <span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
{story && (
<span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">
{story}
</span>
)}
</span>
</button>
);
@@ -9,6 +9,7 @@ import MToggle from '../../components/MToggle'
import { MSetCard, MSetEyebrow, MSetSegments, MSetRow } from './MSettingsUi'
import MMobileNavCustomizer from './MMobileNavCustomizer'
import MMobileDashOrder from './MMobileDashOrder'
import { useIsWide } from '../../../hooks/sizeClass'
function SliderRow({ label, sub, value, onChange }: { label: string; sub?: string; value: number; onChange: (v: number) => void }) {
return (
@@ -41,6 +42,8 @@ function SliderRow({ label, sub, value, onChange }: { label: string; sub?: strin
*/
export default function MSettingsAppearance() {
const { t } = useTranslation()
// A tablet has the rail on its side, no bottom bar: only the dashboard's order is left to set.
const wide = useIsWide()
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
useAppearanceEditor()
@@ -156,11 +159,18 @@ export default function MSettingsAppearance() {
)}
</MSetCard>
<MSetCard title={t('settings.appearance.mobile')} icon={Smartphone} className="mt-3">
<MSetEyebrow className="mb-[6px]">{t('settings.appearance.mobileNav')}</MSetEyebrow>
<MMobileNavCustomizer value={cfg.mobileNav} onChange={(mn) => update({ mobileNav: mn })} />
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.appearance.dashOrder')}</MSetEyebrow>
<MSetCard
title={wide ? t('settings.appearance.dashOrder') : t('settings.appearance.mobile')}
icon={wide ? LayoutDashboard : Smartphone}
className="mt-3"
>
{!wide && (
<>
<MSetEyebrow className="mb-[6px]">{t('settings.appearance.mobileNav')}</MSetEyebrow>
<MMobileNavCustomizer value={cfg.mobileNav} onChange={(mn) => update({ mobileNav: mn })} />
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.appearance.dashOrder')}</MSetEyebrow>
</>
)}
<p className="-mt-[2px] mb-2 font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('settings.appearance.dashOrder.hint')}</p>
<MMobileDashOrder cfg={cfg} onChange={(order) => update({ dashboard: { ...cfg.dashboard, mobileOrder: order } })} />
</MSetCard>
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '../../../i18n'
import MDancingTrek from '../../components/MDancingTrek'
import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
@@ -10,6 +11,36 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
* progress dots track which beat is on. Under reduced motion it holds a single
* frame (the real "loading photos" one) with no transitions.
*/
/**
* How long a trip may take to open before the splash comes up. The page
* transition holds the page the trip was opened from for up to 350ms
* (mobile/routeTransition.ts), so a trip that loads in that time slides in
* whole, the way the dashboard slides back in on the way out. A slower one gets
* the splash right after, so a slow connection never sees the app stand still.
*/
export const TRIP_SPLASH_AFTER_MS = 400
/**
* While a trip opens. At first nothing but the screen's own background, marked
* as a route fallback, which the page transition waits on; the splash only once
* the load takes longer than TRIP_SPLASH_AFTER_MS.
*/
export function MTripOpening({ title }: { title: string }) {
const [slow, setSlow] = useState(false)
useEffect(() => {
const timer = window.setTimeout(() => setSlow(true), TRIP_SPLASH_AFTER_MS)
return () => window.clearTimeout(timer)
}, [])
if (slow) return <MTripLoadingSplash title={title} />
return (
<div
data-route-fallback=""
aria-busy="true"
className="fixed inset-0 z-50 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
/>
)
}
export default function MTripLoadingSplash({ title }: { title: string }) {
const { t } = useTranslation()
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
+4 -2
View File
@@ -16,6 +16,8 @@ interface MTripRailProps {
onSelect: (id: string) => void;
/** Opens the More sheet: trip settings, export, the assistant. */
onMore: () => void;
/** Whether the More sheet is open, which the More button shows with a ring. */
moreOpen?: boolean;
labelOf: (id: string) => string;
}
@@ -27,7 +29,7 @@ const DOCK_ICONS = new Map(DOCK_PRIORITY.map((tab) => [tab.id, tab.icon]));
* collaboration and plugin pages sit in it too, each with its name, and More
* keeps only the trip's actions.
*/
export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: MTripRailProps) {
export default function MTripRail({ tabs, active, onSelect, onMore, moreOpen = false, labelOf }: MTripRailProps) {
const { t } = useTranslation();
return (
<MRail label={t('mobileTrip.sections')}>
@@ -41,7 +43,7 @@ export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: M
);
})}
<MRailSpacer />
<MRailButton label={t('mobileTrip.more')} active={false} onClick={onMore}>
<MRailButton label={t('mobileTrip.more')} active={false} expanded={moreOpen} onClick={onMore}>
<MoreHorizontal size={20} strokeWidth={2.2} />
</MRailButton>
</MRail>
+25 -23
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
import { takeResumeDay } from '../../../utils/resumeRoute'
import {
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
} from 'lucide-react'
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
import { pickDockTabs } from './dockTabs'
@@ -16,7 +16,7 @@ import MPlacesBrowser from './places/MPlacesBrowser'
import MTripTabPanel from './tabs/MTripTabPanel'
import MTripSheets from './sheets/MTripSheets'
import MReceiptScanButton from './tabs/MReceiptScanButton'
import MTripLoadingSplash from './MTripLoadingSplash'
import { MTripOpening } from './MTripLoadingSplash'
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
import { badgeLabel, distanceBadge } from './roadtrip/stageBadges'
@@ -29,12 +29,13 @@ import { useDayPreviewStore } from '../../dayPreviewStore'
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
import MTripRail from './MTripRail'
import {
CollabSegment, CompactToggle, DayRailSteps, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
CollabSegment, CompactToggle, DockMoreButton, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
} from './MTripShellParts'
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
import { useShortcuts } from '../../keyboard/useShortcuts'
import { tripCreateIntent } from '../../../components/Layout/useCreateAction'
import MDockScrim from '../../components/MDockScrim'
import { setTabHeadSlot } from './tabHeadSlot'
/**
* Mobile trip screen frame. Owns the chrome the design shares across every
@@ -328,9 +329,11 @@ export default function MTripShell({
const trTab = planner.activeTab
// The tour editor is a tab on a large screen only; a phone, or a window narrowed
// to one, keeps its tours in the places browser.
useEffect(() => {
if (trTab === 'tour-planner') planner.handleTabChange('plan')
}, [trTab, planner.handleTabChange])
if (trTab === 'tour-planner' && !wide) planner.handleTabChange('plan')
}, [trTab, wide, planner.handleTabChange])
const setTrTab = (tabId: string) => {
planner.handleTabChange(tabId)
@@ -448,9 +451,11 @@ export default function MTripShell({
// With the plan beside the map, the map shows the day the plan shows: its
// places framed, its route drawn, the other days' pins put away, exactly as
// a phone does when it switches over to the map.
// Not before the splash has gone: the map is not there to frame until then.
const mapReady = !planner.isLoading && planner.splashDone
const followDayId = mapReady && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
// Not before the trip has opened: the map is not there to frame until then.
// The store can still hold the trip opened before, for the frame before loadTrip resets it.
const ownTrip = trip && String(trip.id) === String(tripId) ? trip : null
const opened = !planner.isLoading && !!ownTrip
const followDayId = opened && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
const { autoShowRoute, setExpandedDayIds } = planner
const selectDayRef = useRef(planner.handleSelectDay)
useEffect(() => {
@@ -519,9 +524,9 @@ export default function MTripShell({
openDocSyncSignal,
}
// Splash — same gate as the desktop page, in the mobile design language.
if (planner.isLoading || !planner.splashDone) {
return <MTripLoadingSplash title={trip?.title || ''} />
// No minimum splash as on the desktop: the page transition hides the wait (MTripOpening).
if (!opened && (planner.isLoading || trip)) {
return <MTripOpening title={ownTrip?.title || ''} />
}
if (!trip) return null
@@ -641,7 +646,6 @@ export default function MTripShell({
{/* The glass is the outer box, the scroller sits inside its padding: that way
the fade at an edge that runs on takes the chips, not the glass's rim. */}
<div className="relative flex min-w-0 flex-1 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<DayRailSteps rail={dayRail} fade={dayFade} />
<div ref={setDayRail} className={`relative flex min-w-0 flex-1 items-center gap-[2px] overflow-x-auto rounded-full wide:[--m-fade-reach:52px] ${edgeFadeClass(dayFade)}`}>
{dayPill && (
<span
@@ -862,6 +866,8 @@ export default function MTripShell({
not in the dock, so nothing was lit up there either, and the screen
gave no clue which plugin was open. Same treatment as the others, from
the tab entry the planner already builds (id, label, icon). */}
{/* The tours tab and the journal put their own controls here instead (tabHeadSlot.ts). */}
{(trTab === 'tour-planner' || trTab === 'journal') && <div ref={setTabHeadSlot} className="pointer-events-auto absolute inset-x-[52px] top-1/2 flex -translate-y-1/2 items-center gap-2" />}
{trTab.startsWith('plugin:') && (() => {
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
if (!tab) return null
@@ -901,6 +907,7 @@ export default function MTripShell({
active={trTab}
onSelect={setTrTab}
onMore={() => openSheet('mehr')}
moreOpen={sheet?.id === 'mehr'}
labelOf={tabLabel}
/>
)}
@@ -927,17 +934,12 @@ export default function MTripShell({
</button>
)
})}
<button
type="button"
onClick={() => openSheet('mehr')}
aria-label={t('mobileTrip.more')}
aria-expanded={sheet?.id === 'mehr'}
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
<MoreHorizontal size={19} strokeWidth={2.2} />
</button>
<DockMoreButton
open={sheet?.id === 'mehr'}
standsFor={!dockTabs.some(tab => tab.id === trTab)}
onOpen={() => openSheet('mehr')}
label={t('mobileTrip.more')}
/>
</nav>}
<Sheets planner={planner} shell={shell} />
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
import { FolderSync, List, MoreHorizontal, Plus, Rows3 } from 'lucide-react';
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
@@ -6,7 +6,7 @@ import { useIsWide } from '../../../hooks/sizeClass';
import { useAuthStore } from '../../../store/authStore';
import type { Trip } from '../../../types';
import MIconBtn from '../../components/MIconBtn';
import { edgeFadeClass, useEdgeFade, type EdgeFade } from '../../components/useEdgeFade';
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade';
import type { MTripCollabTab } from './MTripShell';
import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
@@ -119,67 +119,6 @@ export function HeaderActions({
);
}
/**
* The day strip's way on past an edge, on a wide window: a round chevron over
* the faded end that pages the strip along. The fade alone was too quiet on a
* large screen, where a day cut in half at the end ("Wed") read as a mistake
* rather than as more to come. The days themselves stay where a screen reader
* and the keyboard reach them, so the chevron is for the eye and the finger only.
*/
export function DayRailSteps({ rail, fade }: { rail: HTMLElement | null; fade: EdgeFade }) {
const wide = useIsWide();
if (!wide || !rail) return null;
// A page along: the day the edge cuts comes to the other edge, whole, so the
// next tap continues from there. Read in reading order, so a right-to-left
// strip pages the same way round.
const step = (toward: 'start' | 'end') => {
const rtl = getComputedStyle(rail).direction === 'rtl';
const box = rail.getBoundingClientRect();
const chips = Array.from(rail.querySelectorAll<HTMLElement>('button'));
const pastEnd = (r: DOMRect) => (rtl ? r.left < box.left - 1 : r.right > box.right + 1);
const pastStart = (r: DOMRect) => (rtl ? r.right > box.right + 1 : r.left < box.left - 1);
const target =
toward === 'end'
? chips.find((chip) => pastEnd(chip.getBoundingClientRect()))
: chips.filter((chip) => pastStart(chip.getBoundingClientRect())).pop();
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
// 'nearest' on the block axis: the shell root is fixed, anything else scrolls the page behind it.
target?.scrollIntoView({
inline: toward === 'end' ? 'start' : 'end',
block: 'nearest',
behavior: reduced ? 'auto' : 'smooth',
});
};
const btn =
'absolute top-1/2 z-[1] flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheetop)] text-m-ink shadow-[0_4px_12px_-4px_rgba(0,0,0,.35)]';
return (
<>
{fade.start && (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => step('start')}
className={`${btn} start-[4px]`}
>
<ChevronLeft size={15} strokeWidth={2.4} className="rtl:-scale-x-100" />
</button>
)}
{fade.end && (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => step('end')}
className={`${btn} end-[4px]`}
>
<ChevronRight size={15} strokeWidth={2.4} className="rtl:-scale-x-100" />
</button>
)}
</>
);
}
interface CollabSegmentProps {
active: MTripCollabTab;
onPick: (tab: MTripCollabTab) => void;
@@ -284,3 +223,37 @@ export function CompactToggle({ active, onToggle, label }: { active: boolean; on
</button>
);
}
/**
* The phone dock's More. It stands for its sections (chat, notes, files and the
* rest) while one is on show, and for itself while open: marked as a ring, as
* the main dock's More is, not filled like a tab it would then read as.
*/
export function DockMoreButton({
open,
standsFor,
onOpen,
label,
}: {
open: boolean;
standsFor: boolean;
onOpen: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onOpen}
aria-label={label}
aria-expanded={open}
aria-current={!open && standsFor ? 'page' : undefined}
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
open || standsFor
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
: 'text-m-muted'
}`}
>
<MoreHorizontal size={19} strokeWidth={2.2} />
</button>
);
}
@@ -345,12 +345,14 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// the overview stack) follows on its own. The band used to float a further 38px up to
// leave the corner under it to the map credit; the phone map carries no visible credit
// any more (see mobile.css), so that row would now only be a gap over the dock.
// A wide window has no dock (the rail stands on the side), so there the floor is
// the safe area alone and the controls sit down in the corners.
//
// The metrics are classes rather than an inline --bottom-nav-h, and the lift is its
// OWN variable folded into them: the compass band below is identified by being the
// one element that sets that name inline, and a second one would make that ambiguous.
<div
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] [--bottom-nav-h:var(--m-map-floor)]"
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] wide:[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+8px+var(--m-stage-lift,0px))] [--bottom-nav-h:var(--m-map-floor)]"
// A stage bar used to stand over the dock for the whole of the road trip tab, and the
// floor cleared its 61px plus a gap the year round. It is gone, so the only thing
// left in that slot is the bar the picker raises while other ways of driving a leg
@@ -544,7 +544,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay, onOp
className={`${GLASS_CHIP} min-w-[4.5rem] shrink gap-[5px] px-2.5`}
>
<HotelChipIcon variant={chip.variant} />
<span className="min-w-0 truncate">{chip.name}</span>
{/* Its own direction, so a Latin name in an Arabic header is cut at its end, not its start. */}
<span dir="auto" className="min-w-0 truncate">{chip.name}</span>
{chip.time && <span className="flex-none tabular-nums text-m-muted">{chip.time.slice(0, 5)}</span>}
</button>
))}
@@ -6,6 +6,8 @@ import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar'
import { MRtTrackEntry } from './MRtTrackSheet'
import MRtRouteImport from './MRtRouteImportSheet'
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import MBadge from '../../../components/MBadge'
import MDancingTrek from '../../../components/MDancingTrek'
@@ -289,6 +291,12 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O
</span>
</div>
)}
<MRtTrackEntry planner={planner} dayId={stage.dayId} />
<MRtRouteImport
tripId={planner.tripId}
dayId={stage.dayId > 0 ? stage.dayId : undefined}
canEdit={canEditPlaces && planner.can('day_edit', planner.trip)}
/>
</section>
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}

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