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
@@ -1138,4 +1138,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision.
|
||||
[2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error.
|
||||
[2026-08-21] Flipped FORTNOX_DOCUMENT_SCOPES_APPROVED to true: Arkivplats and Koppla filer are now enabled for integration 39254 in the Fortnox Developer Portal (founder confirmed). Only the opt-in underlag reconnect requests them, so the ordinary connect is unchanged and no customer is asked for an Arkivplats licence to connect. Set it back to false if the portal ever loses the scopes, since authorize then rejects with invalid_scope before login.
|
||||
[2026-08-21] Korjournal distance suggestions via OSM (Nominatim+OSRM public endpoints) instead of Google Maps API: no key, no cost, AGPL-friendly, works self-hosted; addresses proxied server-side without identifiers and OSMF disclosed on the privacy page.
|
||||
[2026-08-21] Skeptic pass on #1778: routing endpoint switched from router.project-osrm.org (demo server, non-commercial only) to FOSSGIS routing.openstreetmap.de (fair use, attribution shown in UI); lookup click-gated instead of as-you-type per Nominatim's no-autocomplete policy; OSMF/FOSSGIS disclosed as independent recipients, not underbiträden.
|
||||
[2026-08-21] SCHABLONINTAKT_RATE_BY_CLOSING_YEAR backfilled 2020-2024 (SLR 30 Nov per Riksgalden: -0.09/-0.10/0.23 floored to 0.5 %, 1.94 %, 2.62 %) and the rate now resolves lazily (resolveSchablonintaktRate: 0 when no 212X account carried an opening balance): the table only covered 2025/2026 and the builder consulted it unconditionally, so every AB closing a pre-2025 year got a generic 500 at bokslut step 3 (126 open FY2024 periods on prod, incl. a byra trial). 2019 and earlier stay unmapped on purpose: the 100 %-of-SLR rule keys on beskattningsar STARTING 2019-01-01+ (prop. 2017/18:245), so a 2019 closing can be a brutet ar under the old 72 % factor. Unmapped-year-with-fonder now raises SCHABLONINTAKT_RATE_NOT_CONFIGURED (typed, 500 so runtime-error clustering still flags the missed December update) instead of INTERNAL_ERROR.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -7725,6 +7725,10 @@
|
||||
"field_km": "Distance (km, one way)",
|
||||
"field_km_total": "Distance (km total)",
|
||||
"route_prefill_hint": "From your latest trip on this route",
|
||||
"distance_suggest_action": "Suggest distance",
|
||||
"distance_suggest_loading": "Fetching distance...",
|
||||
"distance_suggestion_none": "No driving distance found for the addresses",
|
||||
"distance_suggestion_route": "Via road network: {from} → {to}",
|
||||
"field_round_trip": "Round trip",
|
||||
"more_fields": "More fields",
|
||||
"field_regnr": "Reg. no.",
|
||||
|
||||
@@ -7725,6 +7725,10 @@
|
||||
"field_km": "Sträcka (km, enkel väg)",
|
||||
"field_km_total": "Sträcka (km totalt)",
|
||||
"route_prefill_hint": "Från din senaste resa på samma rutt",
|
||||
"distance_suggest_action": "Föreslå sträcka",
|
||||
"distance_suggest_loading": "Hämtar sträcka...",
|
||||
"distance_suggestion_none": "Ingen körsträcka hittades för adresserna",
|
||||
"distance_suggestion_route": "Via vägnätet: {from} → {to}",
|
||||
"field_round_trip": "Tur och retur",
|
||||
"more_fields": "Fler fält",
|
||||
"field_regnr": "Regnr",
|
||||
|
||||
Reference in New Issue
Block a user