diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eb9e781 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7089ba6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/client/src/lib/incar.ts b/client/src/lib/incar.ts new file mode 100644 index 0000000..3794616 --- /dev/null +++ b/client/src/lib/incar.ts @@ -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'); +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 258f58c..e52159e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom'; import { Toaster } from 'sonner'; import App from './App'; import './styles/globals.css'; +import { detectInCar, applyInCarClass } from './lib/incar'; + +applyInCarClass(detectInCar()); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 81ef214..7b5a8e2 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -7,6 +7,7 @@ import { Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2, Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, + CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown, } from 'lucide-react'; // Fix Leaflet default icons (we still need pins for non-active stops) @@ -18,7 +19,7 @@ L.Icon.Default.mergeOptions({ }); // ─── 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 { category: 'food' | 'do' | 'see' | 'shop' | 'rest'; @@ -39,6 +40,24 @@ interface ChargerOption { 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 { id: 'fast' | 'scenic' | 'cheap'; label: string; @@ -89,6 +108,13 @@ interface Stop { alternatives?: AlternativeStop[]; nearby?: NearbyPlace[]; 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 { @@ -101,6 +127,7 @@ interface Itinerary { hotels: number; highlights?: string[]; }; + needsTravelDates?: boolean; } interface Leg { @@ -116,7 +143,7 @@ const EMPTY_ITINERARY: Itinerary = { 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 { id: string; @@ -223,6 +250,8 @@ function stopMeta(type: StopType): { icon: React.ComponentType<{ className?: str case 'origin': return { icon: HomeIcon, color: '#9ca3af' }; case 'destination': return { icon: Flag, color: 'var(--gd-red)' }; case 'tunnel': return { icon: Route, color: '#a78bfa' }; + case 'ferry': + case 'crossing': return { icon: Ship, color: '#60a5fa' }; case 'supercharger': case 'destination-charger': return { icon: Zap, color: '#4ade80' }; case 'hotel': return { icon: Bed, color: '#60a5fa' }; @@ -256,6 +285,41 @@ async function geocodeLocation(query: string): Promise<{ lat: number; lng: numbe return null; } +// Reverse geocode lat/lng → human-readable place name (Nominatim). +async function reverseGeocode(lat: number, lng: number): Promise { + 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 { + if (typeof navigator === 'undefined' || !navigator.geolocation) return null; + return new Promise((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) ─────────────── async function normalizeAndSanitizeItinerary(raw: any): Promise { if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY; @@ -330,6 +394,28 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { })) : []; + 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 = { estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, @@ -343,6 +429,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { alternatives: cleanAlts.length > 0 ? cleanAlts : undefined, nearby: cleanNearby.length > 0 ? cleanNearby : undefined, chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined, + crossingOptions: cleanCrossings.length > 0 ? cleanCrossings : undefined, }; const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom'; @@ -380,6 +467,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { ? 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 nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined, chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined, + crossingOptions: Array.isArray(s.crossingOptions) ? s.crossingOptions : undefined, }); } if (validStops.length > 0) { @@ -440,6 +529,7 @@ function normalizePartialItinerary(raw: any): Itinerary { ? 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}`; } +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 ────────────────────────────────────────────────────────── class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { 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; onSwap: (alt: AlternativeStop) => void; onRemove: () => void; onCustomise: () => void; + onPickCrossing?: (c: CrossingOption) => void; }) { 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 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; @@ -884,6 +998,7 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: { const amenities = (stop.amenities || []).slice(0, 8); const alts = stop.alternatives || []; const chargers = stop.chargerOptions || []; + const crossings = stop.crossingOptions || []; const nearby = stop.nearby || []; const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all'); @@ -914,6 +1029,16 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: { )} + {isCrossing && crossings.length > 0 && ( +
+ Crossing + onPickCrossing?.(c)} + /> +
+ )} + {nearby.length > 0 && (
@@ -1064,7 +1189,8 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo // ─── Stop card (icon-led) ──────────────────────────────────────────────────── 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, }: { stop: Stop; @@ -1076,6 +1202,11 @@ function StopCard({ onSwap: (alt: AlternativeStop) => void; onRemove: () => void; onCustomise: () => void; + onPickCrossing?: (c: CrossingOption) => void; + onMoveUp: () => void; + onMoveDown: () => void; + canMoveUp: boolean; + canMoveDown: boolean; onDragStart: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void; @@ -1085,7 +1216,8 @@ function StopCard({ const Icon = meta.icon; const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; 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 (
-
- +
+
+ +
+
+ + +
@@ -1129,8 +1283,10 @@ function StopCard({ {isSleep && (
overnight
)} - {isTunnel && ( -
£89 · 35m
+ {isCrossingStop && currentCrossing && ( +
+ €{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)} +
)}
{stop.combo && ( @@ -1142,7 +1298,7 @@ function StopCard({
)}
- {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')}
{!active && stop.description && ( @@ -1151,7 +1307,7 @@ function StopCard({
)} - {active && } + {active && }
@@ -1200,6 +1356,7 @@ function TopBar({ origin, destination, onOriginChange, onDestinationChange, onODCommit, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, + travelDates, onOpenDates, onUseMyLocation, }: { origin: string; destination: string; onOriginChange: (v: string) => void; @@ -1211,7 +1368,19 @@ function TopBar({ vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void; grokStatus: { label?: string }; 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 (
+
@@ -1326,6 +1509,20 @@ function TopBar({ + + onOpenGpx()}> Export @@ -1362,6 +1559,9 @@ export default function TeslaTripPlanner() { const [hoverStopId, setHoverStopId] = useState(null); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); + const [travelDates, setTravelDates] = useState({ outbound: null, return: null, travellers: 2 }); + const [datePickerOpen, setDatePickerOpen] = useState(false); + const [dateAnchor, setDateAnchor] = useState(null); const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState('fast'); @@ -1436,6 +1636,21 @@ export default function TeslaTripPlanner() { lastPrefetchKey.current = prefetchKey; }, [prefetchKey, selectedVariant]); + // Drop variant cache + prefetch ledger when travel dates change — pricing is stale. + const lastDatesKey = React.useRef(''); + 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(() => { if (!prefetchKey) return; if (thinking || variantSwitching) return; @@ -1506,6 +1721,8 @@ export default function TeslaTripPlanner() { selectedVariant: variantToUse, origin: origin.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'); @@ -1650,6 +1867,16 @@ export default function TeslaTripPlanner() { 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) => { if (dragId === targetId) return; const next = structuredClone(itinerary); @@ -1681,6 +1908,27 @@ export default function TeslaTripPlanner() { 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 from = origin.trim(); const to = destination.trim(); @@ -1761,6 +2009,26 @@ export default function TeslaTripPlanner() { 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 dateLabels = ['Today', 'Tomorrow']; @@ -1778,6 +2046,9 @@ export default function TeslaTripPlanner() { onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }} grokStatus={grokStatus} onOpenGpx={() => setModal({ kind: 'gpx' })} + travelDates={travelDates} + onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }} + onUseMyLocation={useMyLocation} /> {variants.length > 0 && ( @@ -2001,6 +2272,24 @@ export default function TeslaTripPlanner() {
+ {/* Travel-dates nudge */} + {itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && ( + + )} + {/* Stops list */}
{itinerary.days.length === 0 ? ( @@ -2060,6 +2349,11 @@ export default function TeslaTripPlanner() { onSwap={(alt) => swapStop(stop.id, alt)} onRemove={() => removeStop(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) => { e.dataTransfer.setData('text/plain', stop.id); e.dataTransfer.effectAllowed = 'move'; @@ -2173,6 +2467,20 @@ export default function TeslaTripPlanner() { onSelect={setVehicle} onClose={() => setVehiclePanelOpen(false)} /> + + setDatePickerOpen(false)} + onApply={() => { + setDatePickerOpen(false); + if (allStops.length > 0) { + sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true }); + } + }} + /> ); } @@ -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 ( + <> +
+
+
+
+
Travel dates
+
+ Sharpens crossing, ferry and hotel pricing +
+
+ {nights != null && ( +
+ {nights} night{nights === 1 ? '' : 's'} +
+ )} +
+ +
+
+
Outbound
+ 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' }} + /> +
+
+
+ Return + {value.return && ( + + )} +
+ 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' }} + /> +
+
+
+ Travellers + +
+
+ {[1, 2, 3, 4, 5].map(n => { + const selected = (value.travellers ?? 2) === n; + return ( + + ); + })} +
+
+
+ +
+ + +
+
+ + ); +} + +// ─── 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 ( +
+
+ +
+ Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'} +
+
+
+ {options.map(opt => { + const isPicked = opt.id === current.id; + const deltaMin = formatDelta(opt.detourMin, 'min'); + const deltaKm = formatDelta(opt.detourKm, 'km'); + return ( + + ); + })} +
+
+ ); +} + // ─── Vehicle selector panel ────────────────────────────────────────────────── function VehicleSelectorPanel({ open, anchorRect, selected, onSelect, onClose, diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index 1222c37..766ff6d 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -79,3 +79,72 @@ html, body, #root { font-size: 12px; line-height: 1.5; } + +/* ─── In-car browser overrides ───────────────────────────────────────────── + * Triggered when 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; +} diff --git a/server/config/env.ts b/server/config/env.ts index 5a8d1fe..f012709 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -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; diff --git a/server/index.ts b/server/index.ts index 53665ac..f5510cf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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' }); diff --git a/server/routes/chat.ts b/server/routes/chat.ts index d755b34..87066af 100644 --- a/server/routes/chat.ts +++ b/server/routes/chat.ts @@ -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; diff --git a/server/routes/tesla.ts b/server/routes/tesla.ts new file mode 100644 index 0000000..fc7536d --- /dev/null +++ b/server/routes/tesla.ts @@ -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; + + 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; diff --git a/server/services/llm/GrokHeadlessClient.ts b/server/services/llm/GrokHeadlessClient.ts index 3cb682d..6fbd9b7 100644 --- a/server/services/llm/GrokHeadlessClient.ts +++ b/server/services/llm/GrokHeadlessClient.ts @@ -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 { + 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 { 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 { + 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 { 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 { + 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 { 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)');