diff --git a/DECISIONS.md b/DECISIONS.md index ed68e932..03116393 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1138,4 +1138,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/mileage/page.tsx b/app/(dashboard)/mileage/page.tsx index 2ef8432d..0d186df2 100644 --- a/app/(dashboard)/mileage/page.tsx +++ b/app/(dashboard)/mileage/page.tsx @@ -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(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() { )} - + { + if (!open) invalidateSuggestLookup() + setFormOpen(open) + }} + > {editingId ? t('edit_trip') : t('new_trip')} @@ -534,21 +602,60 @@ export default function MileagePage() {
- +
+ + {!editingId && ( + + )} +
{ disownPrefill('distance_km') + invalidateSuggestLookup() + setSuggestStatus((s) => (s.kind === 'applied' ? { kind: 'idle' } : s)) setForm({ ...form, distance_km: e.target.value }) }} /> {Boolean(prefill?.distance_km) && (

{t('route_prefill_hint')}

)} + {suggestStatus.kind === 'none' && ( +

{t('distance_suggestion_none')}

+ )} + {suggestStatus.kind === 'applied' && ( +

+ {t('distance_suggestion_route', { + from: suggestStatus.fromLabel.split(',')[0], + to: suggestStatus.toLabel.split(',')[0], + })} + {' · '} + + © OpenStreetMap contributors + +

+ )}
{!editingId && (
diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index b1986acd..7711f3ed 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() { Integritetspolicy

- Senast uppdaterad: 2026-08-19 + Senast uppdaterad: 2026-08-21

@@ -191,6 +191,26 @@ export default function PrivacyPolicyPage() {
+

+ 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. +

+

AI-funktioner är frivilliga och kräver separat samtycke före aktivering: data skickas först när du aktivt godkänner diff --git a/app/api/mileage/distance/__tests__/route.test.ts b/app/api/mileage/distance/__tests__/route.test.ts new file mode 100644 index 00000000..b8f2a074 --- /dev/null +++ b/app/api/mileage/distance/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/mileage/distance/route.ts b/app/api/mileage/distance/route.ts new file mode 100644 index 00000000..b39fc34c --- /dev/null +++ b/app/api/mileage/distance/route.ts @@ -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 }) +}) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index bb1bebb1..bff0a0c9 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 // ============================================================ diff --git a/lib/mileage/__tests__/distance.test.ts b/lib/mileage/__tests__/distance.test.ts new file mode 100644 index 00000000..3b12a162 --- /dev/null +++ b/lib/mileage/__tests__/distance.test.ts @@ -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) + }) +}) diff --git a/lib/mileage/distance.ts b/lib/mileage/distance.ts new file mode 100644 index 00000000..7a257738 --- /dev/null +++ b/lib/mileage/distance.ts @@ -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 { + value: T + expires: number +} + +const geocodeCache = new Map>() +const suggestionCache = new Map>() + +// 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(cache: Map>, key: string): CacheEntry | undefined { + const entry = cache.get(key) + if (!entry) return undefined + if (entry.expires < Date.now()) { + cache.delete(key) + return undefined + } + return entry +} + +function cacheSet(cache: Map>, 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/messages/en.json b/messages/en.json index a5a8708e..3bc00e89 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/sv.json b/messages/sv.json index e995eca0..d71b3e3f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",