feat: Phase 2 — variant strip, while-here, charger swap block

Adds the three big "options" UX wins from Direction B:

1. Route variants (Fastest / Scenic / Cheapest)
   - Grok prompt now returns a top-level variants[] summary with
     drive/charge/cost/distance/pros for each variant, plus a
     selectedVariant indicating which one the stops reflect.
   - VariantStrip renders under the top bar with selected-state
     styling, tone-coloured highlight (red/green/blue) on the most
     relevant stat, and 3-5 pros pills.
   - Clicking a variant fires /api/chat with selectedVariant=<id> so
     Grok re-plans with that variant's bias. A "switching" state
     disables the strip while the request is in flight.
   - The chat route accepts selectedVariant ('fast'|'scenic'|'cheap')
     and the GrokHeadlessClient threads it through both the local CLI
     and xAI API paths.

2. While here (food / do / see / shop / rest)
   - Every Supercharger, destination-charger and hotel stop now
     returns a nearby[] array with category/icon/name/detail.
   - Expanded stop card has tabs (All / Food / Do / See) — tabs
     auto-hide when no items in that category. Two-column grid of
     named places with walk-time + rating, e.g. "M&S Foodhall · 1 min
     walk · 4.3★ · sandwiches".

3. Charger swap block
   - Every charging stop now returns chargerOptions[] with the
     current pick + 1-3 alternatives at the same location, each with
     network (Tesla/Ionity/Allego/Fastned/BP Pulse), stalls, kW,
     pricePerKwh, detourMin and an optional badge (Faster/Cheaper/
     More stalls/Newer).
   - ChargerSwapBlock shows the current charger as a red-tinted
     header row that expands to reveal alternatives with stats and a
     Use button per row.

