feat(ui): Phase 4 — variant cache + compare-on-map overlay

Switching between Fastest / Scenic / Cheapest used to require a fresh
Grok call every time (~90s). Now each variant's itinerary + OSRM
legs are cached the moment they're computed, so:

- Switching back to a previously-viewed variant is instant — no Grok
  round-trip, just a state swap. A toast reports "(cached)" so the
  user knows nothing was refetched.
- The variant strip gains a Compare (n) button that lights up once
  at least two variants are cached. Toggling it overlays the other
  cached variants on the map as dashed lines in their tone colour
  (green for Scenic, blue for Cheapest), while the selected variant
  stays solid red on top.
- Map legend swaps to a variant-key list while Compare is active so
  the user can read which dashed line is which.
- The cache is also kept fresh as soon as legs[] finishes computing
  for the active variant — so toggling Compare immediately after a
  fresh plan still shows real routes, not stale ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:27:02 +01:00
parent b61e3510b9
commit 0a97ea2006
+89 -4
View File
@@ -520,19 +520,24 @@ const VARIANT_ICON: Record<RouteVariant['id'], React.ComponentType<{ className?:
}; };
function VariantStrip({ function VariantStrip({
variants, selected, onSelect, switching, variants, selected, onSelect, switching, cachedIds, showCompare, onToggleCompare,
}: { }: {
variants: RouteVariant[]; variants: RouteVariant[];
selected: string; selected: string;
onSelect: (id: RouteVariant['id']) => void; onSelect: (id: RouteVariant['id']) => void;
switching: boolean; switching: boolean;
cachedIds: string[];
showCompare: boolean;
onToggleCompare: () => void;
}) { }) {
if (variants.length === 0) return null; if (variants.length === 0) return null;
const compareEligible = cachedIds.length >= 2;
return ( return (
<div <div
className="px-6 py-3.5 grid grid-cols-3 gap-3 flex-shrink-0" className="px-6 py-3.5 flex items-center gap-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }} style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }}
> >
<div className="grid grid-cols-3 gap-3 flex-1">
{variants.map(v => { {variants.map(v => {
const isSel = v.id === selected; const isSel = v.id === selected;
const tone = VARIANT_TONE[v.tone]; const tone = VARIANT_TONE[v.tone];
@@ -585,6 +590,23 @@ function VariantStrip({
</button> </button>
); );
})} })}
</div>
<button
onClick={onToggleCompare}
disabled={!compareEligible}
title={compareEligible ? 'Show every cached variant on the map' : 'Visit at least two variants to compare them on the map'}
className="h-[60px] px-3.5 rounded-[14px] flex flex-col justify-center items-center gap-1 transition flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: showCompare ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${showCompare ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: showCompare ? 'var(--gd-red)' : 'var(--gd-text-2)',
}}
>
<ArrowLeftRight className="w-3.5 h-3.5" />
<span className="text-[10.5px] font-medium whitespace-nowrap">
Compare {cachedIds.length > 0 ? `(${cachedIds.length})` : ''}
</span>
</button>
</div> </div>
); );
} }
@@ -1273,6 +1295,8 @@ export default function TeslaTripPlanner() {
const [variants, setVariants] = useState<RouteVariant[]>([]); const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast'); const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false); const [variantSwitching, setVariantSwitching] = useState(false);
const [variantCache, setVariantCache] = useState<Record<string, { itinerary: Itinerary; legs: Leg[] }>>({});
const [showCompare, setShowCompare] = useState(false);
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const [modal, setModal] = useState< const [modal, setModal] = useState<
| { kind: 'customise'; stopId: string } | { kind: 'customise'; stopId: string }
@@ -1313,10 +1337,12 @@ export default function TeslaTripPlanner() {
} }
if (cancelled) return; if (cancelled) return;
setLegs(fetched); setLegs(fetched);
// Refresh cache with the up-to-date legs for the current variant
setVariantCache(prev => ({ ...prev, [selectedVariant]: { itinerary, legs: fetched } }));
}; };
fetchRoutes(); fetchRoutes();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [itinerary]); }, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps
const computedTotals = React.useMemo(() => { const computedTotals = React.useMemo(() => {
if (legs.length === 0) return null; if (legs.length === 0) return null;
@@ -1356,6 +1382,11 @@ export default function TeslaTripPlanner() {
if (data.itinerary) { if (data.itinerary) {
const clean = await normalizeAndSanitizeItinerary(data.itinerary); const clean = await normalizeAndSanitizeItinerary(data.itinerary);
setItinerary(clean); setItinerary(clean);
// Pre-cache for the variant we just rendered (legs will be filled by useEffect)
const variantJustRendered = typeof data.selectedVariant === 'string'
? data.selectedVariant as RouteVariant['id']
: opts.variant ?? selectedVariant;
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
} }
if (Array.isArray(data.variants)) { if (Array.isArray(data.variants)) {
setVariants(normalizeVariants(data.variants)); setVariants(normalizeVariants(data.variants));
@@ -1476,6 +1507,19 @@ export default function TeslaTripPlanner() {
const switchVariant = (variantId: RouteVariant['id']) => { const switchVariant = (variantId: RouteVariant['id']) => {
if (variantId === selectedVariant || variantSwitching || thinking) return; if (variantId === selectedVariant || variantSwitching || thinking) return;
// Cache the current variant before switching
if (itinerary.days.length > 0) {
setVariantCache(prev => ({ ...prev, [selectedVariant]: { itinerary, legs } }));
}
// If target is already cached, swap instantly with no Grok call
const cached = variantCache[variantId];
if (cached) {
setItinerary(cached.itinerary);
setLegs(cached.legs);
setSelectedVariant(variantId);
toast.success(`Switched to ${variantId} (cached)`);
return;
}
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) { if (!lastUserMsg) {
toast.info('Send a trip prompt first'); toast.info('Send a trip prompt first');
@@ -1551,6 +1595,9 @@ export default function TeslaTripPlanner() {
selected={selectedVariant} selected={selectedVariant}
onSelect={switchVariant} onSelect={switchVariant}
switching={variantSwitching} switching={variantSwitching}
cachedIds={Object.keys(variantCache)}
showCompare={showCompare}
onToggleCompare={() => setShowCompare(s => !s)}
/> />
)} )}
@@ -1598,6 +1645,19 @@ export default function TeslaTripPlanner() {
</Marker> </Marker>
); );
})} })}
{showCompare && Object.entries(variantCache)
.filter(([id]) => id !== selectedVariant)
.map(([id, cached]) => {
const variant = variants.find(v => v.id === id);
const color = variant?.tone === 'green' ? '#4ade80' : variant?.tone === 'blue' ? '#60a5fa' : '#e31937';
return cached.legs.map((leg, i) => (
<Polyline
key={`${id}-${i}`}
positions={leg.geometry}
pathOptions={{ color, weight: 2.4, opacity: 0.7, dashArray: '6 5' }}
/>
));
})}
{legs.map((leg, i) => ( {legs.map((leg, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
<Polyline positions={leg.geometry} pathOptions={{ color: 'var(--gd-red)', weight: 6, opacity: 0.18 }} /> <Polyline positions={leg.geometry} pathOptions={{ color: 'var(--gd-red)', weight: 6, opacity: 0.18 }} />
@@ -1606,7 +1666,31 @@ export default function TeslaTripPlanner() {
))} ))}
</MapContainer> </MapContainer>
{/* Map legend */} {/* Map legend (variants when comparing, else stop types) */}
{showCompare ? (
<div
className="absolute top-4 left-4 px-3 py-2 rounded-[10px] flex flex-col gap-1.5 text-[11px]"
style={{ background: 'rgba(20,20,24,0.85)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border-2)', color: 'var(--gd-text-2)' }}
>
{variants.filter(v => v.id === selectedVariant || variantCache[v.id]).map(v => {
const isSel = v.id === selectedVariant;
const c = v.tone === 'green' ? '#4ade80' : v.tone === 'blue' ? '#60a5fa' : '#e31937';
return (
<div key={v.id} className="flex items-center gap-2">
<div
className="w-[18px] h-[3px] rounded"
style={{
background: isSel ? c : `repeating-linear-gradient(to right, ${c} 0 6px, transparent 6px 11px)`,
}}
/>
<div className="text-[12px]" style={{ color: isSel ? 'var(--gd-text)' : 'var(--gd-text-2)', fontWeight: isSel ? 500 : 400 }}>
{v.label}
</div>
</div>
);
})}
</div>
) : (
<div <div
className="absolute top-4 left-4 px-3 py-2 rounded-[10px] flex items-center gap-3.5 text-[11px]" className="absolute top-4 left-4 px-3 py-2 rounded-[10px] flex items-center gap-3.5 text-[11px]"
style={{ background: 'rgba(20,20,24,0.75)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }} style={{ background: 'rgba(20,20,24,0.75)', backdropFilter: 'blur(12px)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
@@ -1615,6 +1699,7 @@ export default function TeslaTripPlanner() {
<div className="flex items-center gap-1.5"><Bed className="w-3 h-3" style={{ color: 'var(--gd-blue)' }} />Sleep</div> <div className="flex items-center gap-1.5"><Bed className="w-3 h-3" style={{ color: 'var(--gd-blue)' }} />Sleep</div>
<div className="flex items-center gap-1.5"><Camera className="w-3 h-3" style={{ color: 'var(--gd-purple)' }} />See</div> <div className="flex items-center gap-1.5"><Camera className="w-3 h-3" style={{ color: 'var(--gd-purple)' }} />See</div>
</div> </div>
)}
{/* Refinements overlay */} {/* Refinements overlay */}
{chips.length > 0 && ( {chips.length > 0 && (