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
34 changes: 34 additions & 0 deletions apps/web/src/components/map/layers/DataSourceLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,40 @@ it("serializes independent label, paint and logo channels with authoritative emp
expect(ids("_mapLogoFileAttributions")).toEqual(["actual-file"]);
});

it("labels the map with a result's label summary, crediting that field, and its summary otherwise", async () => {
const { buildGeoJson } = await import("@/integration-api/map/dataSourceFeatures");
const credit = (sourceId: string) => [{ sourceId, name: sourceId }];
const place = {
id: "station",
name: "Name",
source: "fuel",
variant: "open",
summary: { $t: "summary.priceList" },
labelSummary: { $t: "summary.priceAge" },
coordinates: [8, 50] as [number, number],
contributors: credit("hidden"),
fieldContributors: {
coordinates: credit("coords"),
name: credit("name"),
summary: credit("every-grade"),
labelSummary: credit("one-grade"),
},
};
const translate = (value: unknown) => (value as { $t: string } | undefined)?.$t;
const [labelled, plain] = buildGeoJson(
[place, { ...place, id: "plain", labelSummary: undefined }],
translate,
).features;
const ids = (f: typeof labelled) =>
JSON.parse(f.properties._mapSummaryLabelAttributions).map(
(receipt: { sourceId: string }) => receipt.sourceId,
);
expect(labelled.properties.summary).toBe("summary.priceAge");
expect(ids(labelled)).toEqual(["coords", "name", "one-grade"]);
expect(plain.properties.summary).toBe("summary.priceList");
expect(ids(plain)).toEqual(["coords", "name", "every-grade"]);
});

it("adds captured file receipts only after the actual logo image loads and only to its rendered logo channel", async () => {
vi.useFakeTimers();
fake.state.zoom = 12;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/integration-api/map/dataSourceFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function buildGeoJson(
circleFields.push("variant");
if (markerStyle?.inactiveOpacity !== 1 && r.status) circleFields.push("status");
const marker = contributorsForRecordFields(r, imageId ? ["coordinates"] : circleFields);
const summary = translate(r.summary) ?? "";
const summaryField = r.labelSummary ? "labelSummary" : "summary";
const summary = translate(r[summaryField]) ?? "";
const nameField = r.name ? "name" : "fallbackName";
const labelFields = ["coordinates", nameField];
const logoFields = [
Expand Down Expand Up @@ -71,7 +72,7 @@ export function buildGeoJson(
contributorsForRecordFields(r, labelFields),
),
_mapSummaryLabelAttributions: serializeContributors(
contributorsForRecordFields(r, [...labelFields, ...(summary ? ["summary"] : [])]),
contributorsForRecordFields(r, [...labelFields, ...(summary ? [summaryField] : [])]),
),
_mapLogoSelectionAttributions: serializeContributors(
contributorsForRecordFields(r, logoFields),
Expand Down
91 changes: 90 additions & 1 deletion integrations/fuel/__tests__/mapper.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isI18nToken, resolveToken, sharedStrings } from "@openmapx/integration-framework/strings";
import type { FuelProduct, FuelStation } from "@openmapx/mobility-core/fuel";
import { describe, expect, it } from "vitest";
import { mapFuelStationToDetail, mapFuelStationToResult } from "../mapper.js";
import { comparisonGrade, mapFuelStationToDetail, mapFuelStationToResult } from "../mapper.js";
import de from "../strings/de.json";
import en from "../strings/en.json";

Expand Down Expand Up @@ -204,6 +204,95 @@ describe("mapFuelStationToResult", () => {
expect(text(unknown)).toBe("NEW FUEL €2.100");
});

describe("map label", () => {
const NOW = Date.parse(AT) + 25 * 60_000;
const label = (station: FuelStation, grade?: string, locale = "en") =>
text(mapFuelStationToResult(station, { grade, now: NOW }).labelSummary, locale);

it("shows only the comparison grade's price, with its age, in every locale", () => {
expect(label(makeStation())).toBe("D €1.559 · 25 min");
expect(label(makeStation(), "e10")).toBe("E10 €1.639 · 25 min");
expect(label(makeStation(), "e10", "de")).toBe(`E10 1,639${NB}€ · 25 Min.`);
});

it("compares diesel when no grade is asked for, as the list's price sort does", () => {
expect(comparisonGrade([])).toBe("diesel");
expect(comparisonGrade(["e10", "e5"])).toBe("e10");
expect(text(mapFuelStationToResult(makeStation()).labelSummary)).toMatch(/^D €1\.559 · /);
});

it("shows the price the list sorts by: the cheapest self-service car price per litre", () => {
const station = makeStation({
products: [
product({
key: "d:served",
grade: "diesel",
service: "served",
price: eur(1.399),
priceAt: AT,
}),
product({
key: "d:hgv",
grade: "diesel",
vehicleScope: "hgv",
price: eur(1.299),
priceAt: AT,
}),
product({
key: "d:self",
grade: "diesel",
service: "self",
price: eur(1.599),
priceAt: AT,
}),
product({ key: "d", grade: "diesel", price: eur(1.649), priceAt: AT }),
],
});
const result = mapFuelStationToResult(station, { now: NOW });
expect(result.sortValues?.diesel).toBe(1.599);
expect(text(result.labelSummary)).toBe("D €1.599 · 25 min");
});

it("says the grade's price is missing rather than showing another grade's", () => {
const noE10 = makeStation({
products: [
product({ grade: "diesel", price: eur(1.559), priceAt: AT }),
product({ grade: "e10", available: "unknown" }),
],
});
expect(label(noE10, "e10")).toBe("E10 –");
const servedOnly = makeStation({
products: [product({ grade: "diesel", service: "served", price: eur(1.699) })],
});
expect(label(servedOnly)).toBe("D –");
expect(label(makeStation({ products: [] }))).toBe("D –");
});

it("counts the age in minutes, then hours, then days", () => {
const at = (minutesAgo: number) =>
makeStation({
products: [
product({
grade: "diesel",
price: eur(1.559),
priceAt: new Date(NOW - minutesAgo * 60_000).toISOString(),
}),
],
});
expect(label(at(0))).toBe("D €1.559 · 1 min");
expect(label(at(59))).toBe("D €1.559 · 59 min");
expect(label(at(60))).toBe("D €1.559 · 1 h");
expect(label(at(47 * 60 + 59))).toBe("D €1.559 · 47 h");
expect(label(at(48 * 60))).toBe("D €1.559 · 2 d");
expect(label(at(48 * 60), undefined, "de")).toBe(`D 1,559${NB}€ · 2 Tg.`);
});

it("leaves the age out when the price carries no report time", () => {
const station = makeStation({ products: [product({ grade: "diesel", price: eur(1.559) })] });
expect(label(station)).toBe("D €1.559");
});
});

it("has no sort values, summary or time without a priced product", () => {
const result = mapFuelStationToResult(
makeStation({ products: [product({ grade: "diesel", available: "unknown" })] }),
Expand Down
8 changes: 6 additions & 2 deletions integrations/fuel/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
wrapSiteResult as wrap,
} from "@openmapx/integration-framework";
import type { MobilityResult } from "@openmapx/mobility-core/result";
import { mapFuelStationToDetail, mapFuelStationToResult } from "./mapper.js";
import { comparisonGrade, mapFuelStationToDetail, mapFuelStationToResult } from "./mapper.js";
import { createFuelStationOrchestrator } from "./orchestrator.js";

const META: DataSourceMeta = {
Expand Down Expand Up @@ -110,7 +110,11 @@ export function createFuelDataSource(ctx: IntegrationContext): MobilityDataSourc
const kept = found.sites.filter(
(s) => (grades.length === 0 || sells(s, grades)) && (!pricesOnly || hasPrice(s)),
);
const result = wrap(kept.map(mapFuelStationToResult), siteAttributions(kept));
const options = { grade: comparisonGrade(grades), now: Date.now() };
const result = wrap(
kept.map((station) => mapFuelStationToResult(station, options)),
siteAttributions(kept),
);
return found.partial ? { ...result, partial: found.partial } : result;
},

Expand Down
90 changes: 81 additions & 9 deletions integrations/fuel/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,39 @@ function stationCurrency(station: FuelStation): string | undefined {
}

/**
* Each grade's lowest per-litre price among its self-service or standard
* products. Served-pump prices, lorry-only pumps and per-kg/m³ grades are left
* out: a sort by price must compare what a driver pays at the pump they work
* themselves.
* The products a price comparison counts: priced per litre in the station's
* currency, at a self-service or standard pump cars can use. Served-pump
* prices, lorry-only pumps and per-kg/m³ grades are left out: a sort by price
* must compare what a driver pays at the pump they work themselves.
*/
function comparableProducts(station: FuelStation, currency: string | undefined): PricedProduct[] {
return station.products.filter(
(product): product is PricedProduct =>
isPriced(product) &&
product.per === "L" &&
product.service !== "served" &&
product.vehicleScope !== "hgv" &&
product.price.currency === currency,
);
}

/** The cheapest of a grade's comparable products, if any. */
function cheapestOf(products: readonly PricedProduct[], grade: string): PricedProduct | undefined {
let best: PricedProduct | undefined;
for (const product of products) {
if (product.grade !== grade) continue;
if (best === undefined || product.price.amount < best.price.amount) best = product;
}
return best;
}

/** Each grade's lowest comparable price, the values the result list sorts by. */
function buildSortValues(
station: FuelStation,
currency: string | undefined,
): Record<string, number> | undefined {
const values: Record<string, number> = {};
for (const product of station.products) {
if (!isPriced(product) || product.per !== "L" || product.service === "served") continue;
if (product.vehicleScope === "hgv") continue;
if (product.price.currency !== currency) continue;
for (const product of comparableProducts(station, currency)) {
const current = values[product.grade];
if (current === undefined || product.price.amount < current) {
values[product.grade] = product.price.amount;
Expand All @@ -69,6 +88,47 @@ function buildSortValues(
return Object.keys(values).length > 0 ? values : undefined;
}

/**
* The grade a search compares stations by: the first grade asked for, else
* diesel. The result list sorts by the same grade, so the map's labels and
* the list's order agree.
*/
export function comparisonGrade(grades: readonly string[]): string {
return grades[0] ?? "diesel";
}

const MINUTE_MS = 60_000;

/** How long ago a price was reported, coarsely: minutes within the hour, hours within two days, else days. */
function priceAge(at: number, now: number): I18nToken {
const minutes = Math.max(1, Math.floor((now - at) / MINUTE_MS));
if (minutes < 60) return token("summary.age", { unit: "minutes", count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 48) return token("summary.age", { unit: "hours", count: hours });
return token("summary.age", { unit: "days", count: Math.floor(hours / 24) });
}

/**
* The map label's price: only the comparison grade's cheapest comparable
* price, with how long ago it was reported, so neighbouring labels compare
* like with like. A station with no such price says so under that grade's
* label rather than showing another grade's price.
*/
function formatLabelSummary(
station: FuelStation,
currency: string | undefined,
grade: string,
now: number,
): I18nToken {
const label = summaryLabel(grade);
const product = cheapestOf(comparableProducts(station, currency), grade);
if (!product) return token("summary.noGradePrice", { label });
const amount = fuelMoney(product.price.amount, product.price.currency);
const priceAt = validObservedAt(product.priceAt);
if (!priceAt) return token("summary.price", { label, amount });
return token("summary.priceAge", { label, amount, age: priceAge(Date.parse(priceAt), now) });
}

/** The oldest report time among the priced products: the result is no fresher than that. */
function oldestPriceAt(station: FuelStation): string | undefined {
let oldest: string | undefined;
Expand Down Expand Up @@ -216,7 +276,18 @@ function stationIdentity(station: FuelStation): OsmIdentity | undefined {
return { brand: station.brand, operator: station.brand };
}

export function mapFuelStationToResult(station: FuelStation): DataSourceResult {
/** What a search result is mapped for: the grade its map label compares, and the time it is read at. */
export interface FuelResultOptions {
/** The grade the map label shows; diesel when none is given. */
grade?: string;
/** Epoch milliseconds a price's age is counted to; now when not given. */
now?: number;
}

export function mapFuelStationToResult(
station: FuelStation,
{ grade = comparisonGrade([]), now = Date.now() }: FuelResultOptions = {},
): DataSourceResult {
const currency = stationCurrency(station);
const sortValues = buildSortValues(station, currency);
const variant = openVariant(station);
Expand All @@ -232,6 +303,7 @@ export function mapFuelStationToResult(station: FuelStation): DataSourceResult {
variant,
status: variant,
summary,
labelSummary: formatLabelSummary(station, currency, grade, now),
operator: station.brand,
sortValues,
observedAt: oldestPriceAt(station),
Expand Down
3 changes: 3 additions & 0 deletions integrations/fuel/strings/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"summary": {
"priceList": "{scope, select, car {{prices}} hgv {{hgvPrices} (Lkw)} other {{prices} · {hgvPrices} (Lkw)}}",
"price": "{label} {amount}",
"priceAge": "{label} {amount} · {age}",
"noGradePrice": "{label} –",
"age": "{unit, select, minutes {{count} Min.} hours {{count} Std.} other {{count} Tg.}}",
"join": "{head} · {tail}",
"grade": {
"e5": "E5",
Expand Down
3 changes: 3 additions & 0 deletions integrations/fuel/strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"summary": {
"priceList": "{scope, select, car {{prices}} hgv {{hgvPrices} (HGV)} other {{prices} · {hgvPrices} (HGV)}}",
"price": "{label} {amount}",
"priceAge": "{label} {amount} · {age}",
"noGradePrice": "{label} –",
"age": "{unit, select, minutes {{count} min} hours {{count} h} other {{count} d}}",
"join": "{head} · {tail}",
"grade": {
"e5": "E5",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/types/dataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export interface DataSourceResult extends ContributingRecord {
/** Live EVSE availability rollup, present only for live-covered results. */
availability?: { available: number; total: number };
summary?: I18nToken;
/**
* The shorter text the map shows under the name at close zoom, when it
* differs from `summary` (e.g. one fuel grade's price rather than every
* grade's); the map shows `summary` when it is absent.
*/
labelSummary?: I18nToken;
operator?: string;
branding?: DataSourceBranding;
mapContext?: DataSourceMapContextSelection;
Expand Down
Loading