Renamed the existing AlternativeStop UI label from "alternative(s)"
to "location alternative(s)" so it's clear when the user is swapping
the stop *location* vs swapping the *charger at the same location*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:14:15 +01:00
parent ece882ea29
commit f63af36451
3 changed files with 506 additions and 32 deletions
+389 -13
View File
@@ -6,7 +6,7 @@ import {
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
} from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops)
@@ -20,6 +20,37 @@ L.Icon.Default.mergeOptions({
// ─── Types ───────────────────────────────────────────────────────────────────
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel';
interface NearbyPlace {
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
icon: string;
name: string;
detail: string;
}
interface ChargerOption {
id: string;
name: string;
network?: string;
stalls: number;
kw: number;
pricePerKwh: number;
detourMin: number;
isCurrent?: boolean;
badge?: string | null;
}
interface RouteVariant {
id: 'fast' | 'scenic' | 'cheap';
label: string;
tone: 'primary' | 'green' | 'blue';
distanceKm: number;
driveHours: number;
chargeHours: number;
costEur: number;
highlight?: 'drive' | 'cost' | 'pretty';
pros: string[];
}
interface AlternativeStop {
id: string;
name: string;
@@ -56,6 +87,8 @@ interface Stop {
priceLevel?: number;
notes?: string;
alternatives?: AlternativeStop[];
nearby?: NearbyPlace[];
chargerOptions?: ChargerOption[];
}
interface Itinerary {
@@ -196,6 +229,31 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
})
.filter((a: AlternativeStop | null): a is AlternativeStop => a !== null);
const cleanNearby: NearbyPlace[] = Array.isArray(s.nearby)
? s.nearby.filter((n: any) => n && typeof n.name === 'string').map((n: any) => ({
category: ['food', 'do', 'see', 'shop', 'rest'].includes(n.category) ? n.category : 'food',
icon: typeof n.icon === 'string' ? n.icon : 'coffee',
name: n.name,
detail: typeof n.detail === 'string' ? n.detail : '',
}))
: [];
const cleanChargers: ChargerOption[] = Array.isArray(s.chargerOptions)
? s.chargerOptions
.filter((c: any) => c && typeof c.name === 'string')
.map((c: any): ChargerOption => ({
id: c.id || `charger-${Date.now()}-${Math.random()}`,
name: c.name,
network: typeof c.network === 'string' ? c.network : undefined,
stalls: typeof c.stalls === 'number' ? c.stalls : 0,
kw: typeof c.kw === 'number' ? c.kw : 0,
pricePerKwh: typeof c.pricePerKwh === 'number' ? c.pricePerKwh : 0,
detourMin: typeof c.detourMin === 'number' ? c.detourMin : 0,
isCurrent: c.isCurrent === true,
badge: typeof c.badge === 'string' ? c.badge : null,
}))
: [];
const shared = {
estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes,
@@ -207,6 +265,8 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: s.notes,
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
};
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
@@ -247,6 +307,23 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
};
}
function normalizeVariants(raw: any): RouteVariant[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((v: any) => v && typeof v.id === 'string')
.map((v: any): RouteVariant => ({
id: v.id === 'scenic' || v.id === 'cheap' ? v.id : 'fast',
label: typeof v.label === 'string' ? v.label : v.id,
tone: v.tone === 'green' || v.tone === 'blue' ? v.tone : 'primary',
distanceKm: typeof v.distanceKm === 'number' ? v.distanceKm : 0,
driveHours: typeof v.driveHours === 'number' ? v.driveHours : 0,
chargeHours: typeof v.chargeHours === 'number' ? v.chargeHours : 0,
costEur: typeof v.costEur === 'number' ? v.costEur : 0,
highlight: ['drive', 'cost', 'pretty'].includes(v.highlight) ? v.highlight : undefined,
pros: Array.isArray(v.pros) ? v.pros.filter((p: unknown) => typeof p === 'string') : [],
}));
}
// ─── Routing helpers ─────────────────────────────────────────────────────────
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371;
@@ -353,6 +430,103 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
);
}
// ─── Variant strip ───────────────────────────────────────────────────────────
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
primary: 'var(--gd-red)',
green: 'var(--gd-green)',
blue: 'var(--gd-blue)',
};
const VARIANT_ICON: Record<RouteVariant['id'], React.ComponentType<{ className?: string; size?: number }>> = {
fast: Gauge,
scenic: TreePine,
cheap: Euro,
};
function VariantStrip({
variants, selected, onSelect, switching,
}: {
variants: RouteVariant[];
selected: string;
onSelect: (id: RouteVariant['id']) => void;
switching: boolean;
}) {
if (variants.length === 0) return null;
return (
<div
className="px-6 py-3.5 grid grid-cols-3 gap-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }}
>
{variants.map(v => {
const isSel = v.id === selected;
const tone = VARIANT_TONE[v.tone];
const Icon = VARIANT_ICON[v.id];
return (
<button
key={v.id}
onClick={() => onSelect(v.id)}
disabled={switching}
className="relative text-left p-3.5 rounded-[14px] overflow-hidden transition disabled:opacity-50 disabled:cursor-wait"
style={{
background: isSel ? `color-mix(in srgb, ${tone} 6%, transparent)` : 'var(--gd-panel)',
border: `1px solid ${isSel ? tone : 'var(--gd-border)'}`,
cursor: switching ? 'wait' : 'pointer',
}}
>
{isSel && (
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: tone }} />
)}
<div className="flex items-center gap-2 mb-3">
<div className="text-[14px] font-semibold" style={{ letterSpacing: '-0.01em' }}>{v.label}</div>
{isSel && (
<span
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
style={{ border: `1px solid ${tone}`, color: tone }}
>
SELECTED
</span>
)}
<div className="flex-1" />
<Icon size={16} style={{ color: tone }} />
</div>
<div className="flex gap-4 mb-3">
<VStat label="Drive" value={formatDuration(v.driveHours * 60)} highlight={v.highlight === 'drive'} tone={tone} />
<VStat label="Charge" value={formatDuration(v.chargeHours * 60)} />
<VStat label="Cost" value={`${Math.round(v.costEur)}`} highlight={v.highlight === 'cost'} tone={tone} />
<VStat label="Distance" value={formatKm(v.distanceKm)} />
</div>
<div className="flex flex-wrap gap-1">
{v.pros.map(p => (
<span
key={p}
className="text-[10.5px] px-2 py-0.5 rounded-full"
style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--gd-text-2)' }}
>
{p}
</span>
))}
</div>
</button>
);
})}
</div>
);
}
function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) {
return (
<div className="min-w-0">
<div className="text-[9.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>{label}</div>
<div
className="text-[15px] font-medium num mt-px whitespace-nowrap"
style={{ color: highlight ? tone : 'var(--gd-text)' }}
>
{value}
</div>
</div>
);
}
// ─── Day header (sticky) ─────────────────────────────────────────────────────
function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: {
dayNumber: number;
@@ -418,7 +592,116 @@ function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => v
);
}
// ─── Expanded stop body (charger swap, stats, alts, actions) ─────────────────
// ─── Expanded stop body (stats, charger swap, while-here, alts, actions) ─────
const NEARBY_TABS: { id: 'all' | NearbyPlace['category']; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'food', label: 'Food' },
{ id: 'do', label: 'Do' },
{ id: 'see', label: 'See' },
];
function NearbyGrid({ items }: { items: NearbyPlace[] }) {
return (
<div className="grid grid-cols-2 gap-1.5">
{items.map((n, i) => (
<div
key={i}
className="p-2 rounded-lg flex items-center gap-2"
style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0 text-[13px] leading-none"
style={{ background: 'var(--gd-panel-2)' }}
>
{AMENITY_ICONS[n.icon] || '•'}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11.5px] font-medium truncate">{n.name}</div>
<div className="text-[10px] truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
</div>
</div>
))}
</div>
);
}
function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPick: (c: ChargerOption) => void }) {
const [open, setOpen] = React.useState(false);
if (options.length === 0) return null;
const current = options.find(o => o.isCurrent) || options[0];
const others = options.filter(o => o.id !== current.id);
return (
<div>
<button
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
className="w-full p-2.5 rounded-lg flex items-center gap-2.5 text-left transition"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<div
className="w-7 h-7 rounded-md grid place-items-center flex-shrink-0"
style={{ background: 'rgba(74,222,128,0.18)' }}
>
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--gd-green)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium truncate">{current.name}</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{current.stalls} stalls · {current.kw} kW · {current.pricePerKwh.toFixed(2)}/kWh
{current.network && current.network !== 'Tesla' ? ` · ${current.network}` : ''}
</div>
</div>
<div className="text-[11px] font-medium flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--gd-red)' }}>
{others.length > 0 && `${others.length} alts`}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</div>
</button>
{open && others.length > 0 && (
<div className="mt-1.5 p-1.5 rounded-lg space-y-0.5" style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}>
{others.map(o => (
<div
key={o.id}
onClick={(e) => e.stopPropagation()}
className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]"
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
<Zap className="w-3 h-3" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium flex items-center gap-1.5">
<span className="truncate">{o.name}</span>
{o.badge && (
<span
className="text-[9px] px-1.5 py-px rounded font-semibold whitespace-nowrap"
style={{ color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.4)' }}
>
{o.badge}
</span>
)}
</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{o.stalls} stalls · {o.kw} kW · {o.pricePerKwh.toFixed(2)}/kWh
{o.detourMin > 0 ? ` · +${o.detourMin} min` : ''}
{o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onPick(o); }}
className="h-6 px-2.5 rounded-md text-[10.5px] flex-shrink-0"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Use
</button>
</div>
))}
</div>
)}
</div>
);
}
function StopExpansion({ stop, onSwap, onRemove }: {
stop: Stop;
onSwap: (alt: AlternativeStop) => void;
@@ -431,6 +714,11 @@ function StopExpansion({ stop, onSwap, onRemove }: {
const cost = arrive != null && charge != null ? (Math.max(20, 80 - arrive) * 0.35).toFixed(2) : null;
const amenities = (stop.amenities || []).slice(0, 8);
const alts = stop.alternatives || [];
const chargers = stop.chargerOptions || [];
const nearby = stop.nearby || [];
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
const filteredNearby = nearbyTab === 'all' ? nearby : nearby.filter(n => n.category === nearbyTab);
return (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
@@ -447,6 +735,52 @@ function StopExpansion({ stop, onSwap, onRemove }: {
</div>
)}
{isCharge && chargers.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Charger</SectionLabel>
<ChargerSwapBlock
options={chargers}
onPick={(c) => toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })}
/>
</div>
)}
{nearby.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
<SectionLabel>While here</SectionLabel>
<div className="flex gap-1">
{NEARBY_TABS.map(t => {
const isSel = nearbyTab === t.id;
const exists = t.id === 'all' || nearby.some(n => n.category === t.id);
if (!exists) return null;
return (
<button
key={t.id}
onClick={(e) => { e.stopPropagation(); setNearbyTab(t.id); }}
className="h-5.5 px-2 rounded-md text-[10.5px] transition"
style={{
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
color: isSel ? 'var(--gd-red)' : 'var(--gd-text-3)',
fontWeight: isSel ? 600 : 400,
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{filteredNearby.length > 0 ? (
<NearbyGrid items={filteredNearby.slice(0, 6)} />
) : (
<div className="text-[11px] italic px-2 py-2" style={{ color: 'var(--gd-text-3)' }}>
Nothing in this category nearby.
</div>
)}
</div>
)}
{amenities.length > 0 && (
<div className="mb-3">
<SectionLabel>Amenities</SectionLabel>
@@ -467,7 +801,7 @@ function StopExpansion({ stop, onSwap, onRemove }: {
{alts.length > 0 && (
<div className="mb-3">
<SectionLabel>{alts.length} alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
<SectionLabel>{alts.length} location alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
<div className="space-y-1.5">
{alts.map(alt => (
<AlternativeRow key={alt.id} alt={alt} onSwap={() => onSwap(alt)} />
@@ -805,6 +1139,9 @@ export default function TeslaTripPlanner() {
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false);
React.useEffect(() => {
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
@@ -850,13 +1187,17 @@ export default function TeslaTripPlanner() {
return { totalKm: km, driveMinutes: min };
}, [legs]);
const sendMessage = async (text: string) => {
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => {
const trimmed = text.trim();
if (!trimmed) return;
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
setChatInput('');
setChips(prev => [...prev, trimmed].slice(-6));
setThinking(true);
const variantToUse = opts.variant ?? selectedVariant;
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
setChatInput('');
setChips(prev => [...prev, trimmed].slice(-6));
}
if (opts.variant) setVariantSwitching(true);
else setThinking(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
@@ -866,26 +1207,52 @@ export default function TeslaTripPlanner() {
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })),
selectedVariant: variantToUse,
}),
});
if (!response.ok) throw new Error('Failed to get response from server');
const data = await response.json();
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
}
if (data.itinerary) {
const clean = await normalizeAndSanitizeItinerary(data.itinerary);
setItinerary(clean);
toast.success('Grok updated your route', {
description: `${clean.days.length} day(s) · ${clean.summary.superchargers} chargers · ${clean.summary.hotels} overnight`,
});
}
if (Array.isArray(data.variants)) {
setVariants(normalizeVariants(data.variants));
}
if (typeof data.selectedVariant === 'string') {
setSelectedVariant(data.selectedVariant as RouteVariant['id']);
} else if (opts.variant) {
setSelectedVariant(opts.variant);
}
if (data.itinerary && !opts.silent) {
toast.success('Grok updated your route');
} else if (opts.variant) {
toast.success(`Switched to ${opts.variant} route`);
}
} catch (err: any) {
console.error('[TeslaTrip] Grok call failed:', err);
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
}
} finally {
setThinking(false);
setVariantSwitching(false);
}
};
const switchVariant = (variantId: RouteVariant['id']) => {
if (variantId === selectedVariant || variantSwitching || thinking) return;
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) {
toast.info('Send a trip prompt first');
return;
}
sendMessage(`Replan the trip as the ${variantId} variant`, { variant: variantId, silent: true });
};
const removeStop = (stopId: string) => {
const next = structuredClone(itinerary);
next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); });
@@ -944,6 +1311,15 @@ export default function TeslaTripPlanner() {
grokStatus={grokStatus}
/>
{variants.length > 0 && (
<VariantStrip
variants={variants}
selected={selectedVariant}
onSelect={switchVariant}
switching={variantSwitching}
/>
)}
{/* Body: map left, rail right */}
<div className="flex flex-1 overflow-hidden">
{/* Map */}