diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index a12c1b5..faa3d11 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -702,10 +702,11 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic ); } -function StopExpansion({ stop, onSwap, onRemove }: { +function StopExpansion({ stop, onSwap, onRemove, onCustomise }: { stop: Stop; onSwap: (alt: AlternativeStop) => void; onRemove: () => void; + onCustomise: () => void; }) { const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null; @@ -821,7 +822,7 @@ function StopExpansion({ stop, onSwap, onRemove }: {
)} - {active && } + {active && } @@ -1029,7 +1031,7 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon function TopBar({ origin, destination, onOriginChange, onDestinationChange, onODCommit, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, - vehicle, onVehicleChange, grokStatus, + vehicle, onVehicleChange, grokStatus, onOpenGpx, }: { origin: string; destination: string; onOriginChange: (v: string) => void; @@ -1040,6 +1042,7 @@ function TopBar({ chips: string[]; onRemoveChip: (i: number) => void; vehicle: typeof VEHICLES[number]; onVehicleChange: (v: typeof VEHICLES[number]) => void; grokStatus: { label?: string }; + onOpenGpx: () => void; }) { return (
- toast.success('GPX exported for your Tesla')}> + onOpenGpx()}> Export @@ -1199,6 +1202,12 @@ export default function TeslaTripPlanner() { const [selectedVariant, setSelectedVariant] = useState('fast'); const [variantSwitching, setVariantSwitching] = useState(false); const [draggingId, setDraggingId] = useState(null); + const [modal, setModal] = useState< + | { kind: 'customise'; stopId: string } + | { kind: 'detour'; afterStopId?: string } + | { kind: 'gpx' } + | null + >(null); React.useEffect(() => { fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); @@ -1300,6 +1309,53 @@ export default function TeslaTripPlanner() { } }; + const updateStop = (stopId: string, patch: Partial) => { + const next = structuredClone(itinerary); + for (const d of next.days) { + const idx = d.stops.findIndex(s => s.id === stopId); + if (idx !== -1) { + d.stops[idx] = { ...d.stops[idx], ...patch }; + break; + } + } + setItinerary(next); + }; + + const insertDetour = (place: { name: string; lat: number; lng: number; type: StopType; description?: string }, afterStopId?: string) => { + const next = structuredClone(itinerary); + const newStop: Stop = { + id: `detour-${Date.now()}`, + name: place.name, + type: place.type, + lat: place.lat, + lng: place.lng, + day: 1, + order: 1, + description: place.description, + combo: null, + }; + // Find insertion point — after afterStopId or at the end of the first day + if (afterStopId) { + for (const d of next.days) { + const idx = d.stops.findIndex(s => s.id === afterStopId); + if (idx !== -1) { + newStop.day = d.day; + d.stops.splice(idx + 1, 0, newStop); + break; + } + } + } else if (next.days.length > 0) { + const lastDay = next.days[next.days.length - 1]; + newStop.day = lastDay.day; + lastDay.stops.push(newStop); + } else { + next.days = [{ day: 1, stops: [newStop] }]; + } + for (const d of next.days) d.stops.forEach((s, i) => { s.order = i + 1; }); + setItinerary(next); + toast.success(`Added ${place.name} to your trip`); + }; + const reorderStops = (dragId: string, targetId: string) => { if (dragId === targetId) return; const next = structuredClone(itinerary); @@ -1413,6 +1469,7 @@ export default function TeslaTripPlanner() { chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))} vehicle={vehicle} onVehicleChange={setVehicle} grokStatus={grokStatus} + onOpenGpx={() => setModal({ kind: 'gpx' })} /> {variants.length > 0 && ( @@ -1543,6 +1600,7 @@ export default function TeslaTripPlanner() {
+ + {modal?.kind === 'customise' && ( + s.id === modal.stopId) || itinerary.days.flatMap(d => d.stops).find(s => s.id === modal.stopId) || null} + onClose={() => setModal(null)} + onApply={(patch) => { updateStop(modal.stopId, patch); setModal(null); toast.success('Stop updated'); }} + /> + )} + {modal?.kind === 'detour' && ( + setModal(null)} + onInsert={(place) => insertDetour(place, modal.afterStopId)} + /> + )} + {modal?.kind === 'gpx' && ( + setModal(null)} /> + )} ); } + +// ─── Modal shell ───────────────────────────────────────────────────────────── +function ModalShell({ + onClose, width = 720, title, subtitle, footer, children, +}: { + onClose: () => void; + width?: number; + title?: string; + subtitle?: string; + footer?: React.ReactNode; + children: React.ReactNode; +}) { + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + return ( +
+
e.stopPropagation()} + className="flex flex-col overflow-hidden" + style={{ + background: 'var(--gd-bg-2)', + border: '1px solid var(--gd-border-2)', + borderRadius: 16, + width, + maxWidth: '100%', + maxHeight: '90vh', + boxShadow: '0 32px 80px rgba(0,0,0,0.5)', + }} + > + {title && ( +
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+ +
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} + +// ─── Customise Stop modal (Charger / Overnight / Duration / Things / Detour) ─ +type CustomiseTab = 'charger' | 'overnight' | 'duration' | 'things' | 'detour'; + +function CustomiseStopModal({ + stop, onClose, onApply, +}: { + stop: Stop | null; + onClose: () => void; + onApply: (patch: Partial) => void; +}) { + if (!stop) return null; + const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; + const isSleep = stop.type === 'hotel'; + const initialTab: CustomiseTab = isCharge ? 'charger' : isSleep ? 'overnight' : 'things'; + const [tab, setTab] = React.useState(initialTab); + const [chargeMinutes, setChargeMinutes] = React.useState(stop.chargeMinutes || 30); + const [durationMin, setDurationMin] = React.useState(stop.durationMin || stop.chargeMinutes || 30); + const [pickedNearby, setPickedNearby] = React.useState>(new Set()); + const [chosenChargerId, setChosenChargerId] = React.useState(stop.chargerOptions?.find(c => c.isCurrent)?.id || stop.chargerOptions?.[0]?.id || ''); + + const tabs: { id: CustomiseTab; label: string; icon: React.ComponentType<{ className?: string; size?: number }>; count?: number; show: boolean }[] = [ + { id: 'charger', label: 'Charger', icon: Zap, count: stop.chargerOptions?.length, show: isCharge }, + { id: 'overnight', label: 'Overnight', icon: Bed, count: stop.alternatives?.length, show: isSleep }, + { id: 'duration', label: 'Duration', icon: Clock, show: true }, + { id: 'things', label: 'Things to do', icon: Footprints, count: stop.nearby?.length, show: (stop.nearby?.length ?? 0) > 0 }, + { id: 'detour', label: 'Detour', icon: Route, show: true }, + ]; + + return ( + +
+ Total stop: {durationMin + pickedNearby.size * 8} min +
+
+ + + + } + > +
+ {/* Tab rail */} +
+ {tabs.filter(t => t.show).map(t => { + const TI = t.icon; + const active = tab === t.id; + return ( + + ); + })} +
+ + {/* Body */} +
+ {tab === 'charger' && ( + + )} + {tab === 'overnight' && ( + + )} + {tab === 'duration' && ( + + )} + {tab === 'things' && ( + { + const next = new Set(pickedNearby); + if (next.has(name)) next.delete(name); else next.add(name); + setPickedNearby(next); + }} + /> + )} + {tab === 'detour' && } +
+
+ + ); +} + +function CustomiseChargerTab({ options, chosenId, onChoose }: { + options: ChargerOption[]; chosenId: string; onChoose: (id: string) => void; +}) { + if (options.length === 0) { + return ; + } + return ( +
+ Choose a charger +
+ {options.map(o => { + const sel = o.id === chosenId; + return ( +
onChoose(o.id)} + className="p-3.5 rounded-[12px] cursor-pointer transition" + style={{ + background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)', + border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`, + }} + > +
+
+ {sel &&
} +
+
+
+
{o.name}
+ {o.badge && ( + + {o.badge} + + )} +
+
+ + {o.kw} kW · {o.stalls} stalls + + + €{o.pricePerKwh.toFixed(2)}/kWh + + {o.detourMin > 0 ? ( + +{o.detourMin} min detour + ) : ( + On route + )} + {o.network && {o.network}} +
+
+
+
+ ); + })} +
+
+ ); +} + +function CustomiseOvernightTab({ alternatives, currentName }: { alternatives: AlternativeStop[]; currentName: string }) { + if (alternatives.length === 0) { + return ; + } + return ( +
+ Choose where to sleep +
+
+
+
+ +
+
+
{currentName}
+
Currently selected
+
+ Current +
+
+ {alternatives.map(a => ( +
+
+
+ +
+
+
{a.name}
+
{a.reason || a.description}
+
+ +
+
+ ))} +
+
+ ); +} + +function CustomiseDurationTab({ value, onChange, chargeMinutes, onChargeChange, isCharge, arrivePct }: { + value: number; onChange: (v: number) => void; + chargeMinutes: number; onChargeChange: (v: number) => void; + isCharge: boolean; arrivePct?: number; +}) { + const leavePct = arrivePct != null ? Math.min(100, arrivePct + Math.round(chargeMinutes * 1.4)) : null; + const sliderPct = ((value - 10) / 110) * 100; + return ( +
+ How long here? +
+
{value}
+
minutes
+
+ {isCharge && arrivePct != null && ( +
+ arrive {arrivePct}% · leave {leavePct}% +
+ )} +
+
+ onChange(parseInt(e.target.value))} + className="absolute inset-0 w-full h-10 opacity-0 z-10 cursor-pointer" + /> +
+
+
+ {[15, 30, 45, 60, 90, 120].map(t => ( +
+ {t}m +
+ ))} +
+ Presets +
+ {[ + { label: 'Quick top-up', mins: 15, sub: 'just enough to reach next' }, + { label: 'Coffee + restroom', mins: 30, sub: 'short walk, restrooms' }, + { label: 'Sit-down lunch', mins: 60, sub: 'café or restaurant nearby' }, + { label: 'Explore the town', mins: 90, sub: 'old town loop + lunch' }, + { label: 'Full charge', mins: 120, sub: 'to 100% if needed' }, + { label: 'Skip & risk it', mins: 10, sub: 'bypass entirely', warn: true }, + ].map(p => ( + + ))} +
+
+ ); +} + +function CustomiseThingsTab({ nearby, picked, onTogglePick }: { + nearby: NearbyPlace[]; picked: Set; onTogglePick: (name: string) => void; +}) { + if (nearby.length === 0) { + return ; + } + return ( +
+ Pick what you want to do here +
+ {nearby.map((n, i) => { + const sel = picked.has(n.name); + return ( +
onTogglePick(n.name)} + className="p-3 rounded-[10px] flex items-center gap-2.5 cursor-pointer transition" + style={{ + background: sel ? 'var(--gd-red-soft)' : 'var(--gd-panel)', + border: `1px solid ${sel ? 'var(--gd-red-line)' : 'var(--gd-border)'}`, + }} + > +
+ {AMENITY_ICONS[n.icon] || '📍'} +
+
+
{n.name}
+
{n.detail}
+
+
+ {sel && } +
+
+ ); + })} +
+
+ ); +} + +function CustomiseDetourTab() { + return ( +
+ Add a detour from this stop +
+ + +
+
+ Search is mocked — use the "Add stop" button in the rail header to insert a detour from the popular-places list. +
+
+ ); +} + +function EmptyTab({ text }: { text: string }) { + return ( +
+
+
{text}
+
+
+ ); +} + +// ─── Add Detour overlay ────────────────────────────────────────────────────── +const POPULAR_DETOURS: { name: string; lat: number; lng: number; type: StopType; description: string; detourMin: number }[] = [ + { name: 'York Minster', lat: 53.962, lng: -1.082, type: 'attraction', description: 'Gothic cathedral · 12th century', detourMin: 25 }, + { name: 'Lake District National Park', lat: 54.4609, lng: -3.0886, type: 'viewpoint', description: 'England\'s largest national park', detourMin: 45 }, + { name: 'Tebay Services Farm Shop', lat: 54.4331, lng: -2.6049, type: 'restaurant', description: 'Independent farm shop on the M6', detourMin: 5 }, + { name: 'Beaune town centre', lat: 47.0241, lng: 4.8398, type: 'attraction', description: 'Cobbled lanes, mustard shop, hospices', detourMin: 4 }, + { name: 'Hadrian\'s Wall', lat: 55.0114, lng: -2.2854, type: 'attraction', description: 'Roman wall · UNESCO World Heritage', detourMin: 30 }, + { name: 'Bamburgh Castle', lat: 55.6086, lng: -1.7102, type: 'attraction', description: 'Coastal castle · Northumbrian icon', detourMin: 35 }, +]; + +function AddDetourOverlay({ + onClose, onInsert, +}: { + onClose: () => void; + onInsert: (place: typeof POPULAR_DETOURS[number]) => void; +}) { + const [query, setQuery] = React.useState(''); + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + const filtered = POPULAR_DETOURS.filter(p => !query || p.name.toLowerCase().includes(query.toLowerCase())); + return ( +
+
e.stopPropagation()} + className="w-[640px] max-w-full overflow-hidden" + style={{ + background: 'var(--gd-bg-2)', + border: '1px solid var(--gd-border-2)', + borderRadius: 14, + boxShadow: '0 24px 60px rgba(0,0,0,0.5)', + }} + > +
+ + setQuery(e.target.value)} + placeholder="Add a stop — city, attraction, charger, restaurant…" + className="flex-1 bg-transparent outline-none text-[15px]" + style={{ color: 'var(--gd-text)' }} + /> + esc +
+
+ {filtered.length === 0 ? ( +
+ No matches in the popular-detours list. Real search coming soon. +
+ ) : ( + filtered.map(r => ( +
+
+ +
+
+
{r.name}
+
{r.description} · +{r.detourMin} min
+
+ +
+ )) + )} +
+
+
+ ); +} + +// ─── GPX Export modal ──────────────────────────────────────────────────────── +type ExportFormat = 'gpx' | 'kml' | 'csv'; + +function generateGpx(itinerary: Itinerary, includeNotes: boolean, includeNearby: boolean): string { + const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number'); + const header = ` + + + ${escapeXml(allStops[0]?.name || 'Trip')} → ${escapeXml(allStops[allStops.length - 1]?.name || 'Destination')} + ${itinerary.summary.totalDistanceKm} km · ${itinerary.summary.estDriveHours}h drive · ${itinerary.summary.estChargeHours}h charging + + + + Grok Drive route + `; + const points = allStops.map(s => { + const inner: string[] = [ + ` ${escapeXml(s.name)}`, + ` ${escapeXml(s.type)}`, + ]; + if (includeNotes && s.description) inner.push(` ${escapeXml(s.description)}`); + if (includeNearby && s.nearby && s.nearby.length > 0) { + inner.push(` Nearby: ${escapeXml(s.nearby.slice(0, 3).map(n => n.name).join(', '))}`); + } + return ` \n${inner.join('\n')}\n `; + }).join('\n'); + return `${header}\n${points}\n \n \n`; +} + +function generateKml(itinerary: Itinerary): string { + const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number'); + const placemarks = allStops.map(s => ` + ${escapeXml(s.name)} + ${escapeXml(s.description || s.type)} + ${s.lng.toFixed(5)},${s.lat.toFixed(5)},0 + `).join('\n'); + const lineCoords = allStops.map(s => `${s.lng.toFixed(5)},${s.lat.toFixed(5)},0`).join(' '); + return ` + + + Grok Drive route +${placemarks} + Route${lineCoords} + +`; +} + +function generateCsv(itinerary: Itinerary): string { + const rows = [ + 'day,order,name,type,lat,lng,charge_minutes,arrive_battery_pct,combo,description', + ]; + for (const d of itinerary.days) { + for (const s of d.stops) { + rows.push([ + d.day, s.order, q(s.name), s.type, s.lat ?? '', s.lng ?? '', + s.chargeMinutes ?? '', s.estArrivalBattery ?? '', + q(s.combo || ''), q(s.description || ''), + ].join(',')); + } + } + return rows.join('\n'); + function q(v: string) { return `"${v.replace(/"/g, '""')}"`; } +} + +function escapeXml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function downloadFile(filename: string, content: string, mime: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose: () => void }) { + const [format, setFormat] = React.useState('gpx'); + const [includeNotes, setIncludeNotes] = React.useState(true); + const [includeNearby, setIncludeNearby] = React.useState(false); + const allStops = itinerary.days.flatMap(d => d.stops).filter(s => typeof s.lat === 'number'); + + const content = format === 'gpx' + ? generateGpx(itinerary, includeNotes, includeNearby) + : format === 'kml' + ? generateKml(itinerary) + : generateCsv(itinerary); + + const baseName = `grok-drive-${(allStops[0]?.name || 'trip').toLowerCase().replace(/\s+/g, '-')}-${(allStops[allStops.length - 1]?.name || 'route').toLowerCase().replace(/\s+/g, '-')}`; + const filename = `${baseName}.${format}`; + + const handleDownload = () => { + const mime = format === 'gpx' ? 'application/gpx+xml' : format === 'kml' ? 'application/vnd.google-earth.kml+xml' : 'text/csv'; + downloadFile(filename, content, mime); + toast.success(`Downloaded ${filename}`); + }; + + if (allStops.length === 0) { + return ( + +
+ + + } + > +
+
+ Plan a trip first — there's nothing to export yet. +
+
+ + ); + } + + return ( + +
+ {content.split('\n').length} lines · ~{(content.length / 1024).toFixed(1)} KB +
+
+ + + + } + > +
+
+
+ Format + {([ + { id: 'gpx', name: 'GPX', detail: 'Tesla, car nav, ABRP, most apps' }, + { id: 'kml', name: 'KML', detail: 'Google Earth / Google Maps' }, + { id: 'csv', name: 'CSV', detail: 'Spreadsheet of stops' }, + ] as { id: ExportFormat; name: string; detail: string }[]).map(f => { + const sel = format === f.id; + return ( +
setFormat(f.id)} + className="px-3 py-2.5 rounded-lg mb-1 cursor-pointer" + style={{ + background: sel ? 'var(--gd-red-soft)' : 'transparent', + border: `1px solid ${sel ? 'var(--gd-red-line)' : 'transparent'}`, + }} + > +
{f.name}
+
{f.detail}
+
+ ); + })} +
+ {format === 'gpx' && ( +
+ Include + + +
+ )} +
+
+
+ {filename} +
+ {allStops.length} waypoints +
+
+            {content}
+          
+
+
+ + ); +} + +function CheckRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +}