From 83ff3e9ac99b6cef8cdbaf0556a0e7da0701001e Mon Sep 17 00:00:00 2001 From: yellowhat <1692490+yellowhat@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:52:14 +0200 Subject: [PATCH 01/19] fix(share-url): read the deep-link fuel on the server too --- src/app/[locale]/page.tsx | 11 +++++++++-- src/components/home-client.tsx | 15 ++++----------- src/lib/share-url.test.ts | 13 +++++++++++++ src/lib/share-url.ts | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 0504e1b..9f90fa3 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -2,21 +2,28 @@ import { getConfig } from "@/lib/config"; import { HomeClient } from "@/components/home-client"; import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/og-translations"; import type { Locale } from "@/lib/i18n"; +import { deepLinkFuel } from "@/lib/share-url"; export const dynamic = "force-dynamic"; interface Props { params: Promise<{ locale: string }>; + searchParams: Promise>; } -export default async function Home({ params }: Props) { +export default async function Home({ params, searchParams }: Props) { const { locale: raw } = await params; + // A shared link's fuel, read here too so the server renders what the client will. + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(await searchParams)) { + for (const item of Array.isArray(v) ? v : v != null ? [v] : []) sp.append(k, item); + } const locale = (SUPPORTED_LOCALES.includes(raw as Locale) ? raw : DEFAULT_LOCALE) as Locale; const config = getConfig(); return ( { expect(parseRouteParams(new URLSearchParams("foo=bar"))).toBeNull(); }); }); + +describe("deepLinkFuel", () => { + const q = (s: string) => new URLSearchParams(s); + it("reads the fuel of a route or station link", () => { + expect(deepLinkFuel(q("from=40.4,-3.7&to=41.4,2.2&fuel=EV"))).toBe("EV"); + expect(deepLinkFuel(q("station=ES:1&lat=40.4&lng=-3.7&fuel=B7"))).toBe("B7"); + }); + it("ignores a fuel without a valid link, and unknown fuels", () => { + expect(deepLinkFuel(q("fuel=EV"))).toBeNull(); + expect(deepLinkFuel(q("from=40.4,-3.7&to=41.4,2.2&fuel=NOPE"))).toBeNull(); + }); +}); diff --git a/src/lib/share-url.ts b/src/lib/share-url.ts index 12ee633..7cad68f 100644 --- a/src/lib/share-url.ts +++ b/src/lib/share-url.ts @@ -18,6 +18,8 @@ * the charger is never loaded, let alone selected (#129). */ +import { fuelTypeEnum, type FuelType } from "@/types/fuel"; + /** Maximum number of `via` waypoints honoured when parsing a route. */ export const MAX_VIA = 5; @@ -174,3 +176,19 @@ function parseCoordComponent(raw: string | null, min: number, max: number): numb if (!Number.isFinite(n) || n < min || n > max) return null; return n; } + +/** + * The fuel a route or station link puts the visitor on, or null when the link + * is neither or names no known fuel. Only the station branch strictly needs it + * (the viewport fetch is fuel-filtered, so an EV charger shared from the EV + * layer only loads if the link puts the visitor on that layer, #129), but both + * honour it. Shared by the server page and the client, so the first render + * matches on both and hydration doesn't flip the fuel. + */ +export function deepLinkFuel(sp: URLSearchParams): FuelType | null { + const station = parseStationParams(sp); + const fuel = parseRouteParams(sp)?.fuel + ?? (station && station.lat != null && station.lng != null ? station.fuel : null); + const parsed = fuelTypeEnum.safeParse(fuel); + return parsed.success ? parsed.data : null; +} From c258525304f2d97e80147498ed1fa361aaabdf2c Mon Sep 17 00:00:00 2001 From: yellowhat <1692490+yellowhat@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:52:55 +0200 Subject: [PATCH 02/19] fix(planner): raise the default reserve to 20 % --- src/components/search/refuel-planner.test.tsx | 2 +- src/components/search/refuel-planner.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx index 57c1502..a967be1 100644 --- a/src/components/search/refuel-planner.test.tsx +++ b/src/components/search/refuel-planner.test.tsx @@ -128,7 +128,7 @@ describe("RefuelPlanner", () => { expect(status).toHaveAttribute("aria-live", "polite"); expect(status).toHaveTextContent("Repsol"); expect(screen.getByLabelText("planner.start")).toHaveAttribute("aria-valuetext", "50%"); - expect(screen.getByLabelText("planner.reserve")).toHaveAttribute("aria-valuetext", "10%"); + expect(screen.getByLabelText("planner.reserve")).toHaveAttribute("aria-valuetext", "20%"); expect(screen.getByRole("button", { name: /Repsol/, pressed: true })).toBeInTheDocument(); }); diff --git a/src/components/search/refuel-planner.tsx b/src/components/search/refuel-planner.tsx index 9f4b41f..2165642 100644 --- a/src/components/search/refuel-planner.tsx +++ b/src/components/search/refuel-planner.tsx @@ -45,7 +45,7 @@ export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = { open: false, startPct: 50, arrivalPct: 20, - reservePct: 10, + reservePct: 20, timeValueEur: 15, }; From 6dfe638c45ea5ef5a139cb970e9217944c338b4b Mon Sep 17 00:00:00 2001 From: yellowhat <1692490+yellowhat@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:53:48 +0200 Subject: [PATCH 03/19] feat(planner): charge cap and zero-price stops in planRefuel --- src/components/search/refuel-planner.tsx | 2 +- src/lib/refuel-planner.test.ts | 61 +++++++++++++++++++++++- src/lib/refuel-planner.ts | 36 +++++++++----- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/components/search/refuel-planner.tsx b/src/components/search/refuel-planner.tsx index 2165642..3da715b 100644 --- a/src/components/search/refuel-planner.tsx +++ b/src/components/search/refuel-planner.tsx @@ -97,7 +97,7 @@ export function RefuelPlanner({ const candidates = stations.flatMap((s) => { const p = s.properties; // Mixed currencies can't be compared; only use prices in the display currency. - if (p.price == null || p.detourMin == null || p.detourMin < 0 || p.currency !== currency) return []; + if (p.price == null || p.price <= 0 || p.detourMin == null || p.detourMin < 0 || p.currency !== currency) return []; if ((maxPrice != null && p.price > maxPrice) || (maxDetour != null && p.detourMin > maxDetour)) return []; return [{ id: p.id, km: (p.routeFraction ?? 0) * routeKm, price: p.price, detourMin: p.detourMin }]; }); diff --git a/src/lib/refuel-planner.test.ts b/src/lib/refuel-planner.test.ts index 9fcfd06..0b4845e 100644 --- a/src/lib/refuel-planner.test.ts +++ b/src/lib/refuel-planner.test.ts @@ -180,16 +180,21 @@ describe("planRefuel", () => { expect(at(10).dipsBelowReserve).toBeUndefined(); }); - it("ignores stations with unknown detour or no price", () => { + it("ignores stations with unknown detour or a negative price", () => { const r = planRefuel({ ...base, startPct: 30, routeKm: 400, - stations: [st("bad", 100, 1.0, -1), st("free", 100, 0), st("ok", 100, 1.5)], + stations: [st("bad", 100, 1.0, -1), st("neg", 100, -1), st("ok", 100, 1.5)], }); expect(r.stops.map((s) => s.id)).toEqual(["ok"]); }); + it("accepts a zero price (EV chargers are planned by energy only)", () => { + const r = planRefuel({ ...base, startPct: 30, routeKm: 400, stations: [st("free", 100, 0)] }); + expect(r.stops.map((s) => s.id)).toEqual(["free"]); + }); + it("ignores stations with a non-finite price, detour or km", () => { const r = planRefuel({ ...base, @@ -337,3 +342,55 @@ describe("maxStopsFor", () => { expect(r.stops.length).toBeGreaterThan(3); }); }); + +describe("planRefuel with maxChargePct (EV)", () => { + // 60 kWh at 18 kWh/100 km: 0.3 % per km. + const ev = { ...base, tankL: 60, consumptionL100: 18, startPct: 50 }; + + it("never departs a stop above the cap", () => { + const stations = [st("A", 90, 0), st("B", 180, 0)]; + const capped = planRefuel({ ...ev, routeKm: 300, stations, maxChargePct: 80 }); + expect(capped.status).toBe("ok"); + expect(capped.stops.map((s) => s.id)).toEqual(["A", "B"]); + expect(Math.max(...capped.stops.map((s) => s.departPct))).toBeLessThanOrEqual(80); + // Without the cap one stop at A (to 83 %) is enough. + expect(planRefuel({ ...ev, routeKm: 300, stations }).stops.map((s) => s.id)).toEqual(["A"]); + }); + + it("with no price, adds only the energy needed", () => { + const r = planRefuel({ ...ev, routeKm: 200, stations: [st("A", 90, 0)], maxChargePct: 80 }); + expect(r.status).toBe("ok"); + // Arrive 23 %, need 110 km × 0.3 + 20 = 53 %. + expect(r.stops[0].departPct).toBe(53); + expect(r.totalFuelCost).toBe(0); + }); + + it("reports stops when only a higher cap would do", () => { + const r = planRefuel({ ...ev, routeKm: 300, stations: [st("A", 90, 0)], maxChargePct: 80 }); + expect(r.status).toBe("infeasible"); + }); + + it("caps the stop estimate at the usable band", () => { + expect(maxStopsFor(1000, 60, 18, 10, 80)).toBeGreaterThanOrEqual(maxStopsFor(1000, 60, 18, 10)); + }); + + it("allows up to 20 stops under a charge cap, and treats a reserve above it as no range", () => { + // 60 % usable of 60 kWh at 18 kWh/100 km = 200 km per leg. + expect(maxStopsFor(2300, 60, 18, 20, 80)).toBe(14); + expect(maxStopsFor(20000, 60, 18, 20, 80)).toBe(20); + expect(maxStopsFor(500, 60, 18, 90, 80)).toBe(20); + expect(maxStopsFor(20000, 60, 18, 20)).toBe(10); + }); + + it("plans a 2,300 km EV trip that needs more than 10 stops", () => { + // A charger every 100 km; ~200 km per leg means about 11 stops. + const trip = { ...ev, startPct: 80, arrivalPct: 20, reservePct: 20, routeKm: 2300, maxChargePct: 80 }; + const stations = Array.from({ length: 22 }, (_, i) => st(`C${i}`, (i + 1) * 100, 0)); + expect(planRefuel({ ...trip, stations, maxStops: 10 })).toMatchObject({ status: "infeasible", reason: "stops" }); + const r = planRefuel({ ...trip, stations, maxStops: maxStopsFor(2300, 60, 18, 20, 80) }); + expect(r.status).toBe("ok"); + expect(r.stops.length).toBeGreaterThan(10); + expect(Math.max(...r.stops.map((s) => s.departPct))).toBeLessThanOrEqual(80); + expect(Math.min(...r.stops.map((s) => s.arrivePct))).toBeGreaterThanOrEqual(20 - 1e-9); + }); +}); diff --git a/src/lib/refuel-planner.ts b/src/lib/refuel-planner.ts index 8cf0534..2c711c0 100644 --- a/src/lib/refuel-planner.ts +++ b/src/lib/refuel-planner.ts @@ -35,6 +35,8 @@ export interface PlannerInput { reservePct: number; timeValuePerHour: number; maxStops: number; + /** Highest level (%) a stop may fill to; EV charging slows sharply above ~80 %. Default 100. */ + maxChargePct?: number; } export interface PlannedStop { @@ -126,16 +128,24 @@ export function pruneCandidates(stations: PlannerStation[], routeKm: number, max /** Bounds for the stop cap derived from the trip. */ const MIN_STOPS_CAP = 3; const MAX_STOPS_CAP = 10; +/** + * A charge cap (EV) leaves far less range per stop (~200 km on the default EV + * profile), so 10 stops would stop long trips at ~2,000 km. 20 keeps the DP + * well under a second at the 200-candidate ceiling. + */ +const MAX_STOPS_CAP_CHARGE_CAPPED = 20; /** - * Stop cap for a trip: the full tanks it takes between 100 % and the reserve, - * plus two to spare for cheaper partial fills, kept within [3, 10] so short trips - * still get options and long ones keep the DP fast. + * Stop cap for a trip: the full fills it takes between `maxChargePct` and the + * reserve, plus two to spare for cheaper partial fills, kept within [3, 10] + * ([3, 20] under a charge cap) so short trips still get options and long ones + * keep the DP fast. */ -export function maxStopsFor(routeKm: number, tankL: number, consumptionL100: number, reservePct: number): number { - const usableRangeKm = ((100 - clamp(reservePct, 0, 100)) * tankL) / consumptionL100; +export function maxStopsFor(routeKm: number, tankL: number, consumptionL100: number, reservePct: number, maxChargePct = 100): number { + const maxCap = maxChargePct < 100 ? MAX_STOPS_CAP_CHARGE_CAPPED : MAX_STOPS_CAP; + const usableRangeKm = (Math.max(0, clamp(maxChargePct, 0, 100) - clamp(reservePct, 0, 100)) * tankL) / consumptionL100; const stops = Math.ceil(routeKm / usableRangeKm) + 2; - return Number.isNaN(stops) ? MIN_STOPS_CAP : clamp(stops, MIN_STOPS_CAP, MAX_STOPS_CAP); + return Number.isNaN(stops) ? MIN_STOPS_CAP : clamp(stops, MIN_STOPS_CAP, maxCap); } export function planRefuel(input: PlannerInput): PlanResult { @@ -150,6 +160,8 @@ export function planRefuel(input: PlannerInput): PlanResult { const arrivalPct = clamp(input.arrivalPct, 0, 100); const targetEnd = Math.max(arrivalPct, reservePct); const maxStops = Math.max(0, Math.floor(input.maxStops)); + // Departure levels at a stop run 0..topG; the start level itself may be higher. + const topG = clamp(Math.floor(input.maxChargePct ?? 100), 0, L - 1); // Fuel level (%) consumed per km. const pctPerKm = consumptionL100 / tankL; @@ -161,7 +173,7 @@ export function planRefuel(input: PlannerInput): PlanResult { input.stations .filter((s) => !seen.has(s.id) && seen.add(s.id)) .filter((s) => Number.isFinite(s.km) && Number.isFinite(s.price) && Number.isFinite(s.detourMin)) - .filter((s) => s.km >= 0 && s.km <= routeKm && s.price > 0 && s.detourMin >= 0) + .filter((s) => s.km >= 0 && s.km <= routeKm && s.price >= 0 && s.detourMin >= 0) .sort((a, b) => a.km - b.km), routeKm, ); @@ -224,7 +236,7 @@ export function planRefuel(input: PlannerInput): PlanResult { if (k === 1) { const f = startPct - cons; if (f < firstLegFloor - 1e-9) continue; - for (let g = Math.ceil(f); g < L; g++) { + for (let g = Math.ceil(f); g <= topG; g++) { const c = (g - f) * unit + stopCost[j]; if (c < dp[j * L + g]) { dp[j * L + g] = c; @@ -240,7 +252,7 @@ export function planRefuel(input: PlannerInput): PlanResult { let runMin = Infinity; let runArg = -1; let gi = minGi; - for (let g = 0; g < L; g++) { + for (let g = 0; g <= topG; g++) { // Allowed gi: arrival level gi - cons must not exceed g (can't "buy" negative). const maxGi = Math.min(L - 1, Math.floor(g + cons + 1e-9)); for (; gi <= maxGi; gi++) { @@ -265,7 +277,7 @@ export function planRefuel(input: PlannerInput): PlanResult { // Close out to the destination from this layer. for (let j = 0; j < n; j++) { const need = Math.ceil(end + legPct(j, n) - 1e-9); - for (let g = Math.max(0, need); g < L; g++) { + for (let g = Math.max(0, need); g <= topG; g++) { const c = dp[j * L + g]; if (c < best.cost) best = { cost: c, k, j, g }; } @@ -363,10 +375,10 @@ export function planRefuel(input: PlannerInput): PlanResult { if (startPct - legPct(-1, j) >= firstLegFloor - 1e-9) stopsTo[j] = 1; else { for (let i = 0; i < j; i++) { - if (stopsTo[i] + 1 < stopsTo[j] && 100 - legPct(i, j) >= reservePct - 1e-9) stopsTo[j] = stopsTo[i] + 1; + if (stopsTo[i] + 1 < stopsTo[j] && topG - legPct(i, j) >= reservePct - 1e-9) stopsTo[j] = stopsTo[i] + 1; } } - if (100 - legPct(j, n) >= end - 1e-9) fewest = Math.min(fewest, stopsTo[j]); + if (topG - legPct(j, n) >= end - 1e-9) fewest = Math.min(fewest, stopsTo[j]); } return fewest; } From 9b81e434bca8a9d12ed7fde6cdbfd1c088ebedc2 Mon Sep 17 00:00:00 2001 From: yellowhat <1692490+yellowhat@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:54:25 +0200 Subject: [PATCH 04/19] refactor(vehicle-profile): generic stored profile, add EV profile --- src/lib/vehicle-profile.test.ts | 15 ++++++++- src/lib/vehicle-profile.ts | 54 ++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/lib/vehicle-profile.test.ts b/src/lib/vehicle-profile.test.ts index 342c345..c8ed09e 100644 --- a/src/lib/vehicle-profile.test.ts +++ b/src/lib/vehicle-profile.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { DEFAULT_VEHICLE, isPlannableFuel, readVehicleProfile } from "./vehicle-profile"; +import { DEFAULT_EV, DEFAULT_VEHICLE, isPlannableFuel, readEvProfile, readVehicleProfile } from "./vehicle-profile"; describe("readVehicleProfile", () => { it("returns defaults for missing or corrupt storage", () => { @@ -13,6 +13,19 @@ describe("readVehicleProfile", () => { }); }); +describe("readEvProfile", () => { + it("returns defaults for missing, corrupt or fuel-shaped storage", () => { + expect(readEvProfile(null)).toEqual(DEFAULT_EV); + expect(readEvProfile("{not json")).toEqual(DEFAULT_EV); + expect(readEvProfile(JSON.stringify({ tankL: 60, consumptionL100: 5.2 }))).toEqual(DEFAULT_EV); + expect(readEvProfile(JSON.stringify({ batteryKwh: 5, consumptionKwh100: 18 }))).toEqual(DEFAULT_EV); + }); + + it("returns a valid stored profile", () => { + expect(readEvProfile(JSON.stringify({ batteryKwh: 77, consumptionKwh100: 16.5 }))).toEqual({ batteryKwh: 77, consumptionKwh100: 16.5 }); + }); +}); + describe("isPlannableFuel", () => { it("accepts per-litre fuels", () => { expect(isPlannableFuel("B7")).toBe(true); diff --git a/src/lib/vehicle-profile.ts b/src/lib/vehicle-profile.ts index e9565b5..cf56871 100644 --- a/src/lib/vehicle-profile.ts +++ b/src/lib/vehicle-profile.ts @@ -4,8 +4,6 @@ import { useEffect, useState } from "react"; import { z } from "zod"; import { FUEL_TYPE_MAP, type FuelType } from "@/types/fuel"; -const STORAGE_KEY = "pumperly-vehicle"; - export const vehicleProfileSchema = z.object({ tankL: z.number().finite().min(5).max(500), consumptionL100: z.number().finite().min(1).max(50), @@ -15,6 +13,16 @@ export type VehicleProfile = z.infer; export const DEFAULT_VEHICLE: VehicleProfile = { tankL: 50, consumptionL100: 6.5 }; +/** Stored apart from the fuel profile so each keeps its own values. */ +export const evProfileSchema = z.object({ + batteryKwh: z.number().finite().min(10).max(200), + consumptionKwh100: z.number().finite().min(5).max(50), +}); + +export type EvProfile = z.infer; + +export const DEFAULT_EV: EvProfile = { batteryKwh: 60, consumptionKwh100: 18 }; + /** * Fuels the refuel planner supports: priced per litre and burned by volume. * CNG/LNG/H2 are priced per kg and EV has no prices yet, so they are excluded. @@ -26,36 +34,52 @@ export function isPlannableFuel(fuel: FuelType): boolean { return fuel === "LPG"; } -export function readVehicleProfile(raw: string | null): VehicleProfile { - if (!raw) return DEFAULT_VEHICLE; +function readProfile(raw: string | null, schema: z.ZodType, fallback: T): T { + if (!raw) return fallback; try { - const parsed = vehicleProfileSchema.safeParse(JSON.parse(raw)); - return parsed.success ? parsed.data : DEFAULT_VEHICLE; + const parsed = schema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : fallback; } catch { - return DEFAULT_VEHICLE; + return fallback; } } -export function useVehicleProfile(): [VehicleProfile, (p: VehicleProfile) => void] { - const [profile, setProfile] = useState(() => { - if (typeof window === "undefined") return DEFAULT_VEHICLE; +export function readVehicleProfile(raw: string | null): VehicleProfile { + return readProfile(raw, vehicleProfileSchema, DEFAULT_VEHICLE); +} + +export function readEvProfile(raw: string | null): EvProfile { + return readProfile(raw, evProfileSchema, DEFAULT_EV); +} + +function useStoredProfile(key: string, schema: z.ZodType, fallback: T): [T, (p: T) => void] { + const [profile, setProfile] = useState(() => { + if (typeof window === "undefined") return fallback; try { - return readVehicleProfile(localStorage.getItem(STORAGE_KEY)); + return readProfile(localStorage.getItem(key), schema, fallback); } catch { // Storage blocked (private mode): fall back to defaults. - return DEFAULT_VEHICLE; + return fallback; } }); useEffect(() => { - if (vehicleProfileSchema.safeParse(profile).success) { + if (schema.safeParse(profile).success) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(profile)); + localStorage.setItem(key, JSON.stringify(profile)); } catch { // Storage blocked or full: profile still applies for this session. } } - }, [profile]); + }, [key, schema, profile]); return [profile, setProfile]; } + +export function useVehicleProfile(): [VehicleProfile, (p: VehicleProfile) => void] { + return useStoredProfile("pumperly-vehicle", vehicleProfileSchema, DEFAULT_VEHICLE); +} + +export function useEvProfile(): [EvProfile, (p: EvProfile) => void] { + return useStoredProfile("pumperly-ev", evProfileSchema, DEFAULT_EV); +} From 4b04b7efa3bb9b9e878ba692c88bc3d85a568162 Mon Sep 17 00:00:00 2001 From: yellowhat <1692490+yellowhat@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:55:03 +0200 Subject: [PATCH 05/19] feat(planner): plan EV charging stops by energy --- src/components/search/refuel-planner.test.tsx | 39 +++++ src/components/search/refuel-planner.tsx | 119 ++++++++++----- src/components/search/search-panel.tsx | 4 +- src/lib/i18n.tsx | 136 ++++++++++++++++++ src/lib/vehicle-profile.test.ts | 7 +- src/lib/vehicle-profile.ts | 4 +- 6 files changed, 267 insertions(+), 42 deletions(-) diff --git a/src/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx index a967be1..d5b7005 100644 --- a/src/components/search/refuel-planner.test.tsx +++ b/src/components/search/refuel-planner.test.tsx @@ -356,4 +356,43 @@ describe("RefuelPlanner", () => { expect(tank).toHaveValue(50); expect(JSON.parse(localStorage.getItem("pumperly-vehicle")!)).toEqual({ tankL: 50, consumptionL100: 6.5 }); }); + + describe("EV mode", () => { + // 60 kWh at 18 kWh/100 km: 0.3 % per km. From 50 % over 300 km, one stop + // capped at 80 % can't make it, so it takes both chargers. + const CHARGERS = [ + makeStation("x", { brand: "Ionity", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.3, detourMin: 0 }), + makeStation("y", { brand: "Tesla", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.6, detourMin: 0 }), + ]; + + it("plans price-less chargers by energy, capped at 80 %", async () => { + const { onPlanChange } = renderPlanner({ mode: "ev", stations: CHARGERS, routeKm: 300 }); + await userEvent.click(screen.getByText("planner.titleEv")); + expect(planSpy).toHaveBeenLastCalledWith(expect.objectContaining({ tankL: 60, consumptionL100: 18, maxChargePct: 80 })); + expect(onPlanChange.mock.lastCall![0].map((s: { id: string }) => s.id)).toEqual(["x", "y"]); + expect(screen.getByText("planner.tripEnergy")).toBeInTheDocument(); + expect(screen.getAllByText(/^\+\d+\.\d kWh$/)).toHaveLength(2); + }); + + it("shows battery fields, no value of time, and needs no exchange rates", async () => { + currencyState.currency = "HUF"; + renderPlanner({ mode: "ev", stations: CHARGERS, routeKm: 300 }); + await userEvent.click(screen.getByText("planner.titleEv")); + expect(screen.getByLabelText("planner.battery")).toHaveValue(60); + expect(screen.getByLabelText("planner.consumptionEv")).toHaveValue(18); + expect(screen.queryByText(/planner.timeValue/)).not.toBeInTheDocument(); + expect(screen.queryByText("planner.noRates")).not.toBeInTheDocument(); + expect(planSpy).toHaveBeenCalled(); + }); + + it("persists the EV profile apart from the fuel one", async () => { + renderPlanner({ mode: "ev", stations: CHARGERS, routeKm: 300 }); + await userEvent.click(screen.getByText("planner.titleEv")); + const battery = screen.getByLabelText("planner.battery"); + await userEvent.clear(battery); + await userEvent.type(battery, "77{Enter}"); + expect(JSON.parse(localStorage.getItem("pumperly-ev")!)).toEqual({ batteryKwh: 77, consumptionKwh100: 18 }); + expect(JSON.parse(localStorage.getItem("pumperly-vehicle")!)).toEqual({ tankL: 50, consumptionL100: 6.5 }); + }); + }); }); diff --git a/src/components/search/refuel-planner.tsx b/src/components/search/refuel-planner.tsx index 3da715b..bc854b7 100644 --- a/src/components/search/refuel-planner.tsx +++ b/src/components/search/refuel-planner.tsx @@ -5,14 +5,19 @@ import type { StationGeoJSON } from "@/types/station"; import { useI18n } from "@/lib/i18n"; import { useCurrency } from "@/lib/currency"; import { maxStopsFor, planRefuel, type PlanResult } from "@/lib/refuel-planner"; -import { useVehicleProfile, vehicleProfileSchema, type VehicleProfile } from "@/lib/vehicle-profile"; +import { evProfileSchema, useEvProfile, useVehicleProfile, vehicleProfileSchema } from "@/lib/vehicle-profile"; export interface PlannedStopMarker { id: string; coordinates: [number, number]; } +/** Fill-level cap for EV stops: charging slows sharply above ~80 %. */ +const EV_MAX_CHARGE_PCT = 80; + interface RefuelPlannerProps { + /** `ev` plans by energy only (kWh): chargers have no prices yet. */ + mode?: "fuel" | "ev"; /** Corridor stations (with routeFraction and, once streamed, detourMin). */ stations: StationGeoJSON[]; routeKm: number; @@ -50,6 +55,7 @@ export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = { }; export function RefuelPlanner({ + mode = "fuel", stations, routeKm, detoursLoading, @@ -65,18 +71,25 @@ export function RefuelPlanner({ }: RefuelPlannerProps) { const { t } = useI18n(); const { currency, symbol, decimals, formatPrice, convert, rates } = useCurrency(); - const [profile, setProfile] = useVehicleProfile(); + const ev = mode === "ev"; + const [fuelProfile, setFuelProfile] = useVehicleProfile(); + const [evProfile, setEvProfile] = useEvProfile(); + // Capacity is litres or kWh; the planner works in % of it either way. + const capacity = ev ? evProfile.batteryKwh : fuelProfile.tankL; + const consumption = ev ? evProfile.consumptionKwh100 : fuelProfile.consumptionL100; + const maxChargePct = ev ? EV_MAX_CHARGE_PCT : 100; const { open, startPct, arrivalPct, reservePct, timeValueEur } = settings; const update = (patch: Partial) => onSettingsChange({ ...settings, ...patch }); // Drafts so a half-typed number ("6.") doesn't get rejected mid-edit. - const [tankDraft, setTankDraft] = useState(String(profile.tankL)); - const [consDraft, setConsDraft] = useState(String(profile.consumptionL100)); + const [tankDraft, setTankDraft] = useState(String(capacity)); + const [consDraft, setConsDraft] = useState(String(consumption)); // Same for the value of time; null shows the saved (and planned) value. const [timeDraft, setTimeDraft] = useState(null); // The time value is stored in EUR: without a rate for the display currency it // can't be converted, and `convert` would silently treat it as 1:1. - const ratesMissing = currency !== "EUR" && !rates?.rates[currency]; + // EV plans ignore money, so they don't need rates. + const ratesMissing = !ev && currency !== "EUR" && !rates?.rates[currency]; const eurRate = convert(1, "EUR"); const timeValue = Number((timeValueEur * eurRate).toFixed(Math.min(decimals, 2))); @@ -86,16 +99,21 @@ export function RefuelPlanner({ const byId = useMemo(() => new Map(stations.map((s) => [s.properties.id, s])), [stations]); - // Priced stations exist but every detour request failed: filters are not the cause. + // Usable stations exist but every detour request failed: filters are not the cause. const detoursFailed = useMemo(() => { - const priced = stations.filter((s) => s.properties.price != null); - return priced.length > 0 && priced.every((s) => s.properties.detourMin != null && s.properties.detourMin < 0); - }, [stations]); + const usable = ev ? stations : stations.filter((s) => s.properties.price != null); + return usable.length > 0 && usable.every((s) => s.properties.detourMin != null && s.properties.detourMin < 0); + }, [ev, stations]); const plan: PlanResult | null = useMemo(() => { if (!open || ratesMissing || detoursPending || routeKm <= 0) return null; const candidates = stations.flatMap((s) => { const p = s.properties; + if (ev) { + // Energy only: every charger with a detour is a candidate at no cost per kWh. + if (p.detourMin == null || p.detourMin < 0 || (maxDetour != null && p.detourMin > maxDetour)) return []; + return [{ id: p.id, km: (p.routeFraction ?? 0) * routeKm, price: 0, detourMin: p.detourMin }]; + } // Mixed currencies can't be compared; only use prices in the display currency. if (p.price == null || p.price <= 0 || p.detourMin == null || p.detourMin < 0 || p.currency !== currency) return []; if ((maxPrice != null && p.price > maxPrice) || (maxDetour != null && p.detourMin > maxDetour)) return []; @@ -104,15 +122,17 @@ export function RefuelPlanner({ return planRefuel({ routeKm, stations: candidates, - tankL: profile.tankL, - consumptionL100: profile.consumptionL100, + tankL: capacity, + consumptionL100: consumption, startPct, arrivalPct, reservePct, - timeValuePerHour: timeValue, - maxStops: maxStopsFor(routeKm, profile.tankL, profile.consumptionL100, reservePct), + // With no price, only detour time is weighed, so any positive value gives the same plan. + timeValuePerHour: ev ? 1 : timeValue, + maxStops: maxStopsFor(routeKm, capacity, consumption, reservePct, maxChargePct), + maxChargePct, }); - }, [open, ratesMissing, detoursPending, routeKm, stations, maxPrice, maxDetour, currency, profile, startPct, arrivalPct, reservePct, timeValue]); + }, [open, ratesMissing, detoursPending, routeKm, stations, ev, maxPrice, maxDetour, currency, capacity, consumption, maxChargePct, startPct, arrivalPct, reservePct, timeValue]); useEffect(() => { const stops = plan?.status === "ok" @@ -129,11 +149,13 @@ export function RefuelPlanner({ // Commit on blur/Enter: saving on every keystroke would store "8" and "80" // on the way to an invalid "800". Invalid input reverts to the saved value. - const commitField = (key: keyof VehicleProfile, raw: string) => { - const next = { ...profile, [key]: parseFloat(raw.replace(",", ".")) }; - if (vehicleProfileSchema.safeParse(next).success) setProfile(next); - else if (key === "tankL") setTankDraft(String(profile.tankL)); - else setConsDraft(String(profile.consumptionL100)); + const commitField = (field: "capacity" | "consumption", raw: string) => { + const v = parseFloat(raw.replace(",", ".")); + const ok = ev + ? commitProfile(evProfileSchema, { ...evProfile, [field === "capacity" ? "batteryKwh" : "consumptionKwh100"]: v }, setEvProfile) + : commitProfile(vehicleProfileSchema, { ...fuelProfile, [field === "capacity" ? "tankL" : "consumptionL100"]: v }, setFuelProfile); + if (!ok && field === "capacity") setTankDraft(String(capacity)); + else if (!ok) setConsDraft(String(consumption)); }; const commitTimeValue = (raw: string) => { const v = parseFloat(raw.replace(",", ".")); @@ -144,6 +166,7 @@ export function RefuelPlanner({ if (e.key === "Enter") e.currentTarget.blur(); }; + const kwh = (v: number) => `${v.toFixed(1)} kWh`; const money = (v: number) => `${v.toFixed(Math.min(decimals, 2))} ${symbol}`; const infeasibleText = (p: PlanResult) => { @@ -155,7 +178,7 @@ export function RefuelPlanner({ case "stops": return t("planner.infeasibleStops"); case "no-candidates": - return t(detoursFailed ? "planner.noDetours" : "planner.infeasibleNoCandidates"); + return t(detoursFailed ? "planner.noDetours" : ev ? "planner.infeasibleNoChargers" : "planner.infeasibleNoCandidates"); default: return t("planner.infeasible").replace("{km}", String(Math.round(p.gapKm ?? 0))); } @@ -168,7 +191,7 @@ export function RefuelPlanner({ aria-expanded={open} className="flex w-full items-center justify-between px-4 py-2.5 text-left" > - {t("planner.title")} + {t(ev ? "planner.titleEv" : "planner.title")} @@ -179,39 +202,39 @@ export function RefuelPlanner({
- update({ startPct: v })} /> - update({ arrivalPct: v })} /> + update({ startPct: v })} /> + update({ arrivalPct: v })} /> update({ reservePct: v })} /> - }
{/* Result */}
+ {ev && routeKm > 0 && ( +

+ {t("planner.tripEnergy").replace("{kwh}", kwh((routeKm * consumption) / 100))} +

+ )} {ratesMissing ? (

{t("planner.noRates")}

) : detoursPending ? ( @@ -262,19 +290,27 @@ export function RefuelPlanner({

-

{money(stop.cost)}

-

- {stop.litres.toFixed(1)} L · {formatPrice(f.properties.price ?? 0)} {symbol}/L -

+ {ev ? ( +

+{kwh(stop.litres)}

+ ) : ( + <> +

{money(stop.cost)}

+

+ {stop.litres.toFixed(1)} L · {formatPrice(f.properties.price ?? 0)} {symbol}/L +

+ + )}
); })}
- {t("planner.total")} · {t("planner.atDestination")} {Math.round(plan.endPct)}% + {t(ev ? "planner.energyTotal" : "planner.total")} · {t("planner.atDestination")} {Math.round(plan.endPct)}% + + + {ev ? kwh(plan.stops.reduce((sum, s) => sum + s.litres, 0)) : money(plan.totalFuelCost)} - {money(plan.totalFuelCost)}
{plan.dipsBelowReserve && ( @@ -327,3 +363,10 @@ function FuelGauge({ plan, routeKm, reservePct }: { plan: PlanResult; routeKm: n ); } + +/** Saves `next` when it passes `schema`; false means the input was rejected. */ +function commitProfile(schema: { safeParse: (v: unknown) => { success: boolean } }, next: T, save: (p: T) => void): boolean { + if (!schema.safeParse(next).success) return false; + save(next); + return true; +} diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx index 7cd3f17..c1c9e49 100644 --- a/src/components/search/search-panel.tsx +++ b/src/components/search/search-panel.tsx @@ -784,11 +784,13 @@ export function SearchPanel({ )} - {/* Refuel stop planner — liquid fuels only. Collapsing the panel hides it + {/* Refuel/charge stop planner — liquid fuels and EV (energy only). Collapsing the panel hides it instead of unmounting it: its unmount clears the numbered map markers. */} {phase === "route" && primaryRoute && allCorridorStations.length > 0 && selectedFuel && isPlannableFuel(selectedFuel as FuelType) && (