Files
accounted/lib/mileage/route-memory.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

167 lines
5.7 KiB
TypeScript

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 }
}