feat: Phase 2 — variant strip, while-here, charger swap block

Adds the three big "options" UX wins from Direction B:

1. Route variants (Fastest / Scenic / Cheapest)
   - Grok prompt now returns a top-level variants[] summary with
     drive/charge/cost/distance/pros for each variant, plus a
     selectedVariant indicating which one the stops reflect.
   - VariantStrip renders under the top bar with selected-state
     styling, tone-coloured highlight (red/green/blue) on the most
     relevant stat, and 3-5 pros pills.
   - Clicking a variant fires /api/chat with selectedVariant=<id> so
     Grok re-plans with that variant's bias. A "switching" state
     disables the strip while the request is in flight.
   - The chat route accepts selectedVariant ('fast'|'scenic'|'cheap')
     and the GrokHeadlessClient threads it through both the local CLI
     and xAI API paths.

2. While here (food / do / see / shop / rest)
   - Every Supercharger, destination-charger and hotel stop now
     returns a nearby[] array with category/icon/name/detail.
   - Expanded stop card has tabs (All / Food / Do / See) — tabs
     auto-hide when no items in that category. Two-column grid of
     named places with walk-time + rating, e.g. "M&S Foodhall · 1 min
     walk · 4.3★ · sandwiches".

3. Charger swap block
   - Every charging stop now returns chargerOptions[] with the
     current pick + 1-3 alternatives at the same location, each with
     network (Tesla/Ionity/Allego/Fastned/BP Pulse), stalls, kW,
     pricePerKwh, detourMin and an optional badge (Faster/Cheaper/
     More stalls/Newer).
   - ChargerSwapBlock shows the current charger as a red-tinted
     header row that expands to reveal alternatives with stats and a
     Use button per row.

