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:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { toast } from 'sonner';
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
@@ -22,6 +22,24 @@ const VEHICLES = [
|
|||||||
|
|
||||||
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom';
|
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 {
|
interface Stop {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,6 +57,7 @@ interface Stop {
|
|||||||
cuisine?: string | null;
|
cuisine?: string | null;
|
||||||
priceLevel?: number;
|
priceLevel?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
alternatives?: AlternativeStop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Itinerary {
|
interface Itinerary {
|
||||||
@@ -140,6 +159,33 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
|||||||
if (geo) { lat = geo.lat; lng = geo.lng; }
|
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 = {
|
const sharedFields = {
|
||||||
estArrivalBattery: s.estArrivalBattery,
|
estArrivalBattery: s.estArrivalBattery,
|
||||||
chargeMinutes: s.chargeMinutes,
|
chargeMinutes: s.chargeMinutes,
|
||||||
@@ -150,6 +196,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
|||||||
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
|
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
|
||||||
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
|
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
|
||||||
notes: s.notes,
|
notes: s.notes,
|
||||||
|
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
|
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)
|
interface Leg {
|
||||||
async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
|
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 {
|
try {
|
||||||
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
|
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 res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.routes && data.routes[0]) {
|
const route = data.routes?.[0];
|
||||||
return data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]]); // OSRM returns [lng, lat]
|
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) {
|
} catch (e) {
|
||||||
console.warn('[TeslaTrip] OSRM routing failed, falling back to straight line');
|
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> = {
|
const STOP_DOT: Record<StopType, string> = {
|
||||||
@@ -246,10 +338,61 @@ const STOP_DOT: Record<StopType, string> = {
|
|||||||
custom: 'bg-white/60',
|
custom: 'bg-white/60',
|
||||||
};
|
};
|
||||||
|
|
||||||
function StopCard({ stop, onRemove }: { stop: Stop; onRemove: () => void }) {
|
function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | null {
|
||||||
const amenities = (stop.amenities || []).slice(0, 6);
|
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 (
|
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 justify-between items-start gap-2">
|
||||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
<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'}`} />
|
<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' && (
|
{typeof stop.lat !== 'number' && (
|
||||||
<div className="text-[11px] text-amber-400 mt-1">Location not yet on map</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition flex-shrink-0">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -299,7 +460,7 @@ export default function TeslaTripPlanner() {
|
|||||||
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
|
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
|
||||||
const [vehicle, setVehicle] = useState(VEHICLES[0]);
|
const [vehicle, setVehicle] = useState(VEHICLES[0]);
|
||||||
const [grokStatus, setGrokStatus] = useState({ provider: "local", label: "Local Heavy", detail: "", isLocal: true, model: "Heavy" });
|
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
|
// Fetch Grok provider status for the badge
|
||||||
React.useEffect(() => {
|
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');
|
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
|
// Use real road routes when available, fallback to straight
|
||||||
const displayPolylines = roadRoutes.length > 0 ? roadRoutes : allStops.slice(1).map((stop, i) => {
|
const displayPolylines = legs.length > 0
|
||||||
|
? legs.map(l => l.geometry)
|
||||||
|
: allStops.slice(1).map((stop, i) => {
|
||||||
const prev = allStops[i];
|
const prev = allStops[i];
|
||||||
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
|
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(() => {
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const fetchRoutes = async () => {
|
const fetchRoutes = async () => {
|
||||||
const stops = itinerary.days
|
const stops = itinerary.days
|
||||||
.flatMap(d => d.stops)
|
.flatMap(d => d.stops)
|
||||||
.filter((s): s is Stop => s != null && typeof s.lat === 'number');
|
.filter((s): s is Stop => s != null && typeof s.lat === 'number');
|
||||||
|
|
||||||
if (stops.length < 2) {
|
if (stops.length < 2) {
|
||||||
setRoadRoutes([]);
|
setLegs([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[TeslaTrip] Planning real driving routes between', stops.length, 'stops...');
|
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++) {
|
for (let i = 0; i < stops.length - 1; i++) {
|
||||||
const route = await getRoadRoute(stops[i], stops[i + 1]);
|
const leg = await getRoadLeg(stops[i], stops[i + 1]);
|
||||||
routes.push(route);
|
if (cancelled) return;
|
||||||
|
fetched.push(leg);
|
||||||
}
|
}
|
||||||
setRoadRoutes(routes);
|
if (cancelled) return;
|
||||||
console.log('[TeslaTrip] Route planning complete. Polylines updated on map.');
|
setLegs(fetched);
|
||||||
|
console.log('[TeslaTrip] Route planning complete. Legs updated on map.');
|
||||||
};
|
};
|
||||||
fetchRoutes();
|
fetchRoutes();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [itinerary]);
|
}, [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) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
@@ -407,6 +589,53 @@ export default function TeslaTripPlanner() {
|
|||||||
toast.info('Stop removed from itinerary');
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-[#0a0a0a] text-white overflow-hidden">
|
<div className="flex h-screen bg-[#0a0a0a] text-white overflow-hidden">
|
||||||
{/* LEFT: CHAT */}
|
{/* LEFT: CHAT */}
|
||||||
@@ -474,8 +703,14 @@ export default function TeslaTripPlanner() {
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<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="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 className="flex items-center gap-8">
|
||||||
<div><MapPin className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.totalDistanceKm} km</div>
|
<div title={computedTotals ? 'Live distance from real road routes' : 'Estimated from Grok'}>
|
||||||
<div><Clock className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estDriveHours}h drive</div>
|
<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><BatteryCharging className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estChargeHours}h charging</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -536,20 +771,43 @@ export default function TeslaTripPlanner() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{itinerary.days.length > 0 ? (
|
||||||
<div className="flex gap-6 min-w-max">
|
<div className="flex gap-6 min-w-max">
|
||||||
{itinerary.days.map((day, di) => {
|
{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 (
|
return (
|
||||||
<div key={di} className="w-[340px]">
|
<div key={di} className="w-[360px]">
|
||||||
<div className="flex items-baseline gap-2 mb-2">
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
<div className="uppercase text-xs tracking-[2px] text-[#E82127]">DAY {day.day}</div>
|
<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>
|
</div>
|
||||||
{validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => (
|
{dayLegs.length > 0 && (
|
||||||
<StopCard key={stop.id} stop={stop} onRemove={() => removeStop(stop.id)} />
|
<div className="text-[10px] text-white/40 mb-2 tracking-wide">
|
||||||
)) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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"],
|
"amenities": ["restaurant", "coffee", "toilets", "shopping", "wifi", "playground", "ev-charging", "destination-charging"],
|
||||||
"cuisine": "British pub" | "Italian" | "French" | "Cafe" | null,
|
"cuisine": "British pub" | "Italian" | "French" | "Cafe" | null,
|
||||||
"priceLevel": 1 | 2 | 3 | 4,
|
"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.
|
- "message" should feel like a helpful human assistant.
|
||||||
- If no clear trip is requested yet, set "itinerary" to null.
|
- 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):
|
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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user