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">
|
||||
|
||||
Reference in New Issue
Block a user