import { z } from 'zod'; export const chargingInfoSchema = z.object({ checkedAt: z.string(), status: z.enum(['ok', 'unknown', 'ambiguous', 'unavailable']), station: z.string().nullable(), source: z.string().nullable(), sourceUrl: z.string().nullable(), license: z.string().nullable(), updatedAt: z.string().nullable(), stale: z.boolean(), available: z.number().int().nonnegative().nullable(), total: z.number().int().nonnegative(), unknown: z.number().int().nonnegative(), tariffs: z.array( z.object({ currency: z.string(), updatedAt: z.string(), components: z.array( z.object({ kind: z.string(), price: z.number().nonnegative(), taxIncluded: z.boolean(), conditional: z.boolean(), afterSeconds: z.number().nullable(), }), ), }), ), pricesUnavailable: z.boolean(), }); export type ChargingInfo = z.infer; /** A charger's cheapest energy price: per kWh, in its tariff's currency. */ export interface ChargingEnergyPrice { price: number; currency: string; } /** * The cheapest price per kWh a charger publishes that holds for everyone: an * energy component with tax included and no condition attached. Null when it * publishes none, or publishes them in more than one currency, which no single * figure can compare. The planner's badge and the assistant's card both read it. */ export function cheapestEnergy(tariffs: ChargingInfo['tariffs']): ChargingEnergyPrice | null { const energy = tariffs.flatMap((tariff) => tariff.components .filter((component) => component.kind === 'ENERGY' && component.taxIncluded && !component.conditional) .map((component) => ({ price: component.price, currency: tariff.currency })), ); if (new Set(energy.map((component) => component.currency)).size !== 1) return null; return energy.reduce( (best, item) => (!best || item.price < best.price ? item : best), null, ); } /** * A charging station addressed by where it is, rather than by a row in `places`. * * The saved-stop route answers for a place id, which a hit found along the route does * not have yet, and "is anything free here, and what does it cost" is the question * people want answered before adding the charger, not after. The match needs nothing * more than this: a coordinate, and whatever the search called the place. * * One station per request, deliberately. A cache miss costs the upstream registry * several requests, nothing rate-limits the route, and the caches on both sides of it * are sized for the handful of stops a trip has. A batch would turn one click into a * burst against shared public infrastructure. */ export const chargingLookupSchema = z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), /** * An empty name is allowed. OSM charging nodes are frequently nameless, and the * matcher already treats "nothing to go on" as its own case: it then insists the * station be within 30 m instead of accepting the nearest one inside 100 m. */ name: z.string().max(200), }); export type ChargingLookup = z.infer;