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:
2026-05-31 21:38:27 +01:00
parent 88fc86dc32
commit cff52b4b9e
11 changed files with 885 additions and 24 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.github
.gitea
node_modules
**/node_modules
dist
client/dist
playwright-report
test-results
ui-preview
.vscode
.idea
*.log
.env
.env.*
!.env.example
+44
View File
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.6
# Multi-stage build: install + build the client and server, then run a slim
# node image that serves both from one process on $PORT (default 3000).
# ─── Stage 1: deps + build ──────────────────────────────────────────────────
FROM node:22-bookworm-slim AS builder
WORKDIR /app
# Root deps
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
# Client deps
COPY client/package.json client/package-lock.json* ./client/
RUN --mount=type=cache,target=/root/.npm npm --prefix client ci --no-audit --no-fund
# Source
COPY tsconfig.json ./
COPY server ./server
COPY client ./client
# Build client (vite → client/dist) and server (tsc → dist/server)
RUN npm run build
# ─── Stage 2: runtime ───────────────────────────────────────────────────────
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
# Only ship the prod deps + built artefacts
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/client/dist ./client/dist
EXPOSE 3000
CMD ["node", "dist/server/index.js"]
+45
View File
@@ -0,0 +1,45 @@
// Detect Tesla in-car browser + general touch-only large-screen mode.
// Tesla MCU2 reports a "QtCarBrowser" UA fragment; MCU3 reports a more standard
// Chromium UA but with "Tesla" in some firmware. We also honour ?incar=1 for
// testing on a regular browser, and fall back to a touch + landscape-tablet
// heuristic so the optimisations apply to anything that looks like a car dash.
export interface InCarInfo {
/** True if we're inside (or simulating) a Tesla in-car browser. */
isTesla: boolean;
/** True if we should apply the heavyweight "car dash" UX (big text, no blur, tap-friendly). */
isInCar: boolean;
/** Approximate MCU generation hint when detectable. */
mcu: 'mcu2' | 'mcu3' | 'unknown';
}
export function detectInCar(): InCarInfo {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { isTesla: false, isInCar: false, mcu: 'unknown' };
}
const ua = navigator.userAgent || '';
const isQt = /QtCarBrowser/i.test(ua);
const isTeslaUa = /Tesla/i.test(ua);
const forced = new URLSearchParams(window.location.search).get('incar') === '1';
const isTesla = isQt || isTeslaUa || forced;
// Heuristic touch-only tablet/dash mode: coarse pointer, ≥1200px width, no fine pointer.
const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
const wide = window.innerWidth >= 1200;
const heuristicInCar = coarse && wide;
return {
isTesla,
isInCar: isTesla || forced || heuristicInCar,
mcu: isQt ? 'mcu2' : isTeslaUa ? 'mcu3' : 'unknown',
};
}
/** Apply or remove the body class side effects (idempotent). */
export function applyInCarClass(info: InCarInfo) {
const root = document.documentElement;
root.classList.toggle('incar', info.isInCar);
root.classList.toggle('tesla', info.isTesla);
root.classList.toggle('tesla-mcu2', info.mcu === 'mcu2');
root.classList.toggle('tesla-mcu3', info.mcu === 'mcu3');
}
+3
View File
@@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import App from './App'; import App from './App';
import './styles/globals.css'; import './styles/globals.css';
import { detectInCar, applyInCarClass } from './lib/incar';
applyInCarClass(detectInCar());
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+534 -14
View File
@@ -7,6 +7,7 @@ import {
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2, Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
} from 'lucide-react'; } from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops) // Fix Leaflet default icons (we still need pins for non-active stops)
@@ -18,7 +19,7 @@ L.Icon.Default.mergeOptions({
}); });
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel'; type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel' | 'ferry' | 'crossing';
interface NearbyPlace { interface NearbyPlace {
category: 'food' | 'do' | 'see' | 'shop' | 'rest'; category: 'food' | 'do' | 'see' | 'shop' | 'rest';
@@ -39,6 +40,24 @@ interface ChargerOption {
badge?: string | null; badge?: string | null;
} }
interface CrossingOption {
id: string;
operator: string; // e.g. "Eurotunnel Le Shuttle", "DFDS", "P&O Ferries", "Brittany Ferries"
mode: 'tunnel' | 'ferry';
fromPort: string; // e.g. "Folkestone, UK"
toPort: string; // e.g. "Coquelles (Calais), FR"
durationMin: number;
priceEur: number; // ballpark single-vehicle one-way price
frequency?: string; // e.g. "every 30 min, 24/7"
pros?: string[];
cons?: string[];
badge?: 'Fastest' | 'Cheapest' | 'Most scenic' | 'Overnight' | 'Frequent' | null;
detourMin?: number; // vs the chosen crossing (in road-driving terms)
detourKm?: number;
isCurrent?: boolean;
bookingUrl?: string;
}
interface RouteVariant { interface RouteVariant {
id: 'fast' | 'scenic' | 'cheap'; id: 'fast' | 'scenic' | 'cheap';
label: string; label: string;
@@ -89,6 +108,13 @@ interface Stop {
alternatives?: AlternativeStop[]; alternatives?: AlternativeStop[];
nearby?: NearbyPlace[]; nearby?: NearbyPlace[];
chargerOptions?: ChargerOption[]; chargerOptions?: ChargerOption[];
crossingOptions?: CrossingOption[];
}
interface TravelDates {
outbound: string | null; // ISO yyyy-mm-dd
return: string | null; // ISO yyyy-mm-dd (null if one-way)
travellers?: number;
} }
interface Itinerary { interface Itinerary {
@@ -101,6 +127,7 @@ interface Itinerary {
hotels: number; hotels: number;
highlights?: string[]; highlights?: string[];
}; };
needsTravelDates?: boolean;
} }
interface Leg { interface Leg {
@@ -116,7 +143,7 @@ const EMPTY_ITINERARY: Itinerary = {
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] }, summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] },
}; };
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel']; const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel', 'ferry', 'crossing'];
interface VehicleTrim { interface VehicleTrim {
id: string; id: string;
@@ -223,6 +250,8 @@ function stopMeta(type: StopType): { icon: React.ComponentType<{ className?: str
case 'origin': return { icon: HomeIcon, color: '#9ca3af' }; case 'origin': return { icon: HomeIcon, color: '#9ca3af' };
case 'destination': return { icon: Flag, color: 'var(--gd-red)' }; case 'destination': return { icon: Flag, color: 'var(--gd-red)' };
case 'tunnel': return { icon: Route, color: '#a78bfa' }; case 'tunnel': return { icon: Route, color: '#a78bfa' };
case 'ferry':
case 'crossing': return { icon: Ship, color: '#60a5fa' };
case 'supercharger': case 'supercharger':
case 'destination-charger': return { icon: Zap, color: '#4ade80' }; case 'destination-charger': return { icon: Zap, color: '#4ade80' };
case 'hotel': return { icon: Bed, color: '#60a5fa' }; case 'hotel': return { icon: Bed, color: '#60a5fa' };
@@ -256,6 +285,41 @@ async function geocodeLocation(query: string): Promise<{ lat: number; lng: numbe
return null; return null;
} }
// Reverse geocode lat/lng → human-readable place name (Nominatim).
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=14`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (!data) return null;
const a = data.address || {};
// Build something like "Milton Keynes, MK7 8PJ" — town + postcode if available.
const town = a.city || a.town || a.village || a.hamlet || a.suburb || a.county || '';
const postcode = a.postcode || '';
const country = a.country_code ? a.country_code.toUpperCase() : '';
const parts = [town, postcode, country].filter(Boolean);
return parts.join(', ') || data.display_name || null;
} catch {
console.warn('[TeslaTrip] Reverse geocoding failed', lat, lng);
return null;
}
}
// Browser geolocation wrapped in a promise with a short timeout — Tesla's
// in-car browser sometimes hangs forever waiting for a fix.
async function getBrowserLocation(timeoutMs = 8000): Promise<GeolocationCoordinates | null> {
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
return new Promise<GeolocationCoordinates | null>((resolve) => {
let settled = false;
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(null); } }, timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => { if (!settled) { settled = true; clearTimeout(timer); resolve(pos.coords); } },
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(null); } },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 30_000 },
);
});
}
// ─── Itinerary normalization (preserves all enrichment fields) ─────────────── // ─── Itinerary normalization (preserves all enrichment fields) ───────────────
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> { async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY; if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
@@ -330,6 +394,28 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
})) }))
: []; : [];
const cleanCrossings: CrossingOption[] = Array.isArray(s.crossingOptions)
? s.crossingOptions
.filter((c: any) => c && typeof c.operator === 'string')
.map((c: any): CrossingOption => ({
id: c.id || `crossing-${Date.now()}-${Math.random()}`,
operator: c.operator,
mode: c.mode === 'tunnel' ? 'tunnel' : 'ferry',
fromPort: typeof c.fromPort === 'string' ? c.fromPort : '',
toPort: typeof c.toPort === 'string' ? c.toPort : '',
durationMin: typeof c.durationMin === 'number' ? c.durationMin : 0,
priceEur: typeof c.priceEur === 'number' ? c.priceEur : 0,
frequency: typeof c.frequency === 'string' ? c.frequency : undefined,
pros: Array.isArray(c.pros) ? c.pros.filter((p: unknown) => typeof p === 'string') : undefined,
cons: Array.isArray(c.cons) ? c.cons.filter((p: unknown) => typeof p === 'string') : undefined,
badge: typeof c.badge === 'string' ? c.badge as CrossingOption['badge'] : null,
detourMin: typeof c.detourMin === 'number' ? c.detourMin : undefined,
detourKm: typeof c.detourKm === 'number' ? c.detourKm : undefined,
isCurrent: c.isCurrent === true,
bookingUrl: typeof c.bookingUrl === 'string' ? c.bookingUrl : undefined,
}))
: [];
const shared = { const shared = {
estArrivalBattery: s.estArrivalBattery, estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes, chargeMinutes: s.chargeMinutes,
@@ -343,6 +429,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined, alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
nearby: cleanNearby.length > 0 ? cleanNearby : undefined, nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined, chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
crossingOptions: cleanCrossings.length > 0 ? cleanCrossings : undefined,
}; };
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom'; const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
@@ -380,6 +467,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string') ? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [], : [],
}, },
needsTravelDates: raw.needsTravelDates === true,
}; };
} }
@@ -416,6 +504,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
alternatives: undefined, // skip during partial — final event populates alternatives: undefined, // skip during partial — final event populates
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined, nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined, chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
crossingOptions: Array.isArray(s.crossingOptions) ? s.crossingOptions : undefined,
}); });
} }
if (validStops.length > 0) { if (validStops.length > 0) {
@@ -440,6 +529,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string') ? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [], : [],
}, },
needsTravelDates: raw.needsTravelDates === true,
}; };
} }
@@ -517,6 +607,28 @@ function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | nu
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`; return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
} }
function formatDateShort(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
function formatDateRange(outbound: string | null | undefined, ret: string | null | undefined): string {
if (!outbound) return 'Add dates';
if (!ret) return `${formatDateShort(outbound)} · one-way`;
return `${formatDateShort(outbound)}${formatDateShort(ret)}`;
}
function nightsBetween(outbound: string | null | undefined, ret: string | null | undefined): number | null {
if (!outbound || !ret) return null;
const a = new Date(outbound);
const b = new Date(ret);
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
const diff = Math.round((b.getTime() - a.getTime()) / 86400000);
return diff > 0 ? diff : null;
}
// ─── Error Boundary ────────────────────────────────────────────────────────── // ─── Error Boundary ──────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
constructor(props: any) { super(props); this.state = { hasError: false }; } constructor(props: any) { super(props); this.state = { hasError: false }; }
@@ -870,13 +982,15 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic
); );
} }
function StopExpansion({ stop, onSwap, onRemove, onCustomise }: { function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }: {
stop: Stop; stop: Stop;
onSwap: (alt: AlternativeStop) => void; onSwap: (alt: AlternativeStop) => void;
onRemove: () => void; onRemove: () => void;
onCustomise: () => void; onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
}) { }) {
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry';
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null; const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null; const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null;
const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null; const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null;
@@ -884,6 +998,7 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
const amenities = (stop.amenities || []).slice(0, 8); const amenities = (stop.amenities || []).slice(0, 8);
const alts = stop.alternatives || []; const alts = stop.alternatives || [];
const chargers = stop.chargerOptions || []; const chargers = stop.chargerOptions || [];
const crossings = stop.crossingOptions || [];
const nearby = stop.nearby || []; const nearby = stop.nearby || [];
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all'); const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
@@ -914,6 +1029,16 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
</div> </div>
)} )}
{isCrossing && crossings.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Crossing</SectionLabel>
<CrossingSwapBlock
options={crossings}
onPick={(c) => onPickCrossing?.(c)}
/>
</div>
)}
{nearby.length > 0 && ( {nearby.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -1064,7 +1189,8 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
// ─── Stop card (icon-led) ──────────────────────────────────────────────────── // ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({ function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop, onDragEnd, onDragStart, onDragOver, onDrop, onDragEnd,
}: { }: {
stop: Stop; stop: Stop;
@@ -1076,6 +1202,11 @@ function StopCard({
onSwap: (alt: AlternativeStop) => void; onSwap: (alt: AlternativeStop) => void;
onRemove: () => void; onRemove: () => void;
onCustomise: () => void; onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
onDragStart: (e: React.DragEvent) => void; onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void;
@@ -1085,7 +1216,8 @@ function StopCard({
const Icon = meta.icon; const Icon = meta.icon;
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isSleep = stop.type === 'hotel'; const isSleep = stop.type === 'hotel';
const isTunnel = stop.type === 'tunnel'; const isCrossingStop = stop.type === 'tunnel' || stop.type === 'ferry' || stop.type === 'crossing';
const currentCrossing = isCrossingStop ? (stop.crossingOptions?.find(o => o.isCurrent) || stop.crossingOptions?.[0]) : undefined;
return ( return (
<div <div
@@ -1106,11 +1238,33 @@ function StopCard({
}} }}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div className="flex flex-col items-center gap-1 flex-shrink-0">
className="w-10 h-10 rounded-[10px] grid place-items-center flex-shrink-0" <div
style={{ background: `${meta.color}22` }} className="w-10 h-10 rounded-[10px] grid place-items-center"
> style={{ background: `${meta.color}22` }}
<Icon size={18} className="" /> >
<Icon size={18} className="" />
</div>
<div className="flex flex-col gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onMoveUp(); }}
disabled={!canMoveUp}
title="Move up"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onMoveDown(); }}
disabled={!canMoveDown}
title="Move down"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
</div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1129,8 +1283,10 @@ function StopCard({
{isSleep && ( {isSleep && (
<div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div> <div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div>
)} )}
{isTunnel && ( {isCrossingStop && currentCrossing && (
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>£89 · 35m</div> <div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>
{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)}
</div>
)} )}
</div> </div>
{stop.combo && ( {stop.combo && (
@@ -1142,7 +1298,7 @@ function StopCard({
</div> </div>
)} )}
<div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}> <div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}>
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : 'Stop')} {stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : stop.type === 'tunnel' ? 'Eurotunnel · drive on/off' : stop.type === 'ferry' ? 'Ferry crossing' : stop.type === 'crossing' ? 'Sea crossing' : 'Stop')}
</div> </div>
{!active && stop.description && ( {!active && stop.description && (
@@ -1151,7 +1307,7 @@ function StopCard({
</div> </div>
)} )}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} />} {active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} />}
</div> </div>
</div> </div>
</div> </div>
@@ -1200,6 +1356,7 @@ function TopBar({
origin, destination, onOriginChange, onDestinationChange, onODCommit, origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
travelDates, onOpenDates, onUseMyLocation,
}: { }: {
origin: string; destination: string; origin: string; destination: string;
onOriginChange: (v: string) => void; onOriginChange: (v: string) => void;
@@ -1211,7 +1368,19 @@ function TopBar({
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void; vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
grokStatus: { label?: string }; grokStatus: { label?: string };
onOpenGpx: () => void; onOpenGpx: () => void;
travelDates: TravelDates;
onOpenDates: (rect: DOMRect) => void;
onUseMyLocation: () => void;
}) { }) {
const [locating, setLocating] = React.useState(false);
const handleLocate = async () => {
setLocating(true);
try { await onUseMyLocation(); } finally { setLocating(false); }
};
const datesLabel = travelDates.outbound
? formatDateRange(travelDates.outbound, travelDates.return)
: 'Add dates';
const datesEmpty = !travelDates.outbound;
return ( return (
<div <div
className="flex items-center gap-4 px-5 flex-shrink-0" className="flex items-center gap-4 px-5 flex-shrink-0"
@@ -1249,6 +1418,20 @@ function TopBar({
className="bg-transparent border-none outline-none text-[13px] w-full" className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }} style={{ color: 'var(--gd-text)' }}
/> />
<button
onClick={handleLocate}
disabled={locating}
title="Use my current location"
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
style={{
width: 26, height: 26,
background: 'var(--gd-panel-2)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text-2)',
}}
>
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div> </div>
<div className="px-3.5 flex items-center gap-2 h-full flex-1"> <div className="px-3.5 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} /> <div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
@@ -1326,6 +1509,20 @@ function TopBar({
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} /> <ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
</button> </button>
<button
onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{
background: datesEmpty ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${datesEmpty ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text)',
}}
>
<CalendarDays className="w-3.5 h-3.5" />
<div className="text-[12px] font-medium">{datesLabel}</div>
<ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} />
</button>
<ChipButton onClick={() => onOpenGpx()}> <ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} /> <Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Export Export
@@ -1362,6 +1559,9 @@ export default function TeslaTripPlanner() {
const [hoverStopId, setHoverStopId] = useState<string | null>(null); const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState(''); const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState(''); const [destination, setDestination] = useState('');
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
const [variants, setVariants] = useState<RouteVariant[]>([]); const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast'); const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
@@ -1436,6 +1636,21 @@ export default function TeslaTripPlanner() {
lastPrefetchKey.current = prefetchKey; lastPrefetchKey.current = prefetchKey;
}, [prefetchKey, selectedVariant]); }, [prefetchKey, selectedVariant]);
// Drop variant cache + prefetch ledger when travel dates change — pricing is stale.
const lastDatesKey = React.useRef<string>('');
React.useEffect(() => {
const key = `${travelDates.outbound || ''}|${travelDates.return || ''}|${travelDates.travellers || ''}`;
if (lastDatesKey.current && lastDatesKey.current !== key) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
prefetchedRef.current.clear();
}
lastDatesKey.current = key;
}, [travelDates.outbound, travelDates.return, travelDates.travellers, selectedVariant]);
React.useEffect(() => { React.useEffect(() => {
if (!prefetchKey) return; if (!prefetchKey) return;
if (thinking || variantSwitching) return; if (thinking || variantSwitching) return;
@@ -1506,6 +1721,8 @@ export default function TeslaTripPlanner() {
selectedVariant: variantToUse, selectedVariant: variantToUse,
origin: origin.trim() || undefined, origin: origin.trim() || undefined,
destination: destination.trim() || undefined, destination: destination.trim() || undefined,
travelDates: (travelDates.outbound || travelDates.return || travelDates.travellers)
? travelDates : undefined,
}), }),
}); });
if (!response.ok || !response.body) throw new Error('Failed to get streaming response'); if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
@@ -1650,6 +1867,16 @@ export default function TeslaTripPlanner() {
toast.success(`Added ${place.name} to your trip`); toast.success(`Added ${place.name} to your trip`);
}; };
const moveStop = (stopId: string, direction: -1 | 1) => {
// Move within the flat list across day boundaries — nudges by one slot.
const flat = allStops;
const idx = flat.findIndex(s => s.id === stopId);
if (idx < 0) return;
const targetIdx = idx + direction;
if (targetIdx < 0 || targetIdx >= flat.length) return;
reorderStops(stopId, flat[targetIdx].id);
};
const reorderStops = (dragId: string, targetId: string) => { const reorderStops = (dragId: string, targetId: string) => {
if (dragId === targetId) return; if (dragId === targetId) return;
const next = structuredClone(itinerary); const next = structuredClone(itinerary);
@@ -1681,6 +1908,27 @@ export default function TeslaTripPlanner() {
toast.info('Stop reordered'); toast.info('Stop reordered');
}; };
const useMyLocation = async () => {
const t = toast.loading('Locating your car…');
const coords = await getBrowserLocation();
if (!coords) {
toast.error('Could not get your location', {
id: t,
description: "Tesla's browser may not expose GPS — type your postcode instead.",
});
return;
}
const name = await reverseGeocode(coords.latitude, coords.longitude);
if (!name) {
const fallback = `${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}`;
setOrigin(fallback);
toast.success('Got your location', { id: t, description: fallback });
return;
}
setOrigin(name);
toast.success('Origin set to your location', { id: t, description: name });
};
const handleODCommit = () => { const handleODCommit = () => {
const from = origin.trim(); const from = origin.trim();
const to = destination.trim(); const to = destination.trim();
@@ -1761,6 +2009,26 @@ export default function TeslaTripPlanner() {
toast.success(`Swapped to ${alt.name}`); toast.success(`Swapped to ${alt.name}`);
}; };
const pickCrossing = (stopId: string, crossing: CrossingOption) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx === -1) continue;
const stop = d.stops[idx];
if (!stop.crossingOptions) continue;
stop.crossingOptions = stop.crossingOptions.map(o => ({ ...o, isCurrent: o.id === crossing.id }));
stop.name = `${crossing.operator} · ${crossing.fromPort}${crossing.toPort}`;
stop.durationMin = crossing.durationMin;
stop.type = crossing.mode === 'tunnel' ? 'tunnel' : 'ferry';
}
setItinerary(next);
toast.success(`Switched crossing to ${crossing.operator}`, {
description: `${crossing.fromPort}${crossing.toPort} · ${formatDuration(crossing.durationMin)}`,
});
// Ask Grok to re-plan the road portions either side of the new crossing
sendMessage(`I picked the ${crossing.operator} crossing (${crossing.fromPort}${crossing.toPort}). Re-plan the stops on either side to match.`, { silent: true });
};
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null; const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
const dateLabels = ['Today', 'Tomorrow']; const dateLabels = ['Today', 'Tomorrow'];
@@ -1778,6 +2046,9 @@ export default function TeslaTripPlanner() {
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }} onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
grokStatus={grokStatus} grokStatus={grokStatus}
onOpenGpx={() => setModal({ kind: 'gpx' })} onOpenGpx={() => setModal({ kind: 'gpx' })}
travelDates={travelDates}
onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }}
onUseMyLocation={useMyLocation}
/> />
{variants.length > 0 && ( {variants.length > 0 && (
@@ -2001,6 +2272,24 @@ export default function TeslaTripPlanner() {
</button> </button>
</div> </div>
{/* Travel-dates nudge */}
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
<button
onClick={(e) => { setDateAnchor(e.currentTarget.getBoundingClientRect()); setDatePickerOpen(true); }}
className="mx-4 mt-3 px-3 py-2 rounded-lg text-left flex items-center gap-2.5 transition hover:bg-white/[0.02]"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--gd-red)' }} />
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-medium" style={{ color: 'var(--gd-red)' }}>Add travel dates for accurate pricing</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
Ferry, Eurotunnel and hotel prices vary by date — currently showing off-peak ballpark.
</div>
</div>
<ChevronDown className="w-3 h-3 rotate-[-90deg]" style={{ color: 'var(--gd-red)' }} />
</button>
)}
{/* Stops list */} {/* Stops list */}
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
{itinerary.days.length === 0 ? ( {itinerary.days.length === 0 ? (
@@ -2060,6 +2349,11 @@ export default function TeslaTripPlanner() {
onSwap={(alt) => swapStop(stop.id, alt)} onSwap={(alt) => swapStop(stop.id, alt)}
onRemove={() => removeStop(stop.id)} onRemove={() => removeStop(stop.id)}
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })} onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
onPickCrossing={(c) => pickCrossing(stop.id, c)}
onMoveUp={() => moveStop(stop.id, -1)}
onMoveDown={() => moveStop(stop.id, 1)}
canMoveUp={allStops[0]?.id !== stop.id}
canMoveDown={allStops[allStops.length - 1]?.id !== stop.id}
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id); e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -2173,6 +2467,20 @@ export default function TeslaTripPlanner() {
onSelect={setVehicle} onSelect={setVehicle}
onClose={() => setVehiclePanelOpen(false)} onClose={() => setVehiclePanelOpen(false)}
/> />
<TravelDatesPanel
open={datePickerOpen}
anchorRect={dateAnchor}
value={travelDates}
onChange={setTravelDates}
onClose={() => setDatePickerOpen(false)}
onApply={() => {
setDatePickerOpen(false);
if (allStops.length > 0) {
sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true });
}
}}
/>
</ErrorBoundary> </ErrorBoundary>
); );
} }
@@ -2931,6 +3239,218 @@ function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose:
); );
} }
// ─── Travel dates panel ──────────────────────────────────────────────────────
function TravelDatesPanel({
open, anchorRect, value, onChange, onClose, onApply,
}: {
open: boolean;
anchorRect: DOMRect | null;
value: TravelDates;
onChange: (v: TravelDates) => void;
onClose: () => void;
onApply: () => void;
}) {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!open || !anchorRect) return null;
const panelWidth = 380;
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
const top = anchorRect.bottom + 8;
const nights = nightsBetween(value.outbound, value.return);
const today = new Date().toISOString().slice(0, 10);
return (
<>
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div
className="fixed overflow-hidden"
style={{
zIndex: 9999,
left, top, width: panelWidth,
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div>
<div className="text-[13px] font-medium">Travel dates</div>
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>
Sharpens crossing, ferry and hotel pricing
</div>
</div>
{nights != null && (
<div className="text-[11px] num px-2 py-0.5 rounded-full" style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}>
{nights} night{nights === 1 ? '' : 's'}
</div>
)}
</div>
<div className="px-4 py-3 space-y-3">
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5" style={{ color: 'var(--gd-text-3)' }}>Outbound</div>
<input
type="date"
min={today}
value={value.outbound || ''}
onChange={(e) => onChange({ ...value, outbound: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Return</span>
{value.return && (
<button className="text-[10.5px] hover:underline" onClick={() => onChange({ ...value, return: null })}>
Make one-way
</button>
)}
</div>
<input
type="date"
min={value.outbound || today}
value={value.return || ''}
onChange={(e) => onChange({ ...value, return: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Travellers</span>
<Users className="w-3 h-3" />
</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map(n => {
const selected = (value.travellers ?? 2) === n;
return (
<button
key={n}
onClick={() => onChange({ ...value, travellers: n })}
className="flex-1 h-9 rounded-lg text-[12.5px] num transition"
style={{
background: selected ? 'var(--gd-red)' : 'var(--gd-panel)',
border: `1px solid ${selected ? 'var(--gd-red)' : 'var(--gd-border)'}`,
color: selected ? '#fff' : 'var(--gd-text-2)',
}}
>
{n}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={() => { onChange({ outbound: null, return: null, travellers: value.travellers }); }}
className="flex-1 h-9 text-[12px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Clear
</button>
<button
onClick={onApply}
disabled={!value.outbound}
className="flex-[2] h-9 text-[12px] rounded-lg disabled:opacity-40"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{value.outbound ? 'Apply & re-quote' : 'Pick an outbound date'}
</button>
</div>
</div>
</>
);
}
// ─── Crossing swap block ─────────────────────────────────────────────────────
function CrossingSwapBlock({ options, onPick }: { options: CrossingOption[]; onPick: (c: CrossingOption) => void }) {
if (options.length === 0) return null;
const current = options.find(o => o.isCurrent) || options[0];
return (
<div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<Ship className="w-3.5 h-3.5" style={{ color: 'var(--gd-blue)' }} />
<div className="text-[10.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'}
</div>
</div>
<div className="divide-y" style={{ borderColor: 'var(--gd-border)' }}>
{options.map(opt => {
const isPicked = opt.id === current.id;
const deltaMin = formatDelta(opt.detourMin, 'min');
const deltaKm = formatDelta(opt.detourKm, 'km');
return (
<button
key={opt.id}
onClick={() => !isPicked && onPick(opt)}
disabled={isPicked}
className="w-full text-left px-3 py-2.5 transition hover:bg-white/[0.03] disabled:cursor-default"
style={{
background: isPicked ? 'var(--gd-blue-soft, rgba(80,140,255,0.08))' : 'transparent',
borderTop: 'none',
borderBottom: '1px solid var(--gd-border)',
}}
>
<div className="flex items-center gap-2 mb-1">
<div className="text-[12.5px] font-medium flex-1" style={{ color: 'var(--gd-text)' }}>
{opt.operator}
</div>
{opt.badge && (
<span
className="text-[9.5px] font-semibold tracking-wide uppercase px-1.5 py-0.5 rounded"
style={{
background: isPicked ? 'var(--gd-blue, #3b82f6)' : 'var(--gd-panel-2)',
color: isPicked ? '#fff' : 'var(--gd-text-2)',
}}
>
{opt.badge}
</span>
)}
{isPicked && (
<span className="text-[9.5px] font-semibold uppercase px-1.5 py-0.5 rounded" style={{ background: 'var(--gd-blue, #3b82f6)', color: '#fff' }}>
Current
</span>
)}
</div>
<div className="text-[11px] mb-1.5" style={{ color: 'var(--gd-text-2)' }}>
{opt.fromPort} → {opt.toPort}
</div>
<div className="flex items-center gap-3 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
<span><Clock className="w-3 h-3 inline mr-0.5" />{formatDuration(opt.durationMin)}</span>
<span><Euro className="w-3 h-3 inline mr-0.5" />€{Math.round(opt.priceEur)}</span>
{opt.frequency && <span className="truncate">· {opt.frequency}</span>}
</div>
{(deltaMin || deltaKm) && !isPicked && (
<div className="mt-1 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
Trip impact: {deltaMin || '±0 min'} · {deltaKm || '±0 km'}
</div>
)}
{opt.bookingUrl && (
<a
href={opt.bookingUrl} target="_blank" rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="mt-1 inline-flex items-center gap-1 text-[10.5px] hover:underline"
style={{ color: 'var(--gd-blue, #5b8bff)' }}
>
Book <ExternalLink className="w-2.5 h-2.5" />
</a>
)}
</button>
);
})}
</div>
</div>
);
}
// ─── Vehicle selector panel ────────────────────────────────────────────────── // ─── Vehicle selector panel ──────────────────────────────────────────────────
function VehicleSelectorPanel({ function VehicleSelectorPanel({
open, anchorRect, selected, onSelect, onClose, open, anchorRect, selected, onSelect, onClose,
+69
View File
@@ -79,3 +79,72 @@ html, body, #root {
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
} }
/* ─── In-car browser overrides ─────────────────────────────────────────────
* Triggered when <html class="incar"> is set by client/src/lib/incar.ts.
* Goals: bigger fonts, larger tap targets, no expensive blurs (MCU1/MCU2
* fall off a cliff with backdrop-filter), no accidental hover-only states.
* Scoped to .incar so it never affects desktop builds.
*/
html.incar {
font-size: 17px; /* baseline bump — most leaf text uses px values
so this primarily affects rem-based things */
}
html.incar body {
/* Slightly thicker base text colour for readability at arm's length */
color: var(--gd-text);
}
/* Kill backdrop-filter entirely — it murders frame rate on MCU2 */
html.incar *,
html.incar *::before,
html.incar *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Bump every interactive control to 44px minimum tap target. */
html.incar button,
html.incar [role="button"],
html.incar input[type="date"],
html.incar input[type="text"],
html.incar input[type="number"] {
min-height: 44px;
font-size: 15px;
}
/* Scale tiny utility text up — Tailwind ships these as fixed px values so
* we override them globally inside .incar. */
html.incar .text-\[10px\],
html.incar .text-\[10\.5px\],
html.incar .text-\[11px\] { font-size: 13px !important; }
html.incar .text-\[11\.5px\],
html.incar .text-\[12px\] { font-size: 14px !important; }
html.incar .text-\[12\.5px\],
html.incar .text-\[13px\],
html.incar .text-\[13\.5px\] { font-size: 15px !important; }
html.incar .text-\[14px\] { font-size: 16px !important; }
html.incar .text-\[15px\] { font-size: 17px !important; }
html.incar .text-\[16px\] { font-size: 18px !important; }
html.incar .text-\[18px\] { font-size: 20px !important; }
html.incar .text-\[20px\] { font-size: 22px !important; }
/* Native date input is a tiny target on touch — fatten it. */
html.incar input[type="date"] {
padding: 12px 14px;
font-size: 16px;
}
/* Bigger scrollbars — finger-friendly */
html.incar ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* Hover states cause flicker on touch — disable on the incar build. */
html.incar .hover\:bg-white\/\[0\.04\]:hover,
html.incar .hover\:bg-white\/\[0\.03\]:hover,
html.incar .hover\:bg-white\/\[0\.025\]:hover,
html.incar .hover\:bg-white\/\[0\.02\]:hover {
background: transparent !important;
}
+15
View File
@@ -24,4 +24,19 @@ export const env = {
xaiApiKey: process.env.XAI_API_KEY || '', xaiApiKey: process.env.XAI_API_KEY || '',
grokEnabled: process.env.GROK_ENABLED !== 'false', grokEnabled: process.env.GROK_ENABLED !== 'false',
forceXaiApi: process.env.FORCE_XAI_API === 'true', 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; } as const;
+26
View File
@@ -3,10 +3,14 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import cookieParser from 'cookie-parser'; 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 { env } from './config/env.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import chatRoutes from './routes/chat.js'; import chatRoutes from './routes/chat.js';
import tripsRoutes from './routes/trips.js'; import tripsRoutes from './routes/trips.js';
import teslaRoutes from './routes/tesla.js';
import { createOptionalAuth } from './lib/auth.js'; import { createOptionalAuth } from './lib/auth.js';
const app = express(); const app = express();
@@ -34,9 +38,31 @@ if (auth) {
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts'); 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', chatRoutes);
app.use('/api/trips', tripsRoutes); 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) => { app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error({ err }, 'Unhandled error'); logger.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
+10 -4
View File
@@ -15,6 +15,11 @@ const ChatRequestSchema = z.object({
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(), selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
origin: z.string().optional(), origin: z.string().optional(),
destination: 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) => { router.post('/chat', async (req, res) => {
@@ -30,7 +35,7 @@ router.post('/chat', async (req, res) => {
return res.status(400).json({ error: 'Invalid request' }); 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({ log.info({
requestId, requestId,
@@ -41,6 +46,7 @@ router.post('/chat', async (req, res) => {
selectedVariant, selectedVariant,
origin, origin,
destination, destination,
travelDates,
}, 'Parsed chat request'); }, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient) // Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
@@ -49,7 +55,7 @@ router.post('/chat', async (req, res) => {
itinerary, itinerary,
vehicle, vehicle,
selectedVariant, selectedVariant,
{ origin, destination }, { origin, destination, travelDates },
); );
const duration = Date.now() - start; const duration = Date.now() - start;
@@ -91,7 +97,7 @@ router.post('/chat/stream', async (req, res) => {
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() }); 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('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -124,7 +130,7 @@ router.post('/chat/stream', async (req, res) => {
itinerary, itinerary,
vehicle, vehicle,
selectedVariant, selectedVariant,
{ origin, destination }, { origin, destination, travelDates },
); );
for await (const ev of stream) { for await (const ev of stream) {
if (cancelled) break; if (cancelled) break;
+72
View File
@@ -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;
+51 -6
View File
@@ -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 = { 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.', 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.', 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` ? `\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. 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()} Selected route variant: ${selectedVariant.toUpperCase()}
${variantBrief} ${variantBrief}
Current vehicle: ${vehicleName(vehicle)}${odBlock} Current vehicle: ${vehicleName(vehicle)}${odBlock}${datesBlock}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)} 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. 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, "isCurrent": true,
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null "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, "superchargers": 3,
"hotels": 1, "hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"] "highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
} },
"needsTravelDates": true
}, },
"variants": [ "variants": [
{ {
@@ -310,6 +336,11 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant. - "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null. - 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): Route variants (REQUIRED):
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order. - "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}". - 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. - "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. - 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): 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. - 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. - 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.`; 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); const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ==='); 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. * Streaming chat — yields incremental partial itineraries as Grok produces output.
* Falls back to non-streaming if local CLI is unavailable. * 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); const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ==='); 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); 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)'); log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');