feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub
- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and prefetch ledger invalidate when dates change - Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany, Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with trip-impact deltas and Book links; prompt requires 3-5 real options for every UK ↔ mainland route; picking a crossing triggers silent re-plan - Tesla in-car polish: UA + heuristic detection sets <html class="incar">; CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets, disable hover flicker; geolocation + reverse geocode + crosshair button inside the From input; up/down arrow reorder buttons replace touch-broken HTML5 drag-and-drop - Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem served from TESLA_FLEET_PUBLIC_KEY for partner domain verification; OAuth callback + vehicle_data stub return 503 until partner approval - Dockerfile + .dockerignore for Dokku deployment; server now serves client/dist in production
This commit is contained in:
@@ -24,4 +24,19 @@ export const env = {
|
||||
xaiApiKey: process.env.XAI_API_KEY || '',
|
||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||
|
||||
// Tesla Fleet API
|
||||
tesla: {
|
||||
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
|
||||
// for domain verification. Set TESLA_FLEET_PUBLIC_KEY to the PEM contents
|
||||
// (multi-line; can include literal newlines).
|
||||
publicKey: process.env.TESLA_FLEET_PUBLIC_KEY || '',
|
||||
// OAuth client credentials Tesla gives you after partner approval.
|
||||
clientId: process.env.TESLA_FLEET_CLIENT_ID || '',
|
||||
clientSecret: process.env.TESLA_FLEET_CLIENT_SECRET || '',
|
||||
// Where Tesla redirects after the user authorises.
|
||||
redirectUri: process.env.TESLA_FLEET_REDIRECT_URI || 'https://roadtrip.tony.codes/api/auth/tesla/callback',
|
||||
// Region: 'eu' or 'na'.
|
||||
region: (process.env.TESLA_FLEET_REGION || 'eu') as 'eu' | 'na',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -3,10 +3,14 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import chatRoutes from './routes/chat.js';
|
||||
import tripsRoutes from './routes/trips.js';
|
||||
import teslaRoutes from './routes/tesla.js';
|
||||
import { createOptionalAuth } from './lib/auth.js';
|
||||
|
||||
const app = express();
|
||||
@@ -34,9 +38,31 @@ if (auth) {
|
||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||
}
|
||||
|
||||
// Tesla integration: serves the partner public key + OAuth callback. Mounted
|
||||
// at the app root because Tesla's well-known path is fixed.
|
||||
app.use(teslaRoutes);
|
||||
|
||||
app.use('/api', chatRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
|
||||
// ─── Static client (production only) ─────────────────────────────────────────
|
||||
// In dev, Vite serves the client on :5173. In production (Dokku), the built
|
||||
// client lands in client/dist via `npm run build` and we serve it from here.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const clientDist = path.resolve(__dirname, '../../client/dist');
|
||||
if (existsSync(clientDist)) {
|
||||
app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
|
||||
app.get('*', (req, res, next) => {
|
||||
// Don't shadow API or well-known paths.
|
||||
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
logger.info({ clientDist }, 'Serving built client');
|
||||
} else {
|
||||
logger.info('No client/dist found — relying on Vite dev server');
|
||||
}
|
||||
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
+10
-4
@@ -15,6 +15,11 @@ const ChatRequestSchema = z.object({
|
||||
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
|
||||
origin: z.string().optional(),
|
||||
destination: z.string().optional(),
|
||||
travelDates: z.object({
|
||||
outbound: z.string().nullable().optional(),
|
||||
return: z.string().nullable().optional(),
|
||||
travellers: z.number().int().min(1).max(8).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
router.post('/chat', async (req, res) => {
|
||||
@@ -30,7 +35,7 @@ router.post('/chat', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
|
||||
|
||||
log.info({
|
||||
requestId,
|
||||
@@ -41,6 +46,7 @@ router.post('/chat', async (req, res) => {
|
||||
selectedVariant,
|
||||
origin,
|
||||
destination,
|
||||
travelDates,
|
||||
}, 'Parsed chat request');
|
||||
|
||||
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
|
||||
@@ -49,7 +55,7 @@ router.post('/chat', async (req, res) => {
|
||||
itinerary,
|
||||
vehicle,
|
||||
selectedVariant,
|
||||
{ origin, destination },
|
||||
{ origin, destination, travelDates },
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
@@ -91,7 +97,7 @@ router.post('/chat/stream', async (req, res) => {
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
|
||||
}
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
|
||||
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -124,7 +130,7 @@ router.post('/chat/stream', async (req, res) => {
|
||||
itinerary,
|
||||
vehicle,
|
||||
selectedVariant,
|
||||
{ origin, destination },
|
||||
{ origin, destination, travelDates },
|
||||
);
|
||||
for await (const ev of stream) {
|
||||
if (cancelled) break;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
|
||||
const log = createLogger('tesla');
|
||||
const router = Router();
|
||||
|
||||
// ─── Domain verification ────────────────────────────────────────────────────
|
||||
// Tesla fetches this path to confirm you own the domain registered with the
|
||||
// Fleet API partner account. The body must be the EXACT PEM the partner key
|
||||
// is registered with (the EC public key from your prime256v1 keypair).
|
||||
//
|
||||
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
|
||||
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
|
||||
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
|
||||
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
|
||||
if (!env.tesla.publicKey) {
|
||||
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
|
||||
res.status(404).type('text/plain').send('Public key not configured');
|
||||
return;
|
||||
}
|
||||
res.type('application/x-pem-file');
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.send(env.tesla.publicKey);
|
||||
});
|
||||
|
||||
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
|
||||
// Tesla redirects here with ?code=… after the user grants access. We exchange
|
||||
// the code for a refresh token and store it against the logged-in user.
|
||||
// Full implementation lands once partner approval is granted.
|
||||
router.get('/api/auth/tesla/callback', async (req, res) => {
|
||||
const { code, state, error } = req.query as Record<string, string | undefined>;
|
||||
|
||||
if (error) {
|
||||
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
|
||||
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
res.status(400).type('text/plain').send('Missing ?code from Tesla');
|
||||
return;
|
||||
}
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
log.warn('Tesla OAuth callback hit but client credentials not configured');
|
||||
res.status(503).type('text/plain').send('Tesla integration not yet configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO once Tesla approves partner registration:
|
||||
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
|
||||
// 2. Decode the id_token, persist refresh_token against req.auth.userId
|
||||
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
|
||||
// 4. Redirect to / with a success flag
|
||||
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
|
||||
res.redirect('/?tesla_connected=pending');
|
||||
});
|
||||
|
||||
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
|
||||
// Returns the current battery %, range and location for the connected vehicle.
|
||||
// Until partner approval, returns 503 so the client can hide the integration UI.
|
||||
router.get('/api/tesla/state', async (_req, res) => {
|
||||
if (!env.tesla.clientId) {
|
||||
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
||||
return;
|
||||
}
|
||||
// TODO: look up the user's stored refresh token, exchange for access token,
|
||||
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
|
||||
// and shape the response into { battery, range, lat, lng, state, etc. }.
|
||||
res.status(501).json({ connected: false, reason: 'not_implemented' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -160,7 +160,7 @@ export class GrokHeadlessClient {
|
||||
}
|
||||
}
|
||||
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}) {
|
||||
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}) {
|
||||
const variantBrief = {
|
||||
fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.',
|
||||
scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.',
|
||||
@@ -171,12 +171,18 @@ export class GrokHeadlessClient {
|
||||
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
|
||||
: '';
|
||||
|
||||
const td = opts.travelDates;
|
||||
const hasDates = !!(td && (td.outbound || td.return));
|
||||
const datesBlock = hasDates
|
||||
? `\nTRAVEL DATES (use these for crossing/ferry/hotel pricing — peak vs off-peak vs weekend):\n Outbound: ${td!.outbound || '(not set)'}\n Return: ${td!.return || '(one-way)'}\n Travellers: ${td!.travellers ?? 'unknown'}\n`
|
||||
: `\nTRAVEL DATES: not yet provided by the user. Use ballpark off-peak prices for now and set "needsTravelDates": true on the itinerary so the UI prompts the user to add dates for accurate pricing.\n`;
|
||||
|
||||
return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep.
|
||||
|
||||
Selected route variant: ${selectedVariant.toUpperCase()}
|
||||
${variantBrief}
|
||||
|
||||
Current vehicle: ${vehicleName(vehicle)}${odBlock}
|
||||
Current vehicle: ${vehicleName(vehicle)}${odBlock}${datesBlock}
|
||||
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
|
||||
|
||||
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
|
||||
@@ -245,6 +251,25 @@ Respond with **only** a single valid JSON object in exactly this format. No text
|
||||
"isCurrent": true,
|
||||
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
|
||||
}
|
||||
],
|
||||
"crossingOptions": [
|
||||
{
|
||||
"id": "unique-crossing-id",
|
||||
"operator": "Eurotunnel Le Shuttle" | "DFDS" | "P&O Ferries" | "Brittany Ferries" | "Stena Line" | "Irish Ferries" | "Other",
|
||||
"mode": "tunnel" | "ferry",
|
||||
"fromPort": "Folkestone, UK",
|
||||
"toPort": "Coquelles (Calais), FR",
|
||||
"durationMin": 35,
|
||||
"priceEur": 180,
|
||||
"frequency": "every 30 min, 24/7",
|
||||
"pros": ["Fastest", "Drive on/off, no walking"],
|
||||
"cons": ["Most expensive"],
|
||||
"badge": "Fastest" | "Cheapest" | "Most scenic" | "Overnight" | "Frequent" | null,
|
||||
"detourMin": 0,
|
||||
"detourKm": 0,
|
||||
"isCurrent": true,
|
||||
"bookingUrl": "https://www.eurotunnel.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -257,7 +282,8 @@ Respond with **only** a single valid JSON object in exactly this format. No text
|
||||
"superchargers": 3,
|
||||
"hotels": 1,
|
||||
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
|
||||
}
|
||||
},
|
||||
"needsTravelDates": true
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
@@ -310,6 +336,11 @@ Strict route planning rules:
|
||||
- "message" should feel like a helpful human assistant.
|
||||
- If no clear trip is requested yet, set "itinerary" to null.
|
||||
|
||||
Travel dates & pricing:
|
||||
- If the user has NOT provided travel dates, set "needsTravelDates": true on the itinerary object. In "message", briefly mention that adding dates will sharpen the crossing/ferry and hotel pricing. Use moderate off-peak prices in the meantime.
|
||||
- If dates ARE provided, set "needsTravelDates": false and lean prices to the right tier (weekend, school holidays, peak summer, etc.) and mention that in "message" where relevant.
|
||||
- Hotel prices in "description" can include a rough nightly rate when known ("Premier Inn from £75/night on those dates"). Don't fabricate exact prices for specific rooms.
|
||||
|
||||
Route variants (REQUIRED):
|
||||
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
|
||||
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
|
||||
@@ -333,6 +364,20 @@ Charger options (REQUIRED for every Supercharger and destination-charger stop):
|
||||
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
|
||||
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
|
||||
|
||||
Sea crossings (REQUIRED for every UK ↔ mainland Europe trip, and any other route that crosses water):
|
||||
- When the route includes a Channel crossing or any other sea/tunnel crossing, insert a dedicated stop with type "crossing" (use "tunnel" for Eurotunnel, "ferry" for ferries) at the appropriate point in the day's stops.
|
||||
- That crossing stop MUST populate "crossingOptions" with 3-5 genuinely different real-world options the user could pick from. The currently-chosen one is duplicated as the first entry with isCurrent: true.
|
||||
- For UK ↔ France: at minimum include Eurotunnel Le Shuttle (Folkestone→Coquelles), DFDS Dover→Calais, P&O Dover→Calais (or Dover→Dunkirk), and at least one longer/scenic option (DFDS Newhaven→Dieppe, Brittany Ferries Portsmouth→Caen/Le Havre/Cherbourg, or Brittany Ferries Plymouth→Roscoff) when they make geographic sense for the route.
|
||||
- For UK ↔ Netherlands/Belgium: include P&O Hull→Rotterdam, Stena Line Harwich→Hook of Holland, DFDS Newcastle→Amsterdam where appropriate.
|
||||
- For UK ↔ Ireland: include Irish Ferries / Stena Line Holyhead→Dublin, Liverpool→Belfast/Dublin, Fishguard→Rosslare etc.
|
||||
- "priceEur" should reflect a realistic ballpark for a Tesla with the given travellers. If travel dates are provided, lean toward the right pricing tier (weekday off-peak vs weekend peak vs school holidays). If no dates yet, use moderate off-peak pricing.
|
||||
- "durationMin" is the crossing time itself (35 min for Eurotunnel, ~90 min for Dover-Calais ferry, ~14 h for an overnight Hull-Rotterdam, etc.).
|
||||
- "frequency" is a one-line text description of departure cadence (e.g. "every 30 min, 24/7", "8 sailings/day", "1 overnight sailing/day").
|
||||
- "badge" picks the trade-off: "Fastest" for Eurotunnel, "Cheapest" for the cheapest sensible ferry, "Most scenic" for routes that swap a few hours of UK driving for a longer but prettier crossing (e.g. Portsmouth→Caen), "Overnight" for sleeper ferries (frees up a hotel night), "Frequent" for high-cadence operators.
|
||||
- "detourMin" and "detourKm" express the change in TOTAL trip distance + drive time vs the current chosen crossing (positive = adds, negative = saves). E.g. Portsmouth→Caen vs Dover→Calais might save 200 km of French driving but add 150 km of UK driving and a 6 h crossing.
|
||||
- Always set a sensible "bookingUrl" (operator's main booking page).
|
||||
- If the user already picked a crossing, keep its choice as isCurrent: true and adjust the rest of the itinerary's routing accordingly.
|
||||
|
||||
Alternatives (REQUIRED for every Supercharger and hotel stop):
|
||||
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
|
||||
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
|
||||
@@ -363,7 +408,7 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
|
||||
Respond with ONLY the JSON object.`;
|
||||
}
|
||||
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
|
||||
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
|
||||
|
||||
@@ -440,7 +485,7 @@ Respond with ONLY the JSON object.`;
|
||||
* Streaming chat — yields incremental partial itineraries as Grok produces output.
|
||||
* Falls back to non-streaming if local CLI is unavailable.
|
||||
*/
|
||||
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator<StreamEvent> {
|
||||
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): AsyncGenerator<StreamEvent> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
|
||||
|
||||
@@ -629,7 +674,7 @@ Respond with ONLY the JSON object.`;
|
||||
}
|
||||
}
|
||||
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
|
||||
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
|
||||
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
|
||||
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user