From 77fa2b8e03374b9197976f8a969eb89a3f4dc663 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 1 Nov 2026 18:31:03 +0000 Subject: [PATCH] feat(fuel): label the map with the compared grade's price and its age Map labels at close zoom showed every grade a station prices, so neighbouring stations could not be compared at a glance. A fuel result now carries a labelSummary with only the grade the list sorts by (the first selected grade, else diesel): its cheapest self-service car price per litre and how long ago it was reported. A station without that price shows the grade as missing. The map uses labelSummary when a result has one and credits that field. Closes #414 --- .../map/layers/DataSourceLayer.test.tsx | 34 +++++++ .../integration-api/map/dataSourceFeatures.ts | 5 +- integrations/fuel/__tests__/mapper.test.ts | 91 ++++++++++++++++++- integrations/fuel/data-source.ts | 8 +- integrations/fuel/mapper.ts | 90 ++++++++++++++++-- integrations/fuel/strings/de.json | 3 + integrations/fuel/strings/en.json | 3 + packages/core/src/types/dataSource.ts | 6 ++ 8 files changed, 226 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/map/layers/DataSourceLayer.test.tsx b/apps/web/src/components/map/layers/DataSourceLayer.test.tsx index 2950b6f1b..02b60b325 100644 --- a/apps/web/src/components/map/layers/DataSourceLayer.test.tsx +++ b/apps/web/src/components/map/layers/DataSourceLayer.test.tsx @@ -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; diff --git a/apps/web/src/integration-api/map/dataSourceFeatures.ts b/apps/web/src/integration-api/map/dataSourceFeatures.ts index c556ed798..afc66adbf 100644 --- a/apps/web/src/integration-api/map/dataSourceFeatures.ts +++ b/apps/web/src/integration-api/map/dataSourceFeatures.ts @@ -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 = [ @@ -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), diff --git a/integrations/fuel/__tests__/mapper.test.ts b/integrations/fuel/__tests__/mapper.test.ts index 22d5fe27e..75cf12a02 100644 --- a/integrations/fuel/__tests__/mapper.test.ts +++ b/integrations/fuel/__tests__/mapper.test.ts @@ -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"; @@ -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" })] }), diff --git a/integrations/fuel/data-source.ts b/integrations/fuel/data-source.ts index 9c7fe05ac..29e238a09 100644 --- a/integrations/fuel/data-source.ts +++ b/integrations/fuel/data-source.ts @@ -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 = { @@ -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; }, diff --git a/integrations/fuel/mapper.ts b/integrations/fuel/mapper.ts index 3e677f8a9..3bc731c2c 100644 --- a/integrations/fuel/mapper.ts +++ b/integrations/fuel/mapper.ts @@ -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 | undefined { const values: Record = {}; - 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; @@ -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; @@ -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); @@ -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), diff --git a/integrations/fuel/strings/de.json b/integrations/fuel/strings/de.json index 7f1c226ee..4a8af2e2c 100644 --- a/integrations/fuel/strings/de.json +++ b/integrations/fuel/strings/de.json @@ -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", diff --git a/integrations/fuel/strings/en.json b/integrations/fuel/strings/en.json index 3158528f7..b3f3914d7 100644 --- a/integrations/fuel/strings/en.json +++ b/integrations/fuel/strings/en.json @@ -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", diff --git a/packages/core/src/types/dataSource.ts b/packages/core/src/types/dataSource.ts index c2e5f56b0..ca20d319e 100644 --- a/packages/core/src/types/dataSource.ts +++ b/packages/core/src/types/dataSource.ts @@ -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;