diff --git a/apps/web/src/components/panels/directions/EvPlanCard.test.tsx b/apps/web/src/components/panels/directions/EvPlanCard.test.tsx index cd74539ec..9b11cd531 100644 --- a/apps/web/src/components/panels/directions/EvPlanCard.test.tsx +++ b/apps/web/src/components/panels/directions/EvPlanCard.test.tsx @@ -107,6 +107,31 @@ describe("EvPlanCard", () => { screen.getByText("directions.ev.availability"); }); + it("shows when availability was last reported", () => { + render(, { wrapper: createQueryWrapper() }); + screen.getByText("directions.ev.availabilityAsOf"); + expect(screen.queryByText("directions.ev.availability")).toBeNull(); + }); + + it("labels a price costed on a member or roaming tariff", () => { + const [stop] = baseResult.stops; + const { rerender } = render(, { + wrapper: createQueryWrapper(), + }); + expect(screen.queryByText(/directions\.ev\.contractTariff/)).toBeNull(); + + rerender(); + screen.getByText(/directions\.ev\.contractTariff/); + }); + + it("notes when the plan fell back to straight-line distances", () => { + render( + , + { wrapper: createQueryWrapper() }, + ); + screen.getByText("directions.ev.straightLineDistances"); + }); + it("says there is no charger data instead of calling the route unreachable", () => { render( w.kind === "partial-charger-data"); const hasNoAllowedNetwork = result.warnings.some((w) => w.kind === "no-allowed-network"); const hasTightMargin = result.warnings.some((w) => w.kind === "tight-margin"); + const hasStraightLineDistances = result.warnings.some( + (w) => w.kind === "straight-line-distances", + ); const totalSeconds = result.totals.driveSeconds + result.totals.chargeSeconds; const estimatedCost = result.totals.estimatedCost; @@ -166,6 +169,12 @@ export function EvPlanCard({ )} + {hasStraightLineDistances && ( + + {t("straightLineDistances")} + + )} + {hasNoAllowedNetwork && ( {stop.availability && ( - {t("availability", { - available: stop.availability.available, - total: stop.availability.total, - })} + {availabilityAsOf + ? t("availabilityAsOf", { + available: stop.availability.available, + total: stop.availability.total, + time: availabilityAsOf, + }) + : t("availability", { + available: stop.availability.available, + total: stop.availability.total, + })} )} @@ -317,7 +343,7 @@ function EvPlanStopRow({ variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.25 }} > - {[costFmt, tariffFmt].filter(Boolean).join(" · ")} + {[costFmt, tariffFmt, contractFmt].filter(Boolean).join(" · ")} )} diff --git a/integrations/routing/ev-plan.test.ts b/integrations/routing/ev-plan.test.ts index 474c8f577..356078b6a 100644 --- a/integrations/routing/ev-plan.test.ts +++ b/integrations/routing/ev-plan.test.ts @@ -259,6 +259,53 @@ describe("runEvPlan", () => { }); // still produces a plan (or a clean warning) rather than throwing expect(result.warnings.length + result.stops.length).toBeGreaterThan(0); + expect(result.warnings).toContainEqual({ kind: "straight-line-distances" }); + }); + + it("says nothing of straight-line distances when the road matrix answered", async () => { + const ctx = fakeCtx(); + const getProviders = (): ResolvedRoutingProvider[] => [ + { integrationId: "valhalla", provider: ctx._valhalla }, + ]; + const result = await runEvPlan(ctx as unknown as IntegrationContext, getProviders, { + waypoints: [ + [0, 50], + [2.7, 50], + ], + vehicleId: "tesla:model_3:2024:model_3_long_range", + socStartPct: 40, + avoidClosures: false, + }); + expect(result.stops.length).toBeGreaterThanOrEqual(1); + expect(result.warnings.some((w) => w.kind === "straight-line-distances")).toBe(false); + }); + + it("marks a stop costed on a member or roaming tariff, and not one costed ad hoc", async () => { + const plan = async (tariff: EnergyTariff) => { + const ctx = fakeCtx({ + getIntegrationsByDomain: chargingDomain( + sitesProvider([chargingSite({ tariffs: [tariff] })]), + ), + }); + const getProviders = (): ResolvedRoutingProvider[] => [ + { integrationId: "valhalla", provider: ctx._valhalla }, + ]; + return runEvPlan(ctx as unknown as IntegrationContext, getProviders, { + waypoints: [ + [0, 50], + [2.7, 50], + ], + vehicleId: "tesla:model_3:2024:model_3_long_range", + socStartPct: 40, + avoidClosures: false, + }); + }; + const member = await plan({ ...energyTariff("m", 0.29), type: "profile_cheap" }); + expect(member.stops[0].contractTariff).toBe(true); + const adHoc = await plan({ ...energyTariff("a", 0.59), type: "ad_hoc" }); + expect(adHoc.stops[0]).not.toHaveProperty("contractTariff"); + const untyped = await plan(energyTariff("u", 0.59)); + expect(untyped.stops[0]).not.toHaveProperty("contractTariff"); }); it("omits trip cost when a planned stop has no tariff, even with a home price", async () => { diff --git a/integrations/routing/ev-plan.ts b/integrations/routing/ev-plan.ts index c8ab26bf9..8cad9e5be 100644 --- a/integrations/routing/ev-plan.ts +++ b/integrations/routing/ev-plan.ts @@ -277,6 +277,7 @@ export async function runEvPlan( const sites = chargingSites(ctx); let partialChargerData = false; + let straightLineDistances = false; const nowMs = Date.now(); const socStartKwh = (args.socStartPct / 100) * vehicle.batteryKwh; @@ -329,6 +330,7 @@ export async function runEvPlan( ctx.log.warn("[ev] matrix failed; using great-circle", e as Error); } } + straightLineDistances = true; return greatCircleMatrix(sources, targets); }, }, @@ -435,6 +437,9 @@ export async function runEvPlan( addedKwh: Math.round(s.addedKwh * 10) / 10, availability: availabilityOf(s.site), tariffPrice: s.estimatedCost?.price, + ...(isContractTariff(s.estimatedCost?.tariffType) + ? { contractTariff: true as const } + : {}), estimatedCost: s.estimatedCost && { amount: roundToMinorUnit(s.estimatedCost.amount, s.estimatedCost.currency), currency: s.estimatedCost.currency, @@ -482,6 +487,7 @@ export async function runEvPlan( ...plan.warnings, ...revalidationWarnings, ...(partialChargerData ? [{ kind: "partial-charger-data" } as const] : []), + ...(straightLineDistances ? [{ kind: "straight-line-distances" } as const] : []), ], }; }; @@ -490,6 +496,11 @@ export async function runEvPlan( : buildPlan(); } +/** A stated tariff type other than ad hoc: a member, roaming or profile rate. */ +function isContractTariff(type: string | undefined): boolean { + return type !== undefined && type !== "ad_hoc"; +} + /** Deterministic cache key: rounded waypoints + vehicle + bucketed inputs. */ function evPlanCacheKey( args: EvPlanArgs, diff --git a/packages/core/src/types/routing.ts b/packages/core/src/types/routing.ts index 29e1db0df..922e050e4 100644 --- a/packages/core/src/types/routing.ts +++ b/packages/core/src/types/routing.ts @@ -486,6 +486,12 @@ export interface EvChargeStop extends ContributingRecord { availability?: { available: number; total: number; updatedAt?: string }; /** The main price of the tariff the session was costed on; the client formats it in its locale. */ tariffPrice?: TariffPrice; + /** + * True when that tariff is of a stated type other than ad hoc (a member, + * roaming or profile rate): a price for holders of that contract, not what + * anyone can pay at the charger. Absent when the type is ad hoc or unstated. + */ + contractTariff?: true; /** Modelled cost of this charging session. */ estimatedCost?: { amount: number; currency: string }; attributions: Attribution[]; @@ -498,7 +504,9 @@ export interface EvChargeStop extends ContributingRecord { * and no charging site was found (or none is configured); * `charger-sources-unavailable` that a stop was needed and no charger source * answered for it; `partial-charger-data` that a charger source did not answer - * in full, so a better stop may exist. + * in full, so a better stop may exist; `straight-line-distances` that the road + * distances between candidate stops were not available, so the plan used + * straight-line estimates for them. */ export type EvPlanWarning = | { kind: "unreachable"; afterStopIndex: number } @@ -506,6 +514,7 @@ export type EvPlanWarning = | { kind: "no-charger-data" } | { kind: "charger-sources-unavailable" } | { kind: "partial-charger-data" } + | { kind: "straight-line-distances" } | { kind: "no-allowed-network"; afterStopIndex: number }; /** diff --git a/packages/ev-charge-planner/src/plan.test.ts b/packages/ev-charge-planner/src/plan.test.ts index 334fa1aba..26a744d1b 100644 --- a/packages/ev-charge-planner/src/plan.test.ts +++ b/packages/ev-charge-planner/src/plan.test.ts @@ -889,6 +889,26 @@ describe("estimateSessionCost", () => { ); }); + it("reports the costed tariff's stated type, and none when it states none", () => { + const weekendAdHoc: ChargingSite = { + ...site("s", 1), + tariffs: [ + tariff("cheap", [energy(0.2)], { type: "profile_cheap" }), + tariff("adhoc", [energy(0.5, { days: ["SA", "SU"] })], { type: "ad_hoc" }), + ], + }; + expect(estimateSessionCost(weekendAdHoc, pickOn(weekendAdHoc), facts())?.tariffType).toBe( + "profile_cheap", + ); + const adHoc: ChargingSite = { + ...site("s", 1), + tariffs: [tariff("adhoc", [energy(0.5)], { type: "ad_hoc" })], + }; + expect(estimateSessionCost(adHoc, pickOn(adHoc), facts())?.tariffType).toBe("ad_hoc"); + const untyped: ChargingSite = { ...site("s", 1), tariffs: [tariff("t", [energy(0.5)])] }; + expect(estimateSessionCost(untyped, pickOn(untyped), facts())).not.toHaveProperty("tariffType"); + }); + it("the card's tariff summary is the costed tariff, not the first", () => { const base = site("s", 1); const s: ChargingSite = { diff --git a/packages/ev-charge-planner/src/plan.ts b/packages/ev-charge-planner/src/plan.ts index 2e47f3b2a..11b2bf56c 100644 --- a/packages/ev-charge-planner/src/plan.ts +++ b/packages/ev-charge-planner/src/plan.ts @@ -450,6 +450,7 @@ function costOn( amount, currency: tariff.currency, tariffId: tariff.id, + ...(tariff.type !== undefined ? { tariffType: tariff.type } : {}), price: { // Up to four decimals, as prices are shown; VAT grossing adds float noise. amount: Math.round(main.price * gross(main) * 10_000) / 10_000, diff --git a/packages/ev-charge-planner/src/types.ts b/packages/ev-charge-planner/src/types.ts index 3b5a8990e..ec315634a 100644 --- a/packages/ev-charge-planner/src/types.ts +++ b/packages/ev-charge-planner/src/types.ts @@ -58,6 +58,8 @@ export interface SessionCost { amount: number; currency: string; tariffId: string; + /** The costed tariff's type as published (e.g. `ad_hoc`, `profile_cheap`); absent when unstated. */ + tariffType?: string; /** The costed tariff's main price, e.g. 0.39 EUR per kWh; the client formats it in its locale. */ price: TariffPrice; } diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 1f75cfc88..0f6d576ab 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -1120,6 +1120,9 @@ "tripCostBreakdown": "{homeKwh} kWh zu Hause · {publicKwh} kWh öffentlich", "stopArriveDepart": "{arrive} % → {depart} %", "availability": "{available} von {total} verfügbar", + "straightLineDistances": "Straßenentfernungen zwischen den Ladestopps waren nicht verfügbar, daher nutzt dieser Plan Luftlinien-Schätzungen. Ladezeiten und Ankunftsladestand können abweichen.", + "contractTariff": "Mitglieds- oder Roamingpreis", + "availabilityAsOf": "{available} von {total} verfügbar, Stand {time}", "onYourNetwork": "In deinem Netzwerk", "stopsHeading": "Ladestopps", "unreachable": "Keine ladbare Route gefunden — versuche ein anderes Fahrzeug oder einen anderen Startladestand.", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 6213ac559..525038fa0 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -1120,6 +1120,9 @@ "tripCostBreakdown": "{homeKwh} kWh at home · {publicKwh} kWh public", "stopArriveDepart": "{arrive}% → {depart}%", "availability": "{available} of {total} available", + "straightLineDistances": "Road distances between charging stops were unavailable, so this plan uses straight-line estimates. Charge times and arrival charge may differ.", + "contractTariff": "member or roaming rate", + "availabilityAsOf": "{available} of {total} available as of {time}", "onYourNetwork": "On your network", "stopsHeading": "Charging stops", "unreachable": "No chargeable route found — try a different vehicle or start charge level.",