Skip to content

Commit 77d453c

Browse files
committed
feat(ev-charging): readable source names and payment methods in detail
Resolve source ids to their manifest display names via the existing createManifestAttribution store (e.g. "ocm" -> "OpenChargeMap") instead of showing raw ids, and render payment methods as deduped, brand-cased text ("mastercard, visa" -> "Mastercard, Visa"). The mapper takes an optional source-name resolver so it stays standalone; the provider supplies the manifest-backed one.
1 parent 057a676 commit 77d453c

4 files changed

Lines changed: 77 additions & 6 deletions

File tree

integrations/ev-charging/providers/__tests__/provider.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe("evChargingProvider.getDetail", () => {
199199
expect(mocks.sourceA.fetchDetail).toHaveBeenCalledWith("source-a:123");
200200
expect(mocks.sourceA.search).toHaveBeenCalledOnce();
201201
expect(mocks.sourceB.search).toHaveBeenCalledOnce();
202-
expect(mapStationToDetail).toHaveBeenCalledWith(merged);
202+
expect(mapStationToDetail).toHaveBeenCalledWith(merged, expect.any(Function));
203203
expect(result?.sources).toEqual(["source-a", "source-b"]);
204204
});
205205

integrations/ev-charging/providers/__tests__/station-mapper.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,32 @@ describe("ev-charging station mapper", () => {
9595
values: { count: 2, types: "CCS", power: 50 },
9696
});
9797
});
98+
99+
it("resolves source ids to display names via the provided resolver", () => {
100+
const names: Record<string, string> = { ocm: "OpenChargeMap", osm: "OpenStreetMap" };
101+
const detail = mapStationToDetail(
102+
makeStation({ sources: ["ocm", "osm"] }),
103+
(id) => names[id] ?? id,
104+
);
105+
const sourceSection = detail.sections.find(
106+
(s) => isI18nToken(s.title) && s.title.$t === "shared.section.source",
107+
);
108+
const sourcesRow = sourceSection?.rows?.find(
109+
([label]) => isI18nToken(label) && label.$t === "shared.row.sources",
110+
);
111+
expect(sourcesRow?.[1]).toBe("OpenChargeMap, OpenStreetMap");
112+
});
113+
114+
it("renders payment methods as readable, brand-cased, deduped text", () => {
115+
const detail = mapStationToDetail(
116+
makeStation({ paymentMethods: ["mastercard", "visa", "apple_pay", "mastercard"] }),
117+
);
118+
const usageSection = detail.sections.find(
119+
(s) => isI18nToken(s.title) && s.title.$t === "section.usage",
120+
);
121+
const paymentRow = usageSection?.rows?.find(
122+
([label]) => isI18nToken(label) && label.$t === "row.payment",
123+
);
124+
expect(paymentRow?.[1]).toBe("Mastercard, Visa, Apple Pay");
125+
});
98126
});

integrations/ev-charging/providers/provider.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { mapStationToDetail, mapStationToResult } from "./station-mapper.js";
2525
const attribution = createManifestAttribution();
2626
export const setManifestDataSources = attribution.set;
2727

28+
// Resolve a source id to its human-readable name from the manifest's
29+
// dataSources (e.g. "ocm" → "OpenChargeMap"), falling back to the raw id.
30+
const resolveSourceName = (id: string): string => attribution.bySource(id)?.name ?? id;
31+
2832
// PoiReader-backed sources whose cold-start state should flip
2933
// `freshness.isStale=true` on the wrapped result. Hardcoded (rather than
3034
// derived from `declarePoiSources()`) to avoid a circular import with
@@ -113,14 +117,21 @@ class EvChargingProvider implements MobilityDataSourceProvider {
113117

114118
async getDetail(itemId: string): Promise<MobilityResult<DataSourceDetail | null>> {
115119
const cached = this.stationCache.get(itemId);
116-
if (cached) return wrapStatic(mapStationToDetail(cached), attributionsForStation(cached));
120+
if (cached)
121+
return wrapStatic(
122+
mapStationToDetail(cached, resolveSourceName),
123+
attributionsForStation(cached),
124+
);
117125

118126
const primary = await this.fetchByPrefix(itemId);
119127
if (!primary) return wrapStatic(null, []);
120128

121129
const enriched = await this.enrichStation(primary);
122130
this.cacheStation(enriched);
123-
return wrapStatic(mapStationToDetail(enriched), attributionsForStation(enriched));
131+
return wrapStatic(
132+
mapStationToDetail(enriched, resolveSourceName),
133+
attributionsForStation(enriched),
134+
);
124135
}
125136

126137
private async fetchByPrefix(itemId: string): Promise<EvChargingStation | null> {

integrations/ev-charging/providers/station-mapper.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,33 @@ function formatPower(value: number | undefined): string {
9696
return value ? `${value} kW` : "-";
9797
}
9898

99+
// Brand spellings that title-casing alone gets wrong.
100+
const PAYMENT_BRAND_CASING: Record<string, string> = {
101+
paypal: "PayPal",
102+
applepay: "Apple Pay",
103+
"apple pay": "Apple Pay",
104+
googlepay: "Google Pay",
105+
"google pay": "Google Pay",
106+
nfc: "NFC",
107+
};
108+
109+
// Payment methods arrive as lowercase tokens (from OSM `payment:*` tags or
110+
// provider feeds), e.g. "mastercard", "debit cards", "apple_pay". Render them
111+
// as readable text: brand-cased where known, otherwise title-cased per word.
112+
function formatPaymentMethods(methods: string[]): string {
113+
const seen = new Set<string>();
114+
const formatted: string[] = [];
115+
for (const raw of methods) {
116+
const normalized = raw.trim().toLowerCase().replace(/_/g, " ");
117+
if (!normalized || seen.has(normalized)) continue;
118+
seen.add(normalized);
119+
formatted.push(
120+
PAYMENT_BRAND_CASING[normalized] ?? normalized.replace(/\b\w/g, (char) => char.toUpperCase()),
121+
);
122+
}
123+
return formatted.join(", ");
124+
}
125+
99126
function formatTimestamp(value: string | undefined): string | undefined {
100127
if (!value) return undefined;
101128
const time = Date.parse(value);
@@ -120,7 +147,12 @@ function connectorRows(
120147
]);
121148
}
122149

123-
export function mapStationToDetail(station: EvChargingStation): DataSourceDetail {
150+
export function mapStationToDetail(
151+
station: EvChargingStation,
152+
// Resolve a source id to its display name (from the integration manifest's
153+
// dataSources). Defaults to the id itself so the mapper stays standalone.
154+
resolveSourceName: (id: string) => string = (id) => id,
155+
): DataSourceDetail {
124156
const sections: DataSourceDetailSection[] = [];
125157

126158
if (station.connectors.length > 0) {
@@ -143,7 +175,7 @@ export function mapStationToDetail(station: EvChargingStation): DataSourceDetail
143175
if (station.usageType) usageRows.push([sharedT.row.access, station.usageType]);
144176
if (station.usageCost) usageRows.push([token("row.cost"), station.usageCost]);
145177
if (station.paymentMethods?.length) {
146-
usageRows.push([token("row.payment"), station.paymentMethods.join(", ")]);
178+
usageRows.push([token("row.payment"), formatPaymentMethods(station.paymentMethods)]);
147179
}
148180
if (station.membershipRequired !== undefined) {
149181
usageRows.push([
@@ -180,7 +212,7 @@ export function mapStationToDetail(station: EvChargingStation): DataSourceDetail
180212
}
181213

182214
const sourceRows: [I18nToken, Translatable][] = [];
183-
sourceRows.push([sharedT.row.sources, station.sources.join(", ")]);
215+
sourceRows.push([sharedT.row.sources, station.sources.map(resolveSourceName).join(", ")]);
184216
const updated = formatTimestamp(station.updatedAt);
185217
if (updated) sourceRows.push([sharedT.row.lastUpdated, updated]);
186218
if (station.sourceUrl) sourceRows.push([sharedT.row.sourceUrl, station.sourceUrl]);

0 commit comments

Comments
 (0)