Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
83ff3e9
fix(share-url): read the deep-link fuel on the server too
yellowhat Sep 29, 2026
c258525
fix(planner): raise the default reserve to 20 %
yellowhat Sep 29, 2026
6dfe638
feat(planner): charge cap and zero-price stops in planRefuel
yellowhat Sep 29, 2026
9b81e43
refactor(vehicle-profile): generic stored profile, add EV profile
yellowhat Sep 29, 2026
4b04b7e
feat(planner): plan EV charging stops by energy
yellowhat Sep 29, 2026
edefd6f
feat(scrapers): store EV charger max power
yellowhat Sep 29, 2026
308e0e4
feat(scrapers): read charger power from BNetzA, OCM and REVE
yellowhat Sep 29, 2026
86e071b
feat(api): expose charger powerKw on EV station responses
yellowhat Sep 29, 2026
0eb0313
feat(popup): show EV charger power
yellowhat Sep 29, 2026
c18dfbc
feat(planner): weigh EV charging time by charger power
yellowhat Sep 29, 2026
b4ab9b3
fix(planner): EV wording, arrival cap and power-filter hint
yellowhat Sep 29, 2026
563ea1a
fix(scrapers): validate each connector's power before taking the max
yellowhat Sep 29, 2026
681ec1a
Merge branch 'GeiserX:main' into feat/ev-planner
yellowhat Sep 30, 2026
657d493
Merge branch 'GeiserX:main' into feat/ev-planner
yellowhat Sep 30, 2026
75f97fe
fix(scrapers): read every plug value in multi-plug BNetzA power cells
yellowhat Sep 30, 2026
fb90d21
fix(scrapers): write max_power_kw only for charger batches
yellowhat Sep 30, 2026
1039610
fix(planner): keep the fuel reserve at 10 %, give EV its own 20 % def…
yellowhat Sep 30, 2026
8ebcc47
docs(data-model): include the charger power migration
yellowhat Sep 30, 2026
b215a95
docs(ev): describe the charging stop planner
yellowhat Sep 30, 2026
e7d999f
fix(planner): cap AC charging at the car's max charge power, up to 22 kW
yellowhat Sep 30, 2026
936b984
fix(scrapers): still accept comma decimals in BNetzA plug cells
yellowhat Sep 30, 2026
34dd96d
Merge branch 'GeiserX:main' into feat/ev-planner
yellowhat Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
8 changes: 8 additions & 0 deletions docs/reference/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ erDiagram
text province
varchar station_type
geometry geom
smallint max_power_kw
timestamptz created_at
timestamptz updated_at
}
Expand Down Expand Up @@ -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.
Expand All @@ -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. |

Expand Down
10 changes: 5 additions & 5 deletions docs/using/ev-charging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
yellowhat marked this conversation as resolved.
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz

Expand Down
11 changes: 9 additions & 2 deletions src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | string[] | undefined>>;
}

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 (
<HomeClient
defaultFuel={config.defaultFuel}
defaultFuel={deepLinkFuel(sp) ?? config.defaultFuel}
center={config.center}
zoom={config.zoom}
clusterStations={config.clusterStations}
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/route-stations/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/route-stations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/stations/nearest/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe("stations/nearest API", () => {
...mockRow,
price: null,
reported_at: null,
max_power_kw: 150,
}]);

const response = (await GET(makeRequest({
Expand All @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/stations/nearest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
},
}));
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/stations/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe("stations API", () => {
...mockRow,
price: null,
reported_at: null,
max_power_kw: 150,
}]);

const response = (await GET(makeRequest({
Expand All @@ -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 () => {
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/stations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 } : {}),
},
}));

Expand Down
15 changes: 4 additions & 11 deletions src/components/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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],
Expand All @@ -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;
}

Expand Down
13 changes: 13 additions & 0 deletions src/components/map/station-popup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<StationPopup station={makeStation({ fuelType: "EV", price: null, powerKw: 150 })} onClose={() => {}} />);
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(<StationPopup station={makeStation({ fuelType: "EV", price: null })} onClose={() => {}} />);
expect(screen.getByText("popup.powerUnknown")).toBeInTheDocument();
expect(screen.queryByText("kW")).not.toBeInTheDocument();
});
});
17 changes: 17 additions & 0 deletions src/components/map/station-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,23 @@ export function StationPopup({ station, onClose }: StationPopupProps) {
</p>
)}
</div>
) : properties.fuelType === "EV" ? (
// Chargers have no prices yet; their power is what tells them apart.
<div className="mt-2 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800">
{properties.powerKw != null ? (
<div className="flex items-baseline gap-1">
<span className="text-[22px] font-bold tabular-nums leading-none text-gray-900 dark:text-gray-100">
{properties.powerKw}
</span>
<span className="text-[11px] font-medium text-gray-500 dark:text-gray-400">kW</span>
</div>
) : (
<p className="text-[13px] font-semibold text-gray-700 dark:text-gray-200">{t("popup.powerUnknown")}</p>
)}
<p className="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{t("popup.maxPower")} · {t("popup.noPrice")} {fuelInfo?.label ?? properties.fuelType}
</p>
</div>
) : (
<div className="mt-2 rounded-lg bg-gray-50 px-3 py-2.5 text-center dark:bg-gray-800">
<span className="text-[11px] text-gray-500 dark:text-gray-400">
Expand Down
Loading
Loading