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
+112 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -114,6 +114,12 @@ export default function MileagePage() {
const [showMore, setShowMore] = useState(false)
const [saving, setSaving] = useState(false)
const [prefill, setPrefill] = useState<RoutePrefill | null>(null)
const [suggestStatus, setSuggestStatus] = useState<
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'none' }
| { kind: 'applied'; fromLabel: string; toLabel: string }
>({ kind: 'idle' })
const [bookOpen, setBookOpen] = useState(false)
const [bookFrom, setBookFrom] = useState('')
@@ -169,6 +175,8 @@ export default function MileagePage() {
setForm(emptyForm())
setShowMore(false)
setPrefill(null)
invalidateSuggestLookup()
setSuggestStatus({ kind: 'idle' })
setFormOpen(true)
}
@@ -177,6 +185,8 @@ export default function MileagePage() {
setForm(formFromTrip(trip, true))
setShowMore(Boolean(trip.vehicle_registration || trip.odometer_start || trip.visited || trip.notes))
setPrefill(null)
invalidateSuggestLookup()
setSuggestStatus({ kind: 'idle' })
setFormOpen(true)
}
@@ -185,6 +195,8 @@ export default function MileagePage() {
setForm(formFromTrip(trip, false))
setShowMore(false)
setPrefill(null)
invalidateSuggestLookup()
setSuggestStatus({ kind: 'idle' })
setFormOpen(true)
}
@@ -200,6 +212,10 @@ export default function MileagePage() {
next.purpose = result.purpose
setPrefill(result.prefill)
}
// A changed endpoint invalidates any suggestion hint for the old route,
// including one still in flight.
invalidateSuggestLookup()
setSuggestStatus({ kind: 'idle' })
setForm(next)
}
@@ -212,6 +228,52 @@ export default function MileagePage() {
setPrefill({ ...prefill, [field]: '' })
}
// Distance suggestion (OpenStreetMap via our proxy), create mode only.
// Deliberately click-triggered, never as-you-type: Nominatim's usage
// policy forbids autocomplete-style traffic, and an explicit request is
// also what makes overwriting the km field the user's own action. The
// value stays fully editable afterwards.
const canSuggestDistance =
!editingId && form.from_location.trim().length >= 2 && form.to_location.trim().length >= 2
// Any event that makes an in-flight lookup stale (route edit, manual km
// edit, dialog close/reopen) bumps the generation; a response is applied
// only if its generation is still current, so a slow lookup can never
// write an old route's distance into a changed form.
const suggestGeneration = useRef(0)
const invalidateSuggestLookup = () => {
suggestGeneration.current += 1
}
const suggestDistance = async () => {
if (!canSuggestDistance || suggestStatus.kind === 'loading') return
const from = form.from_location.trim()
const to = form.to_location.trim()
const generation = ++suggestGeneration.current
setSuggestStatus({ kind: 'loading' })
try {
const res = await fetch(
`/api/mileage/distance?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
)
const body = res.ok ? await res.json() : null
if (generation !== suggestGeneration.current) return
if (typeof body?.data?.distance_km === 'number' && body.data.distance_km > 0) {
disownPrefill('distance_km')
setForm((prev) => ({ ...prev, distance_km: String(body.data.distance_km) }))
setSuggestStatus({
kind: 'applied',
fromLabel: body.data.from_label,
toLabel: body.data.to_label,
})
} else {
setSuggestStatus({ kind: 'none' })
}
} catch {
if (generation === suggestGeneration.current) setSuggestStatus({ kind: 'none' })
}
}
const submitForm = async () => {
const km = Number(form.distance_km.replace(',', '.'))
if (!(km > 0) || !form.from_location.trim() || !form.to_location.trim() || !form.purpose.trim()) {
@@ -455,7 +517,13 @@ export default function MileagePage() {
</table>
)}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<Dialog
open={formOpen}
onOpenChange={(open) => {
if (!open) invalidateSuggestLookup()
setFormOpen(open)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? t('edit_trip') : t('new_trip')}</DialogTitle>
@@ -534,21 +602,60 @@ export default function MileagePage() {
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="distance_km">
{editingId ? t('field_km_total') : t('field_km')}
</Label>
<div className="flex items-center justify-between gap-2">
<Label htmlFor="distance_km">
{editingId ? t('field_km_total') : t('field_km')}
</Label>
{!editingId && (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors duration-150 disabled:opacity-50 disabled:pointer-events-none"
disabled={!canSuggestDistance || suggestStatus.kind === 'loading'}
onClick={suggestDistance}
>
{suggestStatus.kind === 'loading'
? t('distance_suggest_loading')
: t('distance_suggest_action')}
</button>
)}
</div>
<Input
id="distance_km"
inputMode="decimal"
value={form.distance_km}
onChange={(e) => {
disownPrefill('distance_km')
invalidateSuggestLookup()
setSuggestStatus((s) => (s.kind === 'applied' ? { kind: 'idle' } : s))
setForm({ ...form, distance_km: e.target.value })
}}
/>
{Boolean(prefill?.distance_km) && (
<p className="text-xs text-muted-foreground">{t('route_prefill_hint')}</p>
)}
{suggestStatus.kind === 'none' && (
<p className="text-xs text-muted-foreground">{t('distance_suggestion_none')}</p>
)}
{suggestStatus.kind === 'applied' && (
<p
className="text-xs text-muted-foreground"
title={`${suggestStatus.fromLabel} → ${suggestStatus.toLabel}`}
>
{t('distance_suggestion_route', {
from: suggestStatus.fromLabel.split(',')[0],
to: suggestStatus.toLabel.split(',')[0],
})}
{' · '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
© OpenStreetMap contributors
</a>
</p>
)}
</div>
{!editingId && (
<div className="flex items-end pb-2">
+21 -1
View File
@@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() {
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-08-19
Senast uppdaterad: 2026-08-21
</p>
</div>
@@ -191,6 +191,26 @@ export default function PrivacyPolicyPage() {
</table>
</div>
<p className="mt-4">
Utöver underbiträdena ovan använder körjournalens avståndsförslag
två självständiga mottagare som vi inte har biträdesavtal med.
Uppgifterna skickas bara när du själv klickar på
&quot;Föreslå sträcka&quot; i körjournalen, via vår server och
utan användar-ID eller andra kontouppgifter, och varje mottagare
får olika uppgifter: OpenStreetMap Foundation (geokodningstjänsten
Nominatim, Storbritannien/EU; Storbritannien omfattas av EU:s
adekvansbeslut) tar emot adresstexterna du angett och översätter
dem till kartkoordinater, och FOSSGIS e.V.
(ruttberäkningstjänsten, Tyskland) tar därefter emot endast
koordinaterna, aldrig adresstexterna. För att minska antalet
anrop mellanlagrar vår server adresstexter, koordinater och
beräknade sträckor i arbetsminnet i upp till 24 timmar; de
skrivs inte till databasen och kopplas inte till ditt konto.
Tänk på att en adress du anger, till exempel en hemadress, i sig
kan vara en personuppgift; skriv platsnamn i stället för exakta
adresser om du inte vill att de skickas.
</p>
<p className="mt-4 text-sm text-muted-foreground">
AI-funktioner är frivilliga och kräver separat samtycke före
aktivering: data skickas först när du aktivt godkänner
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: vi.fn().mockResolvedValue(null) }))
vi.mock('@/lib/mileage/distance', () => ({ fetchDistanceSuggestion: vi.fn() }))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { fetchDistanceSuggestion } from '@/lib/mileage/distance'
const params = { params: Promise.resolve({}) } as never
function authed() {
vi.mocked(requireAuth).mockResolvedValue({
user: { id: 'user-1' } as never,
supabase: {} as never,
error: null,
} as never)
}
function unauthed() {
vi.mocked(requireAuth).mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
} as never)
}
function req(query: string) {
return new Request(`https://x.test/api/mileage/distance${query}`)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/mileage/distance', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET(req('?from=A%20stad&to=B%20stad'), params)
expect(res.status).toBe(401)
expect(fetchDistanceSuggestion).not.toHaveBeenCalled()
})
it('returns 400 when from/to are missing or too short', async () => {
authed()
expect((await GET(req('?from=Storgatan'), params)).status).toBe(400)
expect((await GET(req('?from=A&to=B'), params)).status).toBe(400)
expect(fetchDistanceSuggestion).not.toHaveBeenCalled()
})
it('returns the suggestion on a resolvable route', async () => {
authed()
vi.mocked(fetchDistanceSuggestion).mockResolvedValue({
distance_km: 47.3,
from_label: 'Växjö, Sverige',
to_label: 'Alvesta, Sverige',
})
const res = await GET(req('?from=V%C3%A4xj%C3%B6&to=Alvesta'), params)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.distance_km).toBe(47.3)
expect(vi.mocked(fetchDistanceSuggestion)).toHaveBeenCalledWith('Växjö', 'Alvesta')
})
it('returns data: null when no suggestion could be resolved', async () => {
authed()
vi.mocked(fetchDistanceSuggestion).mockResolvedValue(null)
const res = await GET(req('?from=hemma&to=jobbet'), params)
expect(res.status).toBe(200)
expect((await res.json()).data).toBeNull()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { MileageDistanceQuerySchema } from '@/lib/api/schemas'
import { fetchDistanceSuggestion } from '@/lib/mileage/distance'
import { guardSandbox } from '@/lib/sandbox/guard'
ensureInitialized()
// Proxies the OSM lookups server-side: the user's addresses never reach
// Nominatim/OSRM from their own browser, and the shared politeness caches
// live here. The sandbox guard keeps demo traffic off the public OSM
// budget, mirroring /api/currency/rate.
export const GET = withRouteContext('mileage.distance', async (request, { supabase, companyId }) => {
const params = validateQuery(request, MileageDistanceQuerySchema)
if (!params.success) return params.response
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
// null simply means "no suggestion": the form shows nothing.
const suggestion = await fetchDistanceSuggestion(params.data.from, params.data.to)
return NextResponse.json({ data: suggestion })
})