Compare commits

..

17 Commits

Author SHA1 Message Date
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
42 changed files with 945 additions and 296 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}"
+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"
@@ -6,6 +6,7 @@ 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.JSObject;
@@ -29,6 +30,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;
@@ -315,7 +318,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
+18
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';
@@ -1013,8 +1014,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());
+41 -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 {
@@ -733,7 +757,7 @@ a.linkbtn {
.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 +812,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 +913,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 +933,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 +941,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 +1049,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 +1070,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,
@@ -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,
};
}
@@ -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}
/>
);
}
@@ -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>
+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
+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;
@@ -13,12 +13,12 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
*/
/**
* 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 800ms
* 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, and the splash is
* only for a slow connection.
* 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 = 1000
export const TRIP_SPLASH_AFTER_MS = 400
/**
* While a trip opens. At first nothing but the screen's own background, marked
+49
View File
@@ -1,17 +1,24 @@
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/hashes/utils';
import { startAuthentication } from '@simplewebauthn/browser';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authApi } from '../api/client';
import { nativeAuthApi } from '../api/nativeAuth';
import {
BrowserSignInCancelled,
createHandoffSecret,
handoffUrl,
rememberHandoffReturn,
requestHandoffCode,
signInThroughBrowser,
signInWithPasskey,
takeHandoffReturn,
} from './browserSignIn';
import { TrekShell } from './trekShell';
vi.mock('./trekShell', () => ({ APP_URL_SCHEME: 'com.liketrek.trek', TrekShell: { authenticate: vi.fn() } }));
vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() }));
vi.mock('../api/client', () => ({ authApi: { passkey: { loginOptions: vi.fn(), loginVerify: vi.fn() } } }));
vi.mock('../api/nativeAuth', () => ({ nativeAuthApi: { exchange: vi.fn(), handoff: vi.fn() } }));
beforeEach(() => {
@@ -77,3 +84,45 @@ describe('signInThroughBrowser', () => {
expect(nativeAuthApi.exchange).not.toHaveBeenCalled();
});
});
describe('the remembered handoff return', () => {
const path = `/native-handoff?challenge=${'a'.repeat(64)}`;
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('survives a sign-in that comes back in another tab, once', () => {
rememberHandoffReturn(path);
expect(takeHandoffReturn()).toBe(path);
expect(takeHandoffReturn()).toBeNull();
});
it('is dropped once it is older than a quarter of an hour', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-11-08T12:00:00Z'));
rememberHandoffReturn(path);
vi.setSystemTime(new Date('2026-11-08T12:16:00Z'));
expect(takeHandoffReturn()).toBeNull();
vi.useRealTimers();
});
it('only ever leads back to the handoff page', () => {
rememberHandoffReturn('/admin');
expect(takeHandoffReturn()).toBeNull();
localStorage.setItem('trek_native_handoff_return', 'not json');
expect(takeHandoffReturn()).toBeNull();
});
});
describe('signInWithPasskey', () => {
it('asks for the passkey and has the server verify it', async () => {
vi.mocked(authApi.passkey.loginOptions).mockResolvedValue({ challenge: 'x' });
vi.mocked(startAuthentication).mockResolvedValue({ id: 'cred' } as never);
vi.mocked(authApi.passkey.loginVerify).mockResolvedValue({ token: '', user: {} });
await signInWithPasskey();
expect(startAuthentication).toHaveBeenCalledWith({ optionsJSON: { challenge: 'x' } });
expect(authApi.passkey.loginVerify).toHaveBeenCalledWith({ id: 'cred' });
});
});
+45 -1
View File
@@ -1,5 +1,7 @@
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/hashes/utils';
import { startAuthentication } from '@simplewebauthn/browser';
import { authApi } from '../api/client';
import { nativeAuthApi } from '../api/nativeAuth';
import { APP_URL_SCHEME, TrekShell } from './trekShell';
@@ -19,7 +21,42 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell';
export const HANDOFF_PATH = '/native-handoff';
export type BrowserSignInStart = 'oidc';
/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */
export type BrowserSignInStart = 'oidc' | 'passkey';
const HANDOFF_RETURN_KEY = 'trek_native_handoff_return';
const HANDOFF_RETURN_TTL_MS = 15 * 60 * 1000;
/**
* Remembers the handoff page an SSO round trip has to come back to. The login
* keeps its return path in sessionStorage, which is per tab, and an identity
* provider such as Google may finish its sign-in in another tab or in its own
* app on Android. The return then arrives without it and the user lands in the
* web app instead of being handed back, so the handoff page also leaves a short
* lived copy here.
*/
export function rememberHandoffReturn(path: string): void {
try {
localStorage.setItem(HANDOFF_RETURN_KEY, JSON.stringify({ path, at: Date.now() }));
} catch {
/* storage blocked: the sessionStorage copy still covers a single-tab sign-in */
}
}
/** The remembered handoff page, once: only a fresh path to the handoff page itself. */
export function takeHandoffReturn(): string | null {
try {
const raw = localStorage.getItem(HANDOFF_RETURN_KEY);
localStorage.removeItem(HANDOFF_RETURN_KEY);
if (!raw) return null;
const { path, at } = JSON.parse(raw) as { path?: unknown; at?: unknown };
if (typeof path !== 'string' || typeof at !== 'number') return null;
if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null;
return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null;
} catch {
return null;
}
}
export function createHandoffSecret(): { verifier: string; challenge: string } {
const bytes = crypto.getRandomValues(new Uint8Array(32));
@@ -41,6 +78,13 @@ export async function requestHandoffCode(challenge: string): Promise<string> {
return code;
}
/** The browser side of a passkey sign-in the app started: the prompt, then the session cookie. */
export async function signInWithPasskey(): Promise<void> {
const options = await authApi.passkey.loginOptions();
const assertion = await startAuthentication({ optionsJSON: options });
await authApi.passkey.loginVerify(assertion);
}
export class BrowserSignInCancelled extends Error {
constructor() {
super('Sign-in cancelled');
@@ -67,6 +67,8 @@ beforeEach(() => {
vi.setSystemTime(new Date('2026-07-05T12:00:00Z'));
installMatchMedia();
resetAllStores();
// The currency tool waits for the saved pair (#3009); the page renders with settings in place.
useSettingsStore.setState({ isLoaded: true });
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
usePluginStore.setState({ plugins: [], loaded: true });
onlyTrips([TRIP]);
+8 -5
View File
@@ -544,7 +544,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.tripsTotal')}</div>
<div className="value mono">{statsPending ? <Skeleton width={44} height={30} radius={6} /> : stats.totalTrips ?? 0}</div>
<div className="delta">{statsPending ? <Skeleton width={90} height={12} /> : t('dashboard.atlas.placesMapped', { count: stats.totalPlaces ?? 0 })}</div>
<div className="delta">{statsPending ? <span className="trek-skeleton delta-pending">{t('dashboard.atlas.placesMapped', { count: 0 })}</span> : t('dashboard.atlas.placesMapped', { count: stats.totalPlaces ?? 0 })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<polyline points="0,30 12,26 22,28 32,18 44,22 56,10 68,14 80,4" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -572,7 +572,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{statsPending ? <Skeleton width={60} height={30} radius={6} /> : distanceText}
{' '}<span className="unit">{distanceLabel}</span>
</div>
<div className="delta">{statsPending ? <Skeleton width={110} height={12} /> : t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<div className="delta">{statsPending ? <span className="trek-skeleton delta-pending">{t('dashboard.atlas.aroundEquator', { count: '0.00' })}</span> : t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
<circle cx="40" cy="18" r="14" fill="none" strokeWidth="2" strokeDasharray="58 88" strokeLinecap="round" transform="rotate(-90 40 18)" />
@@ -662,7 +662,7 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
// ── Currency tool (self-contained, mirrors the design's fx widget) ───────────
function CurrencyTool(): React.ReactElement {
const { t } = useTranslation()
const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates: fetchRate } = useCurrencyConverter()
const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates: fetchRate, pairPending, ratePending } = useCurrencyConverter()
const ccyOptions = currencies.map(c => ({ value: c, label: c }))
return (
@@ -671,7 +671,8 @@ function CurrencyTool(): React.ReactElement {
<div className="tool-title"><RefreshCw size={14} /> {t('dashboard.currency')}</div>
<button type="button" className="tool-action" aria-label={t('dashboard.aria.refreshRates')} onClick={fetchRate}><RefreshCw size={14} /></button>
</div>
<div className="fx-input">
{/* Held invisible until the saved pair is known, so the defaults never flash first (#3009). */}
<div className="fx-input" style={pairPending ? { visibility: 'hidden' } : undefined}>
<div className="fx-field">
<div className="lbl">{t('dashboard.fx.from')}</div>
<input className="amt mono" value={amount} onChange={e => setAmount(e.target.value)} inputMode="decimal" />
@@ -685,7 +686,9 @@ function CurrencyTool(): React.ReactElement {
</div>
</div>
<div className="fx-rate">
<span>{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}</span>
{pairPending || ratePending
? <span className="trek-skeleton" aria-hidden>{`1 ${from} = 0.0000 ${to}`}</span>
: <span>{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}</span>}
</div>
</div>
)
+2 -2
View File
@@ -228,8 +228,8 @@ export default function LoginPage(): React.ReactElement {
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: 'var(--font-system)', position: 'relative' }}>
{/* Language dropdown */}
<div style={{ position: 'absolute', top: 16, insetInlineEnd: 16, zIndex: 10 }}>
{/* Language dropdown, below the status bar in the app and under a notch, not behind them */}
<div className="absolute end-4 top-[calc(env(safe-area-inset-top,0px)+16px)] z-10">
<button type="button"
onClick={(e) => {
e.stopPropagation();
+14 -1
View File
@@ -1,4 +1,4 @@
// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-004
// FE-NATIVE-HANDOFF-PAGE-001 to FE-NATIVE-HANDOFF-PAGE-005
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../tests/helpers/render';
import NativeHandoffPage from './NativeHandoffPage';
@@ -8,6 +8,9 @@ vi.mock('./nativeHandoff/useNativeHandoff', () => ({ useNativeHandoff: vi.fn() }
const base = {
valid: true,
passkeyStep: false,
continueWithPasskey: vi.fn(),
otherSignIn: vi.fn(),
ready: true,
userName: 'maria',
status: 'idle' as const,
@@ -52,4 +55,14 @@ describe('NativeHandoffPage', () => {
render(<NativeHandoffPage />);
expect(screen.getByText('Signing in did not work. Please try again.')).toBeInTheDocument();
});
it('FE-NATIVE-HANDOFF-PAGE-005: asks for the passkey itself when the app started a passkey sign-in', () => {
vi.mocked(useNativeHandoff).mockReturnValue({ ...base, passkeyStep: true, ready: false });
render(<NativeHandoffPage />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(base.continueWithPasskey).toHaveBeenCalled();
fireEvent.click(buttons[1]);
expect(base.otherSignIn).toHaveBeenCalled();
});
});
+38 -10
View File
@@ -1,17 +1,49 @@
import { Smartphone } from 'lucide-react';
import { Fingerprint, Smartphone } from 'lucide-react';
import { useTranslation } from '../i18n';
import { useNativeHandoff } from './nativeHandoff/useNativeHandoff';
const PRIMARY = 'w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60';
const SECONDARY = 'py-1 text-caption text-content-muted';
export default function NativeHandoffPage() {
const { t } = useTranslation();
const { valid, ready, userName, status, openApp, switchAccount } = useNativeHandoff();
const { valid, passkeyStep, continueWithPasskey, otherSignIn, ready, userName, status, openApp, switchAccount } =
useNativeHandoff();
const failed = status === 'failed' && (
<p role="alert" className="text-body text-danger">
{t(passkeyStep ? 'login.passkey.failed' : 'native.login.failed')}
</p>
);
return (
<div className="flex min-h-screen items-center justify-center bg-surface-secondary p-6">
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-edge bg-surface-card p-7 text-center">
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
{passkeyStep ? (
<Fingerprint className="mx-auto text-content-muted" size={32} aria-hidden />
) : (
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
)}
{!valid ? (
<p className="text-body text-content">{t('native.login.failed')}</p>
) : passkeyStep ? (
<>
<h1 className="text-title font-semibold text-content">{t('login.passkey.signIn')}</h1>
{failed}
<button
type="button"
onClick={() => {
void continueWithPasskey();
}}
disabled={status === 'sending'}
className={PRIMARY}
>
{t('login.passkey.signIn')}
</button>
<button type="button" onClick={otherSignIn} className={SECONDARY}>
{t('native.handoff.switchAccount')}
</button>
</>
) : !ready ? (
<div
className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-edge border-t-accent"
@@ -21,18 +53,14 @@ export default function NativeHandoffPage() {
<>
<h1 className="text-title font-semibold text-content">{t('native.handoff.title')}</h1>
<p className="text-body text-content-muted">{t('native.handoff.hint', { name: userName })}</p>
{status === 'failed' && (
<p role="alert" className="text-body text-danger">
{t('native.login.failed')}
</p>
)}
{failed}
<button
type="button"
onClick={() => {
void openApp();
}}
disabled={status === 'sending'}
className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-on disabled:opacity-60"
className={PRIMARY}
>
{t('native.handoff.open')}
</button>
@@ -41,7 +69,7 @@ export default function NativeHandoffPage() {
onClick={() => {
void switchAccount();
}}
className="py-1 text-caption text-content-muted"
className={SECONDARY}
>
{t('native.handoff.switchAccount')}
</button>
@@ -106,7 +106,7 @@ describe('useLogin in the native app', () => {
await result.current.handlePasskeyLogin();
});
expect(signInThroughBrowser).toHaveBeenCalledWith(undefined);
expect(signInThroughBrowser).toHaveBeenCalledWith('passkey');
expect(startAuthentication).not.toHaveBeenCalled();
});
+10 -3
View File
@@ -10,7 +10,12 @@ import { getApiErrorMessage } from '../../types'
import { START_DESTINATION_ROUTE } from '../../utils/startDestination'
import { passwordErrorKey } from '../../utils/passwordError'
import { isNativeApp } from '../../native/platform'
import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn'
import {
BrowserSignInCancelled,
signInThroughBrowser,
takeHandoffReturn,
type BrowserSignInStart,
} from '../../native/browserSignIn'
interface AppConfig {
has_users: boolean
@@ -221,7 +226,9 @@ export function useLogin() {
// body is never read, so it can go without breaking SSO sign-in.
if (ok && data.success === true) {
await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || START_DESTINATION_ROUTE
// Taken either way, so a handoff left from an abandoned sign-in cannot catch a later one.
const handoffReturn = takeHandoffReturn()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || handoffReturn || START_DESTINATION_ROUTE
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
@@ -319,7 +326,7 @@ export function useLogin() {
}
const handlePasskeyLogin = async (): Promise<void> => {
if (nativeApp) return handleBrowserSignIn()
if (nativeApp) return handleBrowserSignIn('passkey')
setError('')
setIsLoading(true)
try {
@@ -1,10 +1,10 @@
// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-008
// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-011
import React from 'react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { requestHandoffCode } from '../../native/browserSignIn';
import { rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { useAuthStore } from '../../store/authStore';
import { useNativeHandoff } from './useNativeHandoff';
@@ -13,7 +13,12 @@ vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../native/browserSignIn', () => ({ HANDOFF_PATH: '/native-handoff', requestHandoffCode: vi.fn() }));
vi.mock('../../native/browserSignIn', () => ({
HANDOFF_PATH: '/native-handoff',
rememberHandoffReturn: vi.fn(),
requestHandoffCode: vi.fn(),
signInWithPasskey: vi.fn(),
}));
const challenge = 'ab'.repeat(32);
const realLocation = window.location;
@@ -85,6 +90,7 @@ describe('useNativeHandoff', () => {
renderHandoff();
expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1');
expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`);
expect(rememberHandoffReturn).toHaveBeenCalledWith(`/native-handoff?challenge=${challenge}`);
});
it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => {
@@ -129,4 +135,53 @@ describe('useNativeHandoff', () => {
)
);
});
describe('when the app asked for a passkey', () => {
const loadUser = vi.fn(async () => {
useAuthStore.setState({ isAuthenticated: true });
});
beforeEach(() => {
setSearch(`?challenge=${challenge}&start=passkey`);
useAuthStore.setState({ isAuthenticated: false, loadUser });
loadUser.mockClear();
vi.mocked(signInWithPasskey).mockReset().mockResolvedValue();
});
it('FE-NATIVE-HANDOFF-009: asks for the passkey itself instead of sending the user to the login', () => {
const { result } = renderHandoff();
expect(result.current.passkeyStep).toBe(true);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-NATIVE-HANDOFF-010: signs in with the passkey and goes straight back to the app', async () => {
vi.mocked(requestHandoffCode).mockResolvedValue('c1');
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(signInWithPasskey).toHaveBeenCalled();
expect(loadUser).toHaveBeenCalled();
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c1');
});
it('FE-NATIVE-HANDOFF-011: stays quiet when the prompt is dismissed and offers the full login on request', async () => {
vi.mocked(signInWithPasskey).mockRejectedValue(Object.assign(new Error('no'), { name: 'NotAllowedError' }));
const { result } = renderHandoff();
await act(async () => {
await result.current.continueWithPasskey();
});
expect(result.current.status).toBe('idle');
expect(requestHandoffCode).not.toHaveBeenCalled();
act(() => result.current.otherSignIn());
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith(
`/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`,
{ replace: true }
)
);
});
});
});
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useShallow } from 'zustand/react/shallow';
import { HANDOFF_PATH, requestHandoffCode } from '../../native/browserSignIn';
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { APP_URL_SCHEME } from '../../native/trekShell';
import { useAuthStore } from '../../store/authStore';
@@ -15,31 +15,36 @@ const CHALLENGE = /^[0-9a-f]{64}$/;
*/
export function useNativeHandoff() {
const navigate = useNavigate();
const { isAuthenticated, isLoading, userName, logout } = useAuthStore(
const { isAuthenticated, isLoading, userName, logout, loadUser } = useAuthStore(
useShallow((s) => ({
isAuthenticated: s.isAuthenticated,
isLoading: s.isLoading,
userName: s.user?.username ?? '',
logout: s.logout,
loadUser: s.loadUser,
}))
);
const [params] = useState(() => new URLSearchParams(window.location.search));
const challenge = params.get('challenge') ?? '';
const valid = CHALLENGE.test(challenge);
const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle');
// The app asked for a passkey: this page asks for it itself instead of showing
// the whole login a second time, until the user picks another way to sign in.
const [passkeyStep, setPasskeyStep] = useState(() => params.get('start') === 'passkey');
// Back here once signed in, without `start`, so an SSO round trip cannot loop.
const returnPath = `${HANDOFF_PATH}?challenge=${challenge}`;
useEffect(() => {
if (!valid || isLoading || isAuthenticated) return;
if (!valid || isLoading || isAuthenticated || passkeyStep) return;
if (params.get('start') === 'oidc') {
sessionStorage.setItem('oidc_redirect', returnPath);
rememberHandoffReturn(returnPath);
window.location.replace('/api/auth/oidc/login?remember=1');
return;
}
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
}, [valid, isLoading, isAuthenticated, params, returnPath, navigate]);
}, [valid, isLoading, isAuthenticated, passkeyStep, params, returnPath, navigate]);
const openApp = async (): Promise<void> => {
setStatus('sending');
@@ -51,6 +56,28 @@ export function useNativeHandoff() {
}
};
/**
* The passkey prompt, then straight back to the app: the tap that started it
* is the explicit consent the handoff asks for, so no second page follows.
*/
const continueWithPasskey = async (): Promise<void> => {
setStatus('sending');
try {
await signInWithPasskey();
await loadUser({ silent: true });
} catch (err: unknown) {
const name = (err as { name?: string })?.name;
setStatus(name === 'NotAllowedError' || name === 'AbortError' ? 'idle' : 'failed');
return;
}
await openApp();
};
const otherSignIn = (): void => {
setStatus('idle');
setPasskeyStep(false);
};
const switchAccount = async (): Promise<void> => {
await logout();
void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true });
@@ -58,6 +85,9 @@ export function useNativeHandoff() {
return {
valid,
passkeyStep: valid && !isLoading && !isAuthenticated && passkeyStep,
continueWithPasskey,
otherSignIn,
ready: valid && !isLoading && isAuthenticated,
userName,
status,
+10 -1
View File
@@ -175,6 +175,9 @@
transition: transform .35s cubic-bezier(0.23,1,0.32,1), box-shadow .35s cubic-bezier(0.23,1,0.32,1);
}
.trek-dash .hero-trip:hover { transform: translateY(-4px); box-shadow: var(--sh-xl, 0 24px 60px oklch(0 0 0 / .28)); }
/* The hero's place while the trips load: same box, nothing to open or lift (#3009). */
.trek-dash .hero-trip.hero-trip-pending { cursor: default; box-shadow: none; }
.trek-dash .hero-trip.hero-trip-pending:hover { transform: none; box-shadow: none; }
.trek-dash .hero-trip img.bg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; z-index: 0; transition: transform 12s ease-out;
@@ -400,7 +403,7 @@
.trek-dash .atlas-card.passport .label { color: oklch(1 0 0 / .65); }
.trek-dash .atlas-card.passport .value { font-size: 56px; }
.trek-dash .atlas-card.passport .delta { color: oklch(1 0 0 / .65); }
.trek-dash .passport-flags { display: flex; margin-top: 18px; }
.trek-dash .passport-flags { display: flex; margin-top: 18px; min-height: 24px; } /* a flag's height, so the flags arriving move nothing (#3009) */
.trek-dash .flag {
width: 24px; height: 24px; border-radius: 50%; overflow: hidden;
background: oklch(0.4 0.06 60); border: 2px solid oklch(0.28 0.04 50);
@@ -665,6 +668,8 @@
cursor: pointer; text-align: start; box-shadow: var(--sh-sm); transition: transform .15s cubic-bezier(.2,.7,.2,1), box-shadow .2s;
}
.trek-dash .col-badge:hover { transform: translateY(-2px); box-shadow: var(--sh-md); }
.trek-dash .col-badge.col-badge-pending { cursor: default; box-shadow: none; }
.trek-dash .col-badge.col-badge-pending:hover { transform: none; box-shadow: none; }
.trek-dash .col-badge-media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background-size: cover; background-position: center; }
.trek-dash .col-badge-tint {
position: absolute; inset: 0;
@@ -806,3 +811,7 @@
/* The bottom tab bar's centre "+" replaces the floating FAB on mobile */
.trek-dash .fab-new-trip { display: none; }
}
/* A figure's line while the stats load: its real text, invisible under the
shimmer, so it wraps as the loaded line will and the cards keep their height (#3009). */
.trek-dash .atlas-card .delta .delta-pending { color: transparent; border-radius: 4px; }
+63 -3
View File
@@ -31,6 +31,7 @@ import { isOfflineForbiddenTemplate, isVectorStyle, normalizeTileUrl, resolveTil
import { OFM_DARK, OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchStyleAssets, prefetchVectorForPlaces } from './glPrefetcher'
import { isNativeIos } from '../native/platform'
import { isIosDevice } from '../utils/iosDevice'
import { openCacheStorage } from './openCacheStorage'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -48,6 +49,21 @@ const AVG_TILE_KB = 15
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
/**
* The tile budget on an iPhone or iPad. WebKit reads the origin's Cache Storage
* before the service worker can answer the app's first request, and that read
* grows with the number of entries: a full cache of 12,288 tiles held a home
* screen app on a black screen for about 8 s on every cold start, about 0.7 ms
* an entry (#3008). 2,048 keeps that near a second and still covers the zooms a
* trip map opens at.
*/
export const IOS_MAX_TILES = 2048
/** How many tiles a prefetch may hold here: the iOS budget in Safari and its home screen apps. */
export function tileBudget(): number {
return isIosDevice() && !isNativeIos() ? IOS_MAX_TILES : MAX_TILES
}
/**
* Tile requests in flight at once.
*
@@ -224,7 +240,7 @@ export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): nu
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
total += (maxX - minX + 1) * (maxY - minY + 1)
if (total > MAX_TILES) return total
if (total > tileBudget()) return total
}
return total
}
@@ -252,7 +268,7 @@ export function buildTileUrl(template: string, z: number, x: number, y: number,
/**
* Enumerate the tile coordinates to prefetch, lowest zoom first, stopping at
* the zoom level whose tiles would push the total past MAX_TILES.
* the zoom level whose tiles would push the total past the budget (tileBudget).
*/
function enumerateTiles(
bbox: TileBbox,
@@ -260,11 +276,12 @@ function enumerateTiles(
maxZoom: number,
): Array<[z: number, x: number, y: number]> {
const coords: Array<[number, number, number]> = []
const budget = tileBudget()
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > MAX_TILES) break
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > budget) break
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) coords.push([z, x, y])
@@ -342,6 +359,49 @@ export async function prefetchTiles(
return fetched
}
const TRIM_KEY = 'trek_tile_trim_at'
const TRIM_EVERY_MS = 24 * 60 * 60 * 1000
/**
* Brings a tile cache on an iPhone or iPad back within IOS_MAX_TILES, oldest
* tiles first (Cache Storage lists keys in the order they were stored). The
* service worker's own limit is the same on every platform, so a cache filled
* before this budget, or by browsing, still grows past it there (#3008).
*
* Reading the keys of a large cache is itself slow on iOS, so this runs at most
* once a day and only when called after the app is up. Returns how many tiles
* it removed.
*/
export async function trimTileCacheForIos(now = Date.now()): Promise<number> {
if (!isIosDevice() || isNativeIos()) return 0
try {
const last = Number(localStorage.getItem(TRIM_KEY) || 0)
if (now - last < TRIM_EVERY_MS) return 0
localStorage.setItem(TRIM_KEY, String(now))
} catch {
return 0
}
try {
const cache = await openTileCache()
if (!cache) return 0
const keys = await cache.keys()
const surplus = keys.slice(0, Math.max(0, keys.length - IOS_MAX_TILES))
for (const request of surplus) await cache.delete(request)
return surplus.length
} catch {
// Cache Storage refused: the tiles stay as they are and tomorrow tries again.
return 0
}
}
/** Trims the iOS tile cache once the app has started, never in the way of its first screens. */
export function scheduleTileCacheTrim(delayMs = 15_000): void {
if (!isIosDevice() || isNativeIos()) return
window.setTimeout(() => {
void trimTileCacheForIos()
}, delayMs)
}
/**
* Drop the pre-downloaded map-tile cache. Called when the user turns off
* "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real
@@ -43,6 +43,8 @@ function enableCollectionsAddon(enabled: boolean) {
beforeEach(() => {
resetAllStores();
// The converter waits for the saved pair (#3009); these widgets render with settings in place.
useSettingsStore.setState({ isLoaded: true });
fxHandler();
collectionsHandler([]);
});
@@ -19,9 +19,13 @@ import {
UNPERSISTED_MAX_ZOOM,
clearTileCache,
MAX_TILES,
IOS_MAX_TILES,
TILE_CONCURRENCY,
tileBudget,
trimTileCacheForIos,
type TileBbox,
} from '../../../src/sync/tilePrefetcher';
import { isIosDevice } from '../../../src/utils/iosDevice';
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
import { requestPersistentStorage, _resetPersistentStorage } from '../../../src/sync/persistentStorage';
import { setAuthed } from '../../../src/sync/authGate';
@@ -37,6 +41,10 @@ vi.mock('../../../src/sync/glPrefetcher', async importOriginal => ({
prefetchVectorForPlaces: vi.fn(async () => ({ tiles: 0, template: null })),
prefetchStyleAssets: vi.fn(async () => null),
}));
vi.mock('../../../src/utils/iosDevice', async importOriginal => ({
...(await importOriginal<typeof import('../../../src/utils/iosDevice')>()),
isIosDevice: vi.fn(() => false),
}));
vi.mock('../../../src/native/platform', async importOriginal => ({
...(await importOriginal<typeof import('../../../src/native/platform')>()),
isNativeIos: vi.fn(() => false),
@@ -47,6 +55,7 @@ beforeEach(async () => {
setAuthed(true);
// restoreAllMocks leaves a vi.fn's return value as a test set it; the iOS case below sets true.
vi.mocked(isNativeIos).mockReturnValue(false);
vi.mocked(isIosDevice).mockReturnValue(false);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
@@ -731,3 +740,50 @@ describe('prefetchTiles: a refused tile is not a cached tile (#2180)', () => {
expect(fetched).toBeLessThan(calls);
});
});
// ── iOS tile budget (#3008) ───────────────────────────────────────────────────
describe('the iOS tile budget', () => {
const tileKeys = (n: number) => Array.from({ length: n }, (_, i) => ({ url: `https://tile.example.com/${i}.png` }));
beforeEach(() => localStorage.clear());
it('holds a prefetch on an iPhone or iPad to IOS_MAX_TILES, and nowhere else', () => {
expect(tileBudget()).toBe(MAX_TILES);
vi.mocked(isIosDevice).mockReturnValue(true);
expect(tileBudget()).toBe(IOS_MAX_TILES);
// Inside the iOS app tiles never go through Cache Storage at all.
vi.mocked(isNativeIos).mockReturnValue(true);
expect(tileBudget()).toBe(MAX_TILES);
});
it('stops counting a large area at the iOS budget', () => {
vi.mocked(isIosDevice).mockReturnValue(true);
const europe: TileBbox = { minLat: 36, maxLat: 60, minLng: -10, maxLng: 30 };
const total = countTiles(europe, 0, 16);
expect(total).toBeGreaterThan(IOS_MAX_TILES);
expect(total).toBeLessThan(MAX_TILES);
});
it('trims a full cache down to the budget, oldest tiles first, once a day', async () => {
vi.mocked(isIosDevice).mockReturnValue(true);
const keys = tileKeys(IOS_MAX_TILES + 5);
const del = vi.fn().mockResolvedValue(true);
vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ keys: vi.fn().mockResolvedValue(keys), delete: del }) });
const now = Date.UTC(2026, 10, 8, 12);
expect(await trimTileCacheForIos(now)).toBe(5);
expect(del.mock.calls.map(([k]) => k)).toEqual(keys.slice(0, 5));
// Reading a large cache's keys is slow on iOS: not again the same day.
expect(await trimTileCacheForIos(now + 60 * 60 * 1000)).toBe(0);
expect(del).toHaveBeenCalledTimes(5);
});
it('leaves the cache alone off iOS', async () => {
const open = vi.fn();
vi.stubGlobal('caches', { open });
expect(await trimTileCacheForIos()).toBe(0);
expect(open).not.toHaveBeenCalled();
});
});
+51 -94
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "5.1.0",
"version": "5.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "5.1.0",
"version": "5.1.1",
"workspaces": [
"client",
"server",
@@ -29,7 +29,7 @@
},
"client": {
"name": "@trek/client",
"version": "5.1.0",
"version": "5.1.1",
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@capacitor/android": "^8.5.0",
@@ -4135,9 +4135,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4155,9 +4152,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4175,9 +4169,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4195,9 +4186,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4215,9 +4203,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4235,9 +4220,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4254,9 +4236,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4273,9 +4252,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4293,9 +4269,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4319,9 +4292,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4345,9 +4315,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4371,9 +4338,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4397,9 +4361,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4423,9 +4384,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4448,9 +4406,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4473,9 +4428,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -9897,7 +9849,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.0",
@@ -9911,7 +9864,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.0",
@@ -9925,7 +9879,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.0",
@@ -9939,7 +9894,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.0",
@@ -9953,7 +9909,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.0",
@@ -9967,7 +9924,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.0",
@@ -9981,7 +9939,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.0",
@@ -9995,7 +9954,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.0",
@@ -10009,7 +9969,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.0",
@@ -10036,7 +9997,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.0",
@@ -10050,7 +10012,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.0",
@@ -10064,7 +10027,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.0",
@@ -10078,7 +10042,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.0",
@@ -10092,7 +10057,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.0",
@@ -10106,7 +10072,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.0",
@@ -10120,7 +10087,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.0",
@@ -10134,7 +10102,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.0",
@@ -10161,7 +10130,8 @@
"optional": true,
"os": [
"openbsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.0",
@@ -10175,7 +10145,8 @@
"optional": true,
"os": [
"openharmony"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.0",
@@ -10189,7 +10160,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.0",
@@ -10203,7 +10175,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.0",
@@ -10217,7 +10190,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.0",
@@ -10231,7 +10205,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
@@ -24841,9 +24816,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24861,9 +24833,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24881,9 +24850,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24901,9 +24867,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24921,9 +24884,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24941,9 +24901,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -26036,7 +25993,7 @@
},
"server": {
"name": "@trek/server",
"version": "5.1.0",
"version": "5.1.1",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -26415,7 +26372,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "5.1.0",
"version": "5.1.1",
"dependencies": {
"isomorphic-dompurify": "^3.23.0",
"zod": "^4.6.7"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "5.1.0",
"version": "5.1.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "5.1.0",
"version": "5.1.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -1,5 +1,5 @@
/**
* How TREK 5.1.0 registers with the TREK services.
* How TREK 5.1.1 registers with the TREK services.
*
* The issuer writes this file for each release and the release pipeline
* commits it with the version bump. It is not edited by hand, and its layout
@@ -8,20 +8,49 @@
*/
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
import { createHash, createHmac, pbkdf2Sync } from 'node:crypto';
import { crc32 } from 'node:zlib';
import { createHash, pbkdf2Sync } from 'node:crypto';
const DIALECT = '_53EjggnrEZi';
const PLATEAU_JUNIPER = Buffer.from('e0a200e6a45b6507328ae0f3a781e488ad16c4a56335d2243e6b61989149c23b', 'hex');
const RIVER_CANAL = 'abcdefghijklmnopqrstuvwxyz234567';
const DIALECT = 'uPqlvaOK1lTg';
const REEF_FERN = Buffer.from('f8ae4b4d3888cd3120ee6bd281eba75f55647ea1a4523fcebfc586f9cb5391e9', 'hex');
function pier(input: Buffer, order: number[]): Buffer {
function citadel(input: Buffer, width: number): Buffer {
const out = Buffer.from(input);
order.forEach((from, to) => out.writeUInt8(input.readUInt8(from), to));
for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse();
return out;
}
function kettle(value: number): Buffer {
function lichen(input: Buffer, start: number): Buffer {
const out = Buffer.from(input);
for (let offset = 0; offset < out.length; offset += 32) {
const counter = Buffer.alloc(2);
counter.writeUInt16LE((start + offset / 32) & 0xffff);
const block = createHash('sha256').update(REEF_FERN).update(counter).digest();
for (let j = offset; j < Math.min(offset + 32, out.length); j++) {
out.writeUInt8(out.readUInt8(j) ^ block.readUInt8(j - offset), j);
}
}
return out;
}
function dale(part: Buffer): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16BE(part.length);
return Buffer.concat([head, part]);
}
function sleet(value: number): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64LE(BigInt(value));
return out;
}
function grove(a: Buffer, b: Buffer): Buffer {
const parts: Buffer[] = [];
for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1));
return Buffer.concat(parts);
}
function boulder(value: number): Buffer {
const out: number[] = [];
let rest = value;
do {
@@ -32,92 +61,48 @@ function kettle(value: number): Buffer {
return Buffer.from(out);
}
function sorrel(input: Buffer): string {
const bits = [...input].map((byte) => byte.toString(2).padStart(8, '0')).join('');
let out = '';
for (let i = 0; i < bits.length; i += 5) {
out += RIVER_CANAL.charAt(parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2));
}
return out;
}
function barrel(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value >>> 0);
return out;
}
function slate7(alg: string, size: number, data: Buffer): Buffer {
return createHash(alg, { outputLength: size }).update(data).digest();
}
function lava(input: Buffer, size: number): Buffer {
const out = Buffer.alloc(size);
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
return out;
}
function candle(input: Buffer, rotation: number, consts: Buffer): Buffer {
const out = Buffer.from(input);
let previous = 0;
for (let i = 0; i + 4 <= out.length; i += 4) {
const word = (out.readUInt32LE(i) + consts.readUInt32BE(i % consts.length)) >>> 0;
previous = (((word << rotation) | (word >>> (32 - rotation))) ^ previous) >>> 0;
out.writeUInt32LE(previous, i);
}
return out;
}
export const registrationDialect: RegistrationDialect = {
id: DIALECT,
releasedFor: '5.1.0',
createdAt: '2026-10-31',
releasedFor: '5.1.1',
createdAt: '2026-11-08',
challengePath: `/v1/register/${DIALECT}/challenge`,
registerPath: `/v1/register/${DIALECT}`,
begin({ challenge, version, random, nowSeconds }) {
const zephyr = Buffer.from(challenge.c, 'utf8');
const hollow = Buffer.from(version, 'utf8');
const tide = createHash('sha256').update(zephyr).digest();
const fossil = Buffer.from([0x63]);
const delta = Buffer.concat([hollow, fossil, PLATEAU_JUNIPER, fossil, tide]);
const cherry = createHmac('sha256', delta).update(hollow).digest();
const barge = Buffer.from(cherry.subarray(6, 26));
const gull = Buffer.from('b689ff5d4dbea8f47007ef8acb40fe08d6737d2627f5f206', 'hex');
const wren = candle(candle(barge, 2, gull), 15, gull);
begin({ challenge, version, random }) {
const olive = Buffer.from(challenge.c, 'utf8');
const cactus = Buffer.from(version, 'utf8');
const cypress = createHash('sha256').update(olive).digest();
const creek = Buffer.concat([olive, cactus].map(dale));
const cairn = createHash('shake256', { outputLength: 0x15 }).update(creek).digest();
return {
work(nonce) {
const canyon = Buffer.concat([random, zephyr, hollow, Buffer.from(`${nonce}`)]);
const pollen = Buffer.from(canyon.map((byte) => (byte * 0x73 + 62) & 0xff));
const otter = Buffer.from([
4, 12, 1, 5, 0xe, 8, 7, 0xd, 9, 3, 0xb, 0xa, 0, 2, 6,
].map((from) => pollen.readUInt8(from)));
const river5 = Buffer.concat([otter, pollen.subarray(15)]);
const prairie = lava(river5, 20);
const boulder = createHmac('sha384', PLATEAU_JUNIPER).update(prairie).digest();
const jetty = pier(boulder, [
0x16, 0, 0xd, 0xb, 18, 20, 0x15, 23, 10, 19, 8, 0x11, 4, 0xe, 2, 7, 24, 9, 0xf, 6, 3, 1, 25, 0x10, 0xc, 5,
const breeze = Buffer.from([0xe1]);
const summit = Buffer.concat([
cactus, breeze, random, breeze, olive, breeze, boulder(nonce), breeze, REEF_FERN,
]);
return { password: jetty, salt: wren };
const stamp = Buffer.from([
...summit.subarray(0xb % summit.length), ...summit.subarray(0, 0xb % summit.length),
]);
const ember = createHash('blake2b512').update(stamp).digest();
const dove = lichen(ember, 254);
const autumn = createHash('blake2b512').update(dove).digest();
return { password: autumn, salt: cairn };
},
request(nonce) {
const slate = Buffer.concat([
barrel(nowSeconds), Buffer.from([104]), zephyr, Buffer.from([104]), random, Buffer.from([104]), tide,
Buffer.from([104]), PLATEAU_JUNIPER.subarray(15, 0x1b), Buffer.from([104]), kettle(nonce), Buffer.from([104]),
hollow,
]);
const dawn = pbkdf2Sync(slate, random, 0x1b, 0x1d, 'sha256');
const crane = Buffer.concat([dawn, barrel(crc32(dawn))]);
const shore = slate7('shake256', 51, crane);
const barley = Buffer.concat([sleet(nonce), olive, cactus]);
const wren = grove(barley, cypress);
const chapel = citadel(wren, 4);
const lava = pbkdf2Sync(chapel, olive, 19, 29, 'sha256');
return {
body: {
'glade-wisteria': String(nonce),
'stamp-drift': challenge.c,
jade: random.toString('hex'),
harrier: nowSeconds,
'apple-marble': version,
'dew-basin': 'timber-803',
pilgrim: {
harvest: version,
lindenQuartz: challenge.c,
plateauSorrel: nonce,
apple: 'nest-421',
harvestHollow: random.toString('hex'),
},
},
headers: { 'X-TREK-Spruce-Otter': sorrel(shore) },
headers: { 'X-TREK-Quill-Bramble': lava.toString('hex') },
};
},
};
@@ -126,13 +111,13 @@ export const registrationDialect: RegistrationDialect = {
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
export const SELF_CHECK: DialectSelfCheck = {
challenge: { c: 'v1.selfcheck._53EjggnrEZi', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
version: '5.1.0',
challenge: { c: 'v1.selfcheck.uPqlvaOK1lTg', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
version: '5.1.1',
random: '000102030405060708090a0b0c0d0e0f',
nowSeconds: 1800000000,
nonce: 41,
inputSha256: '3492823dc72bc3566dd8a483fbb041db15235d8069387e6ecd5df4b67d776a63',
saltSha256: '91104fa382a167ba804e06ac859aff52fe9107b420c0880a1ccd319f6699bfe2',
scrypt: '0bf159d19acf7d51c23759e1d71b5ddb9d8b974811ca3478193eca280e0a0101',
requestSha256: '21b99ccdccc6c9ad77109170d26cd9ab3b41d2243d3e9012fdb3dbc3e69d8d9c',
inputSha256: '51bf94954bef8fb0bd92426ffbd3a4301cc26aacc2f1392e36a3589c3d368eed',
saltSha256: 'f574427b733f5f3307704d8e967654cd8a2a1b7cf1ea37c79d682aed6253ab2d',
scrypt: '9603a88e384e7b6a99e79883351d9786ab5294bfe83bb9b80979ddba279d8515',
requestSha256: '30fb4cbca72c689c6b66ba741bd6432a112f0f424619b15ef621935c3dfddf6d',
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "5.1.0",
"version": "5.1.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},