chore: initial checkpoint - Tesla Roadtrip planner

- Proactive Grok integration (xAI API + local CLI fallback)
- Real road routing via OSRM (no more bird's-eye lines)
- Heavy structured logging for fast iteration
- Strong sanitization + geocoding + ErrorBoundary (no black screens)
- Playwright E2E tests (API diagnostic + full UI flow)
- scripts/dev.sh for one-command startup
- Clean .env.example + documentation

This is a stable checkpoint before further prompt/UI refinement.
This commit is contained in:
2026-05-15 19:24:35 +01:00
commit d516e93323
29 changed files with 11927 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Tesla Roadtrip - Environment Configuration
# Copy this file to .env and fill in the values
# === REQUIRED: xAI API Key for real Grok responses ===
# Get one at https://console.x.ai
XAI_API_KEY=xai-YourKeyHere
# === Optional: App configuration ===
APP_URL=http://localhost:5173
API_URL=http://localhost:3000
# === Optional: Auth service (from auth.tony.codes) ===
# Only needed if testing authenticated features locally
# AUTH_SECRET=your-auth-secret-here
# AUTH_URL=https://auth.tony.codes
# === Optional: Force local grok CLI (advanced) ===
# Set to false to always use xAI API instead of local CLI
# GROK_ENABLED=true
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
node_modules/
client/node_modules/
# Environment variables
.env
.env.local
.env.*.local
# Build outputs
dist/
client/dist/
build/
# Playwright
test-results/
playwright-report/
playwright/.cache/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# Editor directories
.vscode/
.idea/
*.swp
*.swo
# Prisma generated client (generated at runtime)
prisma/generated/
# Atlas local files (if any)
atlas/migrations/*_dev/
# Temporary files
*.tmp
*.temp
.cache/
+108
View File
@@ -0,0 +1,108 @@
# Tesla Roadtrip — Grok-Powered Planner (UK & Europe First)
Beautiful split-pane Tesla trip planner.
**Left side**: Natural language chat with Grok Drive (powered by the real xAI Grok API)
**Right side**: Interactive Leaflet map + live itinerary that updates as you talk
## First Release Focus
- United Kingdom
- Europe (France, Germany, Benelux, Switzerland, Spain)
## Key Features
- Real Grok intelligence via the xAI API
- Excellent Supercharger coverage awareness across the UK & Europe
- Metric units (km, Wh/km)
- Proactive route planning — just say where you want to go
- Safe, restricted tool use
## Tech Stack
- Vite + React + Tailwind + Leaflet (client)
- Express + Prisma + pino (server)
- Real xAI Grok API (with local `grok` CLI as optional path)
- Heavy structured logging for fast debugging
## Local Development
### 1. Install dependencies
```bash
npm install
cd client && npm install && cd ..
```
### 2. Set up environment variables
```bash
cp .env.example .env
```
Then edit `.env` and add your xAI API key:
```env
XAI_API_KEY=xai-YourKeyHere
```
You can get a key from: https://console.x.ai
### 3. Start the development environment
You have two options:
**Option A (Recommended)** Better developer experience:
```bash
./scripts/dev.sh
```
**Option B** Using npm:
```bash
npm run dev
```
Both commands will start:
- **Backend**: http://localhost:3000 (with detailed logging)
- **Frontend**: http://localhost:5173
### 4. Open the app
Visit: **http://localhost:5173**
---
## Useful Commands
| Command | Description |
|------------------------|------------------------------------------|
| `npm run dev` | Start both frontend and backend |
| `./scripts/dev.sh` | Recommended way to start (better output) |
| `npm run dev:server` | Start only the backend |
| `npm run dev:client` | Start only the frontend |
---
## Security & Philosophy
This project follows a strict security policy:
- No packages with known active critical or high vulnerabilities
- `react-markdown` / remark ecosystem avoided (LLM output sanitization via DOMPurify)
- All AI tool use is heavily restricted
- Maximum logging so we can debug the "chat → route → stops" flow easily
## Deployment Target
Will be deployed via Dokku on Hetzner with Gitea CI.
First launch target: United Kingdom & Europe.
---
## Need Help?
- Make sure your `XAI_API_KEY` is set (otherwise you'll get very basic responses)
- Check the backend terminal — it has extremely detailed logs
- The app is designed so you can iterate quickly by watching the logs
+13
View File
@@ -0,0 +1,13 @@
data "external_schema" "prisma" {
program = ["npx", "prisma", "migrate", "diff", "--from-empty", "--to-schema", "prisma/schema.prisma", "--script"]
}
env "dev" {
url = getenv("DATABASE_URL")
dev = getenv("ATLAS_DEV_URL")
schema { src = data.external_schema.prisma.url }
migration {
dir = "file://atlas/migrations"
exclude = ["_prisma_migrations"]
}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tesla Trip Planner • Grok</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2918
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "tesla-roadtrip-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tonycodes/auth-react": "^1.4.0",
"framer-motion": "^11.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.21.1",
"sonner": "^1.4.0",
"tailwind-merge": "^2.2.0",
"dompurify": "^3.2.4"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"@types/node": "^20.10.0",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.2",
"vite": "^5.0.8",
"@types/dompurify": "^3.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+14
View File
@@ -0,0 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import { AuthCallback } from '@tonycodes/auth-react';
import TeslaTripPlanner from './pages/TeslaTripPlanner';
function App() {
return (
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/*" element={<TeslaTripPlanner />} />
</Routes>
);
}
export default App;
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster position="top-center" richColors closeButton />
</BrowserRouter>
</React.StrictMode>
);
+424
View File
@@ -0,0 +1,424 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, MapPin, BatteryCharging, Clock, Share2, Download, Car, Zap, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet';
import L from 'leaflet';
// Fix Leaflet default icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
const VEHICLES = [
{ name: 'Model Y Long Range', rangeKm: 514, efficiency: 165 },
{ name: 'Model 3 Highland LR', rangeKm: 549, efficiency: 155 },
{ name: 'Model S Long Range', rangeKm: 634, efficiency: 175 },
{ name: 'Model Y RWD (EU)', rangeKm: 455, efficiency: 158 },
];
interface Stop {
id: string;
name: string;
type: 'supercharger' | 'hotel' | 'attraction' | 'restaurant' | 'custom';
lat: number;
lng: number;
day: number;
order: number;
estArrivalBattery?: number;
chargeMinutes?: number;
notes?: string;
}
interface Itinerary {
days: { day: number; stops: Stop[] }[];
summary: { totalDistanceKm: number; estDriveHours: number; estChargeHours: number; superchargers: number; hotels: number };
}
const EMPTY_ITINERARY: Itinerary = {
days: [],
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0 },
};
const QUICK_PROMPTS = [
"Plan a 2-day trip from London to Edinburgh in my Model Y",
"I want to drive from Amsterdam to Munich",
"Help me plan a scenic route from Paris to the Alps",
"Best way from Glasgow to London avoiding motorways",
];
// Simple in-memory geocache
const geocodeCache = new Map<string, { lat: number; lng: number }>();
async function geocodeLocation(query: string): Promise<{ lat: number; lng: number } | null> {
const cacheKey = query.toLowerCase().trim();
if (geocodeCache.has(cacheKey)) return geocodeCache.get(cacheKey)!;
try {
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (data && data.length > 0) {
const result = { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
geocodeCache.set(cacheKey, result);
await new Promise(r => setTimeout(r, 1100)); // be nice to Nominatim
return result;
}
} catch (e) {
console.warn('[TeslaTrip] Geocoding failed for', query);
}
return null;
}
// Very forgiving sanitization + geocoding
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
const normalizedDays: any[] = [];
for (const day of raw.days) {
if (!day) continue;
let rawStops: any[] = [];
if (Array.isArray(day.stops)) rawStops = day.stops;
else if (Array.isArray(day.chargeStops)) rawStops = day.chargeStops;
else if (Array.isArray(raw.pointsOfInterest)) rawStops = raw.pointsOfInterest;
const validStops: any[] = [];
for (const s of rawStops) {
if (!s) continue;
let name = s.name || s.location || s;
if (typeof name !== 'string') continue;
let lat = typeof s.lat === 'number' ? s.lat : null;
let lng = typeof s.lng === 'number' ? s.lng : null;
if ((lat === null || lng === null) && name) {
const geo = await geocodeLocation(name);
if (geo) { lat = geo.lat; lng = geo.lng; }
}
if (lat === null || lng === null) {
validStops.push({
id: s.id || `text-${Date.now()}-${Math.random()}`,
name, type: s.type || 'custom', lat: null, lng: null,
day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
});
continue;
}
validStops.push({
id: s.id || `stop-${Date.now()}-${Math.random()}`,
name, type: ['supercharger','hotel','attraction','restaurant','custom'].includes(s.type) ? s.type : 'custom',
lat, lng, day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
});
}
if (validStops.length > 0) {
normalizedDays.push({ day: day.day || normalizedDays.length + 1, stops: validStops.sort((a,b) => a.order - b.order) });
}
}
const sortedDays = normalizedDays.sort((a,b) => a.day - b.day);
const allStops = sortedDays.flatMap(d => d.stops);
return {
days: sortedDays,
summary: {
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
estDriveHours: raw.summary?.estDriveHours ?? 0,
estChargeHours: raw.summary?.estChargeHours ?? 0,
superchargers: allStops.filter(s => s.type === 'supercharger').length,
hotels: allStops.filter(s => s.type === 'hotel').length,
},
};
}
// Simple Error Boundary
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
constructor(props: any) { super(props); this.state = { hasError: false }; }
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error: Error) { console.error('[TeslaTrip] ErrorBoundary caught:', error); }
render() {
if (this.state.hasError) {
return (
<div className="h-full flex items-center justify-center bg-red-950/30 text-red-400 p-6 rounded-2xl">
<div className="text-center">
<AlertTriangle className="w-8 h-8 mx-auto mb-3" />
<div>Something went wrong rendering the map/itinerary.</div>
<div className="text-xs mt-1 text-red-400/70">Check console for details.</div>
</div>
</div>
);
}
return this.props.children;
}
}
// Fetch actual road route using OSRM (free, no key)
async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
try {
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
const res = await fetch(url);
const data = await res.json();
if (data.routes && data.routes[0]) {
return data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]]); // OSRM returns [lng, lat]
}
} catch (e) {
console.warn('[TeslaTrip] OSRM routing failed, falling back to straight line');
}
return [[from.lat, from.lng], [to.lat, to.lng]];
}
export default function TeslaTripPlanner() {
const [messages, setMessages] = useState<any[]>([
{ id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. I'm here to help you plan amazing Tesla road trips across the UK and Europe. Where would you like to go?" },
]);
const [input, setInput] = useState('');
const [thinking, setThinking] = useState(false);
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState(VEHICLES[0]);
const [roadRoutes, setRoadRoutes] = useState<[number, number][][]>([]);
// Clean stops for map
const allStops: Stop[] = itinerary.days.flatMap(d => d.stops).filter((s): s is Stop => s != null && typeof s.lat === 'number');
// Use real road routes when available, fallback to straight
const displayPolylines = roadRoutes.length > 0 ? roadRoutes : allStops.slice(1).map((stop, i) => {
const prev = allStops[i];
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
});
// When itinerary changes, fetch real road routes
React.useEffect(() => {
const fetchRoutes = async () => {
if (allStops.length < 2) {
setRoadRoutes([]);
return;
}
const routes: [number, number][][] = [];
for (let i = 0; i < allStops.length - 1; i++) {
const route = await getRoadRoute(allStops[i], allStops[i + 1]);
routes.push(route);
}
setRoadRoutes(routes);
};
fetchRoutes();
}, [itinerary]);
const sendMessage = async (text: string) => {
if (!text.trim()) return;
console.log('[TeslaTrip] Sending to Grok:', { message: text.trim(), vehicle: vehicle.name });
const userMessage = { id: Date.now(), role: 'user' as const, content: text.trim() };
setMessages(prev => [...prev, userMessage]);
setInput('');
setThinking(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text.trim(),
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })),
}),
});
if (!response.ok) throw new Error("Failed to get response from server");
const data = await response.json();
console.log('[TeslaTrip] Grok replied:', { replyLength: data.reply?.length, hasItineraryUpdate: !!data.itinerary });
const assistantMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: data.reply || "Sorry, I could not generate a response.",
};
setMessages(prev => [...prev, assistantMessage]);
if (data.itinerary) {
const cleanItinerary = await normalizeAndSanitizeItinerary(data.itinerary);
console.log('[TeslaTrip] Sanitized itinerary has', cleanItinerary.days.length, 'day(s)');
setItinerary(cleanItinerary);
const hasMapStops = cleanItinerary.days.flatMap(d => d.stops).some(s => typeof s.lat === 'number');
toast.success("Grok updated your route", {
description: hasMapStops
? `${cleanItinerary.days.length} day(s) • ${cleanItinerary.summary.superchargers} Superchargers`
: `${cleanItinerary.days.length} day(s) (some locations could not be placed on map)`,
});
}
} catch (error: any) {
console.error("[TeslaTrip] Grok call failed:", error);
const errorMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: error?.message?.includes('Grok') ? error.message : "I'm having trouble reaching Grok right now. Check backend logs (XAI_API_KEY loaded?).",
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setThinking(false);
}
};
const removeStop = (stopId: string) => {
const newItin = structuredClone(itinerary);
newItin.days.forEach(day => { day.stops = day.stops.filter(s => s.id !== stopId); });
newItin.days = newItin.days.filter(d => d.stops.length > 0);
setItinerary(newItin);
toast.info('Stop removed from itinerary');
};
return (
<div className="flex h-screen bg-[#0a0a0a] text-white overflow-hidden">
{/* LEFT: CHAT */}
<div className="w-[380px] flex flex-col border-r border-white/10 bg-[#111111]">
<div className="p-5 border-b border-white/10 flex items-center gap-3 bg-black/60">
<div className="w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center"><Zap className="w-5 h-5" /></div>
<div>
<div className="font-semibold text-lg tracking-tight">Grok Drive</div>
<div className="text-xs text-white/50">UK &amp; Europe Headless Grok</div>
</div>
</div>
<div className="p-4 border-b border-white/10 bg-black/30">
<div className="text-[10px] uppercase tracking-[1.5px] text-white/40 mb-1.5 px-1">YOUR VEHICLE</div>
<select value={vehicle.name} onChange={e => setVehicle(VEHICLES.find(v => v.name === e.target.value)!)} className="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-2.5 text-sm focus:border-[#E82127]">
{VEHICLES.map(v => <option key={v.name} value={v.name}>{v.name} {v.rangeKm} km</option>)}
</select>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 text-sm">
<AnimatePresence>
{messages.map((msg, index) => (
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`flex ${msg.role === 'user' ? 'justify-end' : ''}`}>
<div className={`max-w-[85%] rounded-2xl px-4 py-3 leading-snug ${msg.role === 'user' ? 'bg-[#E82127] text-white rounded-tr-sm' : 'bg-[#1f242e] border border-white/10 rounded-tl-sm'}`}>
{msg.content}
</div>
</motion.div>
))}
</AnimatePresence>
{thinking && (
<div className="flex items-center gap-2 pl-1 text-[#E82127]">
<div className="flex gap-1">
<div className="w-1 h-1 bg-current rounded-full animate-bounce" />
<div className="w-1 h-1 bg-current rounded-full animate-bounce delay-150" />
<div className="w-1 h-1 bg-current rounded-full animate-bounce delay-300" />
</div>
<span className="text-xs tracking-widest">GROK IS PLANNING YOUR ROUTE...</span>
</div>
)}
</div>
<div className="p-3 border-t border-white/10 flex flex-wrap gap-1.5 bg-black/40">
{QUICK_PROMPTS.map((prompt, i) => (
<button key={i} onClick={() => sendMessage(prompt)} disabled={thinking} className="text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition disabled:opacity-50">
{prompt.length > 42 ? prompt.slice(0, 39) + '...' : prompt}
</button>
))}
</div>
<div className="p-3 border-t border-white/10 bg-black/40">
<div className="flex items-center gap-2 bg-[#1a1a1a] border border-white/10 rounded-2xl px-3">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage(input)} placeholder="Tell me where you want to drive..." className="flex-1 bg-transparent py-3 text-sm placeholder:text-white/40 outline-none" disabled={thinking} />
<button onClick={() => sendMessage(input)} disabled={!input.trim() || thinking} className="p-2 rounded-xl bg-[#E82127] disabled:opacity-40 hover:bg-[#c01a20] transition">
<Send className="w-4 h-4" />
</button>
</div>
<div className="text-center text-[9px] text-white/30 mt-2 tracking-[1.5px]">POWERED BY HEADLESS GROK CLI</div>
</div>
</div>
{/* RIGHT: MAP + ITINERARY */}
<ErrorBoundary>
<div className="flex-1 flex flex-col min-w-0">
<div className="h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm">
<div className="flex items-center gap-8">
<div><MapPin className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.totalDistanceKm} km</div>
<div><Clock className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estDriveHours}h drive</div>
<div><BatteryCharging className="inline w-4 h-4 mr-1 text-[#E82127]" />{itinerary.summary.estChargeHours}h charging</div>
</div>
<div className="flex gap-2">
<button onClick={() => toast.success('GPX exported for your Tesla')} className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-white/20 rounded-lg hover:bg-white/5">
<Download className="w-3.5 h-3.5" /> GPX
</button>
<button onClick={() => toast('Shareable link copied')} className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg">
<Share2 className="w-3.5 h-3.5" /> Share
</button>
</div>
</div>
<div className="flex-1 bg-[#05070d] p-3">
<div className="w-full h-full rounded-2xl overflow-hidden border border-white/10 relative">
<MapContainer center={[54.5, -2.5]} zoom={5.5} style={{ height: '100%', width: '100%', background: '#0a0f1a' }}>
<TileLayer attribution='&copy; OpenStreetMap contributors' url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{allStops.map(stop => (
<Marker key={stop.id} position={[stop.lat, stop.lng]}>
<Popup><strong>{stop.name}</strong><br />{stop.type === 'supercharger' && `${stop.chargeMinutes} min charge`}{stop.type === 'hotel' && '🏨 Destination charging available'}</Popup>
</Marker>
))}
{displayPolylines.map((positions, idx) => (
<Polyline key={idx} positions={positions} color="#E82127" weight={4} opacity={0.8} />
))}
</MapContainer>
{allStops.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
<div className="text-center px-6">
<div className="text-2xl mb-2 text-white/80">Ready when you are</div>
<div className="text-white/50">Tell Grok where you want to go and Ill build the perfect Tesla route.</div>
</div>
</div>
)}
</div>
</div>
<div className="h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
{itinerary.days.length > 0 ? (
<div className="flex gap-6 min-w-max">
{itinerary.days.map((day, di) => {
const validStops = (day.stops || []).filter((s): s is Stop => s != null);
return (
<div key={di} className="w-[310px]">
<div className="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY {day.day}</div>
{validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => (
<div key={stop.id} className="group flex justify-between items-center bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm">
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${stop.type === 'supercharger' ? 'bg-[#E82127]' : stop.type === 'hotel' ? 'bg-blue-500' : 'bg-white/60'}`} />
<div>
<div className="font-medium leading-tight">{stop.name}</div>
{typeof stop.lat === 'number' ? <div className="text-xs text-emerald-400">Placed on map</div> : <div className="text-xs text-amber-400">Location not yet on map</div>}
</div>
</div>
<button onClick={() => removeStop(stop.id)} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg>
</button>
</div>
)) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
</div>
);
})}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="text-white/60 mb-2">No trip planned yet</div>
<div className="text-sm text-white/40 max-w-xs">Describe your journey in the chat and Ill create the perfect route with Superchargers and stops.</div>
</div>
)}
</div>
</div>
</ErrorBoundary>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tesla-red: #E82127;
--bg: #0a0a0a;
--bg-elevated: #111111;
--bg-card: #1a1f2b;
--border: rgba(255, 255, 255, 0.08);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: white;
}
/* Tesla-inspired scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #E82127;
}
/* Map container */
.leaflet-container {
background: #0a0f1a !important;
}
/* Chat bubbles */
.chat-bubble-user {
background: #E82127;
color: white;
border-bottom-right-radius: 4px;
}
.chat-bubble-assistant {
background: #1f242e;
border: 1px solid rgba(255,255,255,0.08);
border-bottom-left-radius: 4px;
}
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
tesla: {
red: '#E82127',
black: '#0a0a0a',
dark: '#111111',
gray: '#1a1f2b',
}
}
},
},
plugins: [],
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
+20
View File
@@ -0,0 +1,20 @@
# Dev override for tesla-roadtrip.test
services:
app:
build:
target: development
command: ["npm", "run", "dev"]
volumes:
- ./server:/app/server
- ./prisma:/app/prisma
labels: !reset {}
frontend:
build:
target: development
command: ["npm", "run", "dev"]
volumes:
- ./client:/app/client
labels:
- "caddy=tesla-roadtrip.test"
- "caddy.reverse_proxy=/api/* app:3000"
- "caddy.reverse_proxy=/* {upstream http://frontend:5173}"
+6955
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"name": "tesla-roadtrip",
"version": "0.1.0",
"description": "AI-powered conversational road trip planner for Tesla owners. Chat with Grok on the left, live interactive map + itinerary on the right.",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"cyan,green\" \"npm:dev:server\" \"npm:dev:client\"",
"dev:server": "tsx watch server/index.ts",
"dev:client": "npm --prefix client run dev",
"build": "npm --prefix client run build && tsc -p tsconfig.json",
"start": "node dist/server/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"db:generate": "prisma generate",
"db:migrate": "atlas migrate apply --env dev",
"db:seed": "tsx prisma/seed.ts",
"prepare": "husky || true"
},
"dependencies": {
"@prisma/client": "^7.4.0",
"@tonycodes/auth-express": "^1.4.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^5.0.1",
"helmet": "^7.1.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^20.10.0",
"@vitest/coverage-v8": "^1.6.0",
"concurrently": "^8.2.2",
"eslint": "^9.0.0",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"prettier": "^3.2.0",
"prisma": "^7.4.0",
"tsx": "^4.6.2",
"typescript": "^5.3.2",
"vitest": "^1.6.0"
},
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: false, // Run tests sequentially for now (we're iterating on one flow)
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // One worker so we can watch logs easily
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// We expect both servers to be running already
// (user will run ./scripts/dev.sh or npm run dev in another terminal)
});
+33
View File
@@ -0,0 +1,33 @@
generator client {
provider = "prisma-client"
output = "./generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Trip {
id String @id @default(uuid())
userId String
title String
vehicleModel String
rangeMi Int
itinerary Json
status String @default("planning")
isPublic Boolean @default(false)
shareSlug String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model ChatMessage {
id String @id @default(uuid())
tripId String
role String
content String @db.Text
createdAt DateTime @default(now())
@@index([tripId])
}
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# One-command development script for Tesla Roadtrip
# Usage: ./scripts/dev.sh
set -e
echo "🚀 Starting Tesla Roadtrip development environment..."
# Load .env if it exists
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Check for XAI_API_KEY
if [ -z "$XAI_API_KEY" ]; then
echo ""
echo "⚠️ XAI_API_KEY is not set."
echo " → Real Grok (via xAI API) will NOT work."
echo " → The app will fall back to very basic responses."
echo ""
echo " To fix this:"
echo " 1. Add this line to your .env file:"
echo " XAI_API_KEY=xai-YourKeyHere"
echo ""
echo " 2. Or export it before running:"
echo " export XAI_API_KEY=xai-YourKeyHere"
echo ""
read -p "Continue without real Grok? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Exiting..."
exit 1
fi
else
echo "✅ XAI_API_KEY found — real Grok API will be used"
fi
echo ""
echo "Starting Backend + Frontend..."
echo " Backend: http://localhost:3000"
echo " Frontend: http://localhost:5173"
echo ""
# Run both with nice colored labels
npx concurrently \
-n "BACKEND,FRONTEND" \
-c "cyan,green" \
--kill-others-on-fail \
"npm run dev:server" \
"npm --prefix client run dev"
+17
View File
@@ -0,0 +1,17 @@
import 'dotenv/config';
export const env = {
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
appUrl: process.env.APP_URL || 'https://tesla-roadtrip.test',
apiUrl: process.env.API_URL || 'https://tesla-roadtrip.test',
// Auth
authSecret: process.env.AUTH_SECRET || '',
authUrl: process.env.AUTH_URL || 'https://auth.tony.codes',
// Grok / xAI
grokBin: process.env.GROK_BIN || '/usr/local/bin/grok',
xaiApiKey: process.env.XAI_API_KEY || '',
grokEnabled: process.env.GROK_ENABLED !== 'false',
} as const;
+172
View File
@@ -0,0 +1,172 @@
[
{
"id": "sc-london-heathrow",
"name": "London Heathrow",
"lat": 51.4706,
"lng": -0.4543,
"stalls": 24,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-london-battersea",
"name": "London Battersea",
"lat": 51.477,
"lng": -0.17,
"stalls": 12,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-folkestone",
"name": "Folkestone Eurotunnel",
"lat": 51.093,
"lng": 1.155,
"stalls": 8,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-birmingham",
"name": "Birmingham",
"lat": 52.4862,
"lng": -1.8904,
"stalls": 16,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-manchester",
"name": "Manchester",
"lat": 53.4808,
"lng": -2.2426,
"stalls": 12,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-edinburgh",
"name": "Edinburgh",
"lat": 55.9533,
"lng": -3.1883,
"stalls": 10,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-glasgow",
"name": "Glasgow",
"lat": 55.8642,
"lng": -4.2518,
"stalls": 8,
"maxKw": 250,
"country": "UK",
"status": "open"
},
{
"id": "sc-calais",
"name": "Calais",
"lat": 50.9513,
"lng": 1.8563,
"stalls": 20,
"maxKw": 250,
"country": "FR",
"status": "open"
},
{
"id": "sc-paris",
"name": "Paris South",
"lat": 48.8566,
"lng": 2.3522,
"stalls": 18,
"maxKw": 250,
"country": "FR",
"status": "open"
},
{
"id": "sc-lyon",
"name": "Lyon",
"lat": 45.764,
"lng": 4.8357,
"stalls": 12,
"maxKw": 250,
"country": "FR",
"status": "open"
},
{
"id": "sc-amsterdam",
"name": "Amsterdam",
"lat": 52.3676,
"lng": 4.9041,
"stalls": 14,
"maxKw": 250,
"country": "NL",
"status": "open"
},
{
"id": "sc-brussels",
"name": "Brussels",
"lat": 50.8503,
"lng": 4.3517,
"stalls": 10,
"maxKw": 250,
"country": "BE",
"status": "open"
},
{
"id": "sc-cologne",
"name": "Cologne",
"lat": 50.9375,
"lng": 6.9603,
"stalls": 12,
"maxKw": 250,
"country": "DE",
"status": "open"
},
{
"id": "sc-frankfurt",
"name": "Frankfurt",
"lat": 50.1109,
"lng": 8.6821,
"stalls": 16,
"maxKw": 250,
"country": "DE",
"status": "open"
},
{
"id": "sc-munich",
"name": "Munich",
"lat": 48.1351,
"lng": 11.582,
"stalls": 14,
"maxKw": 250,
"country": "DE",
"status": "open"
},
{
"id": "sc-zurich",
"name": "Zurich",
"lat": 47.3769,
"lng": 8.5417,
"stalls": 8,
"maxKw": 250,
"country": "CH",
"status": "open"
},
{
"id": "sc-madrid",
"name": "Madrid",
"lat": 40.4168,
"lng": -3.7038,
"stalls": 12,
"maxKw": 250,
"country": "ES",
"status": "open"
}
]
+34
View File
@@ -0,0 +1,34 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { env } from './config/env.js';
import { logger } from './lib/logger.js';
const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.appUrl, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use((req, _res, next) => {
if (req.url !== '/health') logger.info({ method: req.method, url: req.url }, 'request');
next();
});
app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'tesla-roadtrip', time: new Date().toISOString() });
});
// TODO: Mount auth middleware + routes here once client is registered
// TODO: Mount /api/trips and chat routes
app.listen(env.port, () => {
logger.info(`Tesla Roadtrip server running on port ${env.port}`);
});
// Chat routes (real Grok integration)
import chatRoutes from './routes/chat.js';
app.use('/api', chatRoutes);
+11
View File
@@ -0,0 +1,11 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
// Helper used across the server (consistent with other projects)
export const createLogger = (module: string) => logger.child({ module });
+73
View File
@@ -0,0 +1,73 @@
import { Router } from 'express';
import { z } from 'zod';
import { grok } from '../services/llm/GrokHeadlessClient.js';
import { createLogger } from '../lib/logger.js';
import crypto from 'crypto';
const log = createLogger('chat-api');
const router = Router();
const ChatRequestSchema = z.object({
message: z.string().min(1).max(2000),
vehicle: z.object({ name: z.string(), rangeKm: z.number() }),
itinerary: z.any().optional(),
history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).optional(),
});
router.post('/chat', async (req, res) => {
const requestId = crypto.randomUUID().slice(0, 8);
const start = Date.now();
log.info({ requestId, body: req.body }, '=== INCOMING /api/chat REQUEST ===');
try {
const parsed = ChatRequestSchema.safeParse(req.body);
if (!parsed.success) {
log.error({ requestId, errors: parsed.error.format() }, 'Invalid request body');
return res.status(400).json({ error: 'Invalid request' });
}
const { message, vehicle, itinerary, history = [] } = parsed.data;
log.info({
requestId,
userMessage: message,
vehicle: vehicle.name,
historyLength: history.length,
currentItineraryDays: itinerary?.days?.length || 0,
}, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
const result = await grok.chat(
[...history, { role: 'user' as const, content: message }],
itinerary,
vehicle
);
const duration = Date.now() - start;
const payload: any = { reply: result.text };
if (result.updatedItinerary) {
payload.itinerary = result.updatedItinerary;
}
log.info({
requestId,
durationMs: duration,
replyLength: result.text.length,
itineraryUpdated: !!result.updatedItinerary,
newDays: result.updatedItinerary?.days?.length || 0,
}, '=== SENDING RESPONSE TO FRONTEND ===');
if (result.updatedItinerary) {
log.debug({ requestId, fullItinerary: result.updatedItinerary }, 'Full updated itinerary being sent');
}
res.json(payload);
} catch (err) {
log.error({ requestId, err }, 'Chat route crashed');
res.status(500).json({ reply: "Something went wrong on the server." });
}
});
export default router;
+217
View File
@@ -0,0 +1,217 @@
/**
* Tesla Roadtrip — Grok Headless Client (with real xAI API fallback)
* Maximum logging + strict structured output for map rendering
*/
import { spawn } from 'child_process';
import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { createLogger } from '../../lib/logger.js';
import { env } from '../../config/env.js';
import crypto from 'crypto';
const log = createLogger('grok-headless');
const SENTINEL = 'ITINERARY_UPDATE:';
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
export interface GrokResponse { text: string; updatedItinerary?: any; }
export class GrokHeadlessClient {
private useFallback = !!env.xaiApiKey || !env.grokEnabled;
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: string) {
return `You are Grok Drive — an expert Tesla road trip planner for the UK and Europe.
Current vehicle: ${vehicle}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
CRITICAL OUTPUT RULES (must follow exactly):
- When the user gives a clear origin and destination, immediately create a reasonable first-draft multi-day itinerary.
- For every stop in the itinerary (Superchargers, hotels, attractions, etc.), you **MUST** include real latitude and longitude.
Use accurate coordinates for real Tesla Superchargers (examples: London Battersea ≈ 51.477, -0.17; Birmingham NEC ≈ 52.45, -1.72; Leeds Skelton Lake ≈ 53.78, -1.46).
- The output must contain the itinerary in this **exact JSON shape** after your normal reply:
ITINERARY_UPDATE:
{
"days": [
{
"day": 1,
"stops": [
{
"id": "unique-string",
"name": "Human readable name",
"type": "supercharger" | "hotel" | "attraction" | "restaurant" | "custom",
"lat": 51.477,
"lng": -0.17,
"day": 1,
"order": 1,
"estArrivalBattery": 25,
"chargeMinutes": 25,
"notes": "optional short note"
}
]
}
],
"summary": {
"totalDistanceKm": 650,
"estDriveHours": 10.5,
"estChargeHours": 1.5,
"superchargers": 3,
"hotels": 1
}
}
Rules:
- Never mention the JSON in your spoken reply.
- Make realistic assumptions for a first draft (max 5-6h driving/day, include major Superchargers, logical overnights).
- After the draft, ask what the user wants to change.
Conversation:
${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
ASSISTANT:`;
}
async chat(messages: ChatMessage[], itinerary: any, vehicle: string): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicle.name, messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
if (env.xaiApiKey) {
log.info({ requestId }, 'Using real xAI API');
return this.callXaiApi(messages, itinerary, vehicle, requestId);
}
if (this.useFallback) {
return this.dumbFallback(messages, requestId);
}
const prompt = this.buildPrompt(messages, itinerary, vehicle);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
try {
const args = ['-p', prompt, '--output-format', 'json', '--yolo', '--disallowed-tools', 'run_terminal_cmd,search_replace,write_file,Agent', '--tools', 'web_search,web_fetch', '--max-turns', '6', '--effort', 'high', '--cwd', tmp];
const result = await new Promise<string>((resolve, reject) => {
const child = spawn(env.grokBin, args, { cwd: tmp, env: { ...process.env } });
let stdout = '';
child.stdout.on('data', d => stdout += d);
child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(`grok exited ${code}`)));
});
const data = JSON.parse(result);
const rawText = data.text || '';
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
return { text: cleanText, updatedItinerary: parsed };
} catch (err) {
log.error({ requestId, err }, 'Local grok CLI failed — falling back to xAI API');
if (env.xaiApiKey) {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
}
return this.dumbFallback(messages, requestId);
} finally {
await rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: string, requestId: string): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle);
log.info({ requestId, promptLength: prompt.length }, 'Calling real xAI API');
try {
const response = await fetch('https://api.x.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.xaiApiKey}`,
},
body: JSON.stringify({
model: 'grok-3',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
}),
});
if (!response.ok) {
const text = await response.text();
log.error({ requestId, status: response.status }, 'xAI API error');
return this.dumbFallback(messages, requestId);
}
const data = await response.json();
const rawText = data.choices?.[0]?.message?.content || '';
const { cleanText, itinerary: parsed } = this.extractItineraryUpdate(rawText);
return { text: cleanText, updatedItinerary: parsed };
} catch (err) {
log.error({ requestId, err }, 'xAI API call failed');
return this.dumbFallback(messages, requestId);
}
}
private extractItineraryUpdate(text: string): { cleanText: string; itinerary: any | null } {
const upperText = text.toUpperCase();
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
if (sentinelIndex === -1) {
return { cleanText: text.trim(), itinerary: null };
}
const after = text.substring(sentinelIndex + SENTINEL.length).trim();
// Try to find a JSON object, even if wrapped in ```json
let jsonStart = after.indexOf('{');
if (jsonStart === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
// Find matching closing brace
let depth = 0;
let end = -1;
let inString = false;
let escape = false;
for (let i = jsonStart; i < after.length; i++) {
const ch = after[i];
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (!inString) {
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) { end = i; break; }
}
}
}
if (end === -1) return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
const jsonStr = after.substring(jsonStart, end + 1);
try {
const parsed = JSON.parse(jsonStr);
const cleanText = text.substring(0, sentinelIndex).trim();
return { cleanText, itinerary: parsed };
} catch (e) {
log.error({ err: e }, 'Failed to parse ITINERARY_UPDATE JSON');
return { cleanText: text.substring(0, sentinelIndex).trim(), itinerary: null };
}
}
private async dumbFallback(messages: ChatMessage[], requestId: string): Promise<GrokResponse> {
const last = messages[messages.length - 1]?.content.toLowerCase() || '';
if (['hi', 'hello', 'hey'].some(g => last.includes(g))) {
return { text: "Hello! I'm Grok Drive. How can I help plan your UK or European Tesla trip today?", updatedItinerary: null };
}
return {
text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!",
updatedItinerary: null
};
}
}
export const grok = new GrokHeadlessClient();
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
test.describe('Grok API Diagnostic (Backend Only)', () => {
test('backend should successfully call xAI Grok and return a response', async ({ request }) => {
test.setTimeout(120000); // Grok can be slow
const payload = {
message: "Plan a short day trip from Milton Keynes to Telford in a Model Y. Include one Supercharger stop.",
vehicle: { name: "Model Y Long Range", rangeKm: 514 },
itinerary: null,
history: []
};
const response = await request.post('http://localhost:3000/api/chat', {
data: payload,
headers: { 'Content-Type': 'application/json' }
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
console.log('[Diagnostic] Grok reply length:', body.reply?.length);
console.log('[Diagnostic] Has itinerary update:', !!body.itinerary);
expect(body.reply).toBeDefined();
expect(body.reply.length).toBeGreaterThan(50);
// It should NOT be the generic error message
expect(body.reply).not.toContain('having trouble reaching Grok');
// If it produced an itinerary, validate basic shape
if (body.itinerary) {
expect(body.itinerary.days).toBeDefined();
expect(Array.isArray(body.itinerary.days)).toBe(true);
expect(body.itinerary.days.length).toBeGreaterThan(0);
console.log('[Diagnostic] Itinerary days:', body.itinerary.days.length);
}
console.log('✅ Backend successfully called real Grok!');
});
});
+54
View File
@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
test.describe('Tesla Roadtrip - Full Chat to Map Flow', () => {
test.setTimeout(180000);
test('should send a trip request, get real Grok response, and render itinerary + map', async ({ page }) => {
await page.goto('http://localhost:5173');
// Better header selector
const grokHeader = page.locator('div.font-semibold.text-lg.tracking-tight:has-text("Grok Drive")').first();
await expect(grokHeader).toBeVisible({ timeout: 15000 });
const chatInput = page.locator('input[placeholder*="Tell me where you want to drive"], input[placeholder*="drive"]');
await expect(chatInput).toBeVisible();
const tripRequest = 'I want to go from Milton Keynes to Telford in my Model Y';
await chatInput.fill(tripRequest);
const sendButton = page.locator('button:has(svg)').last();
await sendButton.click();
// Wait for thinking state
const thinking = page.locator('text=GROK IS PLANNING YOUR ROUTE');
await thinking.waitFor({ state: 'visible', timeout: 20000 }).catch(() => {});
// Wait for Grok to finish
await thinking.waitFor({ state: 'hidden', timeout: 120000 });
const lastAssistant = page.locator('.chat-bubble-assistant').last();
// === DIAGNOSTIC: Detect the common failure mode early ===
const messageText = await lastAssistant.textContent();
if (messageText?.includes('having trouble reaching Grok')) {
await page.screenshot({ path: `test-results/grok-failed-${Date.now()}.png`, fullPage: true });
throw new Error('Grok call failed (got error message). Check backend logs + make sure XAI_API_KEY is loaded correctly (use ./scripts/dev.sh).');
}
// Success path
const dayOne = page.locator('text=DAY 1').first();
await expect(dayOne).toBeVisible({ timeout: 30000 });
const stop = page.locator('.group:has-text("Supercharger"), .group:has-text("Hotel")').first();
await expect(stop).toBeVisible({ timeout: 15000 });
await page.screenshot({
path: `test-results/success-milton-keynes-telford-${Date.now()}.png`,
fullPage: true
});
console.log('✅ Full flow succeeded: Real Grok itinerary rendered on map!');
});
});
+441
View File
@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tesla Roadtrip • Grok Drive — UI Preview</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600&amp;display=swap');
:root {
--tesla-red: #E82127;
}
body {
font-family: 'Inter', system_ui, sans-serif;
}
.font-display {
font-family: 'Space Grotesk', 'Inter', system_ui, sans-serif;
font-weight: 600;
}
.tesla-red { color: #E82127; }
.bg-tesla-red { background-color: #E82127; }
.chat-bubble-user {
background: #E82127;
color: white;
border-bottom-right-radius: 4px;
}
.chat-bubble-assistant {
background: #1f242e;
border: 1px solid rgba(255,255,255,0.08);
border-bottom-left-radius: 4px;
}
.map-container {
background: #0a0f1a;
position: relative;
overflow: hidden;
}
.map-grid {
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 42px 42px;
}
.tesla-marker {
width: 26px;
height: 26px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
border: 2px solid white;
z-index: 20;
}
.stop-row {
transition: all 0.1s ease;
}
.stop-row:hover {
background-color: #22283a;
}
.section-title {
font-size: 10px;
letter-spacing: 1.5px;
font-weight: 600;
}
.nav-text {
font-feature-settings: "tnum";
}
</style>
</head>
<body class="bg-[#0a0a0a] text-white">
<div class="flex h-screen overflow-hidden">
<!-- LEFT: CHAT -->
<div class="w-[380px] flex flex-col border-r border-white/10 bg-[#111111]">
<!-- Header -->
<div class="px-5 py-4 border-b border-white/10 flex items-center justify-between bg-black/60">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<div class="font-semibold text-[17px] tracking-[-0.4px]">Grok Drive</div>
<div class="text-[10px] text-white/50 -mt-0.5">UK &amp; Europe • Headless Grok</div>
</div>
</div>
<div class="text-[10px] px-2.5 py-0.5 rounded bg-white/5 font-mono tracking-widest text-white/60">v0.1</div>
</div>
<!-- Vehicle Selector -->
<div class="px-4 py-3 border-b border-white/10 bg-black/30">
<div class="text-[10px] uppercase tracking-[1.5px] text-white/40 mb-1.5 px-1">YOUR VEHICLE</div>
<select class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-[9px] text-sm focus:outline-none focus:border-[#E82127]">
<option>Model Y Long Range — 514 km</option>
<option>Model 3 Highland LR — 549 km</option>
<option>Model Y RWD (EU) — 455 km</option>
<option>Model S Long Range — 634 km</option>
</select>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-6 text-sm" id="chat-messages">
<!-- Assistant message -->
<div class="flex">
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
Hello! I'm Grok Drive, your Tesla trip planner for the UK and Europe. Where would you like to go? I know the Supercharger network across Britain, France, Germany, and beyond extremely well.
</div>
</div>
<!-- User message -->
<div class="flex justify-end">
<div class="max-w-[82%] chat-bubble-user rounded-2xl px-4 py-3 leading-snug">
Plan a 2-day trip from London to Edinburgh in my Model Y
</div>
</div>
<!-- Assistant reply -->
<div class="flex">
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
Excellent choice — London to Edinburgh is one of the most popular Tesla routes in the UK. I've routed you via the excellent M1/A1 corridor with fast V3/V4 stalls. Battery stays healthy throughout.
</div>
</div>
</div>
<!-- Quick Prompts -->
<div class="px-3 pb-2 flex flex-wrap gap-1.5 border-t border-white/10 pt-3 bg-black/40">
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Plan a 2-day trip from London to Edinburgh</div>
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Add a lunch stop in the Lake District</div>
<div onclick="useQuickPrompt(this)" class="cursor-pointer text-[10px] px-3 py-1 bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition">Find a hotel with destination charging</div>
</div>
<!-- Input -->
<div class="p-3 border-t border-white/10 bg-black/40">
<div class="flex items-center gap-2 bg-[#1a1a1a] border border-white/10 rounded-2xl px-3 focus-within:border-[#E82127]">
<input id="chat-input"
type="text"
placeholder="Where would you like to drive in the UK or Europe?"
class="flex-1 bg-transparent py-3 text-sm placeholder:text-white/40 outline-none"
onkeypress="if(event.key === 'Enter') sendMessage()">
<button onclick="sendMessage()"
class="p-2 rounded-xl bg-[#E82127] hover:bg-[#c01a20] active:scale-95 transition flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<div class="text-center text-[9px] text-white/30 mt-2 tracking-[1.5px]">POWERED BY HEADLESS GROK CLI</div>
</div>
</div>
<!-- RIGHT: MAP + ITINERARY -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Stats Bar -->
<div class="h-14 border-b border-white/10 bg-black/40 px-6 flex items-center justify-between text-sm">
<div class="flex items-center gap-8 nav-text">
<div class="flex items-center gap-1.5">
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314-11.314z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg></span>
<span class="font-semibold">665</span>
<span class="text-white/50">km</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 01-9 9 9 9 0 01-9-9 9 9 0 019-9 9 9 0 019 9z" /></svg></span>
<span class="font-semibold">7.2</span>
<span class="text-white/50">hrs drive</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-[#E82127]"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg></span>
<span class="font-semibold">1.1</span>
<span class="text-white/50">hrs charging</span>
</div>
<div class="text-white/50">• 3 Superchargers • 1 hotel</div>
</div>
<div class="flex gap-2">
<button onclick="alert('GPX would be downloaded in the real app')"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-white/20 rounded-lg hover:bg-white/5 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13v6m0 0l-3-3m3 3l3-3m-6-4v1m0 0l-3-3m3 3l3-3" /></svg>
GPX
</button>
<button onclick="alert('Share link copied in the real app')"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg transition">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 01-2.25 2.25" /></svg>
Share
</button>
</div>
</div>
<!-- MAP -->
<div class="flex-1 p-3 bg-[#05070d]">
<div class="w-full h-full rounded-2xl overflow-hidden border border-white/10 relative" id="map-wrapper">
<!-- Real Leaflet will be initialized here -->
<div id="map" style="width: 100%; height: 100%; background: #0a0f1a;"></div>
</div>
</div>
<!-- Itinerary -->
<div class="h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
<div class="flex gap-6 min-w-max">
<!-- Day 1 -->
<div class="w-[310px]">
<div class="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY 1 • London → Edinburgh</div>
<div class="space-y-1.5">
<!-- Stop -->
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
<div>
<div class="font-medium">London Battersea Supercharger</div>
<div class="text-xs text-white/40">Arrive 28% • 22 min charge</div>
</div>
</div>
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg></button>
</div>
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
<div>
<div class="font-medium">Birmingham Supercharger</div>
<div class="text-xs text-white/40">Arrive 19% • 28 min charge</div>
</div>
</div>
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg></button>
</div>
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-blue-500 flex-shrink-0"></div>
<div>
<div class="font-medium">The Balmoral Hotel, Edinburgh</div>
<div class="text-xs text-white/40">Destination charging • 4 nights</div>
</div>
</div>
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg></button>
</div>
</div>
</div>
<!-- Day 2 -->
<div class="w-[310px]">
<div class="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY 2 • Edinburgh &amp; Return</div>
<div class="space-y-1.5">
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-white/60 flex-shrink-0"></div>
<div>
<div class="font-medium">Edinburgh Castle</div>
</div>
</div>
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg></button>
</div>
<div class="flex justify-between items-center bg-[#1a1f2b] border border-white/5 rounded-2xl px-4 py-2.5 text-sm group">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full bg-[#E82127] flex-shrink-0"></div>
<div>
<div class="font-medium">Glasgow Supercharger</div>
<div class="text-xs text-white/40">Arrive 24% • 19 min charge</div>
</div>
</div>
<button onclick="removeStop(this)" class="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Tailwind script
function initializeTailwind() {
document.documentElement.style.setProperty('--accent', '#E82127');
}
// Initialize Leaflet Map (Europe / UK view)
function initMap() {
const map = L.map('map', {
zoomControl: false,
attributionControl: false
}).setView([54.8, -2.8], 5.4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>', subdomains: 'abcd', maxZoom: 20
}).addTo(map);
// Stops for London → Edinburgh route
const stops = [
{ id: 1, name: "London Battersea", lat: 51.477, lng: -0.17, type: "supercharger" },
{ id: 2, name: "Birmingham", lat: 52.486, lng: -1.89, type: "supercharger" },
{ id: 3, name: "The Balmoral Hotel", lat: 55.953, lng: -3.188, type: "hotel" },
{ id: 4, name: "Edinburgh Castle", lat: 55.9486, lng: -3.2008, type: "attraction" }
];
// Add markers
stops.forEach(stop => {
const color = stop.type === 'supercharger' ? '#E82127' :
stop.type === 'hotel' ? '#3b82f6' : '#64748b';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color}; width:22px; height:22px; border-radius:9999px; border:2px solid white; display:flex; align-items:center; justify-content:center; box-shadow:0 2px 6px rgba(0,0,0,0.3);">
<span style="color:white; font-size:10px; font-weight:700;">${stop.type === 'supercharger' ? '⚡' : '●'}</span>
</div>`,
iconSize: [22, 22],
iconAnchor: [11, 11]
});
L.marker([stop.lat, stop.lng], { icon })
.addTo(map)
.bindPopup(`<b>${stop.name}</b><br><span style="color:#888">${stop.type}</span>`);
});
// Draw route polyline
const routeCoords = stops.map(s => [s.lat, s.lng]);
L.polyline(routeCoords, {
color: '#E82127',
weight: 4,
opacity: 0.7,
lineJoin: 'round'
}).addTo(map);
}
function sendMessage() {
const input = document.getElementById('chat-input');
if (!input.value.trim()) return;
const messagesContainer = document.getElementById('chat-messages');
// Add user message
const userMsg = document.createElement('div');
userMsg.className = 'flex justify-end';
userMsg.innerHTML = `
<div class="max-w-[82%] chat-bubble-user rounded-2xl px-4 py-3 leading-snug">
${input.value}
</div>
`;
messagesContainer.appendChild(userMsg);
const userText = input.value;
input.value = '';
// Simulate thinking
setTimeout(() => {
const thinking = document.createElement('div');
thinking.className = 'flex items-center gap-2 pl-1 text-[#E82127]';
thinking.innerHTML = `
<div class="flex gap-1">
<div class="w-1 h-1 bg-current rounded-full animate-bounce"></div>
<div class="w-1 h-1 bg-current rounded-full animate-bounce" style="animation-delay:120ms"></div>
<div class="w-1 h-1 bg-current rounded-full animate-bounce" style="animation-delay:240ms"></div>
</div>
<span class="text-xs tracking-widest">GROK IS PLANNING...</span>
`;
messagesContainer.appendChild(thinking);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
setTimeout(() => {
thinking.remove();
const reply = document.createElement('div');
reply.className = 'flex';
reply.innerHTML = `
<div class="max-w-[85%] chat-bubble-assistant rounded-2xl px-4 py-3 leading-snug">
I've added a great lunch stop at Tebay Services in the Lake District. Many Tesla owners rate it as one of the best on this route.
</div>
`;
messagesContainer.appendChild(reply);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Simulate itinerary update
setTimeout(() => {
alert("In the real app, this would dynamically add a new stop to the map and itinerary panel.");
}, 800);
}, 1400);
}, 900);
}
function useQuickPrompt(el) {
const text = el.textContent;
const input = document.getElementById('chat-input');
input.value = text;
// Trigger send
setTimeout(() => {
sendMessage();
}, 50);
}
function removeStop(btn) {
btn.closest('.group').style.transition = 'all 0.2s';
btn.closest('.group').style.opacity = '0';
setTimeout(() => {
btn.closest('.group').remove();
}, 180);
}
// Boot everything
function init() {
initializeTailwind();
initMap();
// Keyboard hint
const input = document.getElementById('chat-input');
input.addEventListener('focus', () => {
input.placeholder = "Try: 'Add a stop near the Lake District'";
});
}
window.onload = init;
</script>
</body>
</html>
EOF
echo "High-fidelity static UI preview created"