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:
co-authored by
Claude Fable 5
parent
60920ec794
commit
8249fcab5e
@@ -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">
|
||||
|
||||
@@ -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å
|
||||
"Föreslå sträcka" 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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user