diff --git a/app/(dashboard)/mileage/page.tsx b/app/(dashboard)/mileage/page.tsx index fa6b5271..2ef8432d 100644 --- a/app/(dashboard)/mileage/page.tsx +++ b/app/(dashboard)/mileage/page.tsx @@ -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(emptyForm()) const [showMore, setShowMore] = useState(false) const [saving, setSaving] = useState(false) + const [prefill, setPrefill] = useState(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() { setForm({ ...form, from_location: e.target.value })} + onChange={(e) => updateRouteField('from_location', e.target.value)} />
setForm({ ...form, to_location: e.target.value })} + onChange={(e) => updateRouteField('to_location', e.target.value)} />
+ + {suggestions.map((location) => ( +
setForm({ ...form, purpose: e.target.value })} + onChange={(e) => { + disownPrefill('purpose') + setForm({ ...form, purpose: e.target.value }) + }} /> + {Boolean(prefill?.purpose) && ( +

{t('route_prefill_hint')}

+ )}
@@ -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) && ( +

{t('route_prefill_hint')}

+ )}
{!editingId && (
diff --git a/lib/mileage/__tests__/route-memory.test.ts b/lib/mileage/__tests__/route-memory.test.ts new file mode 100644 index 00000000..16a4e543 --- /dev/null +++ b/lib/mileage/__tests__/route-memory.test.ts @@ -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 { + 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[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([]) + }) +}) diff --git a/lib/mileage/route-memory.ts b/lib/mileage/route-memory.ts new file mode 100644 index 00000000..0db14db4 --- /dev/null +++ b/lib/mileage/route-memory.ts @@ -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() + +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() + 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 } +} diff --git a/messages/en.json b/messages/en.json index ad8d257e..0b3903ed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/sv.json b/messages/sv.json index 6beee226..c996fdc6 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",