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>
This commit is contained in:
Mattsson
2026-08-21 12:19:35 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 60920ec794
commit 8249fcab5e
10 changed files with 596 additions and 6 deletions
+5
View File
@@ -3581,6 +3581,11 @@ export const MileageSalaryPushSchema = z
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
})
export const MileageDistanceQuerySchema = z.object({
from: z.string().trim().min(2).max(200),
to: z.string().trim().min(2).max(200),
})
// ============================================================
// Bank file import
// ============================================================
+148
View File
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearDistanceCachesForTests, fetchDistanceSuggestion } from '../distance'
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return { ok, status, json: async () => body } as unknown as Response
}
function geocodeHit(lat: string, lon: string, name: string) {
return [{ lat, lon, display_name: name }]
}
const OSRM_OK = { code: 'Ok', routes: [{ distance: 47_250 }] }
// Every call passes minIntervalMs: 0 so tests never sleep for the
// Nominatim politeness spacing.
const OPTS = (fetchImpl: typeof fetch) => ({ fetchImpl, minIntervalMs: 0 })
beforeEach(() => {
vi.clearAllMocks()
clearDistanceCachesForTests()
})
describe('fetchDistanceSuggestion', () => {
it('geocodes both endpoints and returns the OSRM distance rounded to 1 decimal', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'Växjö, Sverige')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.89', '14.55', 'Alvesta, Sverige')))
.mockResolvedValueOnce(jsonResponse(OSRM_OK))
const result = await fetchDistanceSuggestion('Växjö', 'Alvesta', OPTS(fetchImpl))
expect(result).toEqual({
distance_km: 47.3,
from_label: 'Växjö, Sverige',
to_label: 'Alvesta, Sverige',
})
expect(fetchImpl).toHaveBeenCalledTimes(3)
expect(String(fetchImpl.mock.calls[0][0])).toContain('nominatim.openstreetmap.org')
// FOSSGIS instance on purpose: the project-osrm.org demo server is
// restricted to non-commercial use.
expect(String(fetchImpl.mock.calls[2][0])).toContain('routing.openstreetmap.de')
// OSRM wants lon,lat ordering.
expect(String(fetchImpl.mock.calls[2][0])).toContain('14.80,56.87;14.55,56.89')
})
it('returns null and skips the second geocode when the first endpoint has no match', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([]))
const result = await fetchDistanceSuggestion('hemma', 'Alvesta', OPTS(fetchImpl))
expect(result).toBeNull()
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('returns null when OSRM finds no route', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'A')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.89', '14.55', 'B')))
.mockResolvedValueOnce(jsonResponse({ code: 'NoRoute', routes: [] }))
expect(await fetchDistanceSuggestion('A', 'B', OPTS(fetchImpl))).toBeNull()
})
it('returns null on an upstream failure instead of throwing', async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}, false, 503))
expect(await fetchDistanceSuggestion('Växjö', 'Alvesta', OPTS(fetchImpl))).toBeNull()
})
it('serves repeat lookups from cache without new upstream calls', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'A')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.89', '14.55', 'B')))
.mockResolvedValueOnce(jsonResponse(OSRM_OK))
const first = await fetchDistanceSuggestion('Växjö', 'Alvesta', OPTS(fetchImpl))
// Same route, differently cased/spaced: normalizes to the same cache key.
const second = await fetchDistanceSuggestion(' växjö ', 'ALVESTA', OPTS(fetchImpl))
expect(second).toEqual(first)
expect(fetchImpl).toHaveBeenCalledTimes(3)
})
it('caches a miss so half-typed routes are not re-queried immediately', async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([]))
await fetchDistanceSuggestion('hemma', 'jobbet', OPTS(fetchImpl))
await fetchDistanceSuggestion('hemma', 'jobbet', OPTS(fetchImpl))
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('does not suggest a distance that rounds to 0 km', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'A')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'B')))
.mockResolvedValueOnce(jsonResponse({ code: 'Ok', routes: [{ distance: 30 }] }))
expect(await fetchDistanceSuggestion('Storgatan 1', 'Storgatan 3', OPTS(fetchImpl))).toBeNull()
})
it('does not collide cache keys when an address contains the "|" character', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('59.40', '17.94', 'Kista|Stockholm')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('59.86', '17.64', 'Uppsala')))
.mockResolvedValueOnce(jsonResponse({ code: 'Ok', routes: [{ distance: 60_000 }] }))
.mockResolvedValueOnce(jsonResponse(geocodeHit('59.40', '17.94', 'Kista')))
.mockResolvedValueOnce(jsonResponse(geocodeHit('59.33', '18.06', 'Stockholm|Uppsala')))
.mockResolvedValueOnce(jsonResponse({ code: 'Ok', routes: [{ distance: 12_000 }] }))
const a = await fetchDistanceSuggestion('Kista|Stockholm', 'Uppsala', OPTS(fetchImpl))
// Same '|'-joined text, different route: must NOT be served a's cache.
const b = await fetchDistanceSuggestion('Kista', 'Stockholm|Uppsala', OPTS(fetchImpl))
expect(a?.distance_km).toBe(60)
expect(b?.distance_km).toBe(12)
expect(fetchImpl).toHaveBeenCalledTimes(6)
})
it('bails out instead of queueing when the politeness wait exceeds the bound', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(geocodeHit('56.87', '14.80', 'Växjö, Sverige')))
.mockResolvedValueOnce(jsonResponse(OSRM_OK))
// 'Växjö'/'växjö' share a geocode cache entry, so the first lookup makes
// one live Nominatim call and reserves one 10s slot. The next uncached
// route would have to wait far past MAX_QUEUE_WAIT_MS and must bail to
// null without any upstream call instead of holding the handler open.
const first = await fetchDistanceSuggestion('Växjö', 'växjö', {
fetchImpl,
minIntervalMs: 10_000,
})
const second = await fetchDistanceSuggestion('Ljungby', 'Markaryd', {
fetchImpl,
minIntervalMs: 10_000,
})
expect(first?.distance_km).toBe(47.3)
expect(second).toBeNull()
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
})
+196
View File
@@ -0,0 +1,196 @@
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
}
}