Skip to content

Commit dc01473

Browse files
mighteaclaude
andcommitted
feat: auto-link fuel imports to existing or new locations
For each RoadTrip fuel entry, the import flow now tries to attach a fuelStation locationId by: (1) nearest existing station within 100m of the entry's coordinates, then (2) case- and whitespace-insensitive match on the entry's location name, then (3) creating a new fuelStation when only a name is available. Newly-created stations are reused for subsequent records in the same import so a CSV with many fills at the same fresh station does not produce duplicates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c16ff7 commit dc01473

3 files changed

Lines changed: 206 additions & 22 deletions

File tree

app/routes/motorcycle.detail.tsx

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -548,36 +548,93 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
548548
const recordsJson = formData.get("records") as string;
549549
const records = JSON.parse(recordsJson) as any[];
550550

551-
const values = records.map(record => {
552-
const currency = record.currency || "CHF";
553-
const rate = record.currencyRate || getCurrencyFactor(currency);
554-
555-
return {
556-
motorcycleId,
557-
type: "fuel" as const,
558-
date: record.date.split("T")[0],
559-
odo: record.odo,
560-
cost: record.cost,
561-
currency: currency,
562-
fuelType: record.fuelType,
563-
fuelAmount: record.fuelAmount,
564-
pricePerUnit: record.pricePerUnit,
565-
normalizedCost: (record.cost || 0) * rate,
566-
description: null
567-
};
568-
});
551+
const { getLocations } = await import("~/services/settings");
552+
const { findNearestWithinRadius, normalizeLocationName } = await import("~/utils/geo");
553+
const userLocations = await getLocations(token, user.id);
554+
555+
type KnownStation = { id: number; name: string; latitude: number | null; longitude: number | null };
556+
const knownFuelStations: KnownStation[] = userLocations
557+
.filter((l: any) => l.type === "fuelStation")
558+
.map((l: any) => ({
559+
id: l.id,
560+
name: l.name,
561+
latitude: l.latitude ?? null,
562+
longitude: l.longitude ?? null,
563+
}));
564+
565+
const resolveLocationId = async (record: any): Promise<number | null> => {
566+
const hasCoords =
567+
typeof record.latitude === "number" && typeof record.longitude === "number";
568+
const rawName = typeof record.locationName === "string" ? record.locationName.trim() : "";
569+
const hasName = rawName.length > 0;
570+
571+
if (hasCoords) {
572+
const match = findNearestWithinRadius(
573+
{ latitude: record.latitude, longitude: record.longitude },
574+
knownFuelStations,
575+
100,
576+
);
577+
if (match) return match.id;
578+
}
579+
580+
if (hasName) {
581+
const target = normalizeLocationName(rawName);
582+
const match = knownFuelStations.find((l) => normalizeLocationName(l.name) === target);
583+
if (match) return match.id;
584+
}
569585

570-
if (values.length > 0) {
586+
if (hasName) {
587+
const created = await createLocation(token, {
588+
name: rawName,
589+
type: "fuelStation",
590+
userId: user.id,
591+
latitude: hasCoords ? record.latitude : null,
592+
longitude: hasCoords ? record.longitude : null,
593+
});
594+
if (created && typeof created.id === "number") {
595+
knownFuelStations.push({
596+
id: created.id,
597+
name: created.name,
598+
latitude: created.latitude ?? null,
599+
longitude: created.longitude ?? null,
600+
});
601+
return created.id;
602+
}
603+
}
604+
605+
return null;
606+
};
607+
608+
if (records.length > 0) {
571609
try {
572610
// Send requests sequentially to avoid overwhelming the backend/proxy
573-
for (const record of values) {
611+
// and to let later records reuse locations created during this import.
612+
for (const record of records) {
613+
const currency = record.currency || "CHF";
614+
const rate = record.currencyRate || getCurrencyFactor(currency);
615+
// eslint-disable-next-line no-await-in-loop
616+
const locationId = await resolveLocationId(record);
617+
const payload = {
618+
motorcycleId,
619+
type: "fuel" as const,
620+
date: record.date.split("T")[0],
621+
odo: record.odo,
622+
cost: record.cost,
623+
currency,
624+
fuelType: record.fuelType,
625+
fuelAmount: record.fuelAmount,
626+
pricePerUnit: record.pricePerUnit,
627+
normalizedCost: (record.cost || 0) * rate,
628+
locationId,
629+
description: null,
630+
};
574631
// eslint-disable-next-line no-await-in-loop
575632
await fetchFromBackend(`/motorcycles/${motorcycleId}/maintenance`, {
576633
method: "POST",
577-
body: JSON.stringify(record),
634+
body: JSON.stringify(payload),
578635
}, token);
579636
}
580-
return data({ success: true, intent: "importFuelData", count: values.length });
637+
return data({ success: true, intent: "importFuelData", count: records.length });
581638
} catch (e: any) {
582639
return data({ success: false, intent: "importFuelData", error: e.message || "Import fehlgeschlagen" }, { status: 500 });
583640
}

app/utils/geo.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const EARTH_RADIUS_METERS = 6_371_000;
2+
3+
const toRad = (deg: number) => (deg * Math.PI) / 180;
4+
5+
export function haversineMeters(
6+
lat1: number,
7+
lon1: number,
8+
lat2: number,
9+
lon2: number,
10+
): number {
11+
const dLat = toRad(lat2 - lat1);
12+
const dLon = toRad(lon2 - lon1);
13+
const a =
14+
Math.sin(dLat / 2) ** 2 +
15+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
16+
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
17+
}
18+
19+
interface GeoPoint {
20+
latitude: number | null;
21+
longitude: number | null;
22+
}
23+
24+
export function normalizeLocationName(name: string): string {
25+
return name.trim().toLowerCase().replace(/\s+/g, " ");
26+
}
27+
28+
export function findNearestWithinRadius<T extends GeoPoint>(
29+
target: { latitude: number; longitude: number },
30+
candidates: readonly T[],
31+
maxMeters: number,
32+
): T | null {
33+
let best: T | null = null;
34+
let bestDistance = Infinity;
35+
for (const candidate of candidates) {
36+
if (candidate.latitude === null || candidate.longitude === null) continue;
37+
const distance = haversineMeters(
38+
target.latitude,
39+
target.longitude,
40+
candidate.latitude,
41+
candidate.longitude,
42+
);
43+
if (distance <= maxMeters && distance < bestDistance) {
44+
best = candidate;
45+
bestDistance = distance;
46+
}
47+
}
48+
return best;
49+
}

tests/utils/geo.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect } from "vitest";
2+
import { haversineMeters, findNearestWithinRadius, normalizeLocationName } from "~/utils/geo";
3+
4+
describe("normalizeLocationName", () => {
5+
it("lowercases", () => {
6+
expect(normalizeLocationName("Migrol Bern")).toBe("migrol bern");
7+
});
8+
9+
it("trims surrounding whitespace", () => {
10+
expect(normalizeLocationName(" Shell ")).toBe("shell");
11+
});
12+
13+
it("collapses internal whitespace", () => {
14+
expect(normalizeLocationName("Migrol Bern\tWest")).toBe("migrol bern west");
15+
});
16+
17+
it("treats two casings of the same name as equal", () => {
18+
expect(normalizeLocationName("BP TANKSTELLE")).toBe(normalizeLocationName("bp tankstelle"));
19+
});
20+
});
21+
22+
describe("haversineMeters", () => {
23+
it("returns 0 for identical points", () => {
24+
expect(haversineMeters(47.041214, 9.432396, 47.041214, 9.432396)).toBe(0);
25+
});
26+
27+
it("matches a known distance (Zurich HB ↔ Bern HB ≈ 95 km)", () => {
28+
const meters = haversineMeters(47.378177, 8.540192, 46.948832, 7.439136);
29+
expect(meters).toBeGreaterThan(94_000);
30+
expect(meters).toBeLessThan(97_000);
31+
});
32+
33+
it("treats a ~100m offset as ~100m", () => {
34+
// 1° latitude ≈ 111_111 m, so 0.0009° ≈ 100 m.
35+
const meters = haversineMeters(47.0, 9.0, 47.0009, 9.0);
36+
expect(meters).toBeGreaterThan(90);
37+
expect(meters).toBeLessThan(110);
38+
});
39+
});
40+
41+
describe("findNearestWithinRadius", () => {
42+
const target = { latitude: 47.0, longitude: 9.0 };
43+
44+
it("returns null when the candidate list is empty", () => {
45+
expect(findNearestWithinRadius(target, [], 100)).toBeNull();
46+
});
47+
48+
it("returns null when every candidate is outside the radius", () => {
49+
const candidates = [
50+
{ id: 1, latitude: 47.01, longitude: 9.0 }, // ~1.1 km north
51+
{ id: 2, latitude: 47.0, longitude: 9.01 }, // ~750 m east
52+
];
53+
expect(findNearestWithinRadius(target, candidates, 100)).toBeNull();
54+
});
55+
56+
it("returns the nearest candidate when multiple are within the radius", () => {
57+
const candidates = [
58+
{ id: "far", latitude: 47.0008, longitude: 9.0 }, // ~89 m
59+
{ id: "near", latitude: 47.0003, longitude: 9.0 }, // ~33 m
60+
{ id: "outer", latitude: 47.001, longitude: 9.0 }, // ~111 m (out)
61+
];
62+
expect(findNearestWithinRadius(target, candidates, 100)?.id).toBe("near");
63+
});
64+
65+
it("ignores candidates with null coordinates", () => {
66+
const candidates = [
67+
{ id: 1, latitude: null, longitude: null },
68+
{ id: 2, latitude: 47.0003, longitude: 9.0 },
69+
];
70+
expect(findNearestWithinRadius(target, candidates, 100)?.id).toBe(2);
71+
});
72+
73+
it("includes a candidate exactly at the boundary", () => {
74+
// 0.0009° ≈ 100 m, so this point is right around the radius edge.
75+
const candidates = [{ id: 1, latitude: 47.0009, longitude: 9.0 }];
76+
expect(findNearestWithinRadius(target, candidates, 110)?.id).toBe(1);
77+
});
78+
});

0 commit comments

Comments
 (0)