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({
{/* 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) && (
> = {
"planner.noDetours": "No se han podido calcular los tiempos de desvío de esta ruta.",
"planner.total": "Coste de combustible",
"planner.atDestination": "en destino",
+ "planner.titleEv": "Planificar recargas",
+ "planner.battery": "Batería (kWh)",
+ "planner.consumptionEv": "Consumo (kWh/100 km)",
+ "planner.startEv": "Batería al salir",
+ "planner.arrivalEv": "Batería al llegar",
+ "planner.energyTotal": "Energía recargada",
+ "planner.tripEnergy": "El viaje consume unos {kwh}",
+ "planner.infeasibleNoChargers": "Ningún cargador de la ruta tiene un desvío utilizable. Relaja el filtro de desvío o amplía el corredor.",
"fuel.diesel": "Diésel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
@@ -167,6 +175,14 @@ export const translations: Record> = {
"planner.noDetours": "Detour times could not be calculated for this route.",
"planner.total": "Fuel cost",
"planner.atDestination": "at destination",
+ "planner.titleEv": "Plan charging stops",
+ "planner.battery": "Battery (kWh)",
+ "planner.consumptionEv": "Consumption (kWh/100 km)",
+ "planner.startEv": "Battery at start",
+ "planner.arrivalEv": "Battery at arrival",
+ "planner.energyTotal": "Energy charged",
+ "planner.tripEnergy": "The trip uses about {kwh}",
+ "planner.infeasibleNoChargers": "No charger on the route has a usable detour. Relax the detour filter or widen the corridor.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Gasoline",
"fuel.gas": "Gas",
@@ -250,6 +266,14 @@ export const translations: Record> = {
"planner.noDetours": "Impossible de calculer les temps de détour pour ce trajet.",
"planner.total": "Coût du carburant",
"planner.atDestination": "à destination",
+ "planner.titleEv": "Planifier les recharges",
+ "planner.battery": "Batterie (kWh)",
+ "planner.consumptionEv": "Consommation (kWh/100 km)",
+ "planner.startEv": "Batterie au départ",
+ "planner.arrivalEv": "Batterie à l'arrivée",
+ "planner.energyTotal": "Énergie rechargée",
+ "planner.tripEnergy": "Le trajet consomme environ {kwh}",
+ "planner.infeasibleNoChargers": "Aucune borne sur l'itinéraire n'a de détour utilisable. Assouplissez le filtre de détour ou élargissez le corridor.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Essence",
"fuel.gas": "Gaz",
@@ -333,6 +357,14 @@ export const translations: Record> = {
"planner.noDetours": "Die Umwegzeiten für diese Route konnten nicht berechnet werden.",
"planner.total": "Kraftstoffkosten",
"planner.atDestination": "am Ziel",
+ "planner.titleEv": "Ladestopps planen",
+ "planner.battery": "Akku (kWh)",
+ "planner.consumptionEv": "Verbrauch (kWh/100 km)",
+ "planner.startEv": "Akku bei Abfahrt",
+ "planner.arrivalEv": "Akku bei Ankunft",
+ "planner.energyTotal": "Geladene Energie",
+ "planner.tripEnergy": "Die Fahrt verbraucht etwa {kwh}",
+ "planner.infeasibleNoChargers": "Keine Ladestation auf der Route hat einen nutzbaren Umweg. Lockere den Umwegfilter oder erweitere den Korridor.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -416,6 +448,14 @@ export const translations: Record> = {
"planner.noDetours": "Non è stato possibile calcolare i tempi di deviazione per questo percorso.",
"planner.total": "Costo carburante",
"planner.atDestination": "a destinazione",
+ "planner.titleEv": "Pianifica le ricariche",
+ "planner.battery": "Batteria (kWh)",
+ "planner.consumptionEv": "Consumo (kWh/100 km)",
+ "planner.startEv": "Batteria alla partenza",
+ "planner.arrivalEv": "Batteria all'arrivo",
+ "planner.energyTotal": "Energia ricaricata",
+ "planner.tripEnergy": "Il viaggio consuma circa {kwh}",
+ "planner.infeasibleNoChargers": "Nessuna colonnina sul percorso ha una deviazione utilizzabile. Allenta il filtro deviazione o allarga il corridoio.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzina",
"fuel.gas": "Gas",
@@ -499,6 +539,14 @@ export const translations: Record> = {
"planner.noDetours": "Não foi possível calcular os tempos de desvio para este percurso.",
"planner.total": "Custo do combustível",
"planner.atDestination": "no destino",
+ "planner.titleEv": "Planear carregamentos",
+ "planner.battery": "Bateria (kWh)",
+ "planner.consumptionEv": "Consumo (kWh/100 km)",
+ "planner.startEv": "Bateria à partida",
+ "planner.arrivalEv": "Bateria à chegada",
+ "planner.energyTotal": "Energia carregada",
+ "planner.tripEnergy": "A viagem consome cerca de {kwh}",
+ "planner.infeasibleNoChargers": "Nenhum posto de carregamento na rota tem um desvio utilizável. Alivie o filtro de desvio ou alargue o corredor.",
"fuel.diesel": "Gasóleo",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gás",
@@ -582,6 +630,14 @@ export const translations: Record> = {
"planner.noDetours": "Nie udało się obliczyć czasów objazdów dla tej trasy.",
"planner.total": "Koszt paliwa",
"planner.atDestination": "u celu",
+ "planner.titleEv": "Zaplanuj ładowania",
+ "planner.battery": "Bateria (kWh)",
+ "planner.consumptionEv": "Zużycie (kWh/100 km)",
+ "planner.startEv": "Bateria na starcie",
+ "planner.arrivalEv": "Bateria na miejscu",
+ "planner.energyTotal": "Naładowana energia",
+ "planner.tripEnergy": "Podróż zużyje około {kwh}",
+ "planner.infeasibleNoChargers": "Żadna ładowarka na trasie nie ma użytecznego objazdu. Poluzuj filtr objazdu lub poszerz korytarz.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzyna",
"fuel.gas": "Gaz",
@@ -665,6 +721,14 @@ export const translations: Record> = {
"planner.noDetours": "Časy objížděk pro tuto trasu se nepodařilo vypočítat.",
"planner.total": "Cena paliva",
"planner.atDestination": "v cíli",
+ "planner.titleEv": "Naplánovat nabíjení",
+ "planner.battery": "Baterie (kWh)",
+ "planner.consumptionEv": "Spotřeba (kWh/100 km)",
+ "planner.startEv": "Baterie při odjezdu",
+ "planner.arrivalEv": "Baterie při příjezdu",
+ "planner.energyTotal": "Nabitá energie",
+ "planner.tripEnergy": "Cesta spotřebuje asi {kwh}",
+ "planner.infeasibleNoChargers": "Žádná nabíječka na trase nemá použitelnou odbočku. Uvolněte filtr odbočky nebo rozšiřte koridor.",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -748,6 +812,14 @@ export const translations: Record> = {
"planner.noDetours": "Ehhez az útvonalhoz nem sikerült kiszámítani a kitérők idejét.",
"planner.total": "Üzemanyagköltség",
"planner.atDestination": "célban",
+ "planner.titleEv": "Töltési megállók tervezése",
+ "planner.battery": "Akkumulátor (kWh)",
+ "planner.consumptionEv": "Fogyasztás (kWh/100 km)",
+ "planner.startEv": "Akku induláskor",
+ "planner.arrivalEv": "Akku érkezéskor",
+ "planner.energyTotal": "Töltött energia",
+ "planner.tripEnergy": "Az út kb. {kwh} energiát használ",
+ "planner.infeasibleNoChargers": "Az útvonalon egyik töltőnek sincs használható kitérője. Lazítson a kitérőszűrőn vagy szélesítse a folyosót.",
"fuel.diesel": "Dízel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gáz",
@@ -831,6 +903,14 @@ export const translations: Record> = {
"planner.noDetours": "Времето за отклоненията по този маршрут не можа да бъде изчислено.",
"planner.total": "Разход за гориво",
"planner.atDestination": "в крайната точка",
+ "planner.titleEv": "Планирай зареждания",
+ "planner.battery": "Батерия (kWh)",
+ "planner.consumptionEv": "Разход (kWh/100 km)",
+ "planner.startEv": "Батерия при тръгване",
+ "planner.arrivalEv": "Батерия при пристигане",
+ "planner.energyTotal": "Заредена енергия",
+ "planner.tripEnergy": "Пътуването изразходва около {kwh}",
+ "planner.infeasibleNoChargers": "Никоя зарядна станция по маршрута няма използваемо отклонение. Разхлабете филтъра за отклонение или разширете коридора.",
"fuel.diesel": "Дизел",
"fuel.gasoline": "Бензин",
"fuel.gas": "Газ",
@@ -914,6 +994,14 @@ export const translations: Record> = {
"planner.noDetours": "Časy obchádzok pre túto trasu sa nepodarilo vypočítať.",
"planner.total": "Cena paliva",
"planner.atDestination": "v cieli",
+ "planner.titleEv": "Naplánovať nabíjanie",
+ "planner.battery": "Batéria (kWh)",
+ "planner.consumptionEv": "Spotreba (kWh/100 km)",
+ "planner.startEv": "Batéria pri odchode",
+ "planner.arrivalEv": "Batéria pri príchode",
+ "planner.energyTotal": "Nabitá energia",
+ "planner.tripEnergy": "Cesta spotrebuje asi {kwh}",
+ "planner.infeasibleNoChargers": "Žiadna nabíjačka na trase nemá použiteľnú odbočku. Uvoľnite filter odbočky alebo rozšírte koridor.",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -997,6 +1085,14 @@ export const translations: Record> = {
"planner.noDetours": "Omvejstiderne for denne rute kunne ikke beregnes.",
"planner.total": "Brændstofudgift",
"planner.atDestination": "ved destinationen",
+ "planner.titleEv": "Planlæg opladning",
+ "planner.battery": "Batteri (kWh)",
+ "planner.consumptionEv": "Forbrug (kWh/100 km)",
+ "planner.startEv": "Batteri ved start",
+ "planner.arrivalEv": "Batteri ved ankomst",
+ "planner.energyTotal": "Opladet energi",
+ "planner.tripEnergy": "Turen bruger cirka {kwh}",
+ "planner.infeasibleNoChargers": "Ingen lader på ruten har en brugbar omvej. Løsn omvejsfilteret eller udvid korridoren.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1080,6 +1176,14 @@ export const translations: Record> = {
"planner.noDetours": "Omvägstiderna för den här rutten kunde inte beräknas.",
"planner.total": "Bränslekostnad",
"planner.atDestination": "vid målet",
+ "planner.titleEv": "Planera laddstopp",
+ "planner.battery": "Batteri (kWh)",
+ "planner.consumptionEv": "Förbrukning (kWh/100 km)",
+ "planner.startEv": "Batteri vid start",
+ "planner.arrivalEv": "Batteri vid ankomst",
+ "planner.energyTotal": "Laddad energi",
+ "planner.tripEnergy": "Resan förbrukar ungefär {kwh}",
+ "planner.infeasibleNoChargers": "Ingen laddare längs rutten har en användbar omväg. Lätta på omvägsfiltret eller bredda korridoren.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gas",
@@ -1163,6 +1267,14 @@ export const translations: Record> = {
"planner.noDetours": "Omveistidene for denne ruten kunne ikke beregnes.",
"planner.total": "Drivstoffkostnad",
"planner.atDestination": "ved målet",
+ "planner.titleEv": "Planlegg lading",
+ "planner.battery": "Batteri (kWh)",
+ "planner.consumptionEv": "Forbruk (kWh/100 km)",
+ "planner.startEv": "Batteri ved start",
+ "planner.arrivalEv": "Batteri ved ankomst",
+ "planner.energyTotal": "Ladet energi",
+ "planner.tripEnergy": "Turen bruker omtrent {kwh}",
+ "planner.infeasibleNoChargers": "Ingen lader langs ruten har en brukbar omvei. Løsne omveisfilteret eller utvid korridoren.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gass",
@@ -1246,6 +1358,14 @@ export const translations: Record> = {
"planner.noDetours": "Vreme obilazaka za ovu rutu nije moglo da se izračuna.",
"planner.total": "Trošak goriva",
"planner.atDestination": "na odredištu",
+ "planner.titleEv": "Planiraj punjenja",
+ "planner.battery": "Baterija (kWh)",
+ "planner.consumptionEv": "Potrošnja (kWh/100 km)",
+ "planner.startEv": "Baterija na polasku",
+ "planner.arrivalEv": "Baterija po dolasku",
+ "planner.energyTotal": "Napunjena energija",
+ "planner.tripEnergy": "Put troši oko {kwh}",
+ "planner.infeasibleNoChargers": "Nijedan punjač na ruti nema upotrebljivo skretanje. Ublažite filter skretanja ili proširite koridor.",
"fuel.diesel": "Dizel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1329,6 +1449,14 @@ export const translations: Record> = {
"planner.noDetours": "Tämän reitin kiertoteiden aikoja ei voitu laskea.",
"planner.total": "Polttoainekulut",
"planner.atDestination": "perillä",
+ "planner.titleEv": "Suunnittele lataukset",
+ "planner.battery": "Akku (kWh)",
+ "planner.consumptionEv": "Kulutus (kWh/100 km)",
+ "planner.startEv": "Akku lähtiessä",
+ "planner.arrivalEv": "Akku perillä",
+ "planner.energyTotal": "Ladattu energia",
+ "planner.tripEnergy": "Matka kuluttaa noin {kwh}",
+ "planner.infeasibleNoChargers": "Millään reitin laturilla ei ole käyttökelpoista kiertoa. Löysennä kiertosuodatinta tai levennä käytävää.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensiini",
"fuel.gas": "Kaasu",
@@ -1412,6 +1540,14 @@ export const translations: Record> = {
"planner.noDetours": "No s'han pogut calcular els temps de desviament d'aquesta ruta.",
"planner.total": "Cost de combustible",
"planner.atDestination": "a destinació",
+ "planner.titleEv": "Planificar recàrregues",
+ "planner.battery": "Bateria (kWh)",
+ "planner.consumptionEv": "Consum (kWh/100 km)",
+ "planner.startEv": "Bateria en sortir",
+ "planner.arrivalEv": "Bateria en arribar",
+ "planner.energyTotal": "Energia recarregada",
+ "planner.tripEnergy": "El viatge consumeix uns {kwh}",
+ "planner.infeasibleNoChargers": "Cap carregador de la ruta té un desviament utilitzable. Relaxa el filtre de desviament o amplia el corredor.",
"fuel.diesel": "Dièsel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
diff --git a/src/lib/vehicle-profile.test.ts b/src/lib/vehicle-profile.test.ts
index c8ed09e..d9af191 100644
--- a/src/lib/vehicle-profile.test.ts
+++ b/src/lib/vehicle-profile.test.ts
@@ -33,10 +33,13 @@ describe("isPlannableFuel", () => {
expect(isPlannableFuel("LPG")).toBe(true);
});
- it("rejects per-kg fuels, EV and AdBlue", () => {
+ it("accepts EV (planned by energy)", () => {
+ expect(isPlannableFuel("EV")).toBe(true);
+ });
+
+ it("rejects per-kg fuels and AdBlue", () => {
expect(isPlannableFuel("CNG")).toBe(false);
expect(isPlannableFuel("H2")).toBe(false);
- expect(isPlannableFuel("EV")).toBe(false);
expect(isPlannableFuel("ADBLUE")).toBe(false);
});
});
diff --git a/src/lib/vehicle-profile.ts b/src/lib/vehicle-profile.ts
index cf56871..679a448 100644
--- a/src/lib/vehicle-profile.ts
+++ b/src/lib/vehicle-profile.ts
@@ -25,9 +25,11 @@ 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.
+ * CNG/LNG/H2 are priced per kg, so they are excluded. EV is planned by energy
+ * (kWh) alone, since chargers have no prices yet.
*/
export function isPlannableFuel(fuel: FuelType): boolean {
+ if (fuel === "EV") return true;
const info = FUEL_TYPE_MAP.get(fuel);
if (!info) return false;
if (info.category === "gasoline" || info.category === "diesel") return true;
From edefd6fd3466a81da654ea59ff2e35029f208191 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:55:40 +0200
Subject: [PATCH 06/19] feat(scrapers): store EV charger max power
---
.../migration.sql | 5 ++++
prisma/schema.prisma | 2 ++
src/scrapers/base.test.ts | 27 +++++++++++++++++--
src/scrapers/base.ts | 21 +++++++++++----
4 files changed, 48 insertions(+), 7 deletions(-)
create mode 100644 prisma/migrations/20260930000000_station_max_power_kw/migration.sql
diff --git a/prisma/migrations/20260930000000_station_max_power_kw/migration.sql b/prisma/migrations/20260930000000_station_max_power_kw/migration.sql
new file mode 100644
index 0000000..257a954
--- /dev/null
+++ b/prisma/migrations/20260930000000_station_max_power_kw/migration.sql
@@ -0,0 +1,5 @@
+-- Highest single-connector power (kW) for EV chargers, filled by the OCM, REVE
+-- and BNetzA scrapers. Nullable: fuel stations and chargers with no published
+-- power keep NULL. ADD COLUMN with no default is metadata-only, so it is
+-- instant even on a full table.
+ALTER TABLE "stations" ADD COLUMN IF NOT EXISTS "max_power_kw" SMALLINT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e688644..32c40b3 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -19,6 +19,8 @@ model Station {
city String
province String?
stationType String @default("fuel") @map("station_type") @db.VarChar(20)
+ /// Highest single-connector power (kW) for EV chargers; null when unknown or not a charger.
+ maxPowerKw Int? @map("max_power_kw") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
diff --git a/src/scrapers/base.test.ts b/src/scrapers/base.test.ts
index 1aae2b7..57e7c14 100644
--- a/src/scrapers/base.test.ts
+++ b/src/scrapers/base.test.ts
@@ -22,7 +22,7 @@ vi.mock("../generated/prisma/client", () => ({
}));
// Concrete test scraper
-import { BaseScraper, bandFor, type RawStation, type RawFuelPrice } from "./base";
+import { BaseScraper, bandFor, sanePowerKw, type RawStation, type RawFuelPrice } from "./base";
describe("bandFor (per-currency price bands)", () => {
it("accepts a plausible HUF price (595)", () => {
@@ -155,7 +155,7 @@ describe("BaseScraper.run()", () => {
});
it("upserts stations and prices in full pipeline", async () => {
- scraper.mockStations = [makeStation("s1"), makeStation("s2")];
+ scraper.mockStations = [makeStation("s1"), { ...makeStation("s2"), maxPowerKw: 150 }];
scraper.mockPrices = [
makePrice("s1", "B7", 1.45),
makePrice("s2", "E5", 1.60),
@@ -184,6 +184,12 @@ describe("BaseScraper.run()", () => {
(c) => typeof c[0] === "string" && c[0].includes("INSERT INTO stations"),
);
expect(stationCall).toBeDefined();
+ // 11 params per station; the 11th is max_power_kw (null when unset).
+ expect(stationCall![0]).toContain("max_power_kw = EXCLUDED.max_power_kw");
+ const params = stationCall!.slice(1);
+ expect(params).toHaveLength(22);
+ expect(params[10]).toBeNull();
+ expect(params[21]).toBe(150);
// Verify price insert SQL was called
const priceCall = mockExecuteRawUnsafe.mock.calls.find(
@@ -404,3 +410,20 @@ describe("BaseScraper.run()", () => {
expect(mockDisconnect).toHaveBeenCalledTimes(1);
});
});
+
+describe("sanePowerKw", () => {
+ it("rounds plausible values", () => {
+ expect(sanePowerKw(22)).toBe(22);
+ expect(sanePowerKw(149.6)).toBe(150);
+ });
+
+ it("returns null for missing, implausible or sub-kW values", () => {
+ expect(sanePowerKw(null)).toBeNull();
+ expect(sanePowerKw(undefined)).toBeNull();
+ expect(sanePowerKw(Number.NaN)).toBeNull();
+ expect(sanePowerKw(0)).toBeNull();
+ expect(sanePowerKw(-5)).toBeNull();
+ expect(sanePowerKw(0.3)).toBeNull();
+ expect(sanePowerKw(1001)).toBeNull();
+ });
+});
diff --git a/src/scrapers/base.ts b/src/scrapers/base.ts
index 54d9554..96d6ef9 100644
--- a/src/scrapers/base.ts
+++ b/src/scrapers/base.ts
@@ -38,6 +38,15 @@ export interface RawStation {
latitude: number;
longitude: number;
stationType: "fuel" | "ev_charger" | "both";
+ /** EV chargers: highest single-connector power in kW, null/unset when unknown. */
+ maxPowerKw?: number | null;
+}
+
+/** A published charger power as kW, or null when missing or implausible (≤ 0 or > 1000). */
+export function sanePowerKw(kw: number | null | undefined): number | null {
+ if (typeof kw !== "number" || !Number.isFinite(kw) || kw > 1000) return null;
+ const rounded = Math.round(kw);
+ return rounded > 0 ? rounded : null;
}
export interface RawFuelPrice {
@@ -335,19 +344,19 @@ export abstract class BaseScraper {
if (batch.length === 0) return 0;
// Build parameterised VALUES list.
- // Each station needs 10 params: externalId, country, name, brand,
- // address, city, province, stationType, longitude, latitude
+ // Each station needs 11 params: externalId, country, name, brand,
+ // address, city, province, stationType, longitude, latitude, maxPowerKw
const params: unknown[] = [];
const valueClauses: string[] = [];
for (let i = 0; i < batch.length; i++) {
const s = batch[i];
- const offset = i * 10;
+ const offset = i * 11;
valueClauses.push(
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` +
`$${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, ` +
`ST_SetSRID(ST_MakePoint($${offset + 9}, $${offset + 10}), 4326), ` +
- `NOW(), NOW())`,
+ `$${offset + 11}::smallint, NOW(), NOW())`,
);
params.push(
s.externalId,
@@ -360,11 +369,12 @@ export abstract class BaseScraper {
s.stationType,
s.longitude,
s.latitude,
+ s.maxPowerKw ?? null,
);
}
const sql = `
- INSERT INTO stations (external_id, country, name, brand, address, city, province, station_type, geom, created_at, updated_at)
+ INSERT INTO stations (external_id, country, name, brand, address, city, province, station_type, geom, max_power_kw, created_at, updated_at)
VALUES ${valueClauses.join(",\n")}
ON CONFLICT (external_id, country)
DO UPDATE SET
@@ -375,6 +385,7 @@ export abstract class BaseScraper {
province = EXCLUDED.province,
station_type = EXCLUDED.station_type,
geom = EXCLUDED.geom,
+ max_power_kw = EXCLUDED.max_power_kw,
updated_at = NOW()
`;
From 308e0e4e425bbf89e6a30723463123a94cb77a19 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:56:08 +0200
Subject: [PATCH 07/19] feat(scrapers): read charger power from BNetzA, OCM and
REVE
---
src/scrapers/bnetza.test.ts | 26 +++++++++++++++++++++++++
src/scrapers/bnetza.ts | 39 +++++++++++++++++++++++++++++++++----
src/scrapers/ocm.test.ts | 25 ++++++++++++++++++++++++
src/scrapers/ocm.ts | 5 ++++-
src/scrapers/reve.test.ts | 1 +
src/scrapers/reve.ts | 5 +++--
6 files changed, 94 insertions(+), 7 deletions(-)
diff --git a/src/scrapers/bnetza.test.ts b/src/scrapers/bnetza.test.ts
index f60e293..e93bb5d 100644
--- a/src/scrapers/bnetza.test.ts
+++ b/src/scrapers/bnetza.test.ts
@@ -75,6 +75,31 @@ function tsv(...rows: string[]): string {
}
describe("parseBnetzaTsv", () => {
+ it("reads the fastest plug, including German decimals, and keeps the max across merged rows", async () => {
+ const { parseBnetzaTsv } = await import("./bnetza");
+ const { stations } = parseBnetzaTsv(
+ tsv(
+ row({ "Nennleistung Stecker1": "11", "Nennleistung Stecker2": "22,5" }),
+ // Same location, a faster charger on another row.
+ row({ "Nennleistung Stecker1": "150", "Nennleistung Stecker2": "" }),
+ // Same location, slower again: must not lower the max.
+ row({ "Nennleistung Stecker1": "3,7", "Nennleistung Stecker2": "" }),
+ row({ Breitengrad: "51.0", "Längengrad": "10.0", "Nennleistung Stecker1": "", "Nennleistung Stecker2": "abc" }),
+ ),
+ );
+ expect(stations.map((s) => s.maxPowerKw)).toEqual([150, null]);
+ const single = parseBnetzaTsv(tsv(row({ "Nennleistung Stecker1": "11", "Nennleistung Stecker2": "22,5" })));
+ expect(single.stations[0].maxPowerKw).toBe(23);
+ });
+
+ it("still imports when the plug power columns are renamed", async () => {
+ const { parseBnetzaTsv } = await import("./bnetza");
+ const text = tsv(row()).replace(/Nennleistung Stecker/g, "Leistung Stecker");
+ const { stations } = parseBnetzaTsv(text);
+ expect(stations).toHaveLength(1);
+ expect(stations[0].maxPowerKw).toBeNull();
+ });
+
it("maps an operational row into an EV charger station", async () => {
const { parseBnetzaTsv } = await import("./bnetza");
const { stations, stats } = parseBnetzaTsv(tsv(row()));
@@ -90,6 +115,7 @@ describe("parseBnetzaTsv", () => {
latitude: 52.510055,
longitude: 13.377592,
stationType: "ev_charger",
+ maxPowerKw: 22,
});
expect(stats).toEqual({
totalRows: 1,
diff --git a/src/scrapers/bnetza.ts b/src/scrapers/bnetza.ts
index 6d37455..6c829ad 100644
--- a/src/scrapers/bnetza.ts
+++ b/src/scrapers/bnetza.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
-import { BaseScraper, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
+import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
// ---------------------------------------------------------------------------
// BNetzA Ladesäulenregister — official German EV charging point registry
@@ -101,6 +101,12 @@ const COLUMNS = {
type ColumnIndices = Record;
+// Per-plug power, the most a car can draw at one point (the facility-level
+// "Nennleistung Ladeeinrichtung" sums the points). Optional, unlike COLUMNS:
+// losing power is a degraded map, not bad data, so a rename must not abort the
+// import. Missing columns resolve to -1 and are skipped.
+const PLUG_POWER_COLUMNS = [1, 2, 3, 4].map((n) => `Nennleistung Stecker${n}`);
+
// Every field is a `string` by construction after splitting on tabs, so a
// shape-only schema here would be incapable of failing. Zod earns its place by
// owning the string→number conversion instead.
@@ -163,11 +169,15 @@ export function stationKey(latitude: number, longitude: number): string {
* catches it and returns a no-op run, so an upstream format change costs a
* scrape cycle instead of corrupting the table.
*/
-function resolveColumns(headerLine: string): ColumnIndices {
- const header = headerLine
- .replace(/^/, "")
+function headerNames(headerLine: string): string[] {
+ return headerLine
+ .replace(/^\uFEFF/, "")
.split("\t")
.map((h) => unquote(h).toLowerCase());
+}
+
+function resolveColumns(headerLine: string): ColumnIndices {
+ const header = headerNames(headerLine);
const indices = {} as ColumnIndices;
for (const [field, name] of Object.entries(COLUMNS) as Array<
@@ -182,6 +192,21 @@ function resolveColumns(headerLine: string): ColumnIndices {
return indices;
}
+function resolvePlugPowerColumns(headerLine: string): number[] {
+ const header = headerNames(headerLine);
+ return PLUG_POWER_COLUMNS.map((name) => header.indexOf(name.toLowerCase())).filter((i) => i >= 0);
+}
+
+/** Highest plug power on a row, kW. Values are "22" or German-style "22,5". */
+function rowPowerKw(fields: string[], plugCols: number[]): number | null {
+ let max = 0;
+ for (const i of plugCols) {
+ const kw = Number(unquote(fields[i]).replace(",", "."));
+ if (Number.isFinite(kw) && kw > max) max = kw;
+ }
+ return sanePowerKw(max);
+}
+
/**
* Parse the register TSV into deduplicated stations.
*
@@ -196,6 +221,7 @@ export function parseBnetzaTsv(text: string): {
// otherwise append a stray \r to the last column and break the status filter.
const lines = text.split(/\r?\n/);
const cols = resolveColumns(lines[0] ?? "");
+ const plugCols = resolvePlugPowerColumns(lines[0] ?? "");
const fieldCount = (lines[0] ?? "").split("\t").length;
const stats: BnetzaParseStats = {
@@ -254,9 +280,13 @@ export function parseBnetzaTsv(text: string): {
const key = stationKey(latitude, longitude);
const operator = unquote(fields[cols.operator]);
+ const powerKw = rowPowerKw(fields, plugCols);
+
const existing = byKey.get(key);
if (existing) {
stats.mergedDuplicates++;
+ // A location's power is its fastest charger, whichever row it is on.
+ if (powerKw != null && powerKw > (existing.maxPowerKw ?? 0)) existing.maxPowerKw = powerKw;
if (operator && existing.brand && operator !== existing.brand) {
stats.operatorConflicts++;
}
@@ -291,6 +321,7 @@ export function parseBnetzaTsv(text: string): {
latitude,
longitude,
stationType: "ev_charger",
+ maxPowerKw: powerKw,
});
}
diff --git a/src/scrapers/ocm.test.ts b/src/scrapers/ocm.test.ts
index f69f206..5993c50 100644
--- a/src/scrapers/ocm.test.ts
+++ b/src/scrapers/ocm.test.ts
@@ -85,12 +85,37 @@ describe("OCMScraper", () => {
expect(tesla!.province).toBe("Berlin");
expect(tesla!.stationType).toBe("ev_charger");
expect(tesla!.latitude).toBeCloseTo(52.52, 2);
+ expect(tesla!.maxPowerKw).toBe(250);
// Station without OperatorInfo or Title
const noName = stations.find((s) => s.externalId === "ocm-67890");
expect(noName).toBeDefined();
expect(noName!.name).toBe("EV Charger 67890");
expect(noName!.brand).toBeNull();
+ expect(noName!.maxPowerKw).toBeNull();
+ });
+
+ it("takes the fastest connector and keeps a POI whose power is malformed", async () => {
+ const { OCMScraper } = await import("./ocm");
+ const scraper = new OCMScraper("DE");
+ const at = (ID: number, Connections: unknown) => ({
+ ID,
+ AddressInfo: { AddressLine1: "A", Town: "T", Latitude: 52.5, Longitude: 13.4 },
+ Connections,
+ });
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => [
+ at(1, [{ PowerKW: 11 }, { PowerKW: 150.4 }, { PowerKW: null }]),
+ at(2, [{ PowerKW: "fast" }]),
+ at(3, "not an array"),
+ at(4, [{ PowerKW: 5000 }]),
+ ],
+ } as Response);
+
+ const { stations } = await scraper.fetch();
+ const kw = Object.fromEntries(stations.map((s) => [s.externalId, s.maxPowerKw]));
+ expect(kw).toEqual({ "ocm-1": 150, "ocm-2": null, "ocm-3": null, "ocm-4": null });
});
it("returns empty when API key is not set", async () => {
diff --git a/src/scrapers/ocm.ts b/src/scrapers/ocm.ts
index c1ce1fc..a715d33 100644
--- a/src/scrapers/ocm.ts
+++ b/src/scrapers/ocm.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { BaseScraper, type RawFuelPrice, type RawStation } from "./base";
+import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation } from "./base";
// ---------------------------------------------------------------------------
// OpenChargeMap (OCM) — EV charging station scraper
@@ -117,6 +117,8 @@ const OCMPOISchema = z.object({
Longitude: z.number().nullish(),
})
.nullish(),
+ // Power is a nice-to-have: a malformed Connections entry must not drop the POI.
+ Connections: z.array(z.object({ PowerKW: z.number().nullish().catch(null) })).nullish().catch(null),
});
type OCMPOI = z.infer;
@@ -307,6 +309,7 @@ export class OCMScraper extends BaseScraper {
latitude: addr.Latitude,
longitude: addr.Longitude,
stationType: "ev_charger",
+ maxPowerKw: sanePowerKw(Math.max(0, ...(poi.Connections ?? []).map((c) => c.PowerKW ?? 0))),
});
}
diff --git a/src/scrapers/reve.test.ts b/src/scrapers/reve.test.ts
index 2f662d2..5694318 100644
--- a/src/scrapers/reve.test.ts
+++ b/src/scrapers/reve.test.ts
@@ -103,6 +103,7 @@ describe("REVEScraper", () => {
latitude: 43.526146,
longitude: -5.874451,
stationType: "ev_charger",
+ maxPowerKw: 22,
});
});
diff --git a/src/scrapers/reve.ts b/src/scrapers/reve.ts
index 52c8c73..4e3d919 100644
--- a/src/scrapers/reve.ts
+++ b/src/scrapers/reve.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
-import { BaseScraper, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
+import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
// ---------------------------------------------------------------------------
// Mapa REVE — official Spanish EV charging point registry
@@ -201,7 +201,7 @@ function maxPowerKw(loc: REVELocation): number | null {
if (typeof w === "number" && Number.isFinite(w) && w > maxW) maxW = w;
}
}
- return maxW > 0 ? Math.round(maxW / 1000) : null;
+ return maxW > 0 ? sanePowerKw(maxW / 1000) : null;
}
export class REVEScraper extends BaseScraper {
@@ -338,6 +338,7 @@ export class REVEScraper extends BaseScraper {
latitude,
longitude,
stationType: "ev_charger",
+ maxPowerKw: kw,
});
}
From 86e071b7a0c829d5fe1bdbbdfbbdd43bc933bd9b Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:57:04 +0200
Subject: [PATCH 08/19] feat(api): expose charger powerKw on EV station
responses
---
docs/reference/api.md | 1 +
docs/reference/data-model.md | 2 ++
src/app/api/route-stations/route.test.ts | 11 ++++++++---
src/app/api/route-stations/route.ts | 4 ++++
src/app/api/stations/nearest/route.test.ts | 3 +++
src/app/api/stations/nearest/route.ts | 4 ++++
src/app/api/stations/route.test.ts | 3 +++
src/app/api/stations/route.ts | 6 +++++-
src/types/station.ts | 2 ++
9 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/docs/reference/api.md b/docs/reference/api.md
index a82650f..d992dc9 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -450,6 +450,7 @@ curl -sN -X POST http://localhost:3000/api/route-detour \
| `currency` | yes | ISO 4217 code of `price`. `EUR` on EV chargers, where it carries no meaning. |
| `price` | no | Latest price for `fuelType`, in `currency`. Absent on EV chargers. |
| `reportedAt` | no | ISO 8601 time the price was stored. This is when Pumperly's scraper wrote it, not a time the source published. Absent on EV chargers. |
+| `powerKw` | no | EV chargers only: highest single-connector power in kW. Absent when the source publishes none. |
| `distanceKm` | only `/api/stations/nearest` | Approximate distance from the query point. |
| `routeFraction` | only `/api/route-stations` | Position along the route, `0` to `1`. |
diff --git a/docs/reference/data-model.md b/docs/reference/data-model.md
index b8e89b8..fc83382 100644
--- a/docs/reference/data-model.md
+++ b/docs/reference/data-model.md
@@ -21,6 +21,7 @@ erDiagram
text province
varchar station_type
geometry geom
+ smallint max_power_kw
timestamptz created_at
timestamptz updated_at
}
@@ -97,6 +98,7 @@ One row per place: a fuel station, an EV charger, or a site that is both.
| `province` | `text` | Yes | Region, state or county, when the source gives one. |
| `station_type` | `varchar(20)` | No | `fuel`, `ev_charger` or `both`. Defaults to `fuel`. |
| `geom` | `geometry(Point, 4326)` | Yes | The position, as a PostGIS point in WGS84 longitude and latitude. Scrapers always set it. |
+| `max_power_kw` | `smallint` | Yes | EV chargers: highest single-connector power in kW, set by the BNetzA, OCM and REVE scrapers. `null` for fuel stations and chargers with no published power. |
| `created_at` | `timestamptz` | No | When the row was first inserted. Never changed afterwards. |
| `updated_at` | `timestamptz` | No | When a scraper run last wrote this station. Every successful upsert sets it to the current time, even when nothing changed. |
diff --git a/src/app/api/route-stations/route.test.ts b/src/app/api/route-stations/route.test.ts
index ddd205d..091f1ea 100644
--- a/src/app/api/route-stations/route.test.ts
+++ b/src/app/api/route-stations/route.test.ts
@@ -127,8 +127,9 @@ describe("route-stations API", () => {
it("queries EV stations without price join", async () => {
const { prisma } = await import("@/lib/db");
- const evRow = { ...mockStationRow, price: null, reported_at: null };
- vi.mocked(prisma.$queryRawUnsafe).mockResolvedValueOnce([evRow]);
+ const evRow = { ...mockStationRow, price: null, reported_at: null, max_power_kw: 150 };
+ const unknownKw = { ...evRow, id: "unknown-kw", max_power_kw: null };
+ vi.mocked(prisma.$queryRawUnsafe).mockResolvedValueOnce([evRow, unknownKw]);
const { POST } = await import("./route");
const response = (await POST(makeRequest({
@@ -138,9 +139,12 @@ describe("route-stations API", () => {
}) as any)) as any;
expect(response.status).toBe(200);
- expect(response.data.features).toHaveLength(1);
+ expect(response.data.features).toHaveLength(2);
// EV query should not include price
expect(response.data.features[0].properties.price).toBeUndefined();
+ // Charger power passes through; unknown power is omitted, not null.
+ expect(response.data.features[0].properties.powerKw).toBe(150);
+ expect(response.data.features[1].properties).not.toHaveProperty("powerKw");
expect(vi.mocked(prisma.$queryRawUnsafe)).toHaveBeenCalledTimes(1);
const sql = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string;
@@ -152,6 +156,7 @@ describe("route-stations API", () => {
expect(sql).toContain("ST_DWithin(s.geom::geography, r.g::geography, $4)");
expect(sql).toContain("LIMIT 5000");
expect(sql).not.toContain("JOIN LATERAL");
+ expect(sql).toContain("s.max_power_kw::int AS max_power_kw");
});
it("pads longitude wider than latitude for a high-latitude route (cos-lat correction)", async () => {
diff --git a/src/app/api/route-stations/route.ts b/src/app/api/route-stations/route.ts
index 64e3cb7..d502e8f 100644
--- a/src/app/api/route-stations/route.ts
+++ b/src/app/api/route-stations/route.ts
@@ -56,6 +56,8 @@ interface StationRow {
reported_at: Date | null;
route_fraction: number;
distance_m: number;
+ /** EV query only. */
+ max_power_kw?: number | null;
}
export async function POST(request: NextRequest) {
@@ -117,6 +119,7 @@ export async function POST(request: NextRequest) {
ST_X(s.geom) AS longitude, ST_Y(s.geom) AS latitude,
NULL::float AS price, 'EUR' AS currency,
NULL::timestamptz AS reported_at,
+ s.max_power_kw::int AS max_power_kw,
ST_LineLocatePoint(r.g::geometry, s.geom)::float AS route_fraction,
ST_Distance(s.geom::geography, r.g::geography)::float AS distance_m
FROM stations s, route r
@@ -183,6 +186,7 @@ export async function POST(request: NextRequest) {
currency: row.currency,
...(row.price != null ? { price: row.price } : {}),
...(row.reported_at ? { reportedAt: new Date(row.reported_at).toISOString() } : {}),
+ ...(row.max_power_kw != null ? { powerKw: row.max_power_kw } : {}),
routeFraction: row.route_fraction,
// detourMin is computed later via /api/route-detour (Valhalla-based)
},
diff --git a/src/app/api/stations/nearest/route.test.ts b/src/app/api/stations/nearest/route.test.ts
index 8eecfb5..d57a800 100644
--- a/src/app/api/stations/nearest/route.test.ts
+++ b/src/app/api/stations/nearest/route.test.ts
@@ -108,6 +108,7 @@ describe("stations/nearest API", () => {
...mockRow,
price: null,
reported_at: null,
+ max_power_kw: 150,
}]);
const response = (await GET(makeRequest({
@@ -119,6 +120,8 @@ describe("stations/nearest API", () => {
expect(response.status).toBe(200);
const sqlArg = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string;
expect(sqlArg).toContain("station_type");
+ expect(sqlArg).toContain("s.max_power_kw::int AS max_power_kw");
+ expect(response.data.features[0].properties.powerKw).toBe(150);
});
it("sets cache-control header", async () => {
diff --git a/src/app/api/stations/nearest/route.ts b/src/app/api/stations/nearest/route.ts
index f59ccc0..c10f610 100644
--- a/src/app/api/stations/nearest/route.ts
+++ b/src/app/api/stations/nearest/route.ts
@@ -48,6 +48,8 @@ interface NearestStationRow {
currency: string;
reported_at: Date | null;
distance_km: number;
+ /** EV query only. */
+ max_power_kw?: number | null;
}
export async function GET(request: NextRequest) {
@@ -91,6 +93,7 @@ export async function GET(request: NextRequest) {
NULL::float AS price,
'EUR' AS currency,
NULL::timestamptz AS reported_at,
+ s.max_power_kw::int AS max_power_kw,
(ST_Distance(s.geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)) * 111.32)::float AS distance_km
FROM stations s
WHERE s.station_type IN ('ev_charger', 'both')
@@ -165,6 +168,7 @@ export async function GET(request: NextRequest) {
currency: row.currency,
...(row.price != null ? { price: row.price } : {}),
...(row.reported_at ? { reportedAt: new Date(row.reported_at).toISOString() } : {}),
+ ...(row.max_power_kw != null ? { powerKw: row.max_power_kw } : {}),
distanceKm: Math.round(row.distance_km * 1000) / 1000,
},
}));
diff --git a/src/app/api/stations/route.test.ts b/src/app/api/stations/route.test.ts
index 5591e92..fe7c531 100644
--- a/src/app/api/stations/route.test.ts
+++ b/src/app/api/stations/route.test.ts
@@ -85,6 +85,7 @@ describe("stations API", () => {
...mockRow,
price: null,
reported_at: null,
+ max_power_kw: 150,
}]);
const response = (await GET(makeRequest({
@@ -98,6 +99,8 @@ describe("stations API", () => {
// EV query uses station_type filter; verify the SQL includes it
const sqlArg = vi.mocked(prisma.$queryRawUnsafe).mock.calls[0][0] as string;
expect(sqlArg).toContain("station_type");
+ expect(sqlArg).toContain("s.max_power_kw::int AS max_power_kw");
+ expect(response.data.features[0].properties.powerKw).toBe(150);
});
it("returns 400 when bbox is missing", async () => {
diff --git a/src/app/api/stations/route.ts b/src/app/api/stations/route.ts
index 325ae6d..05fb944 100644
--- a/src/app/api/stations/route.ts
+++ b/src/app/api/stations/route.ts
@@ -40,6 +40,8 @@ interface StationRow {
price: number | null;
currency: string;
reported_at: Date | null;
+ /** EV query only. */
+ max_power_kw?: number | null;
}
export async function GET(request: NextRequest) {
@@ -79,7 +81,8 @@ export async function GET(request: NextRequest) {
ST_Y(s.geom) AS latitude,
NULL::float AS price,
'EUR' AS currency,
- NULL::timestamptz AS reported_at
+ NULL::timestamptz AS reported_at,
+ s.max_power_kw::int AS max_power_kw
FROM stations s
WHERE s.station_type IN ('ev_charger', 'both')
AND ST_Within(
@@ -149,6 +152,7 @@ export async function GET(request: NextRequest) {
currency: row.currency,
...(row.price != null ? { price: row.price } : {}),
...(row.reported_at ? { reportedAt: new Date(row.reported_at).toISOString() } : {}),
+ ...(row.max_power_kw != null ? { powerKw: row.max_power_kw } : {}),
},
}));
diff --git a/src/types/station.ts b/src/types/station.ts
index 8315d5c..7e90c6b 100644
--- a/src/types/station.ts
+++ b/src/types/station.ts
@@ -55,6 +55,8 @@ export interface StationGeoJSON {
originalPrice?: number | null;
/** Original currency code before conversion (only set when converted) */
originalCurrency?: string;
+ /** EV chargers: highest single-connector power in kW (absent when unknown). */
+ powerKw?: number;
routeFraction?: number;
detourMin?: number;
/** Distance from the query point in km (only set by /api/stations/nearest) */
From 0eb031382a0ad4a29b4a9c07fafd01688c3abe5a Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:58:07 +0200
Subject: [PATCH 09/19] feat(popup): show EV charger power
---
docs/using/ev-charging.md | 8 ++----
src/components/map/station-popup.test.tsx | 13 +++++++++
src/components/map/station-popup.tsx | 17 ++++++++++++
src/lib/i18n.tsx | 34 +++++++++++++++++++++++
4 files changed, 67 insertions(+), 5 deletions(-)
diff --git a/docs/using/ev-charging.md b/docs/using/ev-charging.md
index 19deb9e..f443b8d 100644
--- a/docs/using/ev-charging.md
+++ b/docs/using/ev-charging.md
@@ -46,15 +46,13 @@ Click a charger to open its popup. It holds:
1. The **brand**. For chargers this is the operator, when the source names one.
2. The **address** and the **city**.
-3. The line "No price for EV Charging".
+3. The charger's **highest power** in kW, such as `150 kW`, labelled "Max. charging power · No price for EV Charging". When the source lists no power, it reads "Power unknown" instead. The German registry, OpenChargeMap and Mapa REVE all publish power.
4. The same four buttons as any station: **Get directions**, **Show on map**, **Copy link** and **Share**. See [Station popup](map.md#station-popup).
In a route's station list, a charger row shows the operator and the station name. Each source builds that name differently. The German registry, for example, combines the operator with the street and a site label, which tells apart two chargers at one address.
!!! note "No tariffs, connectors or live availability"
- Pumperly does not store charging tariffs, connector types or live availability. Its price model holds one price per litre for each fuel. A charging tariff is per kWh and per session, and that does not fit. The popup tells you where a charger is and who runs it. Check the operator's app for price and plug type.
-
- Power has one exception. A Spanish charger from Mapa REVE gets a name that ends in its highest power, such as `150 kW`, when the registry lists one. That name shows in a route's station list, not in the popup.
+ Pumperly does not store charging tariffs, connector types or live availability. Its price model holds one price per litre for each fuel. A charging tariff is per kWh and per session, and that does not fit. The popup tells you where a charger is, who runs it and how fast it can charge. Check the operator's app for price and plug type.
## Chargers along a route
@@ -88,6 +86,6 @@ A charger link must carry `fuel=EV`. The map only loads chargers on the EV layer
- Pick **EV Charging** (`EV`) in the fuel selector to see chargers.
- The EV layer shows every charger in view. Chargers have no price, so dots are grey and there is no legend or price slider.
-- A charger's popup shows the operator, the address and the city. There are no tariffs, connectors or live availability.
+- A charger's popup shows the operator, the address, the city and the highest power in kW. There are no tariffs, connectors or live availability.
- Along a route, detours and the detour filter work as usual. Price sorting and the Cheapest and Balanced badges do not apply.
- Keep the search radius small in cities, or dense corridors run into the detour rate limit.
diff --git a/src/components/map/station-popup.test.tsx b/src/components/map/station-popup.test.tsx
index aa8e935..505614e 100644
--- a/src/components/map/station-popup.test.tsx
+++ b/src/components/map/station-popup.test.tsx
@@ -112,4 +112,17 @@ describe("StationPopup", () => {
expect(copied).toContain("fuel=E5");
vi.unstubAllGlobals();
});
+
+ it("shows an EV charger's power instead of an empty price", () => {
+ render( {}} />);
+ expect(screen.getByText("150")).toBeInTheDocument();
+ expect(screen.getByText("kW")).toBeInTheDocument();
+ expect(screen.getByText(/popup.maxPower/)).toBeInTheDocument();
+ });
+
+ it("says so when an EV charger's power is unknown", () => {
+ render( {}} />);
+ expect(screen.getByText("popup.powerUnknown")).toBeInTheDocument();
+ expect(screen.queryByText("kW")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/map/station-popup.tsx b/src/components/map/station-popup.tsx
index 8941181..a998ff1 100644
--- a/src/components/map/station-popup.tsx
+++ b/src/components/map/station-popup.tsx
@@ -142,6 +142,23 @@ export function StationPopup({ station, onClose }: StationPopupProps) {
)}
+ ) : properties.fuelType === "EV" ? (
+ // Chargers have no prices yet; their power is what tells them apart.
+
+ {properties.powerKw != null ? (
+
+
+ {properties.powerKw}
+
+ kW
+
+ ) : (
+
{t("popup.powerUnknown")}
+ )}
+
+ {t("popup.maxPower")} · {t("popup.noPrice")} {fuelInfo?.label ?? properties.fuelType}
+
+
) : (
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index 3c9e684..670a4f3 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -106,6 +106,8 @@ export const translations: Record> = {
"popup.updatedNow": "Actualizado ahora",
"popup.updated": "Hace",
"popup.noPrice": "Sin precio para",
+ "popup.maxPower": "Potencia máxima de carga",
+ "popup.powerUnknown": "Potencia desconocida",
"popup.navigate": "Cómo llegar",
"popup.showOnMap": "Ver en el mapa",
"popup.share": "Compartir",
@@ -197,6 +199,8 @@ export const translations: Record> = {
"popup.updatedNow": "Updated just now",
"popup.updated": "Updated",
"popup.noPrice": "No price for",
+ "popup.maxPower": "Max. charging power",
+ "popup.powerUnknown": "Power unknown",
"popup.navigate": "Get directions",
"popup.showOnMap": "Show on map",
"popup.share": "Share",
@@ -288,6 +292,8 @@ export const translations: Record> = {
"popup.updatedNow": "Mis à jour maintenant",
"popup.updated": "Mis à jour il y a",
"popup.noPrice": "Pas de prix pour",
+ "popup.maxPower": "Puissance de charge max.",
+ "popup.powerUnknown": "Puissance inconnue",
"popup.navigate": "Itinéraire",
"popup.showOnMap": "Voir sur la carte",
"popup.share": "Partager",
@@ -379,6 +385,8 @@ export const translations: Record> = {
"popup.updatedNow": "Gerade aktualisiert",
"popup.updated": "Vor",
"popup.noPrice": "Kein Preis für",
+ "popup.maxPower": "Max. Ladeleistung",
+ "popup.powerUnknown": "Leistung unbekannt",
"popup.navigate": "Route berechnen",
"popup.showOnMap": "Auf Karte zeigen",
"popup.share": "Teilen",
@@ -470,6 +478,8 @@ export const translations: Record> = {
"popup.updatedNow": "Aggiornato ora",
"popup.updated": "Aggiornato",
"popup.noPrice": "Nessun prezzo per",
+ "popup.maxPower": "Potenza di ricarica max",
+ "popup.powerUnknown": "Potenza sconosciuta",
"popup.navigate": "Indicazioni",
"popup.showOnMap": "Mostra sulla mappa",
"popup.share": "Condividi",
@@ -561,6 +571,8 @@ export const translations: Record> = {
"popup.updatedNow": "Atualizado agora",
"popup.updated": "Atualizado há",
"popup.noPrice": "Sem preço para",
+ "popup.maxPower": "Potência máx. de carga",
+ "popup.powerUnknown": "Potência desconhecida",
"popup.navigate": "Como chegar",
"popup.showOnMap": "Ver no mapa",
"popup.share": "Partilhar",
@@ -652,6 +664,8 @@ export const translations: Record> = {
"popup.updatedNow": "Zaktualizowano teraz",
"popup.updated": "Zaktualizowano",
"popup.noPrice": "Brak ceny dla",
+ "popup.maxPower": "Maks. moc ładowania",
+ "popup.powerUnknown": "Moc nieznana",
"popup.navigate": "Nawiguj",
"popup.showOnMap": "Pokaż na mapie",
"popup.share": "Udostępnij",
@@ -743,6 +757,8 @@ export const translations: Record> = {
"popup.updatedNow": "Právě aktualizováno",
"popup.updated": "Aktualizováno před",
"popup.noPrice": "Bez ceny pro",
+ "popup.maxPower": "Max. nabíjecí výkon",
+ "popup.powerUnknown": "Výkon neznámý",
"popup.navigate": "Navigovat",
"popup.showOnMap": "Zobrazit na mapě",
"popup.share": "Sdílet",
@@ -834,6 +850,8 @@ export const translations: Record> = {
"popup.updatedNow": "Most frissítve",
"popup.updated": "Frissítve",
"popup.noPrice": "Nincs ár ehhez:",
+ "popup.maxPower": "Max. töltési teljesítmény",
+ "popup.powerUnknown": "Ismeretlen teljesítmény",
"popup.navigate": "Útvonaltervezés",
"popup.showOnMap": "Mutasd a térképen",
"popup.share": "Megosztás",
@@ -925,6 +943,8 @@ export const translations: Record> = {
"popup.updatedNow": "Обновено сега",
"popup.updated": "Обновено преди",
"popup.noPrice": "Няма цена за",
+ "popup.maxPower": "Макс. мощност на зареждане",
+ "popup.powerUnknown": "Неизвестна мощност",
"popup.navigate": "Навигация",
"popup.showOnMap": "Покажи на картата",
"popup.share": "Сподели",
@@ -1016,6 +1036,8 @@ export const translations: Record> = {
"popup.updatedNow": "Práve aktualizované",
"popup.updated": "Aktualizované pred",
"popup.noPrice": "Bez ceny pre",
+ "popup.maxPower": "Max. nabíjací výkon",
+ "popup.powerUnknown": "Výkon neznámy",
"popup.navigate": "Navigovať",
"popup.showOnMap": "Zobraziť na mape",
"popup.share": "Zdieľať",
@@ -1107,6 +1129,8 @@ export const translations: Record> = {
"popup.updatedNow": "Opdateret nu",
"popup.updated": "Opdateret for",
"popup.noPrice": "Ingen pris for",
+ "popup.maxPower": "Maks. ladeeffekt",
+ "popup.powerUnknown": "Ukendt effekt",
"popup.navigate": "Få rutevejledning",
"popup.showOnMap": "Vis på kort",
"popup.share": "Del",
@@ -1198,6 +1222,8 @@ export const translations: Record> = {
"popup.updatedNow": "Uppdaterat nu",
"popup.updated": "Uppdaterat för",
"popup.noPrice": "Inget pris för",
+ "popup.maxPower": "Max laddeffekt",
+ "popup.powerUnknown": "Okänd effekt",
"popup.navigate": "Vägbeskrivning",
"popup.showOnMap": "Visa på kartan",
"popup.share": "Dela",
@@ -1289,6 +1315,8 @@ export const translations: Record> = {
"popup.updatedNow": "Oppdatert nå",
"popup.updated": "Oppdatert for",
"popup.noPrice": "Ingen pris for",
+ "popup.maxPower": "Maks ladeeffekt",
+ "popup.powerUnknown": "Ukjent effekt",
"popup.navigate": "Veibeskrivelse",
"popup.showOnMap": "Vis på kart",
"popup.share": "Del",
@@ -1380,6 +1408,8 @@ export const translations: Record> = {
"popup.updatedNow": "Ažurirano sada",
"popup.updated": "Ažurirano pre",
"popup.noPrice": "Nema cene za",
+ "popup.maxPower": "Maks. snaga punjenja",
+ "popup.powerUnknown": "Nepoznata snaga",
"popup.navigate": "Navigacija",
"popup.showOnMap": "Prikaži na mapi",
"popup.share": "Podeli",
@@ -1471,6 +1501,8 @@ export const translations: Record> = {
"popup.updatedNow": "Päivitetty juuri",
"popup.updated": "Päivitetty",
"popup.noPrice": "Ei hintaa:",
+ "popup.maxPower": "Suurin latausteho",
+ "popup.powerUnknown": "Teho tuntematon",
"popup.navigate": "Reittiohjeet",
"popup.showOnMap": "Näytä kartalla",
"popup.share": "Jaa",
@@ -1562,6 +1594,8 @@ export const translations: Record> = {
"popup.updatedNow": "Actualitzat ara",
"popup.updated": "Fa",
"popup.noPrice": "Sense preu per a",
+ "popup.maxPower": "Potència màx. de càrrega",
+ "popup.powerUnknown": "Potència desconeguda",
"popup.navigate": "Com arribar-hi",
"popup.showOnMap": "Mostra-ho en el mapa",
"popup.share": "Comparteix",
From c18dfbc958bbd1956adb95fadc5447f343efe88d Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:59:07 +0200
Subject: [PATCH 10/19] feat(planner): weigh EV charging time by charger power
---
src/components/search/refuel-planner.test.tsx | 39 +++++++++-
src/components/search/refuel-planner.tsx | 73 +++++++++++++++++--
src/lib/i18n.tsx | 51 +++++++++++++
src/lib/refuel-planner.test.ts | 42 +++++++++++
src/lib/refuel-planner.ts | 25 +++++--
src/lib/vehicle-profile.test.ts | 42 ++++++++++-
src/lib/vehicle-profile.ts | 25 ++++++-
7 files changed, 275 insertions(+), 22 deletions(-)
diff --git a/src/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx
index d5b7005..061b6f0 100644
--- a/src/components/search/refuel-planner.test.tsx
+++ b/src/components/search/refuel-planner.test.tsx
@@ -279,15 +279,15 @@ describe("RefuelPlanner", () => {
});
it("says when the trip needs more stops than the planner suggests", async () => {
- planOverride.result = { status: "infeasible", stops: [], totalFuelCost: 0, totalDetourMin: 0, endPct: 0, profile: [], gapKm: 300, reason: "stops" };
+ planOverride.result = { status: "infeasible", stops: [], totalFuelCost: 0, totalDetourMin: 0, totalChargeMin: 0, endPct: 0, profile: [], gapKm: 300, reason: "stops" };
renderPlanner();
await userEvent.click(screen.getByText("planner.title"));
expect(screen.getByText("planner.infeasibleStops")).toBeInTheDocument();
});
it("notes a plan that reaches the first stop below the reserve, and only that one", async () => {
- const stop = { id: "a", km: 100, litres: 30, cost: 42, detourMin: 2, arrivePct: 6, departPct: 66 };
- const ok: PlanResult = { status: "ok", stops: [stop], totalFuelCost: 42, totalDetourMin: 2, endPct: 20, profile: [] };
+ const stop = { id: "a", km: 100, litres: 30, cost: 42, detourMin: 2, chargeMin: 0, arrivePct: 6, departPct: 66 };
+ const ok: PlanResult = { status: "ok", stops: [stop], totalFuelCost: 42, totalDetourMin: 2, totalChargeMin: 0, endPct: 20, profile: [] };
planOverride.result = ok;
renderPlanner();
await userEvent.click(screen.getByText("planner.title"));
@@ -380,18 +380,49 @@ describe("RefuelPlanner", () => {
await userEvent.click(screen.getByText("planner.titleEv"));
expect(screen.getByLabelText("planner.battery")).toHaveValue(60);
expect(screen.getByLabelText("planner.consumptionEv")).toHaveValue(18);
+ expect(screen.getByLabelText("planner.maxChargeKw")).toHaveValue(150);
expect(screen.queryByText(/planner.timeValue/)).not.toBeInTheDocument();
expect(screen.queryByText("planner.noRates")).not.toBeInTheDocument();
expect(planSpy).toHaveBeenCalled();
});
+ it("shows charge time and charger power per stop, and ? kW when unknown", async () => {
+ const chargers = [
+ makeStation("f", { brand: "Ionity", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.3, detourMin: 0, powerKw: 350 }),
+ makeStation("u", { brand: "Mystery", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.6, detourMin: 0 }),
+ ];
+ renderPlanner({ mode: "ev", stations: chargers, routeKm: 300 });
+ await userEvent.click(screen.getByText("planner.titleEv"));
+ const [first, second] = planSpy.mock.lastCall![0].stations;
+ // 0.6 kWh per % at 112.5 kW average vs the 11 kW assumed for unknown power.
+ expect(first.minutesPerPct).toBeCloseTo(0.32);
+ expect(second.minutesPerPct).toBeCloseTo(3.273, 2);
+ expect(screen.getByText(/· 350 kW$/)).toBeInTheDocument();
+ expect(screen.getByText(/· \? kW$/)).toBeInTheDocument();
+ expect(screen.getAllByText(/^\d+ (h \d+ )?min · /).length).toBeGreaterThan(0);
+ });
+
+ it("drops slower and unknown-power chargers under a minimum power", async () => {
+ const chargers = [
+ makeStation("fast", { price: null, fuelType: "EV", routeFraction: 0.3, powerKw: 150 }),
+ makeStation("ac", { price: null, fuelType: "EV", routeFraction: 0.3, powerKw: 22 }),
+ makeStation("unknown", { price: null, fuelType: "EV", routeFraction: 0.3 }),
+ ];
+ renderPlanner({ mode: "ev", stations: chargers, routeKm: 300 });
+ await userEvent.click(screen.getByText("planner.titleEv"));
+ const ids = () => planSpy.mock.lastCall![0].stations.map((s: { id: string }) => s.id);
+ expect(ids()).toEqual(["fast", "ac", "unknown"]);
+ await userEvent.selectOptions(screen.getByLabelText("planner.minChargerKw"), "50");
+ expect(ids()).toEqual(["fast"]);
+ });
+
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-ev")!)).toEqual({ batteryKwh: 77, consumptionKwh100: 18, maxChargeKw: 150 });
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 bc854b7..855c89a 100644
--- a/src/components/search/refuel-planner.tsx
+++ b/src/components/search/refuel-planner.tsx
@@ -5,7 +5,8 @@ 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 { evProfileSchema, useEvProfile, useVehicleProfile, vehicleProfileSchema } from "@/lib/vehicle-profile";
+import { evProfileSchema, minutesPerPct, useEvProfile, useVehicleProfile, vehicleProfileSchema } from "@/lib/vehicle-profile";
+import { formatDuration } from "@/lib/format";
export interface PlannedStopMarker {
id: string;
@@ -14,6 +15,8 @@ export interface PlannedStopMarker {
/** Fill-level cap for EV stops: charging slows sharply above ~80 %. */
const EV_MAX_CHARGE_PCT = 80;
+/** "Min charger power" choices, kW; 0 = any (including chargers with unknown power). */
+const MIN_CHARGER_KW_OPTIONS = [0, 50, 150] as const;
interface RefuelPlannerProps {
/** `ev` plans by energy only (kWh): chargers have no prices yet. */
@@ -44,6 +47,8 @@ export interface PlannerSettings {
reservePct: number;
/** Stored in EUR so the default means the same everywhere; shown and planned in the display currency. */
timeValueEur: number;
+ /** EV only: skip chargers below this power (kW); 0 = any. */
+ minChargerKw: number;
}
export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = {
@@ -52,6 +57,7 @@ export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = {
arrivalPct: 20,
reservePct: 20,
timeValueEur: 15,
+ minChargerKw: 0,
};
export function RefuelPlanner({
@@ -78,11 +84,12 @@ export function RefuelPlanner({
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 { open, startPct, arrivalPct, reservePct, timeValueEur, minChargerKw } = 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(capacity));
const [consDraft, setConsDraft] = useState(String(consumption));
+ const [maxKwDraft, setMaxKwDraft] = useState(String(evProfile.maxChargeKw));
// Same for the value of time; null shows the saved (and planned) value.
const [timeDraft, setTimeDraft] = useState(null);
@@ -110,9 +117,17 @@ export function RefuelPlanner({
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.
+ // No prices yet: chargers compete on detour and charging time only.
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 }];
+ // A power filter can't vouch for a charger whose power is unknown.
+ if (minChargerKw > 0 && !((p.powerKw ?? 0) >= minChargerKw)) return [];
+ return [{
+ id: p.id,
+ km: (p.routeFraction ?? 0) * routeKm,
+ price: 0,
+ detourMin: p.detourMin,
+ minutesPerPct: minutesPerPct(evProfile, p.powerKw),
+ }];
}
// 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 [];
@@ -127,12 +142,12 @@ export function RefuelPlanner({
startPct,
arrivalPct,
reservePct,
- // With no price, only detour time is weighed, so any positive value gives the same plan.
+ // With no price the cost is all time, 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, ev, maxPrice, maxDetour, currency, capacity, consumption, maxChargePct, startPct, arrivalPct, reservePct, timeValue]);
+ }, [open, ratesMissing, detoursPending, routeKm, stations, ev, evProfile, minChargerKw, maxPrice, maxDetour, currency, capacity, consumption, maxChargePct, startPct, arrivalPct, reservePct, timeValue]);
useEffect(() => {
const stops = plan?.status === "ok"
@@ -157,6 +172,10 @@ export function RefuelPlanner({
if (!ok && field === "capacity") setTankDraft(String(capacity));
else if (!ok) setConsDraft(String(consumption));
};
+ const commitMaxKw = (raw: string) => {
+ const next = { ...evProfile, maxChargeKw: parseFloat(raw.replace(",", ".")) };
+ if (!commitProfile(evProfileSchema, next, setEvProfile)) setMaxKwDraft(String(evProfile.maxChargeKw));
+ };
const commitTimeValue = (raw: string) => {
const v = parseFloat(raw.replace(",", "."));
if (!ratesMissing && Number.isFinite(v) && v >= 0) update({ timeValueEur: v / eurRate });
@@ -167,6 +186,7 @@ export function RefuelPlanner({
};
const kwh = (v: number) => `${v.toFixed(1)} kWh`;
+ const minutes = (m: number) => formatDuration(m * 60);
const money = (v: number) => `${v.toFixed(Math.min(decimals, 2))} ${symbol}`;
const infeasibleText = (p: PlanResult) => {
@@ -231,6 +251,36 @@ export function RefuelPlanner({
/>
+ {ev && (
+
+
+ {t("planner.maxChargeKw")}
+ setMaxKwDraft(e.target.value)}
+ onBlur={(e) => commitMaxKw(e.target.value)}
+ onKeyDown={blurOnEnter}
+ className="mt-1 w-full rounded-lg border border-black/[0.08] bg-white px-2 py-1 text-xs font-semibold text-gray-800 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-gray-100"
+ />
+
+
+ {t("planner.minChargerKw")}
+ update({ minChargerKw: Number(e.target.value) })}
+ className="mt-1 w-full rounded-lg border border-black/[0.08] bg-white px-2 py-1 text-xs font-semibold text-gray-800 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-gray-100"
+ >
+ {MIN_CHARGER_KW_OPTIONS.map((kw) => (
+ {kw === 0 ? t("planner.anyPower") : `≥ ${kw} kW`}
+ ))}
+
+
+
+ )}
update({ startPct: v })} />
update({ arrivalPct: v })} />
update({ reservePct: v })} />
@@ -291,7 +341,12 @@ export function RefuelPlanner({
{ev ? (
-
+{kwh(stop.litres)}
+ <>
+
+{kwh(stop.litres)}
+
+ {minutes(stop.chargeMin)} · {f.properties.powerKw != null ? `${f.properties.powerKw} kW` : "? kW"}
+
+ >
) : (
<>
{money(stop.cost)}
@@ -309,7 +364,9 @@ export function RefuelPlanner({
{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)}
+ {ev
+ ? `${kwh(plan.stops.reduce((sum, s) => sum + s.litres, 0))} · ${minutes(plan.totalChargeMin)}`
+ : money(plan.totalFuelCost)}
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index 670a4f3..595b5d3 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -92,6 +92,9 @@ export const translations: Record> = {
"planner.energyTotal": "Energía recargada",
"planner.tripEnergy": "El viaje consume unos {kwh}",
"planner.infeasibleNoChargers": "Ningún cargador de la ruta tiene un desvío utilizable. Relaja el filtro de desvío o amplía el corredor.",
+ "planner.maxChargeKw": "Potencia máx. de carga (kW)",
+ "planner.minChargerKw": "Potencia mín. del cargador",
+ "planner.anyPower": "Cualquiera",
"fuel.diesel": "Diésel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
@@ -185,6 +188,9 @@ export const translations: Record> = {
"planner.energyTotal": "Energy charged",
"planner.tripEnergy": "The trip uses about {kwh}",
"planner.infeasibleNoChargers": "No charger on the route has a usable detour. Relax the detour filter or widen the corridor.",
+ "planner.maxChargeKw": "Max charge power (kW)",
+ "planner.minChargerKw": "Min charger power",
+ "planner.anyPower": "Any",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Gasoline",
"fuel.gas": "Gas",
@@ -278,6 +284,9 @@ export const translations: Record> = {
"planner.energyTotal": "Énergie rechargée",
"planner.tripEnergy": "Le trajet consomme environ {kwh}",
"planner.infeasibleNoChargers": "Aucune borne sur l'itinéraire n'a de détour utilisable. Assouplissez le filtre de détour ou élargissez le corridor.",
+ "planner.maxChargeKw": "Puissance de charge max. (kW)",
+ "planner.minChargerKw": "Puissance min. de la borne",
+ "planner.anyPower": "Toutes",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Essence",
"fuel.gas": "Gaz",
@@ -371,6 +380,9 @@ export const translations: Record> = {
"planner.energyTotal": "Geladene Energie",
"planner.tripEnergy": "Die Fahrt verbraucht etwa {kwh}",
"planner.infeasibleNoChargers": "Keine Ladestation auf der Route hat einen nutzbaren Umweg. Lockere den Umwegfilter oder erweitere den Korridor.",
+ "planner.maxChargeKw": "Max. Ladeleistung (kW)",
+ "planner.minChargerKw": "Min. Ladesäulenleistung",
+ "planner.anyPower": "Beliebig",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -464,6 +476,9 @@ export const translations: Record> = {
"planner.energyTotal": "Energia ricaricata",
"planner.tripEnergy": "Il viaggio consuma circa {kwh}",
"planner.infeasibleNoChargers": "Nessuna colonnina sul percorso ha una deviazione utilizzabile. Allenta il filtro deviazione o allarga il corridoio.",
+ "planner.maxChargeKw": "Potenza di ricarica max (kW)",
+ "planner.minChargerKw": "Potenza min. colonnina",
+ "planner.anyPower": "Qualsiasi",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzina",
"fuel.gas": "Gas",
@@ -557,6 +572,9 @@ export const translations: Record> = {
"planner.energyTotal": "Energia carregada",
"planner.tripEnergy": "A viagem consome cerca de {kwh}",
"planner.infeasibleNoChargers": "Nenhum posto de carregamento na rota tem um desvio utilizável. Alivie o filtro de desvio ou alargue o corredor.",
+ "planner.maxChargeKw": "Potência máx. de carga (kW)",
+ "planner.minChargerKw": "Potência mín. do posto",
+ "planner.anyPower": "Qualquer",
"fuel.diesel": "Gasóleo",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gás",
@@ -650,6 +668,9 @@ export const translations: Record> = {
"planner.energyTotal": "Naładowana energia",
"planner.tripEnergy": "Podróż zużyje około {kwh}",
"planner.infeasibleNoChargers": "Żadna ładowarka na trasie nie ma użytecznego objazdu. Poluzuj filtr objazdu lub poszerz korytarz.",
+ "planner.maxChargeKw": "Maks. moc ładowania (kW)",
+ "planner.minChargerKw": "Min. moc ładowarki",
+ "planner.anyPower": "Dowolna",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzyna",
"fuel.gas": "Gaz",
@@ -743,6 +764,9 @@ export const translations: Record> = {
"planner.energyTotal": "Nabitá energie",
"planner.tripEnergy": "Cesta spotřebuje asi {kwh}",
"planner.infeasibleNoChargers": "Žádná nabíječka na trase nemá použitelnou odbočku. Uvolněte filtr odbočky nebo rozšiřte koridor.",
+ "planner.maxChargeKw": "Max. nabíjecí výkon (kW)",
+ "planner.minChargerKw": "Min. výkon nabíječky",
+ "planner.anyPower": "Jakýkoli",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -836,6 +860,9 @@ export const translations: Record> = {
"planner.energyTotal": "Töltött energia",
"planner.tripEnergy": "Az út kb. {kwh} energiát használ",
"planner.infeasibleNoChargers": "Az útvonalon egyik töltőnek sincs használható kitérője. Lazítson a kitérőszűrőn vagy szélesítse a folyosót.",
+ "planner.maxChargeKw": "Max. töltési teljesítmény (kW)",
+ "planner.minChargerKw": "Töltő min. teljesítménye",
+ "planner.anyPower": "Bármilyen",
"fuel.diesel": "Dízel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gáz",
@@ -929,6 +956,9 @@ export const translations: Record> = {
"planner.energyTotal": "Заредена енергия",
"planner.tripEnergy": "Пътуването изразходва около {kwh}",
"planner.infeasibleNoChargers": "Никоя зарядна станция по маршрута няма използваемо отклонение. Разхлабете филтъра за отклонение или разширете коридора.",
+ "planner.maxChargeKw": "Макс. мощност на зареждане (kW)",
+ "planner.minChargerKw": "Мин. мощност на зарядната",
+ "planner.anyPower": "Всякаква",
"fuel.diesel": "Дизел",
"fuel.gasoline": "Бензин",
"fuel.gas": "Газ",
@@ -1022,6 +1052,9 @@ export const translations: Record> = {
"planner.energyTotal": "Nabitá energia",
"planner.tripEnergy": "Cesta spotrebuje asi {kwh}",
"planner.infeasibleNoChargers": "Žiadna nabíjačka na trase nemá použiteľnú odbočku. Uvoľnite filter odbočky alebo rozšírte koridor.",
+ "planner.maxChargeKw": "Max. nabíjací výkon (kW)",
+ "planner.minChargerKw": "Min. výkon nabíjačky",
+ "planner.anyPower": "Akýkoľvek",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -1115,6 +1148,9 @@ export const translations: Record> = {
"planner.energyTotal": "Opladet energi",
"planner.tripEnergy": "Turen bruger cirka {kwh}",
"planner.infeasibleNoChargers": "Ingen lader på ruten har en brugbar omvej. Løsn omvejsfilteret eller udvid korridoren.",
+ "planner.maxChargeKw": "Maks. ladeeffekt (kW)",
+ "planner.minChargerKw": "Min. ladereffekt",
+ "planner.anyPower": "Alle",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1208,6 +1244,9 @@ export const translations: Record> = {
"planner.energyTotal": "Laddad energi",
"planner.tripEnergy": "Resan förbrukar ungefär {kwh}",
"planner.infeasibleNoChargers": "Ingen laddare längs rutten har en användbar omväg. Lätta på omvägsfiltret eller bredda korridoren.",
+ "planner.maxChargeKw": "Max laddeffekt (kW)",
+ "planner.minChargerKw": "Min. laddareffekt",
+ "planner.anyPower": "Alla",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gas",
@@ -1301,6 +1340,9 @@ export const translations: Record> = {
"planner.energyTotal": "Ladet energi",
"planner.tripEnergy": "Turen bruker omtrent {kwh}",
"planner.infeasibleNoChargers": "Ingen lader langs ruten har en brukbar omvei. Løsne omveisfilteret eller utvid korridoren.",
+ "planner.maxChargeKw": "Maks ladeeffekt (kW)",
+ "planner.minChargerKw": "Min. ladereffekt",
+ "planner.anyPower": "Alle",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gass",
@@ -1394,6 +1436,9 @@ export const translations: Record> = {
"planner.energyTotal": "Napunjena energija",
"planner.tripEnergy": "Put troši oko {kwh}",
"planner.infeasibleNoChargers": "Nijedan punjač na ruti nema upotrebljivo skretanje. Ublažite filter skretanja ili proširite koridor.",
+ "planner.maxChargeKw": "Maks. snaga punjenja (kW)",
+ "planner.minChargerKw": "Min. snaga punjača",
+ "planner.anyPower": "Bilo koja",
"fuel.diesel": "Dizel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1487,6 +1532,9 @@ export const translations: Record> = {
"planner.energyTotal": "Ladattu energia",
"planner.tripEnergy": "Matka kuluttaa noin {kwh}",
"planner.infeasibleNoChargers": "Millään reitin laturilla ei ole käyttökelpoista kiertoa. Löysennä kiertosuodatinta tai levennä käytävää.",
+ "planner.maxChargeKw": "Suurin latausteho (kW)",
+ "planner.minChargerKw": "Laturin vähimmäisteho",
+ "planner.anyPower": "Mikä tahansa",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensiini",
"fuel.gas": "Kaasu",
@@ -1580,6 +1628,9 @@ export const translations: Record> = {
"planner.energyTotal": "Energia recarregada",
"planner.tripEnergy": "El viatge consumeix uns {kwh}",
"planner.infeasibleNoChargers": "Cap carregador de la ruta té un desviament utilitzable. Relaxa el filtre de desviament o amplia el corredor.",
+ "planner.maxChargeKw": "Potència màx. de càrrega (kW)",
+ "planner.minChargerKw": "Potència mín. del carregador",
+ "planner.anyPower": "Qualsevol",
"fuel.diesel": "Dièsel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
diff --git a/src/lib/refuel-planner.test.ts b/src/lib/refuel-planner.test.ts
index 0b4845e..7278887 100644
--- a/src/lib/refuel-planner.test.ts
+++ b/src/lib/refuel-planner.test.ts
@@ -394,3 +394,45 @@ describe("planRefuel with maxChargePct (EV)", () => {
expect(Math.min(...r.stops.map((s) => s.arrivePct))).toBeGreaterThanOrEqual(20 - 1e-9);
});
});
+
+describe("planRefuel with charge time (EV)", () => {
+ // 60 kWh at 18 kWh/100 km: 0.3 % per km. Needs one stop around km 100.
+ const ev = { ...base, tankL: 60, consumptionL100: 18, startPct: 50, arrivalPct: 20, reservePct: 20, timeValuePerHour: 1, maxChargePct: 80 };
+ const at = (id: string, km: number, detourMin: number, minutesPerPct: number): PlannerStation => ({ id, km, price: 0, detourMin, minutesPerPct });
+
+ it("prefers a fast charger with a longer detour over a slow one on the route", () => {
+ // Slow: 11 kW ≈ 3.27 min/%; fast: 112.5 kW ≈ 0.32 min/%.
+ // Start at 60 % so the fast one's detour still arrives above the reserve.
+ const r = planRefuel({ ...ev, startPct: 60, routeKm: 280, stations: [at("slow", 100, 0, 3.27), at("fast", 100, 8, 0.32)] });
+ expect(r.stops.map((s) => s.id)).toEqual(["fast"]);
+ // Without charge time, the on-route one wins.
+ const noTime = [at("slow", 100, 0, 0), at("fast", 100, 8, 0)];
+ expect(planRefuel({ ...ev, startPct: 60, routeKm: 280, stations: noTime }).stops.map((s) => s.id)).toEqual(["slow"]);
+ });
+
+ it("reports charge minutes per stop and in total", () => {
+ const r = planRefuel({ ...ev, routeKm: 250, stations: [at("fast", 100, 0, 0.5)] });
+ expect(r.status).toBe("ok");
+ const [s] = r.stops;
+ // Arrive 20 %, need 150 km × 0.3 + 20 = 65 %: 45 % × 0.5 min.
+ expect(s.departPct).toBe(65);
+ expect(s.chargeMin).toBeCloseTo(22.5);
+ expect(r.totalChargeMin).toBeCloseTo(22.5);
+ });
+
+ it("leaves fuel plans unchanged: no charge time without minutesPerPct", () => {
+ const r = planRefuel({ ...base, startPct: 30, routeKm: 400, stations: [st("A", 100, 1.5)] });
+ expect(r.stops[0].chargeMin).toBe(0);
+ expect(r.totalChargeMin).toBe(0);
+ });
+
+ it("ignores a station with an invalid charge rate", () => {
+ const r = planRefuel({ ...ev, routeKm: 250, stations: [at("bad", 100, 0, -1), at("nan", 100, 0, Number.NaN), at("ok", 100, 0, 1)] });
+ expect(r.stops.map((s) => s.id)).toEqual(["ok"]);
+ });
+
+ it("keeps the fastest charger in each bucket when pruning ties on price", () => {
+ const stations = Array.from({ length: 400 }, (_, i) => at(`S${i}`, i * 5, 5, i === 201 ? 0.1 : 3));
+ expect(pruneCandidates(stations, 2000).map((s) => s.id)).toContain("S201");
+ });
+});
diff --git a/src/lib/refuel-planner.ts b/src/lib/refuel-planner.ts
index 2c711c0..e8175ba 100644
--- a/src/lib/refuel-planner.ts
+++ b/src/lib/refuel-planner.ts
@@ -2,7 +2,7 @@
* Refuel stop planner (liquid fuels).
*
* Picks 0..maxStops corridor stations that minimise
- * fuel bought · price + (detour + fixed stop time) · value of time
+ * fuel bought · price + (detour + fixed stop time + charge time) · value of time
* while the tank never drops below `reservePct` and arrives at the destination
* with at least `arrivalPct`. The one exception is the first leg: when the start
* level can't reach any station with the reserve intact, it may use up to half of
@@ -23,6 +23,8 @@ export interface PlannerStation {
price: number;
/** Extra driving time to visit the station, minutes (≥ 0). */
detourMin: number;
+ /** Time to add 1 % of capacity here, minutes (EV charging). Unset = instant (fuel). */
+ minutesPerPct?: number;
}
export interface PlannerInput {
@@ -45,6 +47,8 @@ export interface PlannedStop {
litres: number;
cost: number;
detourMin: number;
+ /** Time spent charging (0 for fuel), minutes. */
+ chargeMin: number;
arrivePct: number;
departPct: number;
}
@@ -54,6 +58,7 @@ export interface PlanResult {
stops: PlannedStop[];
totalFuelCost: number;
totalDetourMin: number;
+ totalChargeMin: number;
/** Fuel level (%) at the destination. */
endPct: number;
/** Fuel level profile along the route, for the gauge chart. */
@@ -87,7 +92,7 @@ const L = 101; // fuel levels 0..100 %
/**
* Keep at most `max` candidates: split the route into equal buckets and keep
- * four stations from each: the cheapest, the least-detour, the earliest-reachable
+ * four stations from each: the cheapest (ties to the fastest charger), the least-detour, the earliest-reachable
* and the one with the furthest onward reach. The earliest-reachable one means a
* bucket with any station in range keeps one in range, so pruning never drops the
* only station a low tank can reach; the furthest-reaching one means pruning never
@@ -113,7 +118,9 @@ export function pruneCandidates(stations: PlannerStation[], routeKm: number, max
// Where a full tank bought here effectively starts from, on the route.
const onwardKm = (s: PlannerStation) => s.km - halfDetourKm(s);
for (const list of byBucket.values()) {
- const cheapest = list.reduce((a, b) => (b.price < a.price ? b : a));
+ // Ties go to the faster charger: EV prices are all 0, so there "cheapest" means "fastest".
+ const cheapest = list.reduce((a, b) =>
+ b.price < a.price || (b.price === a.price && (b.minutesPerPct ?? 0) < (a.minutesPerPct ?? 0)) ? b : a);
const closest = list.reduce((a, b) => (b.detourMin < a.detourMin ? b : a));
const earliest = list.reduce((a, b) => (reachKm(b) < reachKm(a) ? b : a));
const furthest = list.reduce((a, b) => (onwardKm(b) > onwardKm(a) ? b : a));
@@ -152,7 +159,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
const { routeKm, tankL, consumptionL100 } = input;
// A vehicle without a usable tank or consumption can't be planned for.
if (!(tankL > 0 && tankL < Infinity && consumptionL100 > 0 && consumptionL100 < Infinity)) {
- return { status: "infeasible", stops: [], totalFuelCost: 0, totalDetourMin: 0, endPct: 0, profile: [], gapKm: 0 };
+ return { status: "infeasible", stops: [], totalFuelCost: 0, totalDetourMin: 0, totalChargeMin: 0, endPct: 0, profile: [], gapKm: 0 };
}
const timeValuePerHour = input.timeValuePerHour > 0 && input.timeValuePerHour < Infinity ? input.timeValuePerHour : 0;
const startPct = clamp(input.startPct, 0, 100);
@@ -173,6 +180,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.minutesPerPct == null || (Number.isFinite(s.minutesPerPct) && s.minutesPerPct >= 0))
.filter((s) => s.km >= 0 && s.km <= routeKm && s.price >= 0 && s.detourMin >= 0)
.sort((a, b) => a.km - b.km),
routeKm,
@@ -180,6 +188,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
const n = stations.length;
const detourKm = stations.map((s) => (s.detourMin / 60) * DETOUR_KMH);
const stopCost = stations.map((s) => ((s.detourMin + STOP_OVERHEAD_MIN) / 60) * timeValuePerHour);
+ const minPerPct = stations.map((s) => s.minutesPerPct ?? 0);
// Level consumed from stop i (-1 = origin) to stop j (n = destination).
// Half of each station's detour is charged to the leg in, half to the leg out.
@@ -197,6 +206,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
stops: [],
totalFuelCost: 0,
totalDetourMin: 0,
+ totalChargeMin: 0,
endPct: endPctNoStop,
profile: [{ km: 0, pct: startPct }, { km: routeKm, pct: endPctNoStop }],
};
@@ -228,8 +238,8 @@ export function planRefuel(input: PlannerInput): PlanResult {
const prev = k === 1 ? null : layers[k - 2];
for (let j = 0; j < n; j++) {
- const price = stations[j].price;
- const unit = litresPerPct * price;
+ // Cost of adding 1 %: what it costs to buy, plus the time it takes to charge.
+ const unit = litresPerPct * stations[j].price + (minPerPct[j] / 60) * timeValuePerHour;
const sources = k === 1 ? [-1] : range(j);
for (const i of sources) {
const cons = legPct(i, j);
@@ -312,6 +322,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
stops: [],
totalFuelCost: 0,
totalDetourMin: 0,
+ totalChargeMin: 0,
endPct: 0,
profile: [],
gapKm: Math.min(routeKm, reach),
@@ -343,6 +354,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
litres,
cost: litres * s.price,
detourMin: s.detourMin,
+ chargeMin: (g - arrive) * minPerPct[j],
arrivePct: arrive,
departPct: g,
});
@@ -358,6 +370,7 @@ export function planRefuel(input: PlannerInput): PlanResult {
stops,
totalFuelCost: stops.reduce((sum, s) => sum + s.cost, 0),
totalDetourMin: stops.reduce((sum, s) => sum + s.detourMin, 0),
+ totalChargeMin: stops.reduce((sum, s) => sum + s.chargeMin, 0),
endPct,
profile,
};
diff --git a/src/lib/vehicle-profile.test.ts b/src/lib/vehicle-profile.test.ts
index d9af191..1ee51c2 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_EV, DEFAULT_VEHICLE, isPlannableFuel, readEvProfile, readVehicleProfile } from "./vehicle-profile";
+import { DEFAULT_EV, DEFAULT_VEHICLE, chargeKw, isPlannableFuel, minutesPerPct, readEvProfile, readVehicleProfile } from "./vehicle-profile";
describe("readVehicleProfile", () => {
it("returns defaults for missing or corrupt storage", () => {
@@ -18,11 +18,47 @@ describe("readEvProfile", () => {
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);
+ expect(readEvProfile(JSON.stringify({ batteryKwh: 5, consumptionKwh100: 18, maxChargeKw: 150 }))).toEqual(DEFAULT_EV);
+ });
+
+ it("keeps a profile saved before maxChargeKw existed, adding the default", () => {
+ expect(readEvProfile(JSON.stringify({ batteryKwh: 77, consumptionKwh100: 16.5 }))).toEqual({
+ batteryKwh: 77,
+ consumptionKwh100: 16.5,
+ maxChargeKw: 150,
+ });
});
it("returns a valid stored profile", () => {
- expect(readEvProfile(JSON.stringify({ batteryKwh: 77, consumptionKwh100: 16.5 }))).toEqual({ batteryKwh: 77, consumptionKwh100: 16.5 });
+ const p = { batteryKwh: 77, consumptionKwh100: 16.5, maxChargeKw: 135 };
+ expect(readEvProfile(JSON.stringify(p))).toEqual(p);
+ });
+});
+
+describe("chargeKw", () => {
+ it("assumes a slow charger when power is unknown", () => {
+ expect(chargeKw(null, 150)).toBe(11);
+ expect(chargeKw(undefined, 150)).toBe(11);
+ expect(chargeKw(0, 150)).toBe(11);
+ });
+
+ it("limits AC posts to the car's onboard charger", () => {
+ expect(chargeKw(7, 150)).toBe(7);
+ expect(chargeKw(22, 150)).toBe(11);
+ });
+
+ it("averages DC over the taper, limited by car or station", () => {
+ expect(chargeKw(50, 150)).toBeCloseTo(37.5);
+ expect(chargeKw(350, 150)).toBeCloseTo(112.5);
+ });
+});
+
+describe("minutesPerPct", () => {
+ it("is the time to add 1 % of the battery", () => {
+ // 0.6 kWh at 112.5 kW average = 0.32 min.
+ expect(minutesPerPct(DEFAULT_EV, 350)).toBeCloseTo(0.32);
+ // 0.6 kWh at 11 kW ≈ 3.27 min: 60 % takes over 3 h.
+ expect(minutesPerPct(DEFAULT_EV, null)).toBeCloseTo(3.273, 2);
});
});
diff --git a/src/lib/vehicle-profile.ts b/src/lib/vehicle-profile.ts
index 679a448..06914d3 100644
--- a/src/lib/vehicle-profile.ts
+++ b/src/lib/vehicle-profile.ts
@@ -17,11 +17,34 @@ export const DEFAULT_VEHICLE: VehicleProfile = { tankL: 50, consumptionL100: 6.5
export const evProfileSchema = z.object({
batteryKwh: z.number().finite().min(10).max(200),
consumptionKwh100: z.number().finite().min(5).max(50),
+ /** Peak DC charging power the car accepts, kW. Defaulted so profiles saved before it existed still load. */
+ maxChargeKw: z.number().finite().min(10).max(500).default(150),
});
export type EvProfile = z.infer;
-export const DEFAULT_EV: EvProfile = { batteryKwh: 60, consumptionKwh100: 18 };
+export const DEFAULT_EV: EvProfile = { batteryKwh: 60, consumptionKwh100: 18, maxChargeKw: 150 };
+
+/** Assumed for chargers with no published power: slow, so they never beat a known fast one. */
+const UNKNOWN_CHARGER_KW = 11;
+/** Typical onboard AC charger; AC posts can't charge faster than the car's own charger. */
+const CAR_AC_KW = 11;
+/** Highest power still treated as AC. */
+const MAX_AC_KW = 22;
+/** Average over a 10→80 % DC session as a share of peak: charging tapers as the battery fills. */
+const DC_TAPER = 0.75;
+
+/** Average charging power (kW) at a charger for a car, for planning. */
+export function chargeKw(stationKw: number | null | undefined, carMaxKw: number): number {
+ if (stationKw == null || !(stationKw > 0)) return UNKNOWN_CHARGER_KW;
+ if (stationKw <= MAX_AC_KW) return Math.min(stationKw, CAR_AC_KW);
+ return Math.min(stationKw, carMaxKw) * DC_TAPER;
+}
+
+/** Minutes to add 1 % of the battery at a charger. */
+export function minutesPerPct(ev: EvProfile, stationKw: number | null | undefined): number {
+ return ((ev.batteryKwh / 100) / chargeKw(stationKw, ev.maxChargeKw)) * 60;
+}
/**
* Fuels the refuel planner supports: priced per litre and burned by volume.
From b4ab9b3005e91b335aa7f7a89f0b1d3daef60874 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:14:54 +0200
Subject: [PATCH 11/19] fix(planner): EV wording, arrival cap and power-filter
hint
---
src/components/search/refuel-planner.test.tsx | 43 +++++++++++++++-
src/components/search/refuel-planner.tsx | 18 ++++---
src/lib/i18n.tsx | 51 +++++++++++++++++++
3 files changed, 103 insertions(+), 9 deletions(-)
diff --git a/src/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx
index 061b6f0..0011bf6 100644
--- a/src/components/search/refuel-planner.test.tsx
+++ b/src/components/search/refuel-planner.test.tsx
@@ -386,7 +386,7 @@ describe("RefuelPlanner", () => {
expect(planSpy).toHaveBeenCalled();
});
- it("shows charge time and charger power per stop, and ? kW when unknown", async () => {
+ it("shows charge time and charger power per stop, and unknown power when missing", async () => {
const chargers = [
makeStation("f", { brand: "Ionity", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.3, detourMin: 0, powerKw: 350 }),
makeStation("u", { brand: "Mystery", price: null, currency: undefined, fuelType: "EV", routeFraction: 0.6, detourMin: 0 }),
@@ -398,7 +398,7 @@ describe("RefuelPlanner", () => {
expect(first.minutesPerPct).toBeCloseTo(0.32);
expect(second.minutesPerPct).toBeCloseTo(3.273, 2);
expect(screen.getByText(/· 350 kW$/)).toBeInTheDocument();
- expect(screen.getByText(/· \? kW$/)).toBeInTheDocument();
+ expect(screen.getByText(/· popup.powerUnknown$/)).toBeInTheDocument();
expect(screen.getAllByText(/^\d+ (h \d+ )?min · /).length).toBeGreaterThan(0);
});
@@ -416,6 +416,45 @@ describe("RefuelPlanner", () => {
expect(ids()).toEqual(["fast"]);
});
+ it("caps the arrival level at the 80 % charge limit", () => {
+ // Settings are shared with fuel mode, where arrival can be above the cap.
+ render(
+ ,
+ );
+ const slider = screen.getByLabelText("planner.arrivalEv");
+ expect(slider).toHaveAttribute("max", "80");
+ expect(slider).toHaveAttribute("aria-valuetext", "80%");
+ expect(planSpy).toHaveBeenLastCalledWith(expect.objectContaining({ arrivalPct: 80 }));
+ });
+
+ it("explains a failed plan in charging terms", async () => {
+ const failed: PlanResult = { status: "infeasible", stops: [], totalFuelCost: 0, totalDetourMin: 0, totalChargeMin: 0, endPct: 0, profile: [], gapKm: 0, reason: "stops" };
+ planOverride.result = failed;
+ const { setMounted } = renderPlanner({ mode: "ev", stations: CHARGERS, routeKm: 300 });
+ await userEvent.click(screen.getByText("planner.titleEv"));
+ expect(screen.getByText("planner.infeasibleStopsEv")).toBeInTheDocument();
+ planOverride.result = { ...failed, reason: "arrival" };
+ setMounted(false);
+ setMounted(true);
+ expect(screen.getByText(/^planner.infeasibleArrivalEv/)).toBeInTheDocument();
+ });
+
+ it("blames the power filter when it leaves no charger", async () => {
+ const slow = [makeStation("ac", { price: null, fuelType: "EV", routeFraction: 0.3, detourMin: 0, powerKw: 22 })];
+ renderPlanner({ mode: "ev", stations: slow, routeKm: 300 });
+ await userEvent.click(screen.getByText("planner.titleEv"));
+ await userEvent.selectOptions(screen.getByLabelText("planner.minChargerKw"), "50");
+ expect(screen.getByText("planner.infeasibleNoChargersPower")).toBeInTheDocument();
+ });
+
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"));
diff --git a/src/components/search/refuel-planner.tsx b/src/components/search/refuel-planner.tsx
index 855c89a..df512e8 100644
--- a/src/components/search/refuel-planner.tsx
+++ b/src/components/search/refuel-planner.tsx
@@ -85,6 +85,8 @@ export function RefuelPlanner({
const consumption = ev ? evProfile.consumptionKwh100 : fuelProfile.consumptionL100;
const maxChargePct = ev ? EV_MAX_CHARGE_PCT : 100;
const { open, startPct, arrivalPct, reservePct, timeValueEur, minChargerKw } = settings;
+ // Settings are shared with fuel mode, where arrival may be set above the EV charge cap.
+ const arrival = Math.min(arrivalPct, maxChargePct);
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(capacity));
@@ -140,14 +142,14 @@ export function RefuelPlanner({
tankL: capacity,
consumptionL100: consumption,
startPct,
- arrivalPct,
+ arrivalPct: arrival,
reservePct,
// With no price the cost is all time, 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, ev, evProfile, minChargerKw, maxPrice, maxDetour, currency, capacity, consumption, maxChargePct, startPct, arrivalPct, reservePct, timeValue]);
+ }, [open, ratesMissing, detoursPending, routeKm, stations, ev, evProfile, minChargerKw, maxPrice, maxDetour, currency, capacity, consumption, maxChargePct, startPct, arrival, reservePct, timeValue]);
useEffect(() => {
const stops = plan?.status === "ok"
@@ -192,13 +194,15 @@ export function RefuelPlanner({
const infeasibleText = (p: PlanResult) => {
switch (p.reason) {
case "arrival":
- return t("planner.infeasibleArrival").replace("{pct}", String(Math.max(arrivalPct, reservePct)));
+ return t(ev ? "planner.infeasibleArrivalEv" : "planner.infeasibleArrival").replace("{pct}", String(Math.max(arrival, reservePct)));
case "reserve":
return t("planner.infeasibleReserve").replace("{pct}", String(reservePct));
case "stops":
- return t("planner.infeasibleStops");
+ return t(ev ? "planner.infeasibleStopsEv" : "planner.infeasibleStops");
case "no-candidates":
- return t(detoursFailed ? "planner.noDetours" : ev ? "planner.infeasibleNoChargers" : "planner.infeasibleNoCandidates");
+ if (detoursFailed) return t("planner.noDetours");
+ if (!ev) return t("planner.infeasibleNoCandidates");
+ return t(minChargerKw > 0 ? "planner.infeasibleNoChargersPower" : "planner.infeasibleNoChargers");
default:
return t("planner.infeasible").replace("{km}", String(Math.round(p.gapKm ?? 0)));
}
@@ -282,7 +286,7 @@ export function RefuelPlanner({
)}
update({ startPct: v })} />
- update({ arrivalPct: v })} />
+ update({ arrivalPct: v })} />
update({ reservePct: v })} />
{!ev &&
{t("planner.timeValue").replace("{cur}", symbol)}
@@ -344,7 +348,7 @@ export function RefuelPlanner({
<>
+{kwh(stop.litres)}
- {minutes(stop.chargeMin)} · {f.properties.powerKw != null ? `${f.properties.powerKw} kW` : "? kW"}
+ {minutes(stop.chargeMin)} · {f.properties.powerKw != null ? `${f.properties.powerKw} kW` : t("popup.powerUnknown")}
>
) : (
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index 595b5d3..09da697 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -95,6 +95,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Potencia máx. de carga (kW)",
"planner.minChargerKw": "Potencia mín. del cargador",
"planner.anyPower": "Cualquiera",
+ "planner.infeasibleArrivalEv": "Llegas al destino, pero no con un {pct}%. Baja la batería al llegar.",
+ "planner.infeasibleStopsEv": "Este viaje necesita más paradas de recarga de las que el planificador puede sugerir. Sube la batería al salir o divide el viaje.",
+ "planner.infeasibleNoChargersPower": "Ningún cargador de la ruta con la potencia mínima tiene un desvío utilizable. Baja la potencia mínima del cargador o relaja el filtro de desvío.",
"fuel.diesel": "Diésel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
@@ -191,6 +194,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max charge power (kW)",
"planner.minChargerKw": "Min charger power",
"planner.anyPower": "Any",
+ "planner.infeasibleArrivalEv": "You can reach the destination, but not with {pct}% left. Lower the battery at arrival.",
+ "planner.infeasibleStopsEv": "This trip needs more charging stops than the planner can suggest. Raise the battery at start or split the trip.",
+ "planner.infeasibleNoChargersPower": "No charger on the route with the minimum power has a usable detour. Lower the min charger power or relax the detour filter.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Gasoline",
"fuel.gas": "Gas",
@@ -287,6 +293,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Puissance de charge max. (kW)",
"planner.minChargerKw": "Puissance min. de la borne",
"planner.anyPower": "Toutes",
+ "planner.infeasibleArrivalEv": "Vous atteignez la destination, mais pas avec {pct} %. Baissez la batterie à l'arrivée.",
+ "planner.infeasibleStopsEv": "Ce trajet demande plus d'arrêts de recharge que le planificateur ne peut en proposer. Augmentez la batterie au départ ou divisez le trajet.",
+ "planner.infeasibleNoChargersPower": "Aucune borne de l'itinéraire avec la puissance minimale n'a de détour utilisable. Baissez la puissance min. de la borne ou assouplissez le filtre de détour.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Essence",
"fuel.gas": "Gaz",
@@ -383,6 +392,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max. Ladeleistung (kW)",
"planner.minChargerKw": "Min. Ladesäulenleistung",
"planner.anyPower": "Beliebig",
+ "planner.infeasibleArrivalEv": "Du erreichst das Ziel, aber nicht mit {pct} %. Senke den Akkustand bei Ankunft.",
+ "planner.infeasibleStopsEv": "Diese Fahrt braucht mehr Ladestopps, als der Planer vorschlagen kann. Erhöhe den Akkustand bei Abfahrt oder teile die Fahrt auf.",
+ "planner.infeasibleNoChargersPower": "Keine Ladestation auf der Route mit der Mindestleistung hat einen nutzbaren Umweg. Senke die Min. Ladesäulenleistung oder lockere den Umwegfilter.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -479,6 +491,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Potenza di ricarica max (kW)",
"planner.minChargerKw": "Potenza min. colonnina",
"planner.anyPower": "Qualsiasi",
+ "planner.infeasibleArrivalEv": "Arrivi a destinazione, ma non con il {pct}%. Abbassa la batteria all'arrivo.",
+ "planner.infeasibleStopsEv": "Questo viaggio richiede più soste di ricarica di quante il pianificatore possa suggerirne. Aumenta la batteria alla partenza o dividi il viaggio.",
+ "planner.infeasibleNoChargersPower": "Nessuna colonnina sul percorso con la potenza minima ha una deviazione utilizzabile. Abbassa la potenza min. colonnina o allenta il filtro deviazione.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzina",
"fuel.gas": "Gas",
@@ -575,6 +590,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Potência máx. de carga (kW)",
"planner.minChargerKw": "Potência mín. do posto",
"planner.anyPower": "Qualquer",
+ "planner.infeasibleArrivalEv": "Chega ao destino, mas não com {pct}%. Reduza a bateria à chegada.",
+ "planner.infeasibleStopsEv": "Esta viagem precisa de mais paragens de carregamento do que o planeador consegue sugerir. Aumente a bateria à partida ou divida a viagem.",
+ "planner.infeasibleNoChargersPower": "Nenhum posto na rota com a potência mínima tem um desvio utilizável. Reduza a potência mín. do posto ou alivie o filtro de desvio.",
"fuel.diesel": "Gasóleo",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gás",
@@ -671,6 +689,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Maks. moc ładowania (kW)",
"planner.minChargerKw": "Min. moc ładowarki",
"planner.anyPower": "Dowolna",
+ "planner.infeasibleArrivalEv": "Dojedziesz do celu, ale nie z {pct}%. Obniż poziom baterii na mecie.",
+ "planner.infeasibleStopsEv": "Ta podróż wymaga więcej postojów na ładowanie, niż planer może zaproponować. Zwiększ poziom baterii na starcie lub podziel podróż.",
+ "planner.infeasibleNoChargersPower": "Żadna ładowarka na trasie o minimalnej mocy nie ma użytecznego objazdu. Obniż min. moc ładowarki lub poluzuj filtr objazdu.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzyna",
"fuel.gas": "Gaz",
@@ -767,6 +788,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max. nabíjecí výkon (kW)",
"planner.minChargerKw": "Min. výkon nabíječky",
"planner.anyPower": "Jakýkoli",
+ "planner.infeasibleArrivalEv": "Do cíle dojedete, ale ne s {pct} %. Snižte baterii v cíli.",
+ "planner.infeasibleStopsEv": "Tato cesta vyžaduje víc nabíjecích zastávek, než plánovač dokáže navrhnout. Zvyšte baterii při odjezdu nebo cestu rozdělte.",
+ "planner.infeasibleNoChargersPower": "Žádná nabíječka na trase s minimálním výkonem nemá použitelnou odbočku. Snižte min. výkon nabíječky nebo uvolněte filtr odbočky.",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -863,6 +887,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max. töltési teljesítmény (kW)",
"planner.minChargerKw": "Töltő min. teljesítménye",
"planner.anyPower": "Bármilyen",
+ "planner.infeasibleArrivalEv": "Célba érsz, de nem {pct}%-kal. Csökkentsd az érkezéskori akkuszintet.",
+ "planner.infeasibleStopsEv": "Ehhez az úthoz több töltési megálló kell, mint amennyit a tervező javasolni tud. Növeld az induláskori akkuszintet, vagy oszd fel az utat.",
+ "planner.infeasibleNoChargersPower": "Az útvonalon egyik minimális teljesítményű töltőnek sincs használható kitérője. Csökkentsd a töltő min. teljesítményét, vagy lazíts a kitérőszűrőn.",
"fuel.diesel": "Dízel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gáz",
@@ -959,6 +986,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Макс. мощност на зареждане (kW)",
"planner.minChargerKw": "Мин. мощност на зарядната",
"planner.anyPower": "Всякаква",
+ "planner.infeasibleArrivalEv": "Стигате до целта, но не с {pct}%. Намалете батерията при пристигане.",
+ "planner.infeasibleStopsEv": "Това пътуване изисква повече спирания за зареждане, отколкото планировчикът може да предложи. Увеличете батерията при тръгване или разделете пътуването.",
+ "planner.infeasibleNoChargersPower": "Никоя зарядна станция по маршрута с минималната мощност няма използваемо отклонение. Намалете мин. мощност на зарядната или разхлабете филтъра за отклонение.",
"fuel.diesel": "Дизел",
"fuel.gasoline": "Бензин",
"fuel.gas": "Газ",
@@ -1055,6 +1085,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max. nabíjací výkon (kW)",
"planner.minChargerKw": "Min. výkon nabíjačky",
"planner.anyPower": "Akýkoľvek",
+ "planner.infeasibleArrivalEv": "Do cieľa dôjdete, ale nie s {pct} %. Znížte batériu v cieli.",
+ "planner.infeasibleStopsEv": "Táto cesta si vyžaduje viac nabíjacích zastávok, než dokáže plánovač navrhnúť. Zvýšte batériu pri odchode alebo cestu rozdeľte.",
+ "planner.infeasibleNoChargersPower": "Žiadna nabíjačka na trase s minimálnym výkonom nemá použiteľnú odbočku. Znížte min. výkon nabíjačky alebo uvoľnite filter odbočky.",
"fuel.diesel": "Nafta",
"fuel.gasoline": "Benzín",
"fuel.gas": "Plyn",
@@ -1151,6 +1184,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Maks. ladeeffekt (kW)",
"planner.minChargerKw": "Min. ladereffekt",
"planner.anyPower": "Alle",
+ "planner.infeasibleArrivalEv": "Du når frem, men ikke med {pct} %. Sænk batteri ved ankomst.",
+ "planner.infeasibleStopsEv": "Turen kræver flere ladestop, end planlæggeren kan foreslå. Hæv batteri ved start, eller del turen op.",
+ "planner.infeasibleNoChargersPower": "Ingen lader på ruten med minimumseffekten har en brugbar omvej. Sænk min. ladereffekt eller løsn omvejsfilteret.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1247,6 +1283,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Max laddeffekt (kW)",
"planner.minChargerKw": "Min. laddareffekt",
"planner.anyPower": "Alla",
+ "planner.infeasibleArrivalEv": "Du når fram, men inte med {pct} %. Sänk batteri vid ankomst.",
+ "planner.infeasibleStopsEv": "Resan kräver fler laddstopp än planeraren kan föreslå. Höj batteri vid start eller dela upp resan.",
+ "planner.infeasibleNoChargersPower": "Ingen laddare längs rutten med minimieffekten har en användbar omväg. Sänk min. laddareffekt eller lätta på omvägsfiltret.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gas",
@@ -1343,6 +1382,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Maks ladeeffekt (kW)",
"planner.minChargerKw": "Min. ladereffekt",
"planner.anyPower": "Alle",
+ "planner.infeasibleArrivalEv": "Du kommer frem, men ikke med {pct} %. Senk batteri ved ankomst.",
+ "planner.infeasibleStopsEv": "Turen krever flere ladestopp enn planleggeren kan foreslå. Øk batteri ved start eller del opp turen.",
+ "planner.infeasibleNoChargersPower": "Ingen lader langs ruten med minimumseffekten har en brukbar omvei. Senk min. ladereffekt eller løsne omveisfilteret.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensin",
"fuel.gas": "Gass",
@@ -1439,6 +1481,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Maks. snaga punjenja (kW)",
"planner.minChargerKw": "Min. snaga punjača",
"planner.anyPower": "Bilo koja",
+ "planner.infeasibleArrivalEv": "Stižete do cilja, ali ne sa {pct}%. Smanjite bateriju po dolasku.",
+ "planner.infeasibleStopsEv": "Za ovo putovanje potrebno je više zaustavljanja radi punjenja nego što planer može da predloži. Povećajte bateriju na polasku ili podelite putovanje.",
+ "planner.infeasibleNoChargersPower": "Nijedan punjač na ruti sa minimalnom snagom nema upotrebljivo skretanje. Smanjite min. snagu punjača ili ublažite filter skretanja.",
"fuel.diesel": "Dizel",
"fuel.gasoline": "Benzin",
"fuel.gas": "Gas",
@@ -1535,6 +1580,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Suurin latausteho (kW)",
"planner.minChargerKw": "Laturin vähimmäisteho",
"planner.anyPower": "Mikä tahansa",
+ "planner.infeasibleArrivalEv": "Pääset perille, mutta et {pct} %:lla. Laske akun tasoa perillä.",
+ "planner.infeasibleStopsEv": "Matka vaatii enemmän latauspysähdyksiä kuin suunnittelija pystyy ehdottamaan. Nosta akun tasoa lähtiessä tai jaa matka osiin.",
+ "planner.infeasibleNoChargersPower": "Millään reitin vähimmäistehon täyttävällä laturilla ei ole käyttökelpoista kiertoa. Laske laturin vähimmäistehoa tai löysennä kiertosuodatinta.",
"fuel.diesel": "Diesel",
"fuel.gasoline": "Bensiini",
"fuel.gas": "Kaasu",
@@ -1631,6 +1679,9 @@ export const translations: Record> = {
"planner.maxChargeKw": "Potència màx. de càrrega (kW)",
"planner.minChargerKw": "Potència mín. del carregador",
"planner.anyPower": "Qualsevol",
+ "planner.infeasibleArrivalEv": "Arribes a la destinació, però no amb un {pct}%. Abaixa la bateria en arribar.",
+ "planner.infeasibleStopsEv": "Aquest viatge necessita més parades de recàrrega de les que el planificador pot suggerir. Puja la bateria en sortir o divideix el viatge.",
+ "planner.infeasibleNoChargersPower": "Cap carregador de la ruta amb la potència mínima té un desviament utilitzable. Abaixa la potència mín. del carregador o relaxa el filtre de desviament.",
"fuel.diesel": "Dièsel",
"fuel.gasoline": "Gasolina",
"fuel.gas": "Gas",
From 563ea1a5fd76dbbd2949479dce51204a2dd285f4 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:16:40 +0200
Subject: [PATCH 12/19] fix(scrapers): validate each connector's power before
taking the max
---
src/scrapers/base.test.ts | 14 +++++++++++++-
src/scrapers/base.ts | 13 +++++++++++++
src/scrapers/bnetza.test.ts | 6 ++++++
src/scrapers/bnetza.ts | 9 ++-------
src/scrapers/ocm.test.ts | 3 ++-
src/scrapers/ocm.ts | 4 ++--
src/scrapers/reve.test.ts | 10 ++++++++++
src/scrapers/reve.ts | 14 +++++---------
8 files changed, 53 insertions(+), 20 deletions(-)
diff --git a/src/scrapers/base.test.ts b/src/scrapers/base.test.ts
index 57e7c14..fe017c6 100644
--- a/src/scrapers/base.test.ts
+++ b/src/scrapers/base.test.ts
@@ -22,7 +22,7 @@ vi.mock("../generated/prisma/client", () => ({
}));
// Concrete test scraper
-import { BaseScraper, bandFor, sanePowerKw, type RawStation, type RawFuelPrice } from "./base";
+import { BaseScraper, bandFor, maxSanePowerKw, sanePowerKw, type RawStation, type RawFuelPrice } from "./base";
describe("bandFor (per-currency price bands)", () => {
it("accepts a plausible HUF price (595)", () => {
@@ -427,3 +427,15 @@ describe("sanePowerKw", () => {
expect(sanePowerKw(1001)).toBeNull();
});
});
+
+describe("maxSanePowerKw", () => {
+ it("keeps the highest valid value when a larger one is implausible", () => {
+ expect(maxSanePowerKw([150, 1200])).toBe(150);
+ expect(maxSanePowerKw([22, null, Number.NaN, 49.6])).toBe(50);
+ });
+
+ it("returns null when no value is valid", () => {
+ expect(maxSanePowerKw([])).toBeNull();
+ expect(maxSanePowerKw([null, undefined, 0, 5000])).toBeNull();
+ });
+});
diff --git a/src/scrapers/base.ts b/src/scrapers/base.ts
index 96d6ef9..2402fef 100644
--- a/src/scrapers/base.ts
+++ b/src/scrapers/base.ts
@@ -49,6 +49,19 @@ export function sanePowerKw(kw: number | null | undefined): number | null {
return rounded > 0 ? rounded : null;
}
+/**
+ * The highest plausible power among a charger's connectors, kW. Each value is
+ * checked before comparing, so one bogus reading can't hide a valid one.
+ */
+export function maxSanePowerKw(kws: Iterable): number | null {
+ let max: number | null = null;
+ for (const kw of kws) {
+ const sane = sanePowerKw(kw);
+ if (sane != null && (max == null || sane > max)) max = sane;
+ }
+ return max;
+}
+
export interface RawFuelPrice {
/** Must match a RawStation.externalId in the same batch */
stationExternalId: string;
diff --git a/src/scrapers/bnetza.test.ts b/src/scrapers/bnetza.test.ts
index e93bb5d..30538f4 100644
--- a/src/scrapers/bnetza.test.ts
+++ b/src/scrapers/bnetza.test.ts
@@ -92,6 +92,12 @@ describe("parseBnetzaTsv", () => {
expect(single.stations[0].maxPowerKw).toBe(23);
});
+ it("keeps a valid plug power when another plug on the row is implausible", async () => {
+ const { parseBnetzaTsv } = await import("./bnetza");
+ const { stations } = parseBnetzaTsv(tsv(row({ "Nennleistung Stecker1": "150", "Nennleistung Stecker2": "1200" })));
+ expect(stations[0].maxPowerKw).toBe(150);
+ });
+
it("still imports when the plug power columns are renamed", async () => {
const { parseBnetzaTsv } = await import("./bnetza");
const text = tsv(row()).replace(/Nennleistung Stecker/g, "Leistung Stecker");
diff --git a/src/scrapers/bnetza.ts b/src/scrapers/bnetza.ts
index 6c829ad..bc579f5 100644
--- a/src/scrapers/bnetza.ts
+++ b/src/scrapers/bnetza.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
-import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
+import { BaseScraper, maxSanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
// ---------------------------------------------------------------------------
// BNetzA Ladesäulenregister — official German EV charging point registry
@@ -199,12 +199,7 @@ function resolvePlugPowerColumns(headerLine: string): number[] {
/** Highest plug power on a row, kW. Values are "22" or German-style "22,5". */
function rowPowerKw(fields: string[], plugCols: number[]): number | null {
- let max = 0;
- for (const i of plugCols) {
- const kw = Number(unquote(fields[i]).replace(",", "."));
- if (Number.isFinite(kw) && kw > max) max = kw;
- }
- return sanePowerKw(max);
+ return maxSanePowerKw(plugCols.map((i) => Number(unquote(fields[i]).replace(",", "."))));
}
/**
diff --git a/src/scrapers/ocm.test.ts b/src/scrapers/ocm.test.ts
index 5993c50..98a2b77 100644
--- a/src/scrapers/ocm.test.ts
+++ b/src/scrapers/ocm.test.ts
@@ -110,12 +110,13 @@ describe("OCMScraper", () => {
at(2, [{ PowerKW: "fast" }]),
at(3, "not an array"),
at(4, [{ PowerKW: 5000 }]),
+ at(5, [{ PowerKW: 150 }, { PowerKW: 1200 }]),
],
} as Response);
const { stations } = await scraper.fetch();
const kw = Object.fromEntries(stations.map((s) => [s.externalId, s.maxPowerKw]));
- expect(kw).toEqual({ "ocm-1": 150, "ocm-2": null, "ocm-3": null, "ocm-4": null });
+ expect(kw).toEqual({ "ocm-1": 150, "ocm-2": null, "ocm-3": null, "ocm-4": null, "ocm-5": 150 });
});
it("returns empty when API key is not set", async () => {
diff --git a/src/scrapers/ocm.ts b/src/scrapers/ocm.ts
index a715d33..0eb02ee 100644
--- a/src/scrapers/ocm.ts
+++ b/src/scrapers/ocm.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation } from "./base";
+import { BaseScraper, maxSanePowerKw, type RawFuelPrice, type RawStation } from "./base";
// ---------------------------------------------------------------------------
// OpenChargeMap (OCM) — EV charging station scraper
@@ -309,7 +309,7 @@ export class OCMScraper extends BaseScraper {
latitude: addr.Latitude,
longitude: addr.Longitude,
stationType: "ev_charger",
- maxPowerKw: sanePowerKw(Math.max(0, ...(poi.Connections ?? []).map((c) => c.PowerKW ?? 0))),
+ maxPowerKw: maxSanePowerKw((poi.Connections ?? []).map((c) => c.PowerKW)),
});
}
diff --git a/src/scrapers/reve.test.ts b/src/scrapers/reve.test.ts
index 5694318..917fd73 100644
--- a/src/scrapers/reve.test.ts
+++ b/src/scrapers/reve.test.ts
@@ -107,6 +107,16 @@ describe("REVEScraper", () => {
});
});
+ it("keeps a valid connector power when another connector is implausible", async () => {
+ vi.stubEnv("PUMPERLY_REVE_PAGES_PER_RUN", "1");
+ const { REVEScraper } = await import("./reve");
+ const evses = [{ id: "e1", connectors: [{ standard: "IEC_62196_T2_COMBO", max_electric_power: 150000 }, { standard: "IEC_62196_T2", max_electric_power: 1_200_000 }] }];
+ vi.mocked(fetch).mockResolvedValue(okResponse([location({ evses })], PAGE_HEADERS));
+
+ const { stations } = await new REVEScraper().fetch();
+ expect(stations[0].maxPowerKw).toBe(150);
+ });
+
it("falls back to the legal CPO name when owner is missing", async () => {
vi.stubEnv("PUMPERLY_REVE_PAGES_PER_RUN", "1");
const { REVEScraper } = await import("./reve");
diff --git a/src/scrapers/reve.ts b/src/scrapers/reve.ts
index 4e3d919..0110d3c 100644
--- a/src/scrapers/reve.ts
+++ b/src/scrapers/reve.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
-import { BaseScraper, sanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
+import { BaseScraper, maxSanePowerKw, type RawFuelPrice, type RawStation, type ScraperResult } from "./base";
// ---------------------------------------------------------------------------
// Mapa REVE — official Spanish EV charging point registry
@@ -194,14 +194,10 @@ export function shouldRetireOcmRows(
/** Highest connector power at a location, in kW (rounded). */
function maxPowerKw(loc: REVELocation): number | null {
- let maxW = 0;
- for (const evse of loc.evses ?? []) {
- for (const connector of evse.connectors ?? []) {
- const w = connector.max_electric_power;
- if (typeof w === "number" && Number.isFinite(w) && w > maxW) maxW = w;
- }
- }
- return maxW > 0 ? sanePowerKw(maxW / 1000) : null;
+ const kws = (loc.evses ?? []).flatMap((evse) =>
+ (evse.connectors ?? []).map((c) => (typeof c.max_electric_power === "number" ? c.max_electric_power / 1000 : null)),
+ );
+ return maxSanePowerKw(kws);
}
export class REVEScraper extends BaseScraper {
From 75f97fe8bfbbc372ef4c1c37b38304837a274e21 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:54:16 +0200
Subject: [PATCH 13/19] fix(scrapers): read every plug value in multi-plug
BNetzA power cells
---
src/scrapers/bnetza.test.ts | 19 +++++++++++--------
src/scrapers/bnetza.ts | 4 ++--
2 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/src/scrapers/bnetza.test.ts b/src/scrapers/bnetza.test.ts
index 30538f4..46ce817 100644
--- a/src/scrapers/bnetza.test.ts
+++ b/src/scrapers/bnetza.test.ts
@@ -75,21 +75,24 @@ function tsv(...rows: string[]): string {
}
describe("parseBnetzaTsv", () => {
- it("reads the fastest plug, including German decimals, and keeps the max across merged rows", async () => {
+ it("reads multi-plug cells and decimals, and keeps the max across merged rows", async () => {
const { parseBnetzaTsv } = await import("./bnetza");
const { stations } = parseBnetzaTsv(
tsv(
- row({ "Nennleistung Stecker1": "11", "Nennleistung Stecker2": "22,5" }),
- // Same location, a faster charger on another row.
- row({ "Nennleistung Stecker1": "150", "Nennleistung Stecker2": "" }),
+ // Type 2 + Schuko on one charging point: one value per plug.
+ row({ "Nennleistung Stecker1": "22; 22", "Nennleistung Stecker2": "" }),
+ // Same location, CCS + CHAdeMO on another row.
+ row({ "Nennleistung Stecker1": "300; 300", "Nennleistung Stecker2": "" }),
// Same location, slower again: must not lower the max.
- row({ "Nennleistung Stecker1": "3,7", "Nennleistung Stecker2": "" }),
+ row({ "Nennleistung Stecker1": "30.0", "Nennleistung Stecker2": "" }),
row({ Breitengrad: "51.0", "Längengrad": "10.0", "Nennleistung Stecker1": "", "Nennleistung Stecker2": "abc" }),
),
);
- expect(stations.map((s) => s.maxPowerKw)).toEqual([150, null]);
- const single = parseBnetzaTsv(tsv(row({ "Nennleistung Stecker1": "11", "Nennleistung Stecker2": "22,5" })));
- expect(single.stations[0].maxPowerKw).toBe(23);
+ expect(stations.map((s) => s.maxPowerKw)).toEqual([300, null]);
+ for (const [cell, kw] of [["300; 300", 300], ["22; 22", 22], ["30.0", 30]] as const) {
+ const single = parseBnetzaTsv(tsv(row({ "Nennleistung Stecker1": cell, "Nennleistung Stecker2": "" })));
+ expect(single.stations[0].maxPowerKw).toBe(kw);
+ }
});
it("keeps a valid plug power when another plug on the row is implausible", async () => {
diff --git a/src/scrapers/bnetza.ts b/src/scrapers/bnetza.ts
index bc579f5..f78e951 100644
--- a/src/scrapers/bnetza.ts
+++ b/src/scrapers/bnetza.ts
@@ -197,9 +197,9 @@ function resolvePlugPowerColumns(headerLine: string): number[] {
return PLUG_POWER_COLUMNS.map((name) => header.indexOf(name.toLowerCase())).filter((i) => i >= 0);
}
-/** Highest plug power on a row, kW. Values are "22" or German-style "22,5". */
+/** Highest plug power on a row, kW. A cell holds one value per plug, joined with ";" ("300; 300"). */
function rowPowerKw(fields: string[], plugCols: number[]): number | null {
- return maxSanePowerKw(plugCols.map((i) => Number(unquote(fields[i]).replace(",", "."))));
+ return maxSanePowerKw(plugCols.flatMap((i) => unquote(fields[i]).split(";").map(Number)));
}
/**
From fb90d21c84098f78246c7c34dc9e77f68132fbcd Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:55:35 +0200
Subject: [PATCH 14/19] fix(scrapers): write max_power_kw only for charger
batches
---
src/scrapers/base.test.ts | 34 +++++++++++++++++++++++++++-------
src/scrapers/base.ts | 22 ++++++++++++++--------
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/src/scrapers/base.test.ts b/src/scrapers/base.test.ts
index fe017c6..2d0a265 100644
--- a/src/scrapers/base.test.ts
+++ b/src/scrapers/base.test.ts
@@ -154,8 +154,31 @@ describe("BaseScraper.run()", () => {
expect(mockDisconnect).toHaveBeenCalled();
});
+ it("writes max_power_kw for charger batches", async () => {
+ scraper.mockStations = [
+ { ...makeStation("s1"), stationType: "ev_charger" },
+ { ...makeStation("s2"), stationType: "ev_charger", maxPowerKw: 150 },
+ ];
+ scraper.mockPrices = [];
+ mockQueryRawUnsafe.mockResolvedValue([]);
+ mockExecuteRawUnsafe.mockResolvedValue(undefined);
+
+ await scraper.run();
+
+ const stationCall = mockExecuteRawUnsafe.mock.calls.find(
+ (c) => typeof c[0] === "string" && c[0].includes("INSERT INTO stations"),
+ );
+ expect(stationCall).toBeDefined();
+ // 11 params per station; the 11th is max_power_kw (null when unset).
+ expect(stationCall![0]).toContain("max_power_kw = EXCLUDED.max_power_kw");
+ const params = stationCall!.slice(1);
+ expect(params).toHaveLength(22);
+ expect(params[10]).toBeNull();
+ expect(params[21]).toBe(150);
+ });
+
it("upserts stations and prices in full pipeline", async () => {
- scraper.mockStations = [makeStation("s1"), { ...makeStation("s2"), maxPowerKw: 150 }];
+ scraper.mockStations = [makeStation("s1"), makeStation("s2")];
scraper.mockPrices = [
makePrice("s1", "B7", 1.45),
makePrice("s2", "E5", 1.60),
@@ -184,12 +207,9 @@ describe("BaseScraper.run()", () => {
(c) => typeof c[0] === "string" && c[0].includes("INSERT INTO stations"),
);
expect(stationCall).toBeDefined();
- // 11 params per station; the 11th is max_power_kw (null when unset).
- expect(stationCall![0]).toContain("max_power_kw = EXCLUDED.max_power_kw");
- const params = stationCall!.slice(1);
- expect(params).toHaveLength(22);
- expect(params[10]).toBeNull();
- expect(params[21]).toBe(150);
+ // Fuel-only batch: no max_power_kw, so it runs on an un-migrated database.
+ expect(stationCall![0]).not.toContain("max_power_kw");
+ expect(stationCall!.slice(1)).toHaveLength(20);
// Verify price insert SQL was called
const priceCall = mockExecuteRawUnsafe.mock.calls.find(
diff --git a/src/scrapers/base.ts b/src/scrapers/base.ts
index 2402fef..20614e7 100644
--- a/src/scrapers/base.ts
+++ b/src/scrapers/base.ts
@@ -356,20 +356,27 @@ export abstract class BaseScraper {
): Promise {
if (batch.length === 0) return 0;
+ // Only charger batches write max_power_kw: fuel stations never have a
+ // power, so fuel scrapers keep working on a database without the column.
+ const withPower = batch.some((s) => s.stationType !== "fuel");
+ const perRow = withPower ? 11 : 10;
+
// Build parameterised VALUES list.
- // Each station needs 11 params: externalId, country, name, brand,
- // address, city, province, stationType, longitude, latitude, maxPowerKw
+ // Each station needs 10 params: externalId, country, name, brand,
+ // address, city, province, stationType, longitude, latitude,
+ // plus maxPowerKw for charger batches.
const params: unknown[] = [];
const valueClauses: string[] = [];
for (let i = 0; i < batch.length; i++) {
const s = batch[i];
- const offset = i * 11;
+ const offset = i * perRow;
valueClauses.push(
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` +
`$${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, ` +
`ST_SetSRID(ST_MakePoint($${offset + 9}, $${offset + 10}), 4326), ` +
- `$${offset + 11}::smallint, NOW(), NOW())`,
+ (withPower ? `$${offset + 11}::smallint, ` : "") +
+ `NOW(), NOW())`,
);
params.push(
s.externalId,
@@ -382,12 +389,12 @@ export abstract class BaseScraper {
s.stationType,
s.longitude,
s.latitude,
- s.maxPowerKw ?? null,
);
+ if (withPower) params.push(s.maxPowerKw ?? null);
}
const sql = `
- INSERT INTO stations (external_id, country, name, brand, address, city, province, station_type, geom, max_power_kw, created_at, updated_at)
+ INSERT INTO stations (external_id, country, name, brand, address, city, province, station_type, geom, ${withPower ? "max_power_kw, " : ""}created_at, updated_at)
VALUES ${valueClauses.join(",\n")}
ON CONFLICT (external_id, country)
DO UPDATE SET
@@ -397,8 +404,7 @@ export abstract class BaseScraper {
city = EXCLUDED.city,
province = EXCLUDED.province,
station_type = EXCLUDED.station_type,
- geom = EXCLUDED.geom,
- max_power_kw = EXCLUDED.max_power_kw,
+ geom = EXCLUDED.geom,${withPower ? "\n max_power_kw = EXCLUDED.max_power_kw," : ""}
updated_at = NOW()
`;
From 1039610f87d3b705cfafb3edd22812157e98d96e Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:57:42 +0200
Subject: [PATCH 15/19] fix(planner): keep the fuel reserve at 10 %, give EV
its own 20 % default
---
src/components/search/refuel-planner.test.tsx | 15 ++++++++++++++-
src/components/search/refuel-planner.tsx | 10 +++++++---
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/src/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx
index 0011bf6..ade6547 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", "20%");
+ expect(screen.getByLabelText("planner.reserve")).toHaveAttribute("aria-valuetext", "10%");
expect(screen.getByRole("button", { name: /Repsol/, pressed: true })).toBeInTheDocument();
});
@@ -374,6 +374,19 @@ describe("RefuelPlanner", () => {
expect(screen.getAllByText(/^\+\d+\.\d kWh$/)).toHaveLength(2);
});
+ it("keeps its own reserve, 20 % by default, apart from the fuel one", async () => {
+ const all = { stations: CHARGERS, routeKm: 300, onStopSelect: vi.fn(), onStopToggleOff: vi.fn() };
+ const { rerender } = render( );
+ await userEvent.click(screen.getByText("planner.titleEv"));
+ expect(planSpy).toHaveBeenLastCalledWith(expect.objectContaining({ reservePct: 20 }));
+ fireEvent.change(screen.getByLabelText("planner.reserve"), { target: { value: "30" } });
+
+ rerender( );
+ expect(screen.getByLabelText("planner.reserve")).toHaveValue("10");
+ rerender( );
+ expect(screen.getByLabelText("planner.reserve")).toHaveValue("30");
+ });
+
it("shows battery fields, no value of time, and needs no exchange rates", async () => {
currencyState.currency = "HUF";
renderPlanner({ mode: "ev", stations: CHARGERS, routeKm: 300 });
diff --git a/src/components/search/refuel-planner.tsx b/src/components/search/refuel-planner.tsx
index df512e8..7b9dc5b 100644
--- a/src/components/search/refuel-planner.tsx
+++ b/src/components/search/refuel-planner.tsx
@@ -45,6 +45,8 @@ export interface PlannerSettings {
startPct: number;
arrivalPct: number;
reservePct: number;
+ /** EV keeps its own reserve: a charger can be far or busy, so it needs a larger buffer. */
+ evReservePct: number;
/** Stored in EUR so the default means the same everywhere; shown and planned in the display currency. */
timeValueEur: number;
/** EV only: skip chargers below this power (kW); 0 = any. */
@@ -55,7 +57,8 @@ export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = {
open: false,
startPct: 50,
arrivalPct: 20,
- reservePct: 20,
+ reservePct: 10,
+ evReservePct: 20,
timeValueEur: 15,
minChargerKw: 0,
};
@@ -84,7 +87,8 @@ export function RefuelPlanner({
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, minChargerKw } = settings;
+ const { open, startPct, arrivalPct, timeValueEur, minChargerKw } = settings;
+ const reservePct = ev ? settings.evReservePct : settings.reservePct;
// Settings are shared with fuel mode, where arrival may be set above the EV charge cap.
const arrival = Math.min(arrivalPct, maxChargePct);
const update = (patch: Partial) => onSettingsChange({ ...settings, ...patch });
@@ -287,7 +291,7 @@ export function RefuelPlanner({
)}
update({ startPct: v })} />
update({ arrivalPct: v })} />
- update({ reservePct: v })} />
+ update(ev ? { evReservePct: v } : { reservePct: v })} />
{!ev &&
{t("planner.timeValue").replace("{cur}", symbol)}
Date: Wed, 30 Sep 2026 15:58:12 +0200
Subject: [PATCH 16/19] docs(data-model): include the charger power migration
---
docs/reference/data-model.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/docs/reference/data-model.md b/docs/reference/data-model.md
index fc83382..f25bda8 100644
--- a/docs/reference/data-model.md
+++ b/docs/reference/data-model.md
@@ -82,6 +82,12 @@ The shipped files, included here word for word:
--8<-- "prisma/migrations/20260929000000_widen_fuel_price/migration.sql"
```
+=== "Charger power migration"
+
+ ```sql
+ --8<-- "prisma/migrations/20260930000000_station_max_power_kw/migration.sql"
+ ```
+
## `stations`
One row per place: a fuel station, an EV charger, or a site that is both.
From b215a95cc75649d80c95e9698e6b543add936153 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 15:59:23 +0200
Subject: [PATCH 17/19] docs(ev): describe the charging stop planner
---
docs/using/ev-charging.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/using/ev-charging.md b/docs/using/ev-charging.md
index f23e7b1..b1c608e 100644
--- a/docs/using/ev-charging.md
+++ b/docs/using/ev-charging.md
@@ -78,6 +78,8 @@ Every charger within the radius is included, because none is left out for lackin
Clicking a charger in the list or on the map adds it to the route as a stop, as on any layer. See [Pick a station as a stop](routes.md#pick-a-station-as-a-stop).
+On the EV layer, the fuel stop planner becomes **Plan charging stops**. You set the battery size in kWh, the consumption in kWh/100 km, the car's max charge power, and the battery level at the start and at arrival. The planner then picks the chargers along the route that get you there with the least detour and charging time, since chargers have no prices. Each stop charges to at most 80 %, because charging slows sharply above that. The reserve defaults to 20 %. **Min charger power** (Any, 50 kW or 150 kW) skips slower chargers. It also skips chargers whose power is unknown, because the filter can't vouch for them.
+
## Sharing a charger
A charger link must carry `fuel=EV`. The map only loads chargers on the EV layer, so a link without it opens a fuel layer where the charger never loads. The **Copy link** and **Share** buttons add it for you. [The EV rule](links.md#the-ev-rule) explains it in full.
From e7d999f2652d4ca7a19fdfca186c0ddd4e6c58b0 Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:03:40 +0200
Subject: [PATCH 18/19] fix(planner): cap AC charging at the car's max charge
power, up to 22 kW
---
docs/using/ev-charging.md | 2 +-
src/lib/vehicle-profile.test.ts | 5 +++--
src/lib/vehicle-profile.ts | 7 +++----
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/docs/using/ev-charging.md b/docs/using/ev-charging.md
index b1c608e..88e07b2 100644
--- a/docs/using/ev-charging.md
+++ b/docs/using/ev-charging.md
@@ -78,7 +78,7 @@ Every charger within the radius is included, because none is left out for lackin
Clicking a charger in the list or on the map adds it to the route as a stop, as on any layer. See [Pick a station as a stop](routes.md#pick-a-station-as-a-stop).
-On the EV layer, the fuel stop planner becomes **Plan charging stops**. You set the battery size in kWh, the consumption in kWh/100 km, the car's max charge power, and the battery level at the start and at arrival. The planner then picks the chargers along the route that get you there with the least detour and charging time, since chargers have no prices. Each stop charges to at most 80 %, because charging slows sharply above that. The reserve defaults to 20 %. **Min charger power** (Any, 50 kW or 150 kW) skips slower chargers. It also skips chargers whose power is unknown, because the filter can't vouch for them.
+On the EV layer, the fuel stop planner becomes **Plan charging stops**. You set the battery size in kWh, the consumption in kWh/100 km, the car's max charge power, and the battery level at the start and at arrival. The planner then picks the chargers along the route that get you there with the least detour and charging time, since chargers have no prices. Each stop charges to at most 80 %, because charging slows sharply above that. Charging time uses the lower of the charger's power and the car's max charge power, capped at 22 kW on AC chargers. On DC it averages 75 % of that, because charging tapers as the battery fills. The reserve defaults to 20 %. **Min charger power** (Any, 50 kW or 150 kW) skips slower chargers. It also skips chargers whose power is unknown, because the filter can't vouch for them.
## Sharing a charger
diff --git a/src/lib/vehicle-profile.test.ts b/src/lib/vehicle-profile.test.ts
index 1ee51c2..2d033e1 100644
--- a/src/lib/vehicle-profile.test.ts
+++ b/src/lib/vehicle-profile.test.ts
@@ -42,9 +42,10 @@ describe("chargeKw", () => {
expect(chargeKw(0, 150)).toBe(11);
});
- it("limits AC posts to the car's onboard charger", () => {
+ it("limits AC posts to the car's max charge power, at most 22 kW", () => {
expect(chargeKw(7, 150)).toBe(7);
- expect(chargeKw(22, 150)).toBe(11);
+ expect(chargeKw(22, 150)).toBe(22);
+ expect(chargeKw(22, 11)).toBe(11);
});
it("averages DC over the taper, limited by car or station", () => {
diff --git a/src/lib/vehicle-profile.ts b/src/lib/vehicle-profile.ts
index 06914d3..ed21c3c 100644
--- a/src/lib/vehicle-profile.ts
+++ b/src/lib/vehicle-profile.ts
@@ -17,7 +17,7 @@ export const DEFAULT_VEHICLE: VehicleProfile = { tankL: 50, consumptionL100: 6.5
export const evProfileSchema = z.object({
batteryKwh: z.number().finite().min(10).max(200),
consumptionKwh100: z.number().finite().min(5).max(50),
- /** Peak DC charging power the car accepts, kW. Defaulted so profiles saved before it existed still load. */
+ /** Peak charging power the car accepts, kW; AC is also capped at 22. Defaulted so profiles saved before it existed still load. */
maxChargeKw: z.number().finite().min(10).max(500).default(150),
});
@@ -27,8 +27,6 @@ export const DEFAULT_EV: EvProfile = { batteryKwh: 60, consumptionKwh100: 18, ma
/** Assumed for chargers with no published power: slow, so they never beat a known fast one. */
const UNKNOWN_CHARGER_KW = 11;
-/** Typical onboard AC charger; AC posts can't charge faster than the car's own charger. */
-const CAR_AC_KW = 11;
/** Highest power still treated as AC. */
const MAX_AC_KW = 22;
/** Average over a 10→80 % DC session as a share of peak: charging tapers as the battery fills. */
@@ -37,7 +35,8 @@ const DC_TAPER = 0.75;
/** Average charging power (kW) at a charger for a car, for planning. */
export function chargeKw(stationKw: number | null | undefined, carMaxKw: number): number {
if (stationKw == null || !(stationKw > 0)) return UNKNOWN_CHARGER_KW;
- if (stationKw <= MAX_AC_KW) return Math.min(stationKw, CAR_AC_KW);
+ // AC posts can't charge faster than the car's onboard charger, at most 22 kW.
+ if (stationKw <= MAX_AC_KW) return Math.min(stationKw, carMaxKw, MAX_AC_KW);
return Math.min(stationKw, carMaxKw) * DC_TAPER;
}
From 936b9848852ed6ec30f345c34173c32791d7d99c Mon Sep 17 00:00:00 2001
From: yellowhat <1692490+yellowhat@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:05:01 +0200
Subject: [PATCH 19/19] fix(scrapers): still accept comma decimals in BNetzA
plug cells
---
src/scrapers/bnetza.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/scrapers/bnetza.ts b/src/scrapers/bnetza.ts
index f78e951..0648e14 100644
--- a/src/scrapers/bnetza.ts
+++ b/src/scrapers/bnetza.ts
@@ -199,7 +199,7 @@ function resolvePlugPowerColumns(headerLine: string): number[] {
/** Highest plug power on a row, kW. A cell holds one value per plug, joined with ";" ("300; 300"). */
function rowPowerKw(fields: string[], plugCols: number[]): number | null {
- return maxSanePowerKw(plugCols.flatMap((i) => unquote(fields[i]).split(";").map(Number)));
+ return maxSanePowerKw(plugCols.flatMap((i) => unquote(fields[i]).split(";").map((v) => Number(v.trim().replace(",", ".")))));
}
/**