diff --git a/package-lock.json b/package-lock.json index 453766ef..551ebdc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2043,7 +2042,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2067,7 +2065,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3416,7 +3413,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3813,7 +3809,6 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4457,7 +4452,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4494,7 +4488,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4927,7 +4920,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6478,7 +6470,6 @@ "integrity": "sha512-k/2rVBRIRzOeom3wI9jBPaSEvoTSQEW4iM0EveBmBBKFxO8mSyyRWtDlfC3VnEfu0avmjrMzy8/ZFPSe6F71Hw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "escape-html": "^1.0.3", "sharp": "^0.33.1", @@ -7697,7 +7688,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8346,7 +8336,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9031,7 +9020,6 @@ "resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz", "integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@types/rbush": "4.0.0", "earcut": "^3.0.0", @@ -9473,7 +9461,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9797,7 +9784,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11329,7 +11315,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11564,8 +11549,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", @@ -11610,7 +11594,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11888,7 +11871,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -11938,7 +11920,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/src/sidebar/SettingsBox.tsx b/src/sidebar/SettingsBox.tsx index eb36ae57..d5305a2d 100644 --- a/src/sidebar/SettingsBox.tsx +++ b/src/sidebar/SettingsBox.tsx @@ -10,6 +10,7 @@ import { SettingsContext } from '@/contexts/SettingsContext' import { RoutingProfile } from '@/api/graphhopper' import * as config from 'config' import { ProfileGroupMap } from '@/utils' +import { clearRecentLocations } from '@/sidebar/search/RecentLocations' export default function SettingsBox({ profile }: { profile: RoutingProfile }) { const settings = useContext(SettingsContext) @@ -51,6 +52,14 @@ export default function SettingsBox({ profile }: { profile: RoutingProfile }) { Dispatcher.dispatch(new UpdateSettings({ showDistanceInMiles: !settings.showDistanceInMiles })) } /> + { + if (settings.saveRecentLocations) clearRecentLocations() + Dispatcher.dispatch(new UpdateSettings({ saveRecentLocations: !settings.saveRecentLocations })) + }} + />
{tr('settings_gpx_export')}
diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index a809eeee..dc38e29c 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -1,7 +1,13 @@ import { JSX, ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { QueryPoint, QueryPointType } from '@/stores/QueryStore' import { Bbox, GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper' -import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem } from '@/sidebar/search/AddressInputAutocomplete' +import Autocomplete, { + AutocompleteItem, + GeocodingItem, + POIQueryItem, + RecentLocationItem, +} from '@/sidebar/search/AddressInputAutocomplete' +import { getRecentLocations } from '@/sidebar/search/RecentLocations' import ArrowBack from './arrow_back.svg' import Cross from '@/sidebar/times-solid-thin.svg' import CurrentLocationIcon from './current-location.svg' @@ -17,13 +23,13 @@ import { toLonLat, transformExtent } from 'ol/proj' import { Map } from 'ol' import { AddressParseResult } from '@/pois/AddressParseResult' import { getMap } from '@/map/map' -import { Coordinate, getBBoxFromCoord } from '@/utils' +import { calcDist, Coordinate, getBBoxFromCoord } from '@/utils' export interface AddressInputProps { point: QueryPoint points: QueryPoint[] onCancel: () => void - onAddressSelected: (queryText: string, coord: Coordinate | undefined) => void + onLocationSelected: (mainText: string, secondText: string | undefined, coord: Coordinate | undefined) => void onChange: (value: string) => void clearDragDrop: () => void moveStartIndex: number @@ -41,6 +47,8 @@ export default function AddressInput(props: AddressInputProps) { // keep track of focus and toggle fullscreen display on small screens const [hasFocus, setHasFocus] = useState(false) const isSmallScreen = useMediaQuery({ query: '(max-width: 44rem)' }) + const prevPoint = props.index > 0 ? props.points[props.index - 1] : undefined + const excludeCoord = prevPoint?.isInitialized ? prevPoint.coordinate : undefined // container for geocoding results which gets set by the geocoder class and set to empty if the underlying query // point gets changed from outside also gets filled with an item to select the current location as input if input @@ -72,7 +80,21 @@ export default function AddressInput(props: AddressInputProps) { const [poiSearch] = useState(new ReverseGeocoder(getApi(), props.point, AddressParseResult.handleGeocodingResponse)) // if item is selected we need to clear the autocompletion list - useEffect(() => setAutocompleteItems([]), [props.point]) + useEffect(() => { + if (props.point.isInitialized) setAutocompleteItems([]) + }, [props.point]) + + useEffect(() => { + if (!hasFocus) return + if (isInitialFocus.current) { + isInitialFocus.current = false + return + } + if (text === '') { + const recents = buildRecentItems(undefined, 5, excludeCoord) + if (recents.length > 0) setAutocompleteItems(recents) + } + }, [hasFocus, excludeCoord]) // highlighted result of geocoding results. Keep track which index is highlighted and change things on ArrowUp and Down // on Enter select highlighted result or the 0th if nothing is highlighted @@ -105,7 +127,8 @@ export default function AddressInput(props: AddressInputProps) { setText(origText) } else if (nextIndex >= 0) { const item = autocompleteItems[nextIndex] - if (item instanceof GeocodingItem) setText(item.mainText) + if (item instanceof GeocodingItem || item instanceof RecentLocationItem) + setText(item.mainText) else setText(origText) } } @@ -119,13 +142,15 @@ export default function AddressInput(props: AddressInputProps) { // try to parse input as coordinate. Otherwise query nominatim const coordinate = textToCoordinate(text) if (coordinate) { - props.onAddressSelected(text, coordinate) + props.onLocationSelected(text, undefined, coordinate) } else if (autocompleteItems.length > 0) { const index = highlightedResult >= 0 ? highlightedResult : 0 const item = autocompleteItems[index] if (item instanceof POIQueryItem) { handlePoiSearch(poiSearch, item.result, props.map) - props.onAddressSelected(item.result.text(item.result.poi), undefined) + props.onLocationSelected(item.result.text(item.result.poi), undefined, undefined) + } else if (item instanceof RecentLocationItem) { + props.onLocationSelected(item.mainText, item.secondText, item.point) } else if (highlightedResult < 0 && !props.point.isInitialized) { // by default use the first result, otherwise the highlighted one getApi() @@ -134,13 +159,13 @@ export default function AddressInput(props: AddressInputProps) { if (result && result.hits.length > 0) { const hit: GeocodingHit = result.hits[0] const res = nominatimHitToItem(hit) - props.onAddressSelected(res.mainText + ', ' + res.secondText, hit.point) + props.onLocationSelected(res.mainText, res.secondText, hit.point) } else if (item instanceof GeocodingItem) { - props.onAddressSelected(item.toText(), item.point) + props.onLocationSelected(item.mainText, item.secondText, item.point) } }) } else if (item instanceof GeocodingItem) { - props.onAddressSelected(item.toText(), item.point) + props.onLocationSelected(item.mainText, item.secondText, item.point) } } if (event.key === 'Enter') focusNextOrBlur() @@ -168,6 +193,7 @@ export default function AddressInput(props: AddressInputProps) { // do not focus on mobile as we would hide the map with the "input"-view const focusFirstInput = props.index == 0 && !isSmallScreen + const isInitialFocus = useRef(focusFirstInput) return (
@@ -202,8 +228,21 @@ export default function AddressInput(props: AddressInputProps) { onChange={e => { const query = e.target.value setText(query) - const coordinate = textToCoordinate(query) - if (!coordinate) geocoder.request(e.target.value, biasCoord, getMap().getView().getZoom()) + if (query === '') { + geocoder.cancel() + const recents = buildRecentItems(undefined, 5, excludeCoord) + if (recents.length > 0) setAutocompleteItems(recents) + else setAutocompleteItems([]) + } else { + const coordinate = textToCoordinate(query) + if (!coordinate) { + if (query.length < 2) { + const recents = buildRecentItems(query, 5, excludeCoord) + if (recents.length > 0) setAutocompleteItems(recents) + } + geocoder.request(query, biasCoord, getMap().getView().getZoom()) + } + } props.onChange(query) }} onKeyDown={onKeypress} @@ -232,6 +271,9 @@ export default function AddressInput(props: AddressInputProps) { onClick={e => { setText('') props.onChange('') + const recents = buildRecentItems(undefined, 5, excludeCoord) + if (recents.length > 0) setAutocompleteItems(recents) + else setAutocompleteItems([]) // if we clear the text without focus then explicitly request it to improve usability: searchInput.current!.focus() }} @@ -247,7 +289,7 @@ export default function AddressInput(props: AddressInputProps) { e => e.preventDefault() // prevents that input->onBlur is called when clicking the button (would hide this button and prevent onClick) } onClick={() => { - onCurrentLocationSelected(props.onAddressSelected) + onCurrentLocationSelected((text, coord) => props.onLocationSelected(text, undefined, coord)) // but when clicked => we want to lose the focus e.g. to close mobile-input view searchInput.current!.blur() }} @@ -255,7 +297,7 @@ export default function AddressInput(props: AddressInputProps) { - {autocompleteItems.length > 0 && ( + {hasFocus && autocompleteItems.length > 0 && ( { if (item instanceof GeocodingItem) { setText(item.toText()) - props.onAddressSelected(item.toText(), item.point) + props.onLocationSelected(item.mainText, item.secondText, item.point) + } else if (item instanceof RecentLocationItem) { + setText(item.toText()) + props.onLocationSelected(item.mainText, item.secondText, item.point) } else if (item instanceof POIQueryItem) { handlePoiSearch(poiSearch, item.result, props.map) setText(item.result.text(item.result.poi)) @@ -282,6 +327,24 @@ export default function AddressInput(props: AddressInputProps) { ) } +function buildRecentItems(filter?: string, limit?: number, excludeCoord?: Coordinate): RecentLocationItem[] { + let recents = getRecentLocations(0) + if (excludeCoord) recents = recents.filter(e => calcDist({ lat: e.lat, lng: e.lng }, excludeCoord) > 0) + if (filter) { + const lower = filter.toLowerCase() + recents = recents.filter( + e => + e.mainText.toLowerCase().startsWith(lower) || + e.secondText + .toLowerCase() + .split(/[\s,]+/) + .some(word => word.startsWith(lower)), + ) + } + if (limit) recents = recents.slice(0, limit) + return recents.map(e => new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng })) +} + function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) { if (!result.hasPOIs()) return diff --git a/src/sidebar/search/AddressInputAutocomplete.module.css b/src/sidebar/search/AddressInputAutocomplete.module.css index 6fa88ed9..8fd0577f 100644 --- a/src/sidebar/search/AddressInputAutocomplete.module.css +++ b/src/sidebar/search/AddressInputAutocomplete.module.css @@ -61,3 +61,23 @@ font-size: small; color: #5b616a; } + +.recentEntry { + display: flex; + align-items: center; + text-align: start; + margin: 0.4rem 0.5rem; +} + +.recentEntryText { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.recentIcon { + flex-shrink: 0; + color: #5b616a; + margin-right: 0.5rem; +} diff --git a/src/sidebar/search/AddressInputAutocomplete.tsx b/src/sidebar/search/AddressInputAutocomplete.tsx index 292a8cf7..21f3d6ff 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -22,6 +22,22 @@ export class GeocodingItem implements AutocompleteItem { } } +export class RecentLocationItem implements AutocompleteItem { + mainText: string + secondText: string + point: { lat: number; lng: number } + + constructor(mainText: string, secondText: string, point: { lat: number; lng: number }) { + this.mainText = mainText + this.secondText = secondText + this.point = point + } + + toText() { + return this.mainText + ', ' + this.secondText + } +} + export class POIQueryItem implements AutocompleteItem { result: AddressParseResult @@ -51,6 +67,8 @@ export default function Autocomplete({ items, highlightedItem, onSelect }: Autoc function mapToComponent(item: AutocompleteItem, isHighlighted: boolean, onSelect: (hit: AutocompleteItem) => void) { if (item instanceof GeocodingItem) return + else if (item instanceof RecentLocationItem) + return else if (item instanceof POIQueryItem) return else throw Error('Unsupported item type: ' + typeof item) @@ -95,6 +113,39 @@ function GeocodingEntry({ ) } +function RecentLocationEntry({ + item, + isHighlighted, + onSelect, +}: { + item: RecentLocationItem + isHighlighted: boolean + onSelect: (item: RecentLocationItem) => void +}) { + return ( + onSelect(item)}> +
+ + + +
+ {item.mainText} + {item.secondText} +
+
+
+ ) +} + function AutocompleteEntry({ isHighlighted, children, diff --git a/src/sidebar/search/RecentLocations.ts b/src/sidebar/search/RecentLocations.ts new file mode 100644 index 00000000..8b9a79f1 --- /dev/null +++ b/src/sidebar/search/RecentLocations.ts @@ -0,0 +1,80 @@ +import { calcDist, Coordinate } from '@/utils' +import { tr } from '@/translation/Translation' +import { textToCoordinate } from '@/Converters' + +const STORAGE_KEY = 'recentLocations' +export const MAX_ENTRIES = 15 +const DEDUP_DISTANCE_METERS = 5 + +export interface RecentLocation { + mainText: string + secondText: string + lat: number + lng: number + timestamp: number + count: number +} + +export function getRecentLocations(minCount: number = 0): RecentLocation[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed + .filter( + (e: any) => + typeof e.mainText === 'string' && + typeof e.secondText === 'string' && + typeof e.lat === 'number' && + typeof e.lng === 'number' && + typeof e.timestamp === 'number', + ) + .map((e: any) => ({ ...e, count: typeof e.count === 'number' ? e.count : 1 })) + .filter((e: RecentLocation) => e.count > minCount) + .sort((a: RecentLocation, b: RecentLocation) => b.count - a.count || b.timestamp - a.timestamp) + } catch { + return [] + } +} + +export function clearRecentLocations(): void { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // localStorage unavailable + } +} + +export function saveRecentLocation( + mainText: string, + secondText: string, + coordinate: Coordinate, + now: number = Date.now(), +): void { + if (mainText === tr('current_location')) return + if (textToCoordinate(mainText)) return + + try { + const all = getRecentLocations() + let prevCount = 0 + const filtered = all.filter(e => { + const isDuplicate = calcDist({ lat: e.lat, lng: e.lng }, coordinate) <= DEDUP_DISTANCE_METERS + if (isDuplicate) prevCount = e.count + return !isDuplicate + }) + + filtered.push({ + mainText, + secondText, + lat: coordinate.lat, + lng: coordinate.lng, + timestamp: now, + count: prevCount + 1, + }) + filtered.sort((a, b) => b.timestamp - a.timestamp) + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered.slice(0, MAX_ENTRIES))) + } catch { + // localStorage unavailable (private browsing, quota exceeded) + } +} diff --git a/src/sidebar/search/Search.tsx b/src/sidebar/search/Search.tsx index 40d1fa12..72a0318e 100644 --- a/src/sidebar/search/Search.tsx +++ b/src/sidebar/search/Search.tsx @@ -24,6 +24,9 @@ import { tr } from '@/translation/Translation' import SettingsBox from '@/sidebar/SettingsBox' import { RoutingProfile } from '@/api/graphhopper' import { getBBoxFromCoord } from '@/utils' +import { saveRecentLocation } from '@/sidebar/search/RecentLocations' +import { useContext } from 'react' +import { SettingsContext } from '@/contexts/SettingsContext' export default function Search({ points, profile, map }: { points: QueryPoint[]; profile: RoutingProfile; map: Map }) { const [showSettings, setShowSettings] = useState(false) @@ -103,6 +106,7 @@ const SearchBox = ({ map: Map }) => { const point = points[index] + const saveRecent = useContext(SettingsContext).saveRecentLocations function onClickOrDrop() { onDropPreviewSelect(-1) @@ -176,7 +180,10 @@ const SearchBox = ({ point={point} points={points} onCancel={() => console.log('cancel')} - onAddressSelected={(queryText, coordinate) => { + onLocationSelected={(mainText, secondText, coordinate) => { + const queryText = secondText ? mainText + ', ' + secondText : mainText + if (secondText && coordinate && saveRecent) saveRecentLocation(mainText, secondText, coordinate) + const initCount = points.filter(p => p.isInitialized).length if (coordinate && initCount != points.length) Dispatcher.dispatch(new SetBBox(getBBoxFromCoord(coordinate))) diff --git a/src/stores/SettingsStore.ts b/src/stores/SettingsStore.ts index 57f02f9a..0b29de4f 100644 --- a/src/stores/SettingsStore.ts +++ b/src/stores/SettingsStore.ts @@ -7,6 +7,7 @@ const STORAGE_KEY = 'settings' export interface Settings { showDistanceInMiles: boolean drawAreasEnabled: boolean // temporary, not persisted to localStorage + saveRecentLocations: boolean gpxExportRte: boolean gpxExportWpt: boolean gpxExportTrk: boolean @@ -15,6 +16,7 @@ export interface Settings { export const defaultSettings: Settings = { showDistanceInMiles: false, drawAreasEnabled: false, + saveRecentLocations: true, gpxExportRte: false, gpxExportWpt: false, gpxExportTrk: true, diff --git a/src/translation/tr.json b/src/translation/tr.json index d905530e..e69fdea1 100644 --- a/src/translation/tr.json +++ b/src/translation/tr.json @@ -131,6 +131,7 @@ "back":"Back", "as_start":"As start", "as_destination":"As destination", +"save_recent_locations":"Save recent locations", "route_stats_bike_network":"Bike Network", "route_stats_foot_network":"Foot Network", "route_stats_incline":"Incline", @@ -362,6 +363,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -593,6 +595,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -824,6 +827,7 @@ "back":"Geri", "as_start":"Başlanğıc nöqtəsi kimi", "as_destination":"Təyinat nöqtəsi kimi", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -1055,6 +1059,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -1286,6 +1291,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -1517,6 +1523,7 @@ "back":"Enrera", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -1748,6 +1755,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -1979,6 +1987,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -2210,6 +2219,7 @@ "back":"Zurück", "as_start":"Als Start", "as_destination":"Als Ziel", +"save_recent_locations":"Letzte Orte speichern", "route_stats_bike_network":"Radnetz", "route_stats_foot_network":"Wanderwege", "route_stats_incline":"Anstieg", @@ -2441,6 +2451,7 @@ "back":"Πίσω", "as_start":"Ως αφετηρία", "as_destination":"Ως προορισμό", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -2672,6 +2683,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -2903,6 +2915,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -3134,6 +3147,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -3365,6 +3379,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -3596,6 +3611,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -3827,6 +3843,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -4058,6 +4075,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -4289,6 +4307,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -4520,6 +4539,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -4751,6 +4771,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -4982,6 +5003,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -5213,6 +5235,7 @@ "back":"Vissza", "as_start":"Kiindulási pontnak", "as_destination":"Célpontnak", +"save_recent_locations":"", "route_stats_bike_network":"Kerékpárút-hálózat", "route_stats_foot_network":"Turistaút-hálózat", "route_stats_incline":"Lejtés", @@ -5444,6 +5467,7 @@ "back":"Sebelumnya", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -5675,6 +5699,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -5906,6 +5931,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -6137,6 +6163,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -6368,6 +6395,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -6599,6 +6627,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -6830,6 +6859,7 @@ "back":"Буцах", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -7061,6 +7091,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -7292,6 +7323,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -7523,6 +7555,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -7754,6 +7787,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -7985,6 +8019,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -8216,6 +8251,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -8447,6 +8483,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -8678,6 +8715,7 @@ "back":"Назад", "as_start":"Как пункт отправления", "as_destination":"Как пункт назначения", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -8909,6 +8947,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -9140,6 +9179,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -9371,6 +9411,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -9602,6 +9643,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -9833,6 +9875,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -10064,6 +10107,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -10295,6 +10339,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -10526,6 +10571,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -10757,6 +10803,7 @@ "back":"返回", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -10988,6 +11035,7 @@ "back":"返回", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", @@ -11219,6 +11267,7 @@ "back":"", "as_start":"", "as_destination":"", +"save_recent_locations":"", "route_stats_bike_network":"", "route_stats_foot_network":"", "route_stats_incline":"", diff --git a/test/RecentLocations.test.ts b/test/RecentLocations.test.ts new file mode 100644 index 00000000..d6a59e0e --- /dev/null +++ b/test/RecentLocations.test.ts @@ -0,0 +1,44 @@ +import { getRecentLocations, saveRecentLocation, MAX_ENTRIES } from '@/sidebar/search/RecentLocations' + +jest.mock('@/translation/Translation', () => ({ tr: (key: string) => key })) +jest.mock('@/Converters', () => ({ textToCoordinate: () => null })) + +beforeEach(() => localStorage.clear()) + +function save(name: string, lat: number, timestamp: number) { + saveRecentLocation(name, '', { lat, lng: 0 }, timestamp) +} + +function names() { + return getRecentLocations().map(l => l.mainText) +} + +describe('RecentLocations', () => { + it('should dedup nearby locations and increment count', () => { + save('A', 1, 1000) + save('A2', 1, 2000) + const locs = getRecentLocations() + expect(locs).toHaveLength(1) + expect(locs[0]).toMatchObject({ mainText: 'A2', count: 2, timestamp: 2000 }) + }) + + it('should evict oldest entry when MAX is reached, not a recent one', () => { + for (let i = 0; i < MAX_ENTRIES; i++) save(`Old${i}`, 10 + i, 1000 + i) + + save('Recent', 90, 100_000) + + expect(names()).toContain('Recent') + expect(names()).not.toContain('Old0') // oldest timestamp evicted + expect(getRecentLocations()).toHaveLength(MAX_ENTRIES) + }) + + it('should keep a re-used location from being evicted', () => { + for (let i = 0; i < MAX_ENTRIES; i++) save(`P${i}`, 10 + i, 1000 + i) + + save('P0', 10, 200_000) // re-use refreshes timestamp + save('New', 90, 200_001) // triggers eviction + + expect(names()).toContain('P0') + expect(names()).not.toContain('P1') // now the oldest + }) +})