diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index f5e4746..ee08f99 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -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 { 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 { 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 { 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 = { @@ -246,10 +338,61 @@ const STOP_DOT: Record = { 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 ( -
+
+
+ + {formatKm(leg.distanceKm)} · {formatDuration(leg.durationMin)} drive +
+
+ ); +} + +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 ( + + ); +} + +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 ( +
@@ -280,12 +423,30 @@ function StopCard({ stop, onRemove }: { stop: Stop; onRemove: () => void }) { {typeof stop.lat !== 'number' && (
Location not yet on map
)} + {hasAlts && ( + + )}
+ {showAlts && hasAlts && ( +
+
Alternatives
+ {alts.map(alt => ( + { onSwap(alt); setShowAlts(false); }} /> + ))} +
+ )}
); } @@ -299,7 +460,7 @@ export default function TeslaTripPlanner() { const [itinerary, setItinerary] = useState(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([]); // 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(); + 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 (
{/* LEFT: CHAT */} @@ -474,8 +703,14 @@ export default function TeslaTripPlanner() {
-
{itinerary.summary.totalDistanceKm} km
-
{itinerary.summary.estDriveHours}h drive
+
+ + {computedTotals ? `${Math.round(computedTotals.totalKm)} km` : `${itinerary.summary.totalDistanceKm} km`} +
+
+ + {computedTotals ? formatDuration(computedTotals.driveMinutes) : `${itinerary.summary.estDriveHours}h`} drive +
{itinerary.summary.estChargeHours}h charging
@@ -536,20 +771,43 @@ export default function TeslaTripPlanner() {
-
+
{itinerary.days.length > 0 ? (
{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 ( -
-
+
+
DAY {day.day}
- {day.title &&
{day.title}
} + {day.title &&
{day.title}
}
- {validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => ( - removeStop(stop.id)} /> - )) :
No valid stops for this day
} + {dayLegs.length > 0 && ( +
+ {Math.round(dayKm)} km · {formatDuration(dayMin)} driving +
+ )} + {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 ( +
+
+ {legAbove && } +
+ removeStop(stop.id)} onSwap={(alt) => swapStop(stop.id, alt)} /> +
+ ); + }) :
No valid stops for this day
}
); })} diff --git a/server/services/llm/GrokHeadlessClient.ts b/server/services/llm/GrokHeadlessClient.ts index bc69943..d608eb5 100644 --- a/server/services/llm/GrokHeadlessClient.ts +++ b/server/services/llm/GrokHeadlessClient.ts @@ -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.