From d5417b4873c53b7811e11d4268f6da1bc5ffca97 Mon Sep 17 00:00:00 2001 From: Owen Voke Date: Fri, 25 Sep 2026 11:21:20 +0100 Subject: [PATCH] feat: allow showing airport names in card routes Routes can now be shown as any combination of airport codes, locations and airport names, in a chosen order. `route` is now a list, and the editor uses a badge selector that always keeps at least one selected. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- .../airtrail/frontend/airtrail-card.js | 76 +++++++++++++------ 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8ab0423..7000f73 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ type: custom:airtrail-card | `lists` | `[upcoming]` | Flight lists to show, in order: `upcoming` and/or `past` (`[]` hides them) | | `max_flights` | `5` | Maximum number of flights in each list | | `entry_details` | `flight_number`, `airline`, `departure_time`, `relative` | What each list entry shows, in order ³ | -| `route` | `both` | How routes are shown: `codes` (`LHR → JFK`), `locations` (`London → New York`) or `both` | +| `route` | `[codes, locations]` | How routes are shown, in order ⁴ | ² Any of `upcoming_flights`, `past_flights`, `total_flights`, `total_distance`, `total_flight_time`, `airports_visited`, `top_airline`, `top_airport`, `top_aircraft` and `top_route`. @@ -99,6 +99,9 @@ type: custom:airtrail-card ³ Any of `flight_number`, `airline`, `departure_time`, `arrival_time`, `duration`, `distance`, `aircraft`, `seat`, `seat_class` and `relative` (the time until or since the flight, shown on the right). +⁴ Any of `codes` (`LHR → JFK`), `locations` (`London → New York`) and `names` +(`London Heathrow → John F Kennedy Intl.`), joined with `·`. At least one is always shown. + Tapping the next flight or any flight in the lists expands it to show all of its details (airports, local times, terminals and gates, aircraft, seat, duration, distance, other passengers and notes). Tapping a statistic opens the entity's details. The card uses Mushroom's theme variables, so diff --git a/custom_components/airtrail/frontend/airtrail-card.js b/custom_components/airtrail/frontend/airtrail-card.js index 3679a01..bee3616 100644 --- a/custom_components/airtrail/frontend/airtrail-card.js +++ b/custom_components/airtrail/frontend/airtrail-card.js @@ -46,10 +46,15 @@ const DEFAULT_CONFIG = { lists: ["upcoming"], max_flights: 5, entry_details: ["flight_number", "airline", "departure_time", "relative"], - route: "both", + route: ["codes", "locations"], }; -const ROUTES = ["codes", "locations", "both"]; +// The parts a route can be shown as, with an example for the editor +const ROUTE_PARTS = { + codes: { label: "Airport codes", example: "LHR → JFK" }, + locations: { label: "Locations", example: "London → New York" }, + names: { label: "Airport names", example: "London Heathrow → John F Kennedy Intl." }, +}; const escape = (value) => String(value ?? "").replace( @@ -75,8 +80,11 @@ const parseLocal = (value) => { const LISTS = ["upcoming", "past"]; const validateConfig = (config) => { - if (config.route !== undefined && !ROUTES.includes(config.route)) { - throw new Error(`route must be one of: ${ROUTES.join(", ")}`); + if (config.route !== undefined) { + const parts = Object.keys(ROUTE_PARTS); + if (!Array.isArray(config.route) || config.route.some((p) => !parts.includes(p))) { + throw new Error(`route must only contain: ${parts.join(", ")}`); + } } if (config.lists !== undefined) { if (!Array.isArray(config.lists) || config.lists.some((l) => !LISTS.includes(l))) { @@ -208,21 +216,27 @@ class AirTrailCard extends HTMLElement { return h ? `${h}h ${m}m` : `${m}m`; } - /** The route as airport codes, locations or both, honouring the card options. */ + /** The route in each of the configured forms (codes, locations, names), in order. */ _route(f, { html = false } = {}) { - const codes = f.origin && f.destination ? `${f.origin} → ${f.destination}` : f.route; - const cities = - f.origin_city && f.destination_city ? `${f.origin_city} → ${f.destination_city}` : null; - const route = this._config.route || "both"; - // Fall back to codes when a flight has no locations recorded - const showCities = route !== "codes" && cities; - const showCodes = route !== "locations" || !showCities; - - if (!html) return [showCodes && codes, showCities && cities].filter(Boolean).join(" · "); - if (showCodes && showCities) { - return `${escape(codes)}·${escape(cities)}`; - } - return escape(showCodes ? codes : cities); + const pair = (from, to) => (from && to ? `${from} → ${to}` : null); + const forms = { + codes: pair(f.origin, f.destination) || f.route, + locations: pair(f.origin_city, f.destination_city), + names: pair(f.origin_name, f.destination_name), + }; + const configured = this._config.route?.length ? this._config.route : ["codes"]; + // Skip forms a flight has no data for, falling back to codes if none remain + const parts = configured.map((key) => forms[key]).filter(Boolean); + if (!parts.length) parts.push(forms.codes); + + if (!html) return parts.join(" · "); + return parts + .map((part, i) => + i === 0 + ? escape(part) + : `·${escape(part)}`, + ) + .join(""); } _title(f, options) { @@ -811,18 +825,20 @@ const EDITOR_SCHEMA = [ name: "route", selector: { select: { - mode: "box", - options: [ - { value: "codes", label: "Airport codes", description: "LHR → JFK" }, - { value: "locations", label: "Locations", description: "London → New York" }, - { value: "both", label: "Both", description: "LHR → JFK · London → New York" }, - ], + multiple: true, + reorder: true, + mode: "dropdown", + options: Object.entries(ROUTE_PARTS).map(([value, { label, example }]) => ({ + value, + label: `${label} (${example})`, + })), }, }, }, ]; const EDITOR_HELPERS = { + route: "How routes are shown, in this order. At least one is always shown", device: "Leave empty to use the first AirTrail device", stats: "Badges shown below the next flight, in this order", lists: "Flight lists shown at the bottom of the card, in this order", @@ -861,9 +877,19 @@ class AirTrailCardEditor extends HTMLElement { this._form.computeHelper = (schema) => EDITOR_HELPERS[schema.name]; this._form.addEventListener("value-changed", (ev) => { ev.stopPropagation(); + let config = ev.detail.value; + // A route must always show something, so undo removing its last part + if (!config.route?.length) { + const route = [...(this._config.route?.length ? this._config.route : DEFAULT_CONFIG.route)]; + config = { ...config, route }; + // Restore the badge once the form has finished handling the removal + requestAnimationFrame(() => { + this._form.data = { ...DEFAULT_CONFIG, ...config, route: [...route] }; + }); + } this.dispatchEvent( new CustomEvent("config-changed", { - detail: { config: ev.detail.value }, + detail: { config }, bubbles: true, composed: true, }),