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
+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();