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:
@@ -520,19 +520,24 @@ const VARIANT_ICON: Record<RouteVariant['id'], React.ComponentType<{ className?:
|
||||
};
|
||||
|
||||
function VariantStrip({
|
||||
variants, selected, onSelect, switching,
|
||||
variants, selected, onSelect, switching, cachedIds, showCompare, onToggleCompare,
|
||||
}: {
|
||||
variants: RouteVariant[];
|
||||
selected: string;
|
||||
onSelect: (id: RouteVariant['id']) => void;
|
||||
switching: boolean;
|
||||
cachedIds: string[];
|
||||
showCompare: boolean;
|
||||
onToggleCompare: () => void;
|
||||
}) {
|
||||
if (variants.length === 0) return null;
|
||||
const compareEligible = cachedIds.length >= 2;
|
||||
return (
|
||||
<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)' }}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3 flex-1">
|
||||
{variants.map(v => {
|
||||
const isSel = v.id === selected;
|
||||
const tone = VARIANT_TONE[v.tone];
|
||||
@@ -586,6 +591,23 @@ function VariantStrip({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1273,6 +1295,8 @@ export default function TeslaTripPlanner() {
|
||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||
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 [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() {
|
||||
</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) => (
|
||||
<React.Fragment key={i}>
|
||||
<Polyline positions={leg.geometry} pathOptions={{ color: 'var(--gd-red)', weight: 6, opacity: 0.18 }} />
|
||||
@@ -1606,7 +1666,31 @@ export default function TeslaTripPlanner() {
|
||||
))}
|
||||
</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
|
||||
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)' }}
|
||||
@@ -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"><Camera className="w-3 h-3" style={{ color: 'var(--gd-purple)' }} />See</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refinements overlay */}
|
||||
{chips.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user