feat: leg metrics + swappable alternatives per stop

Timeline now shows the driving distance and duration between every
consecutive pair of stops, taken from real OSRM road routes (not Grok
estimates). Top stat bar and per-day headers also use the live totals
so they update immediately as the itinerary changes.

Grok now returns 1-3 alternative picks for each Supercharger and
hotel stop, each with deltaKm/deltaMin vs the chosen pick and a short
reason explaining the trade-off. The Swap (n) button on each card
expands an inline list of alternatives; clicking one swaps the stop
in place. The previously-chosen stop is kept in the alternatives
list with inverted deltas so the user can swap back. The map +
polylines + stat bar all recompute automatically.

Other tweaks:
- haversine fallback when OSRM is unreachable so legs still show
  approximate metrics in offline / degraded mode.
- Leg geometry storage moves from raw polyline[] to typed Leg[] with
  per-leg distance/duration/fromId/toId.
- Stop schema gains alternatives[]; client normalization filters out
  alternatives missing lat/lng.
- Day cards widened (340 → 360px); timeline pane grew 280 → 340px
  with vertical scroll so swap panels don't clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:44:54 +01:00
parent 89b24d4c34
commit 225cd250a3
2 changed files with 318 additions and 32 deletions
+289 -31
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, MapPin, BatteryCharging, Clock, Share2, Download, Car, Zap, AlertTriangle } from 'lucide-react';
import { Send, MapPin, BatteryCharging, Clock, Share2, Download, Car, Zap, AlertTriangle, ArrowLeftRight, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet';
import L from 'leaflet';
@@ -22,6 +22,24 @@ const VEHICLES = [
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom';
interface AlternativeStop {
id: string;
name: string;
type: StopType;
lat: number;
lng: number;
description?: string;
combo?: string | null;
amenities?: string[];
cuisine?: string | null;
priceLevel?: number;
chargeMinutes?: number;
durationMin?: number;
deltaKm?: number; // estimated change vs the currently-chosen stop (negative = saves distance)
deltaMin?: number; // estimated change in drive minutes vs currently-chosen
reason?: string; // why this is a worthwhile alternative
}
interface Stop {
id: string;
name: string;
@@ -39,6 +57,7 @@ interface Stop {
cuisine?: string | null;
priceLevel?: number;
notes?: string;
alternatives?: AlternativeStop[];
}
interface Itinerary {
@@ -140,6 +159,33 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (geo) { lat = geo.lat; lng = geo.lng; }
}
const rawAlts = Array.isArray(s.alternatives) ? s.alternatives : [];
const cleanAlts: AlternativeStop[] = rawAlts
.map((a: any): AlternativeStop | null => {
if (!a || typeof a.name !== 'string') return null;
const altLat = typeof a.lat === 'number' ? a.lat : null;
const altLng = typeof a.lng === 'number' ? a.lng : null;
if (altLat === null || altLng === null) return null;
return {
id: a.id || `alt-${Date.now()}-${Math.random()}`,
name: a.name,
type: STOP_TYPES.includes(a.type) ? a.type : 'custom',
lat: altLat,
lng: altLng,
description: typeof a.description === 'string' ? a.description : undefined,
combo: a.combo ?? null,
amenities: Array.isArray(a.amenities) ? a.amenities.filter((x: unknown) => typeof x === 'string') : undefined,
cuisine: typeof a.cuisine === 'string' ? a.cuisine : null,
priceLevel: typeof a.priceLevel === 'number' ? a.priceLevel : undefined,
chargeMinutes: typeof a.chargeMinutes === 'number' ? a.chargeMinutes : undefined,
durationMin: typeof a.durationMin === 'number' ? a.durationMin : undefined,
deltaKm: typeof a.deltaKm === 'number' ? a.deltaKm : undefined,
deltaMin: typeof a.deltaMin === 'number' ? a.deltaMin : undefined,
reason: typeof a.reason === 'string' ? a.reason : undefined,
};
})
.filter((a: AlternativeStop | null): a is AlternativeStop => a !== null);
const sharedFields = {
estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes,
@@ -150,6 +196,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: s.notes,
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
};
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
@@ -220,19 +267,64 @@ class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { has
}
}
// Fetch actual road route using OSRM (free, no key)
async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
interface Leg {
geometry: [number, number][];
distanceKm: number | null;
durationMin: number | null;
fromId: string;
toId: string;
}
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371;
const toRad = (x: number) => (x * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// Fetch actual road route using OSRM (free, no key); falls back to straight line + great-circle distance
async function getRoadLeg(from: Stop, to: Stop): Promise<Leg> {
try {
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
const res = await fetch(url);
const data = await res.json();
if (data.routes && data.routes[0]) {
return data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]]); // OSRM returns [lng, lat]
const route = data.routes?.[0];
if (route) {
return {
geometry: route.geometry.coordinates.map((c: number[]) => [c[1], c[0]]),
distanceKm: route.distance / 1000,
durationMin: route.duration / 60,
fromId: from.id,
toId: to.id,
};
}
} catch (e) {
console.warn('[TeslaTrip] OSRM routing failed, falling back to straight line');
}
return [[from.lat, from.lng], [to.lat, to.lng]];
const dist = haversineKm(from, to);
return {
geometry: [[from.lat, from.lng], [to.lat, to.lng]],
distanceKm: dist,
durationMin: (dist / 80) * 60, // assume ~80 km/h average if OSRM unavailable
fromId: from.id,
toId: to.id,
};
}
function formatDuration(min: number | null): string {
if (min == null || !Number.isFinite(min)) return '';
const total = Math.round(min);
if (total < 60) return `${total}m`;
const h = Math.floor(total / 60);
const m = total % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatKm(km: number | null): string {
if (km == null || !Number.isFinite(km)) return '';
return `${Math.round(km)} km`;
}
const STOP_DOT: Record<StopType, string> = {
@@ -246,10 +338,61 @@ const STOP_DOT: Record<StopType, string> = {
custom: 'bg-white/60',
};
function StopCard({ stop, onRemove }: { stop: Stop; onRemove: () => void }) {
const amenities = (stop.amenities || []).slice(0, 6);
function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const rounded = Math.round(value);
if (rounded === 0) return `±0 ${unit}`;
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
}
function LegPill({ leg }: { leg: Leg | undefined }) {
if (!leg) return null;
return (
<div className="group bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm">
<div className="flex items-center justify-center my-1.5">
<div className="flex items-center gap-2 px-2.5 py-0.5 bg-white/[0.04] border border-white/10 rounded-full text-[10px] text-white/55">
<span className="text-white/30"></span>
<span>{formatKm(leg.distanceKm)} · {formatDuration(leg.durationMin)} drive</span>
</div>
</div>
);
}
function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => void }) {
const km = formatDelta(alt.deltaKm, 'km');
const min = formatDelta(alt.deltaMin, 'min');
const isFaster = typeof alt.deltaMin === 'number' && alt.deltaMin < 0;
const isLonger = typeof alt.deltaMin === 'number' && alt.deltaMin > 0;
return (
<button
onClick={onSwap}
className="w-full text-left bg-white/[0.04] hover:bg-white/[0.08] border border-white/5 rounded-xl p-2 transition"
>
<div className="flex justify-between items-start gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-xs truncate">{alt.name}</div>
{alt.combo && (
<div className="inline-block mt-0.5 px-1.5 py-0 rounded text-[9px] font-medium bg-[#E82127]/15 text-[#E82127] uppercase tracking-wider">
{alt.combo}
</div>
)}
{alt.reason && <div className="text-[10px] text-white/55 mt-0.5 leading-snug">{alt.reason}</div>}
</div>
<div className="flex flex-col items-end text-[10px] flex-shrink-0">
{km && <span className={isLonger ? 'text-amber-400' : isFaster ? 'text-emerald-400' : 'text-white/50'}>{km}</span>}
{min && <span className={isLonger ? 'text-amber-400' : isFaster ? 'text-emerald-400' : 'text-white/50'}>{min}</span>}
</div>
</div>
</button>
);
}
function StopCard({ stop, onRemove, onSwap }: { stop: Stop; onRemove: () => void; onSwap: (alt: AlternativeStop) => void }) {
const [showAlts, setShowAlts] = React.useState(false);
const amenities = (stop.amenities || []).slice(0, 6);
const alts = stop.alternatives || [];
const hasAlts = alts.length > 0;
return (
<div className="group bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 text-sm">
<div className="flex justify-between items-start gap-2">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={`w-2.5 h-2.5 mt-1.5 rounded-full flex-shrink-0 ${STOP_DOT[stop.type] || 'bg-white/60'}`} />
@@ -280,12 +423,30 @@ function StopCard({ stop, onRemove }: { stop: Stop; onRemove: () => void }) {
{typeof stop.lat !== 'number' && (
<div className="text-[11px] text-amber-400 mt-1">Location not yet on map</div>
)}
{hasAlts && (
<button
onClick={() => setShowAlts(v => !v)}
className="mt-2 inline-flex items-center gap-1 text-[11px] text-white/55 hover:text-white/90 transition"
>
<ArrowLeftRight className="w-3 h-3" />
<span>{showAlts ? 'Hide' : `Swap (${alts.length})`}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${showAlts ? 'rotate-180' : ''}`} />
</button>
)}
</div>
</div>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg>
</button>
</div>
{showAlts && hasAlts && (
<div className="mt-2 pt-2 border-t border-white/5 space-y-1.5">
<div className="text-[10px] uppercase tracking-wider text-white/40">Alternatives</div>
{alts.map(alt => (
<AlternativeRow key={alt.id} alt={alt} onSwap={() => { onSwap(alt); setShowAlts(false); }} />
))}
</div>
)}
</div>
);
}
@@ -299,7 +460,7 @@ export default function TeslaTripPlanner() {
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState(VEHICLES[0]);
const [grokStatus, setGrokStatus] = useState({ provider: "local", label: "Local Heavy", detail: "", isLocal: true, model: "Heavy" });
const [roadRoutes, setRoadRoutes] = useState<[number, number][][]>([]);
const [legs, setLegs] = useState<Leg[]>([]);
// Fetch Grok provider status for the badge
React.useEffect(() => {
@@ -310,36 +471,57 @@ export default function TeslaTripPlanner() {
const allStops: Stop[] = itinerary.days.flatMap(d => d.stops).filter((s): s is Stop => s != null && typeof s.lat === 'number');
// Use real road routes when available, fallback to straight
const displayPolylines = roadRoutes.length > 0 ? roadRoutes : allStops.slice(1).map((stop, i) => {
const prev = allStops[i];
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
});
const displayPolylines = legs.length > 0
? legs.map(l => l.geometry)
: allStops.slice(1).map((stop, i) => {
const prev = allStops[i];
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
});
// When itinerary changes, fetch real road routes using OSRM
// Lookup: leg by fromId (one leg per "from" stop)
const legByFromId = React.useMemo(() => {
const map = new Map<string, Leg>();
for (const l of legs) map.set(l.fromId, l);
return map;
}, [legs]);
// When itinerary changes, fetch real road routes + leg metrics
React.useEffect(() => {
let cancelled = false;
const fetchRoutes = async () => {
const stops = itinerary.days
.flatMap(d => d.stops)
.filter((s): s is Stop => s != null && typeof s.lat === 'number');
if (stops.length < 2) {
setRoadRoutes([]);
setLegs([]);
return;
}
console.log('[TeslaTrip] Planning real driving routes between', stops.length, 'stops...');
const routes: [number, number][][] = [];
const fetched: Leg[] = [];
for (let i = 0; i < stops.length - 1; i++) {
const route = await getRoadRoute(stops[i], stops[i + 1]);
routes.push(route);
const leg = await getRoadLeg(stops[i], stops[i + 1]);
if (cancelled) return;
fetched.push(leg);
}
setRoadRoutes(routes);
console.log('[TeslaTrip] Route planning complete. Polylines updated on map.');
if (cancelled) return;
setLegs(fetched);
console.log('[TeslaTrip] Route planning complete. Legs updated on map.');
};
fetchRoutes();
return () => { cancelled = true; };
}, [itinerary]);
// Aggregate live totals from real OSRM legs (more accurate than Grok's estimate)
const computedTotals = React.useMemo(() => {
if (legs.length === 0) return null;
const km = legs.reduce((sum, l) => sum + (l.distanceKm ?? 0), 0);
const min = legs.reduce((sum, l) => sum + (l.durationMin ?? 0), 0);
return { totalKm: km, driveMinutes: min };
}, [legs]);
const sendMessage = async (text: string) => {
if (!text.trim()) return;
@@ -407,6 +589,53 @@ export default function TeslaTripPlanner() {
toast.info('Stop removed from itinerary');
};
const swapStop = (stopId: string, alt: AlternativeStop) => {
const newItin = structuredClone(itinerary);
for (const day of newItin.days) {
const idx = day.stops.findIndex(s => s.id === stopId);
if (idx === -1) continue;
const original = day.stops[idx];
// Keep the original as an alternative so the user can swap back
const originalAsAlt: AlternativeStop = {
id: original.id,
name: original.name,
type: original.type,
lat: original.lat,
lng: original.lng,
description: original.description,
combo: original.combo ?? null,
amenities: original.amenities,
cuisine: original.cuisine ?? null,
priceLevel: original.priceLevel,
chargeMinutes: original.chargeMinutes,
durationMin: original.durationMin,
deltaKm: typeof alt.deltaKm === 'number' ? -alt.deltaKm : undefined,
deltaMin: typeof alt.deltaMin === 'number' ? -alt.deltaMin : undefined,
reason: 'Original pick',
};
const otherAlts = (original.alternatives || []).filter(a => a.id !== alt.id);
day.stops[idx] = {
...original,
id: alt.id,
name: alt.name,
type: alt.type,
lat: alt.lat,
lng: alt.lng,
description: alt.description,
combo: alt.combo ?? null,
amenities: alt.amenities,
cuisine: alt.cuisine ?? null,
priceLevel: alt.priceLevel,
chargeMinutes: alt.chargeMinutes,
durationMin: alt.durationMin,
notes: undefined,
alternatives: [originalAsAlt, ...otherAlts],
};
}
setItinerary(newItin);
toast.success(`Swapped to ${alt.name}`);
};
return (
<div className="flex h-screen bg-[#0a0a0a] text-white overflow-hidden">
{/* LEFT: CHAT */}
@@ -474,8 +703,14 @@ export default function TeslaTripPlanner() {
<div className="flex-1 flex flex-col min-w-0">
<div className="h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm">
<div className="flex items-center gap-8">
<div><MapPin className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.totalDistanceKm} km</div>
<div><Clock className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estDriveHours}h drive</div>
<div title={computedTotals ? 'Live distance from real road routes' : 'Estimated from Grok'}>
<MapPin className="inline w-4 h-4 mr-1 text-[#E82127]" />
{computedTotals ? `${Math.round(computedTotals.totalKm)} km` : `${itinerary.summary.totalDistanceKm} km`}
</div>
<div title={computedTotals ? 'Live drive time from real road routes' : 'Estimated from Grok'}>
<Clock className="inline w-4 h-4 mr-1 text-[#E82127]" />
{computedTotals ? formatDuration(computedTotals.driveMinutes) : `${itinerary.summary.estDriveHours}h`} drive
</div>
<div><BatteryCharging className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estChargeHours}h charging</div>
</div>
<div className="flex gap-2">
@@ -536,20 +771,43 @@ export default function TeslaTripPlanner() {
</div>
</div>
<div className="h-[280px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
<div className="h-[340px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto overflow-y-auto">
{itinerary.days.length > 0 ? (
<div className="flex gap-6 min-w-max">
{itinerary.days.map((day, di) => {
const validStops = (day.stops || []).filter((s): s is Stop => s != null);
const validStops = (day.stops || []).filter((s): s is Stop => s != null).sort((a,b) => a.order - b.order);
// Compute day-level totals from real legs (within this day's stops only)
const dayLegs: Leg[] = [];
for (let i = 0; i < validStops.length - 1; i++) {
const leg = legByFromId.get(validStops[i].id);
if (leg && leg.toId === validStops[i + 1].id) dayLegs.push(leg);
}
const dayKm = dayLegs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
const dayMin = dayLegs.reduce((s, l) => s + (l.durationMin ?? 0), 0);
return (
<div key={di} className="w-[340px]">
<div className="flex items-baseline gap-2 mb-2">
<div key={di} className="w-[360px]">
<div className="flex items-baseline gap-2 mb-1">
<div className="uppercase text-xs tracking-[2px] text-[#E82127]">DAY {day.day}</div>
{day.title && <div className="text-xs text-white/60 truncate">{day.title}</div>}
{day.title && <div className="text-xs text-white/70 truncate">{day.title}</div>}
</div>
{validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => (
<StopCard key={stop.id} stop={stop} onRemove={() => removeStop(stop.id)} />
)) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
{dayLegs.length > 0 && (
<div className="text-[10px] text-white/40 mb-2 tracking-wide">
{Math.round(dayKm)} km · {formatDuration(dayMin)} driving
</div>
)}
{validStops.length > 0 ? validStops.map(stop => {
const allStopsIndex = allStops.findIndex(s => s.id === stop.id);
const prevStop = allStopsIndex > 0 ? allStops[allStopsIndex - 1] : null;
const legAbove = prevStop ? legByFromId.get(prevStop.id) : undefined;
return (
<div key={stop.id}>
<div style={{ minHeight: legAbove ? undefined : 0 }}>
{legAbove && <LegPill leg={legAbove} />}
</div>
<StopCard stop={stop} onRemove={() => removeStop(stop.id)} onSwap={(alt) => swapStop(stop.id, alt)} />
</div>
);
}) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
</div>
);
})}
+29 -1
View File
@@ -91,7 +91,26 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"amenities": ["restaurant", "coffee", "toilets", "shopping", "wifi", "playground", "ev-charging", "destination-charging"],
"cuisine": "British pub" | "Italian" | "French" | "Cafe" | null,
"priceLevel": 1 | 2 | 3 | 4,
"notes": "optional extra hint (booking tips, opening hours, etc.)"
"notes": "optional extra hint (booking tips, opening hours, etc.)",
"alternatives": [
{
"id": "unique-alt-string",
"name": "Alternative pick name",
"type": "supercharger" | "hotel" | "restaurant" | "cafe" | "attraction" | "destination-charger" | "viewpoint" | "custom",
"lat": 51.5,
"lng": -0.1,
"description": "1-2 sentences explaining why this is a viable swap",
"combo": "charge + eat" | "stay + destination charging" | null,
"amenities": ["restaurant", "toilets"],
"cuisine": "Italian" | null,
"priceLevel": 2,
"chargeMinutes": 25,
"durationMin": 60,
"deltaKm": 12,
"deltaMin": 9,
"reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')"
}
]
}
]
}
@@ -117,6 +136,15 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null.
Alternatives (REQUIRED for every Supercharger and hotel stop):
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
- "deltaKm" is the estimated change in total trip distance vs the chosen stop (positive = adds km, negative = saves km).
- "deltaMin" is the estimated change in total drive time vs the chosen stop, in minutes.
- "reason" explains the trade-off in one short sentence ("Cheaper hotel, no destination charging" / "Adds 15 mins but has the best food on this stretch of the M6").
- Alternatives must be genuinely different choices a driver would consider — not minor variants. Mix the trade-offs: faster, cheaper, fancier, better food, closer to attractions, etc.
- For non-Supercharger/non-hotel stops (a viewpoint, a quick coffee), alternatives are optional.
Combo philosophy (THIS IS THE IMPORTANT PART — don't skip):
- Whenever possible, pick Superchargers that are co-located with a real restaurant, cafe, services area, supermarket, or visitor attraction. Mention what's there in "description" and tag the stop with combo: "charge + eat" (or similar).
- Prefer hotels that offer destination charging (Tesla destination chargers, Type 2, or onsite EV charging). Tag those combo: "stay + destination charging" and add "destination-charging" to amenities.