feat(mileage): prefill distance and purpose from earlier trips on the same route (#1657)
* feat(mileage): prefill distance and purpose from earlier trips on the same route Christoffer's beta feedback: recurring routes meant retyping the same km every time. Fran/Till now autocomplete from earlier trips, and when the pair matches a previous trip the one-way distance and purpose prefill from the latest match. Only empty fields are filled, edit mode is untouched, and a hint under the km field shows when a value came from route memory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): invalidate route prefill when the route changes Skeptic + review findings on the first push, resolved in one pass: - Stale prefill: km/purpose filled from a matched route survived onto a different route (typing past the match, or switching Fran), with the hint still claiming same-route provenance. Prefill state now tracks the route key and the exact prefilled strings; when the key changes, fields still holding those strings are cleared and the match re-derives for the new route. User-typed values are never touched. - Purpose was filled with no indicator: the hint now renders under both km and purpose, each cleared independently by manual edits. - Prefill now uses the unrounded half of a stored round trip (21.25, as the copy flow does) so the round-trip toggle re-doubles to the exact stored km. - Per-keystroke O(n log n) sort replaced with a WeakMap-cached sorted order per trips array. - Docstrings on all route-memory exports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): offer a route's prefill at most once and drop stray NUL byte Second skeptic pass on the rework found two issues, both fixed: - Same-key refill rebuilt the prefill record from scratch, dropping the other field's live tracking and re-filling a field the user had deliberately emptied. applyRoutePrefill now matches only when no record exists for the current route key; the record survives as an offered-marker even fully disowned, so per-field tracking is stable and an emptied field stays empty until the route actually changes. - routeKey embedded a raw 0x00 byte as separator, which made git treat the file as binary and killed diff review. The separator is now an explicit String.fromCharCode(10) newline, which normalizeLocation can never produce, keeping keys collision-free and the source printable. 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
798a76ed7a
commit
dc311726de
@@ -27,6 +27,8 @@ import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { applyRoutePrefill, locationSuggestions } from '@/lib/mileage/route-memory'
|
||||
import type { RoutePrefill } from '@/lib/mileage/route-memory'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Car, Copy, Download, Plus, Trash2, Pencil, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { MileageTrip, MileageVehicleType } from '@/types'
|
||||
@@ -111,6 +113,7 @@ export default function MileagePage() {
|
||||
const [form, setForm] = useState<TripFormState>(emptyForm())
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [prefill, setPrefill] = useState<RoutePrefill | null>(null)
|
||||
|
||||
const [bookOpen, setBookOpen] = useState(false)
|
||||
const [bookFrom, setBookFrom] = useState('')
|
||||
@@ -159,10 +162,13 @@ export default function MileagePage() {
|
||||
[draftTrips]
|
||||
)
|
||||
|
||||
const suggestions = useMemo(() => locationSuggestions(trips), [trips])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setShowMore(false)
|
||||
setPrefill(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
@@ -170,6 +176,7 @@ export default function MileagePage() {
|
||||
setEditingId(trip.id)
|
||||
setForm(formFromTrip(trip, true))
|
||||
setShowMore(Boolean(trip.vehicle_registration || trip.odometer_start || trip.visited || trip.notes))
|
||||
setPrefill(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
@@ -177,9 +184,34 @@ export default function MileagePage() {
|
||||
setEditingId(null)
|
||||
setForm(formFromTrip(trip, false))
|
||||
setShowMore(false)
|
||||
setPrefill(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
// Route memory: when the from/to pair matches an earlier trip, empty km and
|
||||
// purpose fields prefill from the latest match. Prefilled values are cleared
|
||||
// again if the route stops matching before they were touched; user-typed
|
||||
// input is never overwritten, and edit mode is untouched.
|
||||
const updateRouteField = (field: 'from_location' | 'to_location', value: string) => {
|
||||
const next = { ...form, [field]: value }
|
||||
if (!editingId) {
|
||||
const result = applyRoutePrefill(trips, next, prefill)
|
||||
next.distance_km = result.distance_km
|
||||
next.purpose = result.purpose
|
||||
setPrefill(result.prefill)
|
||||
}
|
||||
setForm(next)
|
||||
}
|
||||
|
||||
// A manual edit disowns that field's prefill so the hint disappears and the
|
||||
// value survives later route changes. The record itself is kept (even fully
|
||||
// disowned) as an offered-marker, so the same route never re-fills a field
|
||||
// the user deliberately emptied.
|
||||
const disownPrefill = (field: 'distance_km' | 'purpose') => {
|
||||
if (!prefill || !prefill[field]) return
|
||||
setPrefill({ ...prefill, [field]: '' })
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
const km = Number(form.distance_km.replace(',', '.'))
|
||||
if (!(km > 0) || !form.from_location.trim() || !form.to_location.trim() || !form.purpose.trim()) {
|
||||
@@ -465,27 +497,40 @@ export default function MileagePage() {
|
||||
<Label htmlFor="from_location">{t('field_from')}</Label>
|
||||
<Input
|
||||
id="from_location"
|
||||
list="mileage-locations"
|
||||
value={form.from_location}
|
||||
onChange={(e) => setForm({ ...form, from_location: e.target.value })}
|
||||
onChange={(e) => updateRouteField('from_location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="to_location">{t('field_to')}</Label>
|
||||
<Input
|
||||
id="to_location"
|
||||
list="mileage-locations"
|
||||
value={form.to_location}
|
||||
onChange={(e) => setForm({ ...form, to_location: e.target.value })}
|
||||
onChange={(e) => updateRouteField('to_location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="mileage-locations">
|
||||
{suggestions.map((location) => (
|
||||
<option key={location} value={location} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purpose">{t('field_purpose')}</Label>
|
||||
<Input
|
||||
id="purpose"
|
||||
value={form.purpose}
|
||||
placeholder={t('purpose_placeholder')}
|
||||
onChange={(e) => setForm({ ...form, purpose: e.target.value })}
|
||||
onChange={(e) => {
|
||||
disownPrefill('purpose')
|
||||
setForm({ ...form, purpose: e.target.value })
|
||||
}}
|
||||
/>
|
||||
{Boolean(prefill?.purpose) && (
|
||||
<p className="text-xs text-muted-foreground">{t('route_prefill_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -496,8 +541,14 @@ export default function MileagePage() {
|
||||
id="distance_km"
|
||||
inputMode="decimal"
|
||||
value={form.distance_km}
|
||||
onChange={(e) => setForm({ ...form, distance_km: e.target.value })}
|
||||
onChange={(e) => {
|
||||
disownPrefill('distance_km')
|
||||
setForm({ ...form, distance_km: e.target.value })
|
||||
}}
|
||||
/>
|
||||
{Boolean(prefill?.distance_km) && (
|
||||
<p className="text-xs text-muted-foreground">{t('route_prefill_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
{!editingId && (
|
||||
<div className="flex items-end pb-2">
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyRoutePrefill,
|
||||
locationSuggestions,
|
||||
matchRoute,
|
||||
normalizeLocation,
|
||||
routeKey,
|
||||
} from '../route-memory'
|
||||
import type { MileageTrip } from '@/types'
|
||||
|
||||
function makeTrip(overrides: Partial<MileageTrip> = {}): MileageTrip {
|
||||
return {
|
||||
id: 'trip-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
employee_id: null,
|
||||
trip_date: '2026-08-10',
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: null,
|
||||
odometer_start: null,
|
||||
odometer_end: null,
|
||||
distance_km: 42,
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden AB',
|
||||
purpose: 'Kundbesök',
|
||||
visited: null,
|
||||
is_round_trip: false,
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
salary_run_id: null,
|
||||
notes: null,
|
||||
created_via: 'manual',
|
||||
created_at: '2026-08-10T08:00:00Z',
|
||||
updated_at: '2026-08-10T08:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('normalizeLocation', () => {
|
||||
it('trims, lowercases, and collapses inner whitespace', () => {
|
||||
expect(normalizeLocation(' Kontoret Söder ')).toBe('kontoret söder')
|
||||
})
|
||||
|
||||
it('keeps åäö significant', () => {
|
||||
expect(normalizeLocation('Växjö')).toBe('växjö')
|
||||
expect(normalizeLocation('Vaxjo')).not.toBe(normalizeLocation('Växjö'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchRoute', () => {
|
||||
it('returns the distance and purpose of a matching earlier trip', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
expect(matchRoute(trips, 'Kontoret', 'Kunden AB')).toEqual({
|
||||
distance_km: 42.5,
|
||||
purpose: 'Kundbesök',
|
||||
})
|
||||
})
|
||||
|
||||
it('matches case- and whitespace-insensitively', () => {
|
||||
const trips = [makeTrip()]
|
||||
expect(matchRoute(trips, ' kontoret ', 'KUNDEN ab')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is direction-sensitive', () => {
|
||||
const trips = [makeTrip()]
|
||||
expect(matchRoute(trips, 'Kunden AB', 'Kontoret')).toBeNull()
|
||||
})
|
||||
|
||||
it('halves a stored round trip back to the one-way distance', () => {
|
||||
const trips = [makeTrip({ distance_km: 85, is_round_trip: true })]
|
||||
expect(matchRoute(trips, 'Kontoret', 'Kunden AB')?.distance_km).toBe(42.5)
|
||||
})
|
||||
|
||||
it('returns the unrounded half so re-doubling restores the exact stored km', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, is_round_trip: true })]
|
||||
expect(matchRoute(trips, 'Kontoret', 'Kunden AB')?.distance_km).toBe(21.25)
|
||||
})
|
||||
|
||||
it('prefers the most recent trip by date, then created_at', () => {
|
||||
const trips = [
|
||||
makeTrip({ id: 'old', trip_date: '2026-08-01', distance_km: 40 }),
|
||||
makeTrip({ id: 'new', trip_date: '2026-08-12', distance_km: 44 }),
|
||||
makeTrip({
|
||||
id: 'same-day-later',
|
||||
trip_date: '2026-08-12',
|
||||
created_at: '2026-08-12T15:00:00Z',
|
||||
distance_km: 45,
|
||||
}),
|
||||
]
|
||||
expect(matchRoute(trips, 'Kontoret', 'Kunden AB')?.distance_km).toBe(45)
|
||||
})
|
||||
|
||||
it('returns null when either endpoint is blank or nothing matches', () => {
|
||||
const trips = [makeTrip()]
|
||||
expect(matchRoute(trips, '', 'Kunden AB')).toBeNull()
|
||||
expect(matchRoute(trips, 'Kontoret', ' ')).toBeNull()
|
||||
expect(matchRoute(trips, 'Kontoret', 'Annan kund')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not mutate the input order', () => {
|
||||
const trips = [
|
||||
makeTrip({ id: 'a', trip_date: '2026-08-01' }),
|
||||
makeTrip({ id: 'b', trip_date: '2026-08-12' }),
|
||||
]
|
||||
matchRoute(trips, 'Kontoret', 'Kunden AB')
|
||||
expect(trips.map((trip) => trip.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeKey', () => {
|
||||
it('is null while either endpoint is blank', () => {
|
||||
expect(routeKey('', 'Kunden AB')).toBeNull()
|
||||
expect(routeKey('Kontoret', ' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes both endpoints', () => {
|
||||
expect(routeKey(' Kontoret ', 'KUNDEN ab')).toBe(routeKey('kontoret', 'Kunden AB'))
|
||||
})
|
||||
|
||||
it('never collides ambiguous concatenations', () => {
|
||||
expect(routeKey('a b', 'c')).not.toBe(routeKey('a', 'b c'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRoutePrefill', () => {
|
||||
const fields = (overrides: Partial<Parameters<typeof applyRoutePrefill>[1]> = {}) => ({
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden AB',
|
||||
distance_km: '',
|
||||
purpose: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('fills empty km and purpose on a match and records the prefill', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
const result = applyRoutePrefill(trips, fields(), null)
|
||||
expect(result.distance_km).toBe('42.5')
|
||||
expect(result.purpose).toBe('Kundbesök')
|
||||
expect(result.prefill).toEqual({
|
||||
key: routeKey('Kontoret', 'Kunden AB'),
|
||||
distance_km: '42.5',
|
||||
purpose: 'Kundbesök',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears prefilled values when typing past the match onto a new route', () => {
|
||||
// Skeptic scenario: "Kunden AB" matched and prefilled; user keeps typing
|
||||
// " Syd". The stale km and purpose must not survive onto the new route.
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
const matched = applyRoutePrefill(trips, fields(), null)
|
||||
const result = applyRoutePrefill(
|
||||
trips,
|
||||
fields({
|
||||
to_location: 'Kunden AB Syd',
|
||||
distance_km: matched.distance_km,
|
||||
purpose: matched.purpose,
|
||||
}),
|
||||
matched.prefill
|
||||
)
|
||||
expect(result.distance_km).toBe('')
|
||||
expect(result.purpose).toBe('')
|
||||
expect(result.prefill).toBeNull()
|
||||
})
|
||||
|
||||
it('re-derives the prefill when switching to a different known route', () => {
|
||||
// Skeptic scenario: Kontoret->Kunden AB prefilled 42.5; switching Fran to
|
||||
// Lagret must swap to that route's latest km, not keep the stale 42.5.
|
||||
const trips = [
|
||||
makeTrip({ id: 'kontoret', distance_km: 42.5, purpose: 'Kundbesök' }),
|
||||
makeTrip({
|
||||
id: 'lagret',
|
||||
from_location: 'Lagret',
|
||||
distance_km: 80,
|
||||
purpose: 'Leverans',
|
||||
trip_date: '2026-08-01',
|
||||
}),
|
||||
]
|
||||
const matched = applyRoutePrefill(trips, fields(), null)
|
||||
const result = applyRoutePrefill(
|
||||
trips,
|
||||
fields({
|
||||
from_location: 'Lagret',
|
||||
distance_km: matched.distance_km,
|
||||
purpose: matched.purpose,
|
||||
}),
|
||||
matched.prefill
|
||||
)
|
||||
expect(result.distance_km).toBe('80')
|
||||
expect(result.purpose).toBe('Leverans')
|
||||
expect(result.prefill?.key).toBe(routeKey('Lagret', 'Kunden AB'))
|
||||
})
|
||||
|
||||
it('keeps user-typed values when the route changes', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
const matched = applyRoutePrefill(trips, fields(), null)
|
||||
// User overwrote the km but left the prefilled purpose in place.
|
||||
const result = applyRoutePrefill(
|
||||
trips,
|
||||
fields({
|
||||
to_location: 'Annan kund',
|
||||
distance_km: '55',
|
||||
purpose: matched.purpose,
|
||||
}),
|
||||
matched.prefill
|
||||
)
|
||||
expect(result.distance_km).toBe('55')
|
||||
expect(result.purpose).toBe('')
|
||||
expect(result.prefill).toBeNull()
|
||||
})
|
||||
|
||||
it('does not refill a field the user has disowned on the same route', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
// The page clears the field's prefill entry on manual edits; a later
|
||||
// route keystroke must not clear the user's value as stale.
|
||||
const result = applyRoutePrefill(
|
||||
trips,
|
||||
fields({ distance_km: '50', purpose: 'Kundbesök' }),
|
||||
{ key: routeKey('Kontoret', 'Kunden AB')!, distance_km: '', purpose: 'Kundbesök' }
|
||||
)
|
||||
expect(result.distance_km).toBe('50')
|
||||
expect(result.purpose).toBe('Kundbesök')
|
||||
})
|
||||
|
||||
it('keeps the other field tracked when one field is disowned on the same key', () => {
|
||||
// Skeptic cycle-2 scenario: both fields prefill, user empties purpose
|
||||
// (page disowns it), then a key-preserving keystroke (trailing space).
|
||||
// The emptied purpose must stay empty and the km tracking must survive,
|
||||
// so a later route switch still clears the machine-written km.
|
||||
const trips = [
|
||||
makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' }),
|
||||
makeTrip({
|
||||
id: 'lagret',
|
||||
from_location: 'Lagret',
|
||||
distance_km: 80,
|
||||
purpose: 'Leverans',
|
||||
trip_date: '2026-08-01',
|
||||
}),
|
||||
]
|
||||
const matched = applyRoutePrefill(trips, fields(), null)
|
||||
const disowned = { ...matched.prefill!, purpose: '' }
|
||||
const sameKey = applyRoutePrefill(
|
||||
trips,
|
||||
fields({ to_location: 'Kunden AB ', distance_km: matched.distance_km, purpose: '' }),
|
||||
disowned
|
||||
)
|
||||
expect(sameKey.purpose).toBe('')
|
||||
expect(sameKey.distance_km).toBe(matched.distance_km)
|
||||
expect(sameKey.prefill).toEqual(disowned)
|
||||
const switched = applyRoutePrefill(
|
||||
trips,
|
||||
fields({ from_location: 'Lagret', distance_km: sameKey.distance_km, purpose: '' }),
|
||||
sameKey.prefill
|
||||
)
|
||||
expect(switched.distance_km).toBe('80')
|
||||
expect(switched.purpose).toBe('Leverans')
|
||||
})
|
||||
|
||||
it('offers a route at most once: emptied fields are not re-filled on the same key', () => {
|
||||
const trips = [makeTrip({ distance_km: 42.5, purpose: 'Kundbesök' })]
|
||||
const matched = applyRoutePrefill(trips, fields(), null)
|
||||
// User empties both fields; the page keeps the marker with both entries ''.
|
||||
const marker = { key: matched.prefill!.key, distance_km: '', purpose: '' }
|
||||
const result = applyRoutePrefill(
|
||||
trips,
|
||||
fields({ to_location: 'Kunden AB ', distance_km: '', purpose: '' }),
|
||||
marker
|
||||
)
|
||||
expect(result.distance_km).toBe('')
|
||||
expect(result.purpose).toBe('')
|
||||
expect(result.prefill).toEqual(marker)
|
||||
})
|
||||
|
||||
it('is a no-op without a match or an existing prefill', () => {
|
||||
const result = applyRoutePrefill([], fields({ distance_km: '12', purpose: 'Möte' }), null)
|
||||
expect(result).toEqual({ distance_km: '12', purpose: 'Möte', prefill: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('locationSuggestions', () => {
|
||||
it('returns distinct trimmed locations from both endpoints, most recent first', () => {
|
||||
const trips = [
|
||||
makeTrip({
|
||||
id: 'old',
|
||||
trip_date: '2026-08-01',
|
||||
from_location: 'Lagret',
|
||||
to_location: 'Kunden AB',
|
||||
}),
|
||||
makeTrip({
|
||||
id: 'new',
|
||||
trip_date: '2026-08-12',
|
||||
from_location: ' Kontoret ',
|
||||
to_location: 'kunden ab',
|
||||
}),
|
||||
]
|
||||
expect(locationSuggestions(trips)).toEqual(['Kontoret', 'kunden ab', 'Lagret'])
|
||||
})
|
||||
|
||||
it('returns an empty list for no trips', () => {
|
||||
expect(locationSuggestions([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { MileageTrip } from '@/types'
|
||||
|
||||
/** What matchRoute found for a from/to pair: one-way km and the latest purpose. */
|
||||
export interface RouteMatch {
|
||||
/**
|
||||
* One-way distance in km, unrounded (a stored round trip halves to e.g.
|
||||
* 21.25) so that the round-trip toggle re-doubles back to the exact stored
|
||||
* value. The server rounds to 1 decimal on save, same as the copy flow.
|
||||
*/
|
||||
distance_km: number
|
||||
purpose: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill bookkeeping for the trip form: which route produced the prefill and
|
||||
* the exact strings written into each field. A field entry is '' when that
|
||||
* field was not prefilled (or the user has since edited it, which disowns the
|
||||
* prefill). Lets the form clear values that are still ours when the route
|
||||
* changes, without ever touching user-typed input.
|
||||
*/
|
||||
export interface RoutePrefill {
|
||||
key: string
|
||||
distance_km: string
|
||||
purpose: string
|
||||
}
|
||||
|
||||
/** The form fields route prefill reads and writes. */
|
||||
export interface RoutePrefillFields {
|
||||
from_location: string
|
||||
to_location: string
|
||||
distance_km: string
|
||||
purpose: string
|
||||
}
|
||||
|
||||
/**
|
||||
* From/To are free text, so matching is normalized: trimmed, lowercased,
|
||||
* inner whitespace collapsed. Åäö are significant and kept as-is.
|
||||
*/
|
||||
export function normalizeLocation(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
// Newline can never appear in normalizeLocation output (all whitespace
|
||||
// collapses to single spaces), so it is a collision-free separator:
|
||||
// 'a b'->'c' never keys the same as 'a'->'b c'.
|
||||
const ROUTE_KEY_SEPARATOR = String.fromCharCode(10)
|
||||
|
||||
/**
|
||||
* Identity of a route for prefill tracking. Null while either endpoint is
|
||||
* blank, so half-typed routes never match or hold a prefill.
|
||||
*/
|
||||
export function routeKey(from: string, to: string): string | null {
|
||||
const fromKey = normalizeLocation(from)
|
||||
const toKey = normalizeLocation(to)
|
||||
if (!fromKey || !toKey) return null
|
||||
return fromKey + ROUTE_KEY_SEPARATOR + toKey
|
||||
}
|
||||
|
||||
function byMostRecent(a: MileageTrip, b: MileageTrip): number {
|
||||
if (a.trip_date !== b.trip_date) return a.trip_date < b.trip_date ? 1 : -1
|
||||
if (a.created_at !== b.created_at) return a.created_at < b.created_at ? 1 : -1
|
||||
return 0
|
||||
}
|
||||
|
||||
// matchRoute runs on every from/to keystroke; cache the sorted order per
|
||||
// trips array so the sort happens once per load, not once per keystroke.
|
||||
const sortedCache = new WeakMap<MileageTrip[], MileageTrip[]>()
|
||||
|
||||
function sortedByMostRecent(trips: MileageTrip[]): MileageTrip[] {
|
||||
let sorted = sortedCache.get(trips)
|
||||
if (!sorted) {
|
||||
sorted = [...trips].sort(byMostRecent)
|
||||
sortedCache.set(trips, sorted)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct location suggestions across both endpoints of earlier trips,
|
||||
* most recently used first. Feeds the Från/Till datalists.
|
||||
*/
|
||||
export function locationSuggestions(trips: MileageTrip[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const suggestions: string[] = []
|
||||
for (const trip of sortedByMostRecent(trips)) {
|
||||
for (const location of [trip.from_location, trip.to_location]) {
|
||||
const key = normalizeLocation(location)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
suggestions.push(location.trim())
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest earlier trip with the same from/to pair (direction-sensitive:
|
||||
* the return leg can have a different purpose, and km is symmetric anyway).
|
||||
* The stored distance always covers the full logged distance, so a stored
|
||||
* round trip is halved back to the one-way value the create form expects.
|
||||
*/
|
||||
export function matchRoute(
|
||||
trips: MileageTrip[],
|
||||
from: string,
|
||||
to: string
|
||||
): RouteMatch | null {
|
||||
const key = routeKey(from, to)
|
||||
if (!key) return null
|
||||
const match = sortedByMostRecent(trips).find(
|
||||
(trip) => routeKey(trip.from_location, trip.to_location) === key
|
||||
)
|
||||
if (!match) return null
|
||||
return {
|
||||
distance_km: match.is_round_trip
|
||||
? Number(match.distance_km) / 2
|
||||
: Number(match.distance_km),
|
||||
purpose: match.purpose,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One step of route prefill, run after every from/to edit in create mode.
|
||||
* First disowns a stale prefill: when the route no longer matches the one
|
||||
* that produced it, fields still holding the exact prefilled strings are
|
||||
* cleared (user-edited values are kept). Then, if the pair matches an earlier
|
||||
* trip AND this route has not been offered yet, still-empty fields are filled
|
||||
* from the latest match. A route is offered at most once: as long as a
|
||||
* prefill record exists for the current key (even with every field disowned),
|
||||
* it is kept as-is, so a field the user deliberately emptied is never
|
||||
* re-filled by a key-preserving keystroke and per-field tracking survives.
|
||||
* Pure: returns the new field values and prefill state, mutates nothing.
|
||||
*/
|
||||
export function applyRoutePrefill(
|
||||
trips: MileageTrip[],
|
||||
fields: RoutePrefillFields,
|
||||
prefill: RoutePrefill | null
|
||||
): { distance_km: string; purpose: string; prefill: RoutePrefill | null } {
|
||||
const key = routeKey(fields.from_location, fields.to_location)
|
||||
let distanceKm = fields.distance_km
|
||||
let purpose = fields.purpose
|
||||
let nextPrefill = prefill
|
||||
|
||||
if (prefill && prefill.key !== key) {
|
||||
if (prefill.distance_km && distanceKm === prefill.distance_km) distanceKm = ''
|
||||
if (prefill.purpose && purpose === prefill.purpose) purpose = ''
|
||||
nextPrefill = null
|
||||
}
|
||||
|
||||
if (key && !nextPrefill) {
|
||||
const match = matchRoute(trips, fields.from_location, fields.to_location)
|
||||
if (match) {
|
||||
const applied: RoutePrefill = { key, distance_km: '', purpose: '' }
|
||||
if (distanceKm === '') {
|
||||
distanceKm = String(match.distance_km)
|
||||
applied.distance_km = distanceKm
|
||||
}
|
||||
if (purpose === '') {
|
||||
purpose = match.purpose
|
||||
applied.purpose = purpose
|
||||
}
|
||||
nextPrefill = applied
|
||||
}
|
||||
}
|
||||
|
||||
return { distance_km: distanceKm, purpose, prefill: nextPrefill }
|
||||
}
|
||||
@@ -7371,6 +7371,7 @@
|
||||
"purpose_placeholder": "E.g. client visit, material purchase",
|
||||
"field_km": "Distance (km, one way)",
|
||||
"field_km_total": "Distance (km total)",
|
||||
"route_prefill_hint": "From your latest trip on this route",
|
||||
"field_round_trip": "Round trip",
|
||||
"more_fields": "More fields",
|
||||
"field_regnr": "Reg. no.",
|
||||
|
||||
@@ -7371,6 +7371,7 @@
|
||||
"purpose_placeholder": "T.ex. kundbesök, materialinköp",
|
||||
"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",
|
||||
"field_round_trip": "Tur och retur",
|
||||
"more_fields": "Fler fält",
|
||||
"field_regnr": "Regnr",
|
||||
|
||||
Reference in New Issue
Block a user