feat(pwa): reopen the trip and tab the installed app was in (#1024)

This commit is contained in:
Maurice
2026-10-01 13:18:47 +02:00
parent 1f464ce805
commit d1f4deaa31
5 changed files with 191 additions and 10 deletions
+12
View File
@@ -23,6 +23,7 @@ import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
@@ -144,6 +145,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
// instance across a navigation and the error would follow the user around.
return (
<MobileShell isPhone={isPhone}>
<RouteMemory />
<ErrorBoundary
key={location.pathname}
boundaryId="route"
@@ -214,6 +216,12 @@ function ViewportRoute({ phone: Phone, desktop: Desktop }: {
* didn't set it. Only 'active_trip' costs the one lookup that finds the trip,
* and any failure along the way lands on the dashboard.
*/
/** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */
function RouteMemory() {
useRememberRoute()
return null
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
@@ -233,6 +241,10 @@ function RootRedirect() {
useEffect(() => {
if (isLoading || !isAuthenticated || target) return
// The installed app coming back after the system threw it away (#1024):
// back to the trip and tab it was in, ahead of the start page.
const resume = takeResumeRoute()
if (resume) { setTarget(resume); return }
const mirrored = readStartDestination()
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
// Ask for the settings instead of waiting for whoever else might. The
@@ -5,6 +5,7 @@ import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
import { useSettingsStore } from '../../store/settingsStore'
import { isInstalledApp } from '../../utils/resumeRoute'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
@@ -197,13 +198,3 @@ export function openNavigationTarget(target: NavigationTarget): void {
if (target.id === 'geo' || isInstalledApp()) window.location.href = target.url
else window.open(target.url, '_blank', 'noopener,noreferrer')
}
/** True in a display mode that has no tab strip to close a stray window from. */
function isInstalledApp(): boolean {
if (typeof window === 'undefined') return false
const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some(
mode => window.matchMedia?.(`(display-mode: ${mode})`).matches,
)
// iOS predates the display-mode query for home-screen apps.
return standalone || (window.navigator as { standalone?: boolean }).standalone === true
}
+2
View File
@@ -14,6 +14,7 @@ import { clearAppearanceSnapshot } from '../theme/applyAppearance'
import { clearAllPluginSessions } from './pluginStore'
import { forgetStartDestination } from '../utils/startDestination'
import { forgetServerLanguage } from './settingsStore'
import { forgetResumeRoute } from '../utils/resumeRoute'
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
@@ -267,6 +268,7 @@ export const useAuthStore = create<AuthState>()(
// browser language is one TREK ships, so otherwise the next user here stays
// in the previous account's language, launch after launch.
forgetServerLanguage()
forgetResumeRoute()
// And work-offline, for the same reason with sharper teeth: the switch lives
// in localStorage, step 6 below deletes the offline database it reads from,
// and the next account would come up believing it is offline over a working
+61
View File
@@ -0,0 +1,61 @@
// FE-UTIL-RESUME-001 to FE-UTIL-RESUME-005
import { beforeEach, describe, expect, it } from 'vitest'
import { renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import type { ReactNode } from 'react'
import { forgetResumeRoute, rememberRoute, RESUME_MAX_AGE_MS, takeResumeRoute, useRememberRoute } from './resumeRoute'
const NOW = 1_800_000_000_000
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('resume route (#1024)', () => {
it('FE-UTIL-RESUME-001: an installed relaunch goes back to the trip and its tab, once', () => {
sessionStorage.setItem('trip-tab-7', 'buchungen')
rememberRoute('/trips/7?tab=plan', NOW)
sessionStorage.clear() // the system threw the app away
expect(takeResumeRoute(NOW + 60_000, true)).toBe('/trips/7')
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen')
// Within the same run the start page is the start page again.
expect(takeResumeRoute(NOW + 120_000, true)).toBeNull()
})
it('FE-UTIL-RESUME-002: a deep-linked tab is kept when the planner never switched', () => {
rememberRoute('/trips/7?tab=finanzplan', NOW)
sessionStorage.clear()
expect(takeResumeRoute(NOW, true)).toBe('/trips/7')
expect(sessionStorage.getItem('trip-tab-7')).toBe('finanzplan')
})
it('FE-UTIL-RESUME-003: not in a browser tab, not when stale, not for places that are no destination', () => {
rememberRoute('/journey/3', NOW)
expect(takeResumeRoute(NOW, false)).toBeNull()
sessionStorage.clear()
expect(takeResumeRoute(NOW + RESUME_MAX_AGE_MS + 1, true)).toBeNull()
sessionStorage.clear()
rememberRoute('/settings', NOW)
expect(localStorage.getItem('trek:resume-route')).toBeNull()
expect(takeResumeRoute(NOW, true)).toBeNull()
})
it('FE-UTIL-RESUME-004: logout forgets it, and a broken record is ignored', () => {
rememberRoute('/atlas', NOW)
forgetResumeRoute()
expect(takeResumeRoute(NOW, true)).toBeNull()
sessionStorage.clear()
localStorage.setItem('trek:resume-route', '{nope')
expect(takeResumeRoute(NOW, true)).toBeNull()
})
it('FE-UTIL-RESUME-005: the hook records the route it renders on, and again when the page is hidden', () => {
const wrapper = ({ children }: { children: ReactNode }) => <MemoryRouter initialEntries={['/journey/9']}>{children}</MemoryRouter>
renderHook(() => useRememberRoute(), { wrapper })
expect(JSON.parse(localStorage.getItem('trek:resume-route')!).path).toBe('/journey/9')
localStorage.clear()
window.dispatchEvent(new Event('pagehide'))
expect(JSON.parse(localStorage.getItem('trek:resume-route')!).path).toBe('/journey/9')
})
})
+115
View File
@@ -0,0 +1,115 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router'
/**
* Where the installed app was when it went to the background (#1024).
*
* iOS throws a home-screen app away soon after it is switched out (to Google
* Maps, say), and the relaunch opens the start URL: back on the dashboard, the
* trip and the tab the traveller was in gone. The route and the trip's tab are
* kept here instead, and RootRedirect resumes them on a relaunch that comes soon
* enough to still be the same session of use.
*
* Installed app only: in a browser tab, typing the address means "start".
*/
const KEY = 'trek:resume-route'
const CHECKED_KEY = 'trek:resume-checked'
/** Older than this, a relaunch is a fresh start again. */
export const RESUME_MAX_AGE_MS = 6 * 60 * 60 * 1000
interface ResumeRecord { path: string; tab?: string; at: number }
/** True in a display mode that has no tab strip, i.e. launched from the home screen. */
export function isInstalledApp(): boolean {
if (typeof window === 'undefined') return false
const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some(
mode => window.matchMedia?.(`(display-mode: ${mode})`).matches,
)
// iOS predates the display-mode query for home-screen apps.
return standalone || (window.navigator as { standalone?: boolean }).standalone === true
}
/** Routes that are a place to come back to; the root, auth pages and settings are not. */
function resumable(path: string): boolean {
return /^\/(trips\/\d+|journey(\/\d+)?|collections|vacay|atlas|files)(\/|\?|$)/.test(path)
}
const tripIdOf = (path: string): string | null => /^\/trips\/(\d+)/.exec(path)?.[1] ?? null
function withoutTabParam(path: string): string {
const [pathname, search = ''] = path.split('?')
const params = new URLSearchParams(search)
params.delete('tab')
const rest = params.toString()
return rest ? `${pathname}?${rest}` : pathname
}
/** Called on every protected navigation and when the app is hidden. */
export function rememberRoute(path: string, now = Date.now()): void {
try {
if (!resumable(path)) {
localStorage.removeItem(KEY)
return
}
const tripId = tripIdOf(path)
// The tab the planner last switched to, else the one the address asked for. Kept
// apart from the address, where a ?tab= would outrank a tab switched to since.
const asked = new URLSearchParams(path.split('?')[1] ?? '').get('tab') ?? undefined
const tab = tripId ? sessionStorage.getItem(`trip-tab-${tripId}`) ?? asked : undefined
const kept = tripId ? withoutTabParam(path) : path
const record: ResumeRecord = { path: kept, at: now, ...(tab ? { tab } : {}) }
localStorage.setItem(KEY, JSON.stringify(record))
} catch {
// Storage refused: the relaunch simply starts where it always did.
}
}
/**
* The route to resume on this launch, or null. A trip's tab is put back where
* the planner reads it, so the tab opens rather than the plan.
*/
export function takeResumeRoute(now = Date.now(), installed = isInstalledApp()): string | null {
if (!installed) return null
try {
// Once per launch. sessionStorage dies with the app the system threw away and
// survives everything else, so a later trip to the start page within the same
// run goes to the start page, not back into the trip.
if (sessionStorage.getItem(CHECKED_KEY)) return null
sessionStorage.setItem(CHECKED_KEY, '1')
const raw = localStorage.getItem(KEY)
if (!raw) return null
const record = JSON.parse(raw) as Partial<ResumeRecord>
if (typeof record.path !== 'string' || typeof record.at !== 'number') return null
if (now - record.at > RESUME_MAX_AGE_MS || !resumable(record.path)) return null
const tripId = tripIdOf(record.path)
if (tripId && record.tab) sessionStorage.setItem(`trip-tab-${tripId}`, record.tab)
return record.path
} catch {
return null
}
}
/** On logout, so the next account on this device does not resume someone else's trip. */
export function forgetResumeRoute(): void {
try { localStorage.removeItem(KEY) } catch { /* nothing to forget */ }
}
/**
* Keeps the record current: on each navigation, and once more as the app is
* hidden, which is the last moment a tab switched inside the trip gets saved.
*/
export function useRememberRoute(): void {
const location = useLocation()
const path = location.pathname + location.search
useEffect(() => {
rememberRoute(path)
const onVisibility = () => { if (document.visibilityState === 'hidden') rememberRoute(path) }
const onPageHide = () => rememberRoute(path)
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', onPageHide)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', onPageHide)
}
}, [path])
}