From 0a97ea2006a530f21370dce6aadb336063d66b66 Mon Sep 17 00:00:00 2001 From: Tony James Date: Wed, 20 May 2026 14:27:02 +0100 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=204=20=E2=80=94=20variant=20c?= =?UTF-8?q?ache=20+=20compare-on-map=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- client/src/pages/TeslaTripPlanner.tsx | 93 +++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index e920b90..64cb7ad 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -520,19 +520,24 @@ const VARIANT_ICON: Record void; switching: boolean; + cachedIds: string[]; + showCompare: boolean; + onToggleCompare: () => void; }) { if (variants.length === 0) return null; + const compareEligible = cachedIds.length >= 2; return (
+
{variants.map(v => { const isSel = v.id === selected; const tone = VARIANT_TONE[v.tone]; @@ -585,6 +590,23 @@ function VariantStrip({ ); })} +
+
); } @@ -1273,6 +1295,8 @@ export default function TeslaTripPlanner() { const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState('fast'); const [variantSwitching, setVariantSwitching] = useState(false); + const [variantCache, setVariantCache] = useState>({}); + const [showCompare, setShowCompare] = useState(false); const [draggingId, setDraggingId] = useState(null); const [modal, setModal] = useState< | { kind: 'customise'; stopId: string } @@ -1313,10 +1337,12 @@ export default function TeslaTripPlanner() { } if (cancelled) return; setLegs(fetched); + // Refresh cache with the up-to-date legs for the current variant + setVariantCache(prev => ({ ...prev, [selectedVariant]: { itinerary, legs: fetched } })); }; fetchRoutes(); return () => { cancelled = true; }; - }, [itinerary]); + }, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps const computedTotals = React.useMemo(() => { if (legs.length === 0) return null; @@ -1356,6 +1382,11 @@ export default function TeslaTripPlanner() { if (data.itinerary) { const clean = await normalizeAndSanitizeItinerary(data.itinerary); 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)) { setVariants(normalizeVariants(data.variants)); @@ -1476,6 +1507,19 @@ export default function TeslaTripPlanner() { const switchVariant = (variantId: RouteVariant['id']) => { 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'); if (!lastUserMsg) { toast.info('Send a trip prompt first'); @@ -1551,6 +1595,9 @@ export default function TeslaTripPlanner() { selected={selectedVariant} onSelect={switchVariant} switching={variantSwitching} + cachedIds={Object.keys(variantCache)} + showCompare={showCompare} + onToggleCompare={() => setShowCompare(s => !s)} /> )} @@ -1598,6 +1645,19 @@ export default function TeslaTripPlanner() { ); })} + {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) => ( + + )); + })} {legs.map((leg, i) => ( @@ -1606,7 +1666,31 @@ export default function TeslaTripPlanner() { ))} - {/* Map legend */} + {/* Map legend (variants when comparing, else stop types) */} + {showCompare ? ( +
+ {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 ( +
+
+
+ {v.label} +
+
+ ); + })} +
+ ) : (
Sleep
See
+ )} {/* Refinements overlay */} {chips.length > 0 && (