Renamed the existing AlternativeStop UI label from "alternative(s)"
to "location alternative(s)" so it's clear when the user is swapping
the stop *location* vs swapping the *charger at the same location*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:14:15 +01:00
parent ece882ea29
commit f63af36451
3 changed files with 506 additions and 32 deletions
+106 -17
View File
@@ -15,7 +15,7 @@ const log = createLogger('grok-headless');
const SENTINEL = 'ITINERARY_UPDATE:';
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
export interface GrokResponse { text: string; updatedItinerary?: any; }
export interface GrokResponse { text: string; updatedItinerary?: any; variants?: any[]; selectedVariant?: string; }
export type VehicleInput = string | { name: string; rangeKm?: number };
function vehicleName(v: VehicleInput): string {
@@ -59,9 +59,18 @@ export class GrokHeadlessClient {
}
}
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput) {
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast') {
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.',
cheap: 'Cheapest — minimise cost. Avoid toll roads where possible, prefer off-peak charging, pick budget overnight options (car sleep or basic hotels), and choose cheaper chargers when available. Drive time can be a bit longer to save €.',
}[selectedVariant] || 'Fastest — minimise drive time.';
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)}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
@@ -110,6 +119,27 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"deltaMin": 9,
"reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')"
}
],
"nearby": [
{
"category": "food" | "do" | "see" | "shop" | "rest",
"icon": "coffee" | "restaurant" | "fast-food" | "shopping" | "supermarket" | "viewpoint" | "museum" | "park" | "beach" | "playground" | "toilets" | "wifi",
"name": "Boulangerie Pâtisserie L. Marc",
"detail": "3 min walk · 4.7★ · open until 19:00"
}
],
"chargerOptions": [
{
"id": "unique-charger-id",
"name": "Aire de Beaune Supercharger",
"network": "Tesla" | "Ionity" | "Allego" | "TotalEnergies" | "Fastned" | "BP Pulse" | "Other",
"stalls": 12,
"kw": 250,
"pricePerKwh": 0.42,
"detourMin": 0,
"isCurrent": true,
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
}
]
}
]
@@ -123,7 +153,42 @@ Respond with **only** a single valid JSON object in exactly this format. No text
"hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
}
}
},
"variants": [
{
"id": "fast",
"label": "Fastest",
"tone": "primary",
"distanceKm": 2074,
"driveHours": 23.5,
"chargeHours": 4.5,
"costEur": 312,
"highlight": "drive" | "cost" | "pretty",
"pros": ["8 stops", "Sleep in car · Reims", "1 night", "A26 corridor"]
},
{
"id": "scenic",
"label": "Scenic",
"tone": "green",
"distanceKm": 2218,
"driveHours": 26.2,
"chargeHours": 4.8,
"costEur": 328,
"highlight": "pretty",
"pros": ["Via Burgundy + Pyrénées", "Hotel night · Avignon", "10 stops", "+2h 42m"]
},
{
"id": "cheap",
"label": "Cheapest",
"tone": "blue",
"distanceKm": 2098,
"driveHours": 24.0,
"chargeHours": 5.2,
"costEur": 270,
"highlight": "cost",
"pros": ["Avoids tolls", "Off-peak charging", "€42 cheaper"]
}
]
}
Strict route planning rules:
@@ -136,6 +201,29 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null.
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}".
- "distanceKm" (number, km), "driveHours" (number, decimal hours, e.g. 23.5), "chargeHours" (number, decimal hours), "costEur" (number, € for tolls + charging combined).
- "pros" is 3-5 short pills (max ~30 chars each) that describe the unique selling points of that variant relative to the others (e.g. "Avoids tolls", "Sleep in car · Reims", "+2h 42m drive").
- "highlight" picks the stat to colour-highlight: "drive" for fastest, "pretty" for scenic, "cost" for cheapest.
- The 3 variants must be genuinely different (different stops, different days, different totals). Don't just shuffle the same route.
Nearby (REQUIRED for every Supercharger, destination-charger and hotel stop):
- Populate "nearby" with 3-6 places within walking distance of the stop.
- Categories: "food" (restaurants/cafes/bakeries), "do" (walks, things to do), "see" (sights/viewpoints/museums), "shop" (supermarkets, retail), "rest" (toilets, lounges).
- "detail" should include walk time and a quick descriptor or rating (e.g. "3 min walk · 4.5★ · paella", "8 min · UNESCO ruins").
- "icon" should be one of the amenity tokens (coffee, restaurant, fast-food, shopping, supermarket, viewpoint, museum, park, beach, playground, toilets, wifi).
- These are real places at or near the stop — pick named establishments where possible.
Charger options (REQUIRED for every Supercharger and destination-charger stop):
- "chargerOptions" must list 1-4 real charging operators in the immediate area of this stop. The current pick is duplicated as the first entry with isCurrent: true.
- "network" must be the real charging network (Tesla / Ionity / Allego / TotalEnergies / Fastned / BP Pulse / Other).
- "stalls" is the total number of charging stalls at that location, "kw" is the max charging power, "pricePerKwh" is the public €/kWh price.
- "detourMin" is the extra drive time vs the currently-chosen charger (0 for 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.
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.
@@ -166,21 +254,21 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
Respond with ONLY the JSON object.`;
}
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): Promise<GrokResponse> {
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ===');
const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider === 'xai') {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant);
}
if (activeProvider === 'fallback') {
return this.dumbFallback(messages, requestId);
}
// LOCAL PERSONAL GROK CLI
const prompt = this.buildPrompt(messages, itinerary, vehicle);
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
const disallowed = env.nodeEnv === 'development'
@@ -224,14 +312,14 @@ Respond with ONLY the JSON object.`;
const data = JSON.parse(result) as { text?: string };
const rawText = data.text || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed };
const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) {
log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API');
if (env.xaiApiKey) {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant);
}
return this.dumbFallback(messages, requestId);
} finally {
@@ -239,8 +327,8 @@ Respond with ONLY the JSON object.`;
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle);
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
try {
@@ -266,15 +354,15 @@ Respond with ONLY the JSON object.`;
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const rawText = data.choices?.[0]?.message?.content || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed };
const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) {
log.error({ requestId, err }, 'xAI API call failed');
return this.dumbFallback(messages, requestId);
}
}
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null } {
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null; variants?: any[] } {
try {
const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim();
const parsed = JSON.parse(cleaned);
@@ -283,6 +371,7 @@ Respond with ONLY the JSON object.`;
return {
text: parsed.message || parsed.reply || '',
itinerary: parsed.itinerary || null,
variants: Array.isArray(parsed.variants) ? parsed.variants : undefined,
};
}
} catch (e) {
@@ -293,7 +382,7 @@ Respond with ONLY the JSON object.`;
return this.extractItineraryUpdate(rawText);
}
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null } {
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null; variants?: any[] } {
const upperText = text.toUpperCase();
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };