Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions src/components/home-client.deeplink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,27 @@ describe("HomeClient — deep-link read on load", () => {
expect(searchPanelProps.initialRoute).toBeNull();
});

it("applies the fuel from a station deep-link so the shared layer is the one that loads (#129)", async () => {
// A charger shared from the EV layer: the viewport fetch is fuel-filtered,
// so unless the link switches the visitor to EV the charger never loads.
window.history.replaceState(null, "", "/es?station=ES:reve-1&lat=40.4768&lng=-3.6891&fuel=EV");
renderHome();

expect(searchPanelProps.selectedFuel).toBe("EV");
expect(searchPanelProps.initialRoute).toBeNull();
await waitFor(() => expect(flyTo).toHaveBeenCalled());
});

it("keeps the default fuel when a station deep-link carries an invalid or no fuel", () => {
window.history.replaceState(null, "", "/es?station=ES:12345&lat=40.41672&lng=-3.70379&fuel=NOTAFUEL");
renderHome();
expect(searchPanelProps.selectedFuel).toBe("E5");

window.history.replaceState(null, "", "/es?station=ES:12345&lat=40.41672&lng=-3.70379");
renderHome();
expect(searchPanelProps.selectedFuel).toBe("E5");
});

it("resolves & selects a station deep-link from the lifted on-screen stations", async () => {
// The real MapView lifts on-screen bbox stations to the parent; the resolve
// effect matches the deep-linked station (externalId+country) and selects it.
Expand Down
10 changes: 7 additions & 3 deletions src/components/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ function readDeepLink(): { fuel: FuelType | null; route: InitialRoute | null; st

const station = parseStationParams(sp);
if (station && station.lat != null && station.lng != null) {
return { fuel: null, route: null, station };
// 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 };
}
return empty;
}
Expand Down Expand Up @@ -476,10 +480,10 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
const country = feature?.properties.country;
if (feature && extId != null && country != null) {
const [lng, lat] = feature.geometry.coordinates;
const qs = buildStationQuery({ country, externalId: extId, lat, lng }).toString();
const qs = buildStationQuery({ country, externalId: extId, lat, lng, fuel: selectedFuel }).toString();
window.history.replaceState(null, "", `${pathname}?${qs}`);
}
}, [selectedStationId, routeState, primaryStations]);
}, [selectedStationId, routeState, primaryStations, selectedFuel]);

// Streaming Valhalla-based detour calculation — results appear per-station
const { detourMap, detoursLoading } = useDetourStream({ primaryStations, routeState, detourBasis });
Expand Down
2 changes: 2 additions & 0 deletions src/components/map/station-popup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ describe("StationPopup", () => {
expect(copied).toContain("station=ES%3A4710");
expect(copied).toContain("lat=40.4168");
expect(copied).toContain("lng=-3.7038");
// The layer the popup was opened from travels with the link (#129).
expect(copied).toContain("fuel=E5");
vi.unstubAllGlobals();
});
});
7 changes: 5 additions & 2 deletions src/components/map/station-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,17 @@ export function StationPopup({ station, onClose }: StationPopupProps) {
const lat = geometry.coordinates[1];
const lng = geometry.coordinates[0];

// Absolute deep-link to this station (?station=CC:extId&lat&lng on the
// current locale path) — shared and copied by the action buttons below.
// Absolute deep-link to this station (?station=CC:extId&lat&lng&fuel on the
// current locale path) — shared and copied by the action buttons below. The
// fuel is the layer this popup was opened from; without it an EV charger
// link would open on the default fuel and never load the charger (#129).
function shareUrl(): string {
const sp = buildStationQuery({
country: properties.country ?? "",
externalId: properties.externalId ?? "",
lat,
lng,
fuel: properties.fuelType,
});
return `${window.location.origin}${window.location.pathname}?${sp}`;
}
Expand Down
23 changes: 22 additions & 1 deletion src/lib/share-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,29 @@ describe("buildStationQuery / parseStationParams round-trip", () => {
externalId: "12345",
lat: 40.41672,
lng: -3.70379,
fuel: null,
});
});

it("carries the fuel the station was shared from (#129)", () => {
// An EV charger only loads on the EV layer: without the fuel, a fresh
// visitor lands on the default fuel and the popup can never open.
const sp = buildStationQuery({ country: "ES", externalId: "reve-1", lat: 40.4768, lng: -3.6891, fuel: "EV" });
expect(sp.get("fuel")).toBe("EV");
expect(parseStationParams(sp)?.fuel).toBe("EV");
});

it("omits the fuel param when none is given", () => {
const sp = buildStationQuery({ country: "ES", externalId: "1", lat: 1, lng: 2, fuel: "" });
expect(sp.has("fuel")).toBe(false);
expect(parseStationParams(sp)?.fuel).toBeNull();
});

it("passes fuel through raw without validating it", () => {
const parsed = parseStationParams(new URLSearchParams("station=ES:1&lat=1&lng=2&fuel=NOTAFUEL"));
expect(parsed?.fuel).toBe("NOTAFUEL");
});

it("uppercases the country on build", () => {
const sp = buildStationQuery({ country: "es", externalId: "abc", lat: 1, lng: 2 });
expect(sp.get("station")).toBe("ES:abc");
Expand All @@ -145,7 +165,7 @@ describe("buildStationQuery / parseStationParams round-trip", () => {
const sp = new URLSearchParams();
sp.set("station", "ES");
const parsed = parseStationParams(sp);
expect(parsed).toEqual({ country: "ES", externalId: null, lat: null, lng: null });
expect(parsed).toEqual({ country: "ES", externalId: null, lat: null, lng: null, fuel: null });
});

it("yields null coords when lat/lng are out of range", () => {
Expand All @@ -169,6 +189,7 @@ describe("buildStationQuery / parseStationParams round-trip", () => {
externalId: null,
lat: 40.41672,
lng: -3.70379,
fuel: null,
});
});

Expand Down
16 changes: 12 additions & 4 deletions src/lib/share-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
* Applying the result to the current browser URL lives in components, not here.
*
* URL shapes:
* STATION: /{locale}?station={COUNTRY}:{externalId}&lat={LAT}&lng={LNG}
* STATION: /{locale}?station={COUNTRY}:{externalId}&lat={LAT}&lng={LNG}&fuel={CODE}
* ROUTE: /{locale}?from={LAT,LNG}&to={LAT,LNG}&via={LAT,LNG}&...&fuel={CODE}
*
* Stations are keyed on the durable COUNTRY:externalId (UUIDs churn on re-import);
* lat/lng is the recenter fallback. Coordinates are rounded to 5 decimal places.
* `fuel` is the layer the station was shared from: the viewport fetch is
* fuel-filtered, so without it an EV charger link opens on the default fuel and
* the charger is never loaded, let alone selected (#129).
*/

/** Maximum number of `via` waypoints honoured when parsing a route. */
Expand Down Expand Up @@ -56,28 +59,33 @@ export interface StationShareParams {
externalId: string;
lat: number;
lng: number;
/** Fuel code the station was shared from; omitted when empty. */
fuel?: string;
}

/** Build the query for a shared station: `station=CC:extId&lat=..&lng=..`. */
/** Build the query for a shared station: `station=CC:extId&lat=..&lng=..&fuel=..`. */
export function buildStationQuery(p: StationShareParams): URLSearchParams {
const sp = new URLSearchParams();
sp.set("station", `${p.country.toUpperCase()}:${p.externalId}`);
sp.set("lat", String(roundCoord(p.lat)));
sp.set("lng", String(roundCoord(p.lng)));
if (p.fuel) sp.set("fuel", p.fuel);
return sp;
}

/**
* Parse station params from a query. Returns null when no station-ish params are
* present at all. The `station` value is split on the FIRST colon only, since the
* external id may itself contain colons. lat/lng are validated independently and
* may be null while country/externalId are present (and vice versa).
* may be null while country/externalId are present (and vice versa). `fuel` is
* returned raw (null when absent); the caller validates it against `fuelTypeEnum`.
*/
export function parseStationParams(sp: URLSearchParams): {
country: string | null;
externalId: string | null;
lat: number | null;
lng: number | null;
fuel: string | null;
} | null {
const station = sp.get("station");
const latRaw = sp.get("lat");
Expand All @@ -101,7 +109,7 @@ export function parseStationParams(sp: URLSearchParams): {
const lat = parseCoordComponent(latRaw, -90, 90);
const lng = parseCoordComponent(lngRaw, -180, 180);

return { country, externalId, lat, lng };
return { country, externalId, lat, lng, fuel: sp.get("fuel") };
}

// ---------------------------------------------------------------------------
Expand Down
Loading