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.",