diff --git a/docs/reference/api.md b/docs/reference/api.md index ac21738..c04fc39 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..f25bda8 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 } @@ -81,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. @@ -97,6 +104,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/docs/using/ev-charging.md b/docs/using/ev-charging.md index 1dfb670..88e07b2 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 @@ -80,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. 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 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. @@ -88,6 +88,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/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/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 ( { 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/components/home-client.tsx b/src/components/home-client.tsx index bf9e575..98a61ce 100644 --- a/src/components/home-client.tsx +++ b/src/components/home-client.tsx @@ -13,8 +13,7 @@ import { MapView } from "@/components/map/map-view"; import { SearchPanel } from "@/components/search/search-panel"; import type { PlannedStopMarker } from "@/components/search/refuel-planner"; import { useDetourStream } from "@/lib/use-detour-stream"; -import { parseStationParams, parseRouteParams, buildStationQuery } from "@/lib/share-url"; -import { fuelTypeEnum } from "@/types/fuel"; +import { parseStationParams, parseRouteParams, buildStationQuery, deepLinkFuel } from "@/lib/share-url"; interface Props { defaultFuel: string; @@ -41,12 +40,12 @@ function readDeepLink(): { fuel: FuelType | null; route: InitialRoute | null; st const empty = { fuel: null, route: null, station: null }; if (typeof window === "undefined") return empty; const sp = new URLSearchParams(window.location.search); + const fuel = deepLinkFuel(sp); const route = parseRouteParams(sp); if (route) { - const parsedFuel = fuelTypeEnum.safeParse(route.fuel); return { - fuel: parsedFuel.success ? parsedFuel.data : null, + fuel, route: { from: [route.from.lng, route.from.lat], to: [route.to.lng, route.to.lat], @@ -57,13 +56,7 @@ function readDeepLink(): { fuel: FuelType | null; route: InitialRoute | null; st } const station = parseStationParams(sp); - if (station && station.lat != null && station.lng != null) { - // Same fuel handling as the route branch: the viewport fetch is fuel- - // filtered, so an EV charger shared from the EV layer only ever loads (and - // gets selected) if the link puts the visitor on that layer (#129). - const parsedFuel = fuelTypeEnum.safeParse(station.fuel); - return { fuel: parsedFuel.success ? parsedFuel.data : null, route: null, station }; - } + if (station && station.lat != null && station.lng != null) return { fuel, route: null, station }; return empty; } 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/components/search/refuel-planner.test.tsx b/src/components/search/refuel-planner.test.tsx index 57c1502..ade6547 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")); @@ -356,4 +356,126 @@ 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("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 }); + 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 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 }), + ]; + 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(/· popup.powerUnknown$/)).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("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")); + 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, 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 9f4b41f..7b9dc5b 100644 --- a/src/components/search/refuel-planner.tsx +++ b/src/components/search/refuel-planner.tsx @@ -5,14 +5,22 @@ 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, minutesPerPct, useEvProfile, useVehicleProfile, vehicleProfileSchema } from "@/lib/vehicle-profile"; +import { formatDuration } from "@/lib/format"; 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; +/** "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. */ + mode?: "fuel" | "ev"; /** Corridor stations (with routeFraction and, once streamed, detourMin). */ stations: StationGeoJSON[]; routeKm: number; @@ -37,8 +45,12 @@ 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. */ + minChargerKw: number; } export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = { @@ -46,10 +58,13 @@ export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = { startPct: 50, arrivalPct: 20, reservePct: 10, + evReservePct: 20, timeValueEur: 15, + minChargerKw: 0, }; export function RefuelPlanner({ + mode = "fuel", stations, routeKm, detoursLoading, @@ -65,18 +80,29 @@ export function RefuelPlanner({ }: RefuelPlannerProps) { const { t } = useI18n(); const { currency, symbol, decimals, formatPrice, convert, rates } = useCurrency(); - const [profile, setProfile] = useVehicleProfile(); - const { open, startPct, arrivalPct, reservePct, timeValueEur } = settings; + 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, 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 }); // 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)); + 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); // 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,33 +112,48 @@ 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) { + // No prices yet: chargers compete on detour and charging time only. + if (p.detourMin == null || p.detourMin < 0 || (maxDetour != null && p.detourMin > maxDetour)) return []; + // 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.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 }]; }); return planRefuel({ routeKm, stations: candidates, - tankL: profile.tankL, - consumptionL100: profile.consumptionL100, + tankL: capacity, + consumptionL100: consumption, startPct, - arrivalPct, + arrivalPct: arrival, reservePct, - timeValuePerHour: timeValue, - maxStops: maxStopsFor(routeKm, profile.tankL, profile.consumptionL100, 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, maxPrice, maxDetour, currency, profile, 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" @@ -129,11 +170,17 @@ 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 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(",", ".")); @@ -144,18 +191,22 @@ export function RefuelPlanner({ if (e.key === "Enter") e.currentTarget.blur(); }; + 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) => { 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" : "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))); } @@ -168,7 +219,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 +230,69 @@ export function RefuelPlanner({
- update({ startPct: v })} /> - update({ arrivalPct: v })} /> - update({ reservePct: v })} /> - }
{/* Result */}
+ {ev && routeKm > 0 && ( +

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

+ )} {ratesMissing ? (

{t("planner.noRates")}

) : detoursPending ? ( @@ -262,19 +348,34 @@ export function RefuelPlanner({

-

{money(stop.cost)}

-

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

+ {ev ? ( + <> +

+{kwh(stop.litres)}

+

+ {minutes(stop.chargeMin)} · {f.properties.powerKw != null ? `${f.properties.powerKw} kW` : t("popup.powerUnknown")} +

+ + ) : ( + <> +

{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))} · ${minutes(plan.totalChargeMin)}` + : money(plan.totalFuelCost)} - {money(plan.totalFuelCost)}
{plan.dipsBelowReserve && ( @@ -327,3 +428,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) && (