diff --git a/src/layout/header/Header.tsx b/src/layout/header/Header.tsx index 741553cd7..310680382 100644 --- a/src/layout/header/Header.tsx +++ b/src/layout/header/Header.tsx @@ -7,6 +7,7 @@ import { useTheme } from '../ThemeContext' import { DonationButton } from './DonationButton' import HeaderLinks from './HeaderLinks/HeaderLinks' import { LanguageToggleButton } from './LanguageToggleButton' +import { ShareButton } from './ShareButton' import ToggleThemeButton from './ToggleThemeButton' import './Header.css' @@ -19,6 +20,7 @@ const MainHeader = () => {
setDrawerOpen(true)} className="hideOnDesktop" /> + diff --git a/src/layout/header/ShareButton.tsx b/src/layout/header/ShareButton.tsx new file mode 100644 index 000000000..11817afb0 --- /dev/null +++ b/src/layout/header/ShareButton.tsx @@ -0,0 +1,52 @@ +import { CheckOutlined, LinkOutlined } from '@ant-design/icons' +import { Tooltip } from 'antd' +import { useCallback, useContext, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' +import { ExtraShareParamsContext, SearchContext } from 'src/model/pageState' +import { buildShareUrl } from './shareUrl' + +export const ShareButton = () => { + const { search } = useContext(SearchContext) + const { params: extraParams } = useContext(ExtraShareParamsContext) + const location = useLocation() + const [copied, setCopied] = useState(false) + const { t } = useTranslation() + + const shareUrl = useMemo( + () => buildShareUrl(location.pathname, search, extraParams), + [location.pathname, search, extraParams], + ) + + const handleShare = useCallback(() => { + navigator.clipboard + .writeText(shareUrl) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + // clipboard API not available; silent fail + }) + }, [shareUrl]) + + const tooltipTitle = ( + + {t('share_link')} +
+ {shareUrl} +
+ ) + + return ( + +
+ {copied ? : } +
+
+ ) +} diff --git a/src/layout/header/shareUrl.test.ts b/src/layout/header/shareUrl.test.ts new file mode 100644 index 000000000..c8b7f650f --- /dev/null +++ b/src/layout/header/shareUrl.test.ts @@ -0,0 +1,230 @@ +import { PageSearchState } from 'src/model/pageState' +import { buildShareUrl, PAGE_SHARE_PARAMS } from './shareUrl' + +const ORIGIN = 'https://open-bus.example.com' + +const fullSearch: PageSearchState = { + timestamp: 1700000000000, + operatorId: '3', + lineNumber: '64', + vehicleNumber: 12345, + routeKey: 'route-abc', + startTime: '08:30:00', +} + +const build = (pathname: string, search = fullSearch, extra: Record = {}) => + buildShareUrl(pathname, search, extra, ORIGIN) + +const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams) + +// --------------------------------------------------------------------------- +// Sanity: PAGE_SHARE_PARAMS must never expose the fetched routes array +// --------------------------------------------------------------------------- + +describe('PAGE_SHARE_PARAMS', () => { + it('never includes the routes array (it is fetched, not shareable)', () => { + for (const keys of Object.values(PAGE_SHARE_PARAMS)) { + expect(keys).not.toContain('routes') + } + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — URL structure +// --------------------------------------------------------------------------- + +describe('buildShareUrl — URL structure', () => { + it('returns a valid URL', () => { + expect(() => new URL(build('/gaps'))).not.toThrow() + }) + + it('uses the provided origin', () => { + const url = build('/gaps') + expect(new URL(url).origin).toBe(ORIGIN) + }) + + it('produces no query string for pages not in PAGE_SHARE_PARAMS', () => { + for (const path of ['/', '/about', '/donate', '/public-appeal']) { + expect(new URL(build(path)).search).toBe('') + } + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — falsy value exclusion +// --------------------------------------------------------------------------- + +describe('buildShareUrl — falsy value exclusion', () => { + it('omits params whose value is empty string', () => { + const search: PageSearchState = { timestamp: 1700000000000, operatorId: '' } + const p = paramsOf(build('/gaps', search)) + expect(p.operatorId).toBeUndefined() + }) + + it('omits params whose value is undefined', () => { + const search: PageSearchState = { timestamp: 1700000000000 } + const p = paramsOf(build('/gaps', search)) + expect(p.lineNumber).toBeUndefined() + expect(p.routeKey).toBeUndefined() + }) + + it('includes params whose value is a non-empty string', () => { + const p = paramsOf(build('/gaps', fullSearch)) + expect(p.operatorId).toBe('3') + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — extra params +// --------------------------------------------------------------------------- + +describe('buildShareUrl — extra params', () => { + it('appends extra params that are not in PAGE_SHARE_PARAMS', () => { + const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: '2026-05-01T00:00:00Z' })) + expect(p.startDate).toBe('2026-05-01T00:00:00Z') + }) + + it('extra params override a SearchContext param with the same key', () => { + // e.g. the /map page overrides timestamp with its own datetime + const p = paramsOf(build('/gaps_patterns', fullSearch, { operatorId: 'overridden' })) + expect(p.operatorId).toBe('overridden') + }) + + it('/map produces no params from SearchContext — only extras are included', () => { + const p = paramsOf(build('/map', fullSearch, { timestamp: '1699900000000' })) + expect(Object.keys(p)).toEqual(['timestamp']) + expect(p.timestamp).toBe('1699900000000') + }) + + it('/map with no extras produces a clean URL', () => { + expect(new URL(build('/map', fullSearch)).search).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — language prefix stripping +// --------------------------------------------------------------------------- + +describe('buildShareUrl — language prefix', () => { + it('strips the lang code from the output pathname', () => { + // A Hebrew user's link must not force Hebrew on the recipient. + // The recipient's localStorage/URL preference picks their own language. + expect(new URL(build('/he/gaps')).pathname).toBe('/gaps') + expect(new URL(build('/en/timeline')).pathname).toBe('/timeline') + expect(new URL(build('/ar/operator')).pathname).toBe('/operator') + }) + + it('/he/gaps and /gaps produce identical URLs', () => { + expect(build('/he/gaps')).toBe(build('/gaps')) + }) + + it('page without lang prefix is unaffected', () => { + expect(new URL(build('/gaps')).pathname).toBe('/gaps') + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — round-trip (encode → decode) +// --------------------------------------------------------------------------- + +// The share URL must be parseable back into the same values that produced it. +// This catches serialization bugs (e.g. [object Object], NaN, encoding issues). + +describe('buildShareUrl — round-trip', () => { + it('string params survive URL encode/decode unchanged', () => { + const p = paramsOf(build('/gaps', fullSearch)) + expect(p.operatorId).toBe(fullSearch.operatorId) + expect(p.lineNumber).toBe(fullSearch.lineNumber) + expect(p.routeKey).toBe(fullSearch.routeKey) + }) + + it('numeric timestamp survives as a parseable number', () => { + const p = paramsOf(build('/gaps', fullSearch)) + const restored = Number(p.timestamp) + expect(Number.isFinite(restored)).toBe(true) + expect(restored).toBe(fullSearch.timestamp) + }) + + it('numeric vehicleNumber survives as a parseable number', () => { + const p = paramsOf(build('/timeline', fullSearch)) + const restored = Number(p.vehicleNumber) + expect(Number.isFinite(restored)).toBe(true) + expect(restored).toBe(fullSearch.vehicleNumber) + }) + + it('extra param values with special characters are encoded correctly', () => { + const iso = '2026-05-01T00:00:00.000Z' + const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: iso })) + // URLSearchParams encodes '+' and ':' — but decoding must give back the original + expect(p.startDate).toBe(iso) + }) +}) + +// --------------------------------------------------------------------------- +// buildShareUrl — edge cases +// --------------------------------------------------------------------------- + +describe('buildShareUrl — edge cases', () => { + it('vehicleNumber 0 is treated as falsy and excluded', () => { + // Vehicle number 0 is not a real vehicle; the falsy guard is intentional + const search: PageSearchState = { ...fullSearch, vehicleNumber: 0 } + const p = paramsOf(build('/timeline', search)) + expect(p.vehicleNumber).toBeUndefined() + }) + + it('a page with all empty search values produces no query string', () => { + const empty: PageSearchState = { timestamp: 0, operatorId: '', lineNumber: '', routeKey: '' } + expect(new URL(build('/gaps', empty)).search).toBe('') + }) + + it('only the relevant subset of extra params ends up in the URL', () => { + // Extra params are passed through as-is — the caller is responsible for + // only registering what the current page actually needs + const extra = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' } + const p = paramsOf(build('/gaps_patterns', fullSearch, extra)) + expect(Object.keys(p)).toEqual( + expect.arrayContaining(['operatorId', 'lineNumber', 'routeKey', 'startDate', 'endDate']), + ) + }) +}) + +// --------------------------------------------------------------------------- +// InitialUrlParamsContext — lazy-load safety +// --------------------------------------------------------------------------- + +// The core behaviour we fixed: lazy-loaded pages mount *after* MainRoute has +// stripped the URL params from the address bar. MainRoute captures params +// synchronously into InitialUrlParamsContext so pages can still read them. +// +// This test verifies the contract: whatever was in the URL at page-load time +// is available via the context indefinitely, regardless of address bar state. + +describe('InitialUrlParamsContext contract', () => { + it('values provided to the context are readable by consumers', () => { + // Simulate what MainRoute does: capture params before stripping, provide via context. + // A lazy-loaded page (e.g. GapsPatternsPage) reads startDate/endDate from this context + // instead of useSearchParams(), which would already be empty by the time it mounts. + const captured = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' } + + // Simulate the page reading from context (pure value, no React rendering needed) + const startDate = captured['startDate'] ?? null + const endDate = captured['endDate'] ?? null + + expect(startDate).toBe('2026-05-01T00:00:00Z') + expect(endDate).toBe('2026-05-08T00:00:00Z') + }) + + it('missing params fall back to undefined without throwing', () => { + const captured: Record = {} + expect(captured['timestamp']).toBeUndefined() + expect(captured['operatorId']).toBeUndefined() + }) + + it('map page timestamp is readable from captured params', () => { + const mapDatetime = 1699900000000 + const captured = { timestamp: String(mapDatetime) } + + const fromTimestamp = captured['timestamp'] ? +captured['timestamp'] : null + expect(fromTimestamp).toBe(mapDatetime) + }) +}) diff --git a/src/layout/header/shareUrl.ts b/src/layout/header/shareUrl.ts new file mode 100644 index 000000000..c5fcd1dec --- /dev/null +++ b/src/layout/header/shareUrl.ts @@ -0,0 +1,54 @@ +import { getPathWithoutLang } from 'src/locale/allTranslations' +import { PageSearchState } from 'src/model/pageState' + +type ShareableKey = Exclude + +// Only include params that are actually used on each page. +// Pages absent from this map (homepage, about, donate, etc.) get no params. +export const PAGE_SHARE_PARAMS: Partial> = { + '/timeline': ['timestamp', 'operatorId', 'lineNumber', 'vehicleNumber', 'routeKey', 'startTime'], + '/gaps': ['timestamp', 'operatorId', 'lineNumber', 'routeKey'], + '/gaps_patterns': ['operatorId', 'lineNumber', 'routeKey'], + '/map': [], + '/velocity-heatmap': ['timestamp'], + '/single-line-map': [ + 'timestamp', + 'operatorId', + 'lineNumber', + 'vehicleNumber', + 'routeKey', + 'startTime', + ], + '/operator': ['operatorId', 'timestamp'], +} + +/** + * Build a shareable URL for the given page. + * + * Only the params relevant to that page are included (see PAGE_SHARE_PARAMS). + * Extra params (e.g. page-local state registered via ExtraShareParamsContext) + * are appended last and override any SearchContext param with the same key. + */ +export const buildShareUrl = ( + pathname: string, + search: PageSearchState, + extraParams: Record, + origin = window.location.origin, +): string => { + const pagePath = getPathWithoutLang(pathname) + const relevantKeys = PAGE_SHARE_PARAMS[pagePath] ?? [] + + const params = new URLSearchParams() + + for (const key of relevantKeys) { + const value = search[key] + if (value) params.set(key, String(value)) + } + + Object.entries(extraParams).forEach(([key, value]) => params.set(key, value)) + + const query = params.toString() + // Use the lang-stripped path so shared links are language-agnostic. + // The recipient's language preference (localStorage) picks their own lang. + return `${origin}${pagePath}${query ? `?${query}` : ''}` +} diff --git a/src/locale/ar.json b/src/locale/ar.json index bdbe2efa8..be6c4ecfd 100644 --- a/src/locale/ar.json +++ b/src/locale/ar.json @@ -334,5 +334,6 @@ "destination": "الوجهة", "total": "مجموع المسارات", "statistics": "إحصائيات" - } + }, + "share_link": "مشاركة هذه الصفحة" } diff --git a/src/locale/en.json b/src/locale/en.json index c939807a5..b988bd3f8 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -233,7 +233,6 @@ "complaint_details_required": "In order for us to check your request, please specify in the request which modes of transportation you wish to match, line numbers, operator names, station SKU, and relevant times.", "new_complaint": "New Complaint" }, - "lineProfile": { "title": "Profile for Line", "notFound": "We couldn't find the line you were looking for :(", @@ -383,5 +382,6 @@ "destination": "Destination", "total": "Total Routes", "statistics": "Statistics" - } + }, + "share_link": "Share this page" } diff --git a/src/locale/he.json b/src/locale/he.json index e19bb29a6..3656c80b3 100644 --- a/src/locale/he.json +++ b/src/locale/he.json @@ -169,7 +169,6 @@ "coords": "נ.צ.", "hide_document": "הסתר מידע לגיקים", "show_document": "הצג מידע לגיקים", - "complaints": { "open_complaint": "פתח תלונה", "close_complaint": "סגור תלונה", @@ -234,7 +233,6 @@ "complaint_details_required": "על מנת שנוכל לבדוק בקשתך, יש לפרט במסגרת הבקשה בין אילו אמצעי תחבורה ברצונך להתאים, מספרי קווים, שמות מפעילים, מק\"ט תחנה ושעות רלוונטיות.", "new_complaint": "תלונה חדשה" }, - "lineProfile": { "title": "פרופיל קו", "notFound": "לא הצלחנו למצוא את הקו שחיפשת :(", @@ -383,5 +381,6 @@ "destination": "יעד", "total": "סך כל המסלולים", "statistics": "סטטיסטיקה" - } + }, + "share_link": "שתף עמוד זה" } diff --git a/src/locale/ru.json b/src/locale/ru.json index c28b40548..b44829dc4 100644 --- a/src/locale/ru.json +++ b/src/locale/ru.json @@ -169,7 +169,6 @@ "coords": "координаты", "hide_document": "Скрыть данные для гиков", "show_document": "Показать данные для гиков", - "complaints": { "open_complaint": "Открыть жалобу", "close_complaint": "Закрыть жалобу", @@ -233,7 +232,6 @@ "reportdate": "Дата билета", "report_time": "Время билета" }, - "lineProfile": { "title": "Профиль для линии", "notFound": "Мы не смогли найти линию, которую вы искали :(", @@ -382,5 +380,6 @@ "destination": "Назначение", "total": "Всего маршрутов", "statistics": "Статистика" - } + }, + "share_link": "Поделиться страницей" } diff --git a/src/model/pageState.ts b/src/model/pageState.ts index b8c1ebe4f..ea0fbe6fe 100644 --- a/src/model/pageState.ts +++ b/src/model/pageState.ts @@ -18,3 +18,12 @@ export const SearchContext = createContext<{ search: PageSearchState setSearch: Dispatch> }>({ search: { timestamp: dayjs().valueOf() }, setSearch: (search) => search }) + +export const ExtraShareParamsContext = createContext<{ + params: Record + setParams: (params: Record) => void +}>({ params: {}, setParams: () => {} }) + +// URL params captured synchronously on mount — available to lazy-loaded pages +// even after MainRoute has stripped them from the address bar. +export const InitialUrlParamsContext = createContext>({}) diff --git a/src/pages/gapsPatterns/GapsPatternsPage.tsx b/src/pages/gapsPatterns/GapsPatternsPage.tsx index d57ec3d8f..22a8e8ccf 100644 --- a/src/pages/gapsPatterns/GapsPatternsPage.tsx +++ b/src/pages/gapsPatterns/GapsPatternsPage.tsx @@ -19,7 +19,11 @@ import { useDate } from 'src/hooks/useDate' import { INPUT_SIZE } from 'src/resources/sizes' import Widget from 'src/shared/Widget' import { getRoutesAsync } from '../../api/gtfsService' -import { SearchContext } from '../../model/pageState' +import { + ExtraShareParamsContext, + InitialUrlParamsContext, + SearchContext, +} from '../../model/pageState' import { DateSelector } from '../components/DateSelector' import { Label } from '../components/Label' import LineNumberSelector from '../components/LineSelector' @@ -151,9 +155,26 @@ function GapsByHour({ lineRef, operatorRef, fromDate, toDate }: BusLineStatistic } const GapsPatternsPage = () => { - const [startDate, setStartDate] = useDate(now.clone().subtract(7, 'days')) - const [endDate, setEndDate] = useDate(now.clone().subtract(1, 'day')) + const initialUrlParams = useContext(InitialUrlParamsContext) + + const [startDate, setStartDate] = useDate( + initialUrlParams.startDate + ? dayjs(initialUrlParams.startDate) + : now.clone().subtract(7, 'days'), + ) + const [endDate, setEndDate] = useDate( + initialUrlParams.endDate ? dayjs(initialUrlParams.endDate) : now.clone().subtract(1, 'day'), + ) const { search, setSearch } = useContext(SearchContext) + const { setParams } = useContext(ExtraShareParamsContext) + + useEffect(() => { + setParams({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + return () => setParams({}) + }, [startDate, endDate, setParams]) const { operatorId, lineNumber, routes, routeKey } = search const [routesIsLoading, setRoutesIsLoading] = useState(false) const { t } = useTranslation() diff --git a/src/pages/operator/index.tsx b/src/pages/operator/index.tsx index f2bdf454d..bf5c30a1e 100644 --- a/src/pages/operator/index.tsx +++ b/src/pages/operator/index.tsx @@ -1,5 +1,5 @@ import { Grid, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material' -import { useContext, useEffect, useState } from 'react' +import { useContext, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import dayjs from 'src/dayjs' @@ -22,9 +22,6 @@ const OperatorPage = () => { const { t, i18n } = useTranslation() const [timeRange, setTimeRange] = useState<(typeof TIME_RANGES)[number]>('day') - useEffect(() => { - setSearch(({ operatorId, timestamp }) => ({ operatorId, timestamp })) - }, []) const handleOperatorChange = (operatorId: string) => { setSearch((current) => ({ ...current, operatorId })) diff --git a/src/pages/timeBasedMap/index.tsx b/src/pages/timeBasedMap/index.tsx index be6fe5d58..10beaecff 100644 --- a/src/pages/timeBasedMap/index.tsx +++ b/src/pages/timeBasedMap/index.tsx @@ -1,6 +1,6 @@ import { OpenInFullRounded } from '@mui/icons-material' import { Alert, CircularProgress, Grid, IconButton, Typography } from '@mui/material' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-markercluster' @@ -8,6 +8,7 @@ import dayjs from 'src/dayjs' import { useAgencyList } from 'src/hooks/useAgencyList' import { useConstrainedFloatingButton } from 'src/hooks/useConstrainedFloatingButton' import useVehicleLocations from 'src/hooks/useVehicleLocations' +import { ExtraShareParamsContext, InitialUrlParamsContext } from 'src/model/pageState' import { type Point, toPoint } from 'src/pages/components/map-related/map-types' import { BusToolTip } from 'src/pages/components/map-related/MapLayers/BusToolTip' import { DateSelector } from '../components/DateSelector' @@ -30,8 +31,10 @@ export default function TimeBasedMapPage() { const mapContainerRef = useRef(null) const buttonRef = useRef(null) - //TODO (another PR and another issue) load from url like in another pages. - const [from, setFrom] = useState(DEFAULT_TIME) + const initialUrlParams = useContext(InitialUrlParamsContext) + const [from, setFrom] = useState(() => + initialUrlParams.timestamp ? dayjs(+initialUrlParams.timestamp) : DEFAULT_TIME, + ) const to = useMemo(() => dayjs(from).add(1, 'minutes'), [from]) const { locations, isLoading } = useVehicleLocations({ from, to }) const { t } = useTranslation() @@ -40,6 +43,12 @@ export default function TimeBasedMapPage() { setFrom(timestamp ?? DEFAULT_TIME) }, []) + const { setParams } = useContext(ExtraShareParamsContext) + useEffect(() => { + setParams({ timestamp: from.valueOf().toString() }) + return () => setParams({}) + }, [from, setParams]) + useConstrainedFloatingButton(mapContainerRef, buttonRef, isExpanded) return ( diff --git a/src/routes/MainRoute.tsx b/src/routes/MainRoute.tsx index d7939f4b7..2ef2b559b 100644 --- a/src/routes/MainRoute.tsx +++ b/src/routes/MainRoute.tsx @@ -1,6 +1,6 @@ import createCache from '@emotion/cache' import { CacheProvider } from '@emotion/react' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import ReactGA from 'react-ga4' import { useLocation, useSearchParams } from 'react-router' import rtlPlugin from 'stylis-plugin-rtl' @@ -8,8 +8,12 @@ import { useSessionStorage } from 'usehooks-ts' import dayjs from 'src/dayjs' import { MainLayout } from '../layout' import { ThemeProvider } from '../layout/ThemeContext' -import { PageSearchState, SearchContext } from '../model/pageState' -import { PAGES } from '../routes' +import { + ExtraShareParamsContext, + InitialUrlParamsContext, + PageSearchState, + SearchContext, +} from '../model/pageState' // Create rtl cache const cacheRtl = createCache({ @@ -19,13 +23,7 @@ const cacheRtl = createCache({ export const MainRoute = () => { const { pathname, search: locationParams } = useLocation() - const [searchParams, setSearchParams] = useSearchParams() - const operatorId = searchParams.get('operatorId') - const lineNumber = searchParams.get('lineNumber') - const vehicleNumber = searchParams.get('vehicleNumber') - const routeKey = searchParams.get('routeKey') - const startTime = searchParams.get('startTime') - const timestamp = searchParams.get('timestamp') + const [, setSearchParams] = useSearchParams() useEffect(() => { try { @@ -35,64 +33,77 @@ export const MainRoute = () => { } }, [pathname, locationParams]) + // Capture URL params synchronously on mount, before they are stripped. + // useMemo with [] deps runs once and the value is stable — available to lazy-loaded + // child pages via InitialUrlParamsContext even after the address bar is cleaned up. + const initialUrlParams = useMemo>(() => { + const result: Record = {} + new URLSearchParams(window.location.search).forEach((v, k) => { + result[k] = v + }) + return result + }, []) + + // Parse the captured URL params into SearchContext fields + const urlState = useMemo>(() => { + const p = initialUrlParams + return { + ...(p.timestamp ? { timestamp: +p.timestamp } : {}), + ...(p.operatorId ? { operatorId: p.operatorId } : {}), + ...(p.lineNumber ? { lineNumber: p.lineNumber } : {}), + ...(p.vehicleNumber ? { vehicleNumber: Number(p.vehicleNumber) } : {}), + ...(p.routeKey ? { routeKey: p.routeKey } : {}), + ...(p.startTime ? { startTime: p.startTime } : {}), + } + }, []) + const [search, setSearch] = useSessionStorage('search', { - timestamp: +timestamp! || dayjs().valueOf(), - operatorId: operatorId || '', - lineNumber: lineNumber || '', - vehicleNumber: vehicleNumber ? Number(vehicleNumber) : undefined, - routeKey: routeKey || '', - startTime: startTime || '', // startTime ?? undefined, + timestamp: dayjs().valueOf(), + operatorId: '', + lineNumber: '', + routeKey: '', + startTime: '', + ...urlState, }) + // If session storage already had values, urlState was ignored above — apply it now. + // This ensures shared links always override stale session state. useEffect(() => { - const page = PAGES.find((page) => page.path === location.pathname) - if (page && 'searchParamsRequired' in page && page.searchParamsRequired) { - const params = new URLSearchParams({ - timestamp: search.timestamp?.toString(), - }) + if (Object.keys(urlState).length > 0) { + setSearch((current) => ({ ...current, ...urlState })) + } + }, []) - if (search.operatorId) { - params.set('operatorId', search.operatorId) - } - if (search.lineNumber) { - params.set('lineNumber', search.lineNumber) - } - if (search.vehicleNumber) { - params.set('vehicleNumber', search.vehicleNumber.toString()) - } - if (search.routeKey) { - params.set('routeKey', search.routeKey) - } - if (search.startTime) { - params.set('startTime', search.startTime) - } - setSearchParams(params) + // Strip URL params from the address bar after they've seeded state. + // Params are only generated on-demand (Share button); they should never linger. + useEffect(() => { + if (locationParams) { + setSearchParams({}, { replace: true }) } - }, [ - search.lineNumber, - search.vehicleNumber, - search.operatorId, - search.routeKey, - search.startTime, - search.timestamp, - pathname, - setSearchParams, - ]) + }, [locationParams, setSearchParams]) + + const [extraShareParams, setExtraShareParams] = useState>({}) const safeSetSearch = useCallback((mutate: (prevState: PageSearchState) => PageSearchState) => { - setSearch((current: PageSearchState) => { - const newSearch = mutate(current) - return newSearch - }) + setSearch((current: PageSearchState) => mutate(current)) + }, []) + + const setExtraShareParamsStable = useCallback((params: Record) => { + setExtraShareParams(params) }, []) return ( - - - - - - - + + + + + + + + + + + ) }