Files
accounted/lib/mileage/distance.ts
T
MattssonandClaude Fable 5 8249fcab5e feat(mileage): suggest driving distance from the from/to addresses (#1778)
* feat(mileage): suggest driving distance from the from/to addresses

When both endpoints are typed in the trip form (create mode), a debounced
lookup geocodes them via Nominatim and fetches the driving distance via
OSRM, both proxied through /api/mileage/distance so addresses leave only
our server, without user identifiers. The suggestion renders as a
click-to-apply hint under the distance field, never auto-fills, and stays
fully editable. Tooltip shows what the geocoder matched.

In-instance caching (24h hits, 10min misses) plus 1.1s politeness spacing
keep usage inside the OSM public-endpoint policies. OSMF is disclosed as a
data recipient on the privacy page.

Requested by a beta user: first-time routes had to be measured by hand;
route memory (PR #1657) only helps from the second trip onward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve skeptic findings on the distance suggestion

Compliance: routing switched from router.project-osrm.org (demo server,
non-commercial use only) to FOSSGIS's routing.openstreetmap.de; the lookup
is now click-triggered ("Foresla stracka") instead of as-you-type, per
Nominatim's no-autocomplete policy; visible OpenStreetMap attribution next
to the applied suggestion; privacy page reworked to name OSMF and FOSSGIS
e.V. as independent recipients outside the sub-processor table, with an
honest note that typed addresses can themselves be personal data.

Correctness: suggestion-cache key separator changed from '|' (collidable
by address text) to newline; routes rounding to 0.0 km are no longer
suggested (the form rejects 0); the Nominatim politeness queue is bounded
at 3s wait and bails to null instead of holding request handlers open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit findings on the distance suggestion

A generation counter invalidates in-flight lookups when the route or km
field changes or the dialog closes, so a slow response can never write an
old route's distance into a changed form. Privacy page now states each
recipient's actual payload (Nominatim gets address texts, FOSSGIS only
coordinates), discloses the 24h in-memory server cache, and carries
today's revision date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:19:35 +02:00

197 lines
7.2 KiB
TypeScript

import { createLogger } from '@/lib/logger'
const log = createLogger('mileage-distance')
/**
* Driving-distance suggestion for the körjournal trip form, resolved from
* free OpenStreetMap services: Nominatim (geocoding) + OSRM (routing).
* Suggestion-only: every failure path resolves to null and the user always
* confirms or edits the value, so a wrong match can never book itself.
*/
export interface DistanceSuggestion {
/** One-way driving distance in km, rounded to 1 decimal like stored trips. */
distance_km: number
/** What the geocoder actually matched, so the UI can show it for trust. */
from_label: string
to_label: string
}
interface GeocodeHit {
lat: string
lon: string
display_name: string
}
export interface DistanceFetchOptions {
fetchImpl?: typeof fetch
/** Spacing between live Nominatim calls (usage policy: max 1 req/s). */
minIntervalMs?: number
}
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
// FOSSGIS e.V.'s public OSRM instance (the one osm.org itself routes with).
// Unlike router.project-osrm.org it carries no non-commercial restriction;
// its policy asks for max 1 req/s, no heavy usage, a valid User-Agent and
// visible attribution, all of which this module and the UI provide.
const OSRM_URL = 'https://routing.openstreetmap.de/routed-car/route/v1/driving'
// Nominatim's usage policy requires an identifying User-Agent.
const USER_AGENT = 'Accounted korjournal (https://accounted.se)'
const REQUEST_TIMEOUT_MS = 5_000
const DEFAULT_MIN_INTERVAL_MS = 1_100
const HIT_TTL_MS = 24 * 60 * 60 * 1000
// Misses retry sooner: the address may simply not be typed out fully yet.
const MISS_TTL_MS = 10 * 60 * 1000
const CACHE_MAX_ENTRIES = 500
interface CacheEntry<T> {
value: T
expires: number
}
const geocodeCache = new Map<string, CacheEntry<GeocodeHit | null>>()
const suggestionCache = new Map<string, CacheEntry<DistanceSuggestion | null>>()
// Newline can never appear in normalizeQuery output (all whitespace collapses
// to single spaces), so it is a collision-free separator between the two
// endpoints; '|' would let 'Kista|Stockholm'->'Uppsala' share a key with
// 'Kista'->'Stockholm|Uppsala'. Same reasoning as route-memory's ROUTE_KEY.
const ROUTE_CACHE_SEPARATOR = String.fromCharCode(10)
export function clearDistanceCachesForTests(): void {
geocodeCache.clear()
suggestionCache.clear()
nominatimNextSlot = 0
}
function cacheGet<T>(cache: Map<string, CacheEntry<T>>, key: string): CacheEntry<T> | undefined {
const entry = cache.get(key)
if (!entry) return undefined
if (entry.expires < Date.now()) {
cache.delete(key)
return undefined
}
return entry
}
function cacheSet<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number): void {
if (cache.size >= CACHE_MAX_ENTRIES) {
// Maps iterate in insertion order; dropping the first key evicts the oldest.
const oldest = cache.keys().next().value
if (oldest !== undefined) cache.delete(oldest)
}
cache.set(key, { value, expires: Date.now() + ttlMs })
}
function normalizeQuery(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ')
}
// In-instance politeness spacing for live Nominatim calls. Serverless
// instances cannot guarantee a global rate, but combined with the
// click-triggered lookup and the 24h caches this keeps a single instance
// well under the 1 req/s policy. The queue is bounded: rather than holding
// request handlers open, a lookup that would wait longer than
// MAX_QUEUE_WAIT_MS bails out (uncached, so a later retry can succeed)
// without reserving a slot.
let nominatimNextSlot = 0
const MAX_QUEUE_WAIT_MS = 3_000
async function waitForNominatimSlot(minIntervalMs: number): Promise<void> {
const now = Date.now()
const waitMs = nominatimNextSlot - now
if (waitMs > MAX_QUEUE_WAIT_MS) throw new Error('geocoding queue saturated')
nominatimNextSlot = Math.max(now, nominatimNextSlot) + minIntervalMs
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs))
}
async function geocode(
query: string,
fetchImpl: typeof fetch,
minIntervalMs: number
): Promise<GeocodeHit | null> {
const key = normalizeQuery(query)
const cached = cacheGet(geocodeCache, key)
if (cached) return cached.value
await waitForNominatimSlot(minIntervalMs)
const params = new URLSearchParams({ q: query.trim(), format: 'jsonv2', limit: '1' })
const res = await fetchImpl(`${NOMINATIM_URL}?${params}`, {
headers: { 'User-Agent': USER_AGENT, 'Accept-Language': 'sv' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!res.ok) throw new Error(`Nominatim responded ${res.status}`)
const body = (await res.json()) as GeocodeHit[]
const hit = body[0]?.lat && body[0]?.lon ? body[0] : null
cacheSet(geocodeCache, key, hit, hit ? HIT_TTL_MS : MISS_TTL_MS)
return hit
}
async function fetchRouteKm(
from: GeocodeHit,
to: GeocodeHit,
fetchImpl: typeof fetch
): Promise<number | null> {
const coords = `${from.lon},${from.lat};${to.lon},${to.lat}`
const res = await fetchImpl(`${OSRM_URL}/${coords}?overview=false`, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!res.ok) throw new Error(`OSRM responded ${res.status}`)
const body = (await res.json()) as { code?: string; routes?: Array<{ distance?: number }> }
const meters = body.code === 'Ok' ? body.routes?.[0]?.distance : undefined
if (typeof meters !== 'number') return null
const km = Math.round(meters / 100) / 10
// Guard the ROUNDED value: 30 m rounds to 0.0 km, which the trip form
// rejects (km must be > 0), so it must never be suggested.
return km > 0 ? km : null
}
/**
* Resolve a one-way driving-distance suggestion between two free-text
* locations. Returns null when either endpoint cannot be geocoded, no route
* exists, or an upstream call fails: the form simply shows no suggestion.
*/
export async function fetchDistanceSuggestion(
from: string,
to: string,
options: DistanceFetchOptions = {}
): Promise<DistanceSuggestion | null> {
const fetchImpl = options.fetchImpl ?? fetch
const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
const routeCacheKey = normalizeQuery(from) + ROUTE_CACHE_SEPARATOR + normalizeQuery(to)
const cached = cacheGet(suggestionCache, routeCacheKey)
if (cached) return cached.value
try {
// Sequential on purpose: Nominatim's policy forbids parallel requests.
const fromHit = await geocode(from, fetchImpl, minIntervalMs)
const toHit = fromHit ? await geocode(to, fetchImpl, minIntervalMs) : null
if (!fromHit || !toHit) {
cacheSet(suggestionCache, routeCacheKey, null, MISS_TTL_MS)
return null
}
const km = await fetchRouteKm(fromHit, toHit, fetchImpl)
if (km === null) {
cacheSet(suggestionCache, routeCacheKey, null, MISS_TTL_MS)
return null
}
const suggestion: DistanceSuggestion = {
distance_km: km,
from_label: fromHit.display_name,
to_label: toHit.display_name,
}
cacheSet(suggestionCache, routeCacheKey, suggestion, HIT_TTL_MS)
return suggestion
} catch (error) {
// Upstream hiccups are not cached: the next debounced attempt may succeed.
log.warn('suggestion lookup failed', { error: String(error) })
return null
}
}