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
+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 = {
fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.',
scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.',
@@ -171,12 +171,18 @@ export class GrokHeadlessClient {
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
: '';
const td = opts.travelDates;
const hasDates = !!(td && (td.outbound || td.return));
const datesBlock = hasDates
? `\nTRAVEL DATES (use these for crossing/ferry/hotel pricing — peak vs off-peak vs weekend):\n Outbound: ${td!.outbound || '(not set)'}\n Return: ${td!.return || '(one-way)'}\n Travellers: ${td!.travellers ?? 'unknown'}\n`
: `\nTRAVEL DATES: not yet provided by the user. Use ballpark off-peak prices for now and set "needsTravelDates": true on the itinerary so the UI prompts the user to add dates for accurate pricing.\n`;
return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep.
Selected route variant: ${selectedVariant.toUpperCase()}
${variantBrief}
Current vehicle: ${vehicleName(vehicle)}${odBlock}
Current vehicle: ${vehicleName(vehicle)}${odBlock}${datesBlock}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
@@ -245,6 +251,25 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"isCurrent": true,
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
}
],
"crossingOptions": [
{
"id": "unique-crossing-id",
"operator": "Eurotunnel Le Shuttle" | "DFDS" | "P&O Ferries" | "Brittany Ferries" | "Stena Line" | "Irish Ferries" | "Other",
"mode": "tunnel" | "ferry",
"fromPort": "Folkestone, UK",
"toPort": "Coquelles (Calais), FR",
"durationMin": 35,
"priceEur": 180,
"frequency": "every 30 min, 24/7",
"pros": ["Fastest", "Drive on/off, no walking"],
"cons": ["Most expensive"],
"badge": "Fastest" | "Cheapest" | "Most scenic" | "Overnight" | "Frequent" | null,
"detourMin": 0,
"detourKm": 0,
"isCurrent": true,
"bookingUrl": "https://www.eurotunnel.com"
}
]
}
]
@@ -257,7 +282,8 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"superchargers": 3,
"hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
}
},
"needsTravelDates": true
},
"variants": [
{
@@ -310,6 +336,11 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null.
Travel dates & pricing:
- If the user has NOT provided travel dates, set "needsTravelDates": true on the itinerary object. In "message", briefly mention that adding dates will sharpen the crossing/ferry and hotel pricing. Use moderate off-peak prices in the meantime.
- If dates ARE provided, set "needsTravelDates": false and lean prices to the right tier (weekend, school holidays, peak summer, etc.) and mention that in "message" where relevant.
- Hotel prices in "description" can include a rough nightly rate when known ("Premier Inn from £75/night on those dates"). Don't fabricate exact prices for specific rooms.
Route variants (REQUIRED):
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
@@ -333,6 +364,20 @@ Charger options (REQUIRED for every Supercharger and destination-charger stop):
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
Sea crossings (REQUIRED for every UK ↔ mainland Europe trip, and any other route that crosses water):
- When the route includes a Channel crossing or any other sea/tunnel crossing, insert a dedicated stop with type "crossing" (use "tunnel" for Eurotunnel, "ferry" for ferries) at the appropriate point in the day's stops.
- That crossing stop MUST populate "crossingOptions" with 3-5 genuinely different real-world options the user could pick from. The currently-chosen one is duplicated as the first entry with isCurrent: true.
- For UK ↔ France: at minimum include Eurotunnel Le Shuttle (Folkestone→Coquelles), DFDS Dover→Calais, P&O Dover→Calais (or Dover→Dunkirk), and at least one longer/scenic option (DFDS Newhaven→Dieppe, Brittany Ferries Portsmouth→Caen/Le Havre/Cherbourg, or Brittany Ferries Plymouth→Roscoff) when they make geographic sense for the route.
- For UK ↔ Netherlands/Belgium: include P&O Hull→Rotterdam, Stena Line Harwich→Hook of Holland, DFDS Newcastle→Amsterdam where appropriate.
- For UK ↔ Ireland: include Irish Ferries / Stena Line Holyhead→Dublin, Liverpool→Belfast/Dublin, Fishguard→Rosslare etc.
- "priceEur" should reflect a realistic ballpark for a Tesla with the given travellers. If travel dates are provided, lean toward the right pricing tier (weekday off-peak vs weekend peak vs school holidays). If no dates yet, use moderate off-peak pricing.
- "durationMin" is the crossing time itself (35 min for Eurotunnel, ~90 min for Dover-Calais ferry, ~14 h for an overnight Hull-Rotterdam, etc.).
- "frequency" is a one-line text description of departure cadence (e.g. "every 30 min, 24/7", "8 sailings/day", "1 overnight sailing/day").
- "badge" picks the trade-off: "Fastest" for Eurotunnel, "Cheapest" for the cheapest sensible ferry, "Most scenic" for routes that swap a few hours of UK driving for a longer but prettier crossing (e.g. Portsmouth→Caen), "Overnight" for sleeper ferries (frees up a hotel night), "Frequent" for high-cadence operators.
- "detourMin" and "detourKm" express the change in TOTAL trip distance + drive time vs the current chosen crossing (positive = adds, negative = saves). E.g. Portsmouth→Caen vs Dover→Calais might save 200 km of French driving but add 150 km of UK driving and a 6 h crossing.
- Always set a sensible "bookingUrl" (operator's main booking page).
- If the user already picked a crossing, keep its choice as isCurrent: true and adjust the rest of the itinerary's routing accordingly.
Alternatives (REQUIRED for every Supercharger and hotel stop):
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
@@ -363,7 +408,7 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
Respond with ONLY the JSON object.`;
}
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
@@ -440,7 +485,7 @@ Respond with ONLY the JSON object.`;
* Streaming chat — yields incremental partial itineraries as Grok produces output.
* Falls back to non-streaming if local CLI is unavailable.
*/
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator<StreamEvent> {
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): AsyncGenerator<StreamEvent> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
@@ -629,7 +674,7 @@ Respond with ONLY the JSON object.`;
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');