Dashboard Studio 1.14.0: new cards, kiosk announcements, guest mode, templates, sharing and phone app

- Seven new cards: parcels, air quality, water and gas usage, solar forecast, garage and gate, sports, Formula One
- Kiosk: announcements from automations, tab rotation, guest mode, click sound
- Editor: templates, widget presets, undo history, alignment guides, Lovelace import, share link and QR code, theme variants, suggestions, touch and hold actions
- Phones: installable app with shortcuts, offline states, tab swipe, pull to refresh, bottom sheets, haptics, phone/large visibility, landscape layout, editing on the phone, away tab
- Fixed: the weather forecast and to-do list could stay on Loading…

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:08:42 +01:00
parent 93bb42dcbb
commit 79d4a0f011
17 changed files with 2256 additions and 58 deletions
+352 -2
View File
@@ -549,7 +549,7 @@
if (ctx.cache.key !== key) {
ctx.cache.key = key;
try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; }
if (el.isConnected) ctx.rerender();
ctx.rerender();
return;
}
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
@@ -630,7 +630,7 @@
cache.key = c.entity; cache.at = Date.now();
const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]);
cache.daily = daily; cache.hourly = hourly;
if (el.isConnected) ctx.rerender();
ctx.rerender();
},
};
@@ -1266,6 +1266,356 @@
},
};
// ---------------------------------------------------------------- parcels
// Parcel integrations (PostNL, DHL, …) keep their parcels as lists in a sensor's attributes;
// field names differ, so each parcel is read from whichever names it has.
const pick = (o, ...keys) => { for (const k of keys) if (o?.[k] != null && o[k] !== "") return o[k]; return null; };
const dayLabel = (d) => {
const today = new Date(); today.setHours(0, 0, 0, 0);
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400000);
return diff === 0 ? t("Today") : diff === 1 ? t("Tomorrow") : diff === -1 ? t("Yesterday") : new Date(d).toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
};
function parcelsOf(s) {
const out = [];
for (const [k, v] of Object.entries(s.attributes)) {
if (!Array.isArray(v) || !v.length || typeof v[0] !== "object") continue;
const listDelivered = /deliver|bezorgd|afgeleverd/i.test(k) && !/route|onderweg|transit/i.test(k);
for (const p of v) {
const status = String(pick(p, "status_message", "status", "state", "statusText") || "");
const when = pick(p, "expected_datetime", "planned_date", "delivery_date", "expected_delivery", "eta", "date", "planned_from");
const date = when && !isNaN(new Date(when)) ? new Date(when) : null;
out.push({
name: String(pick(p, "name", "title", "description", "sender", "shop", "product", "key", "tracking_number", "barcode") || t("Parcel")),
status, date, until: pick(p, "planned_to", "expected_to"),
done: listDelivered || p.delivered === true || /delivered|bezorgd|afgeleverd/i.test(status),
url: pick(p, "url", "track_url", "tracking_url"),
});
}
}
return out;
}
W.parcels = {
label: "Parcels", icon: "package", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Parcels" },
{ k: "entities", t: "entities", l: "Parcel sensors (PostNL, DHL, …)", domains: ["sensor"] },
{ k: "showDelivered", t: "check", l: "Show parcels delivered in the last two days", d: true },
],
entities: (c) => c.entities || [],
render(c, { ha }) {
const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean);
if (!list.length) return `${title(c.title)}${missing((c.entities || [])[0], "a parcel sensor")}`;
let items = list.flatMap((s) => {
const ps = parcelsOf(s);
return ps.length || !isFinite(Number(s.state)) ? ps : []; // a count with no list means nothing on the way
});
const recent = Date.now() - 2 * 86400000;
if (c.showDelivered === false) items = items.filter((p) => !p.done);
else items = items.filter((p) => !p.done || !p.date || p.date.getTime() > recent);
items.sort((a, b) => a.done - b.done || (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15));
const coming = items.filter((p) => !p.done), today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length;
const hm2 = (d) => new Date(d).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
const when = (p) => {
if (!p.date) return "";
const hasTime = p.date.getHours() || p.date.getMinutes();
return `${dayLabel(p.date)}${hasTime ? ` ${hm2(p.date)}${p.until && !isNaN(new Date(p.until)) ? `–${hm2(p.until)}` : ""}` : ""}`;
};
const rows = items.map((p) => `<${p.url ? `a href="${esc(p.url)}" target="_blank" rel="noopener"` : "div"} class="pc-row ${p.done ? "done" : ""} ${p.date && dayLabel(p.date) === t("Today") && !p.done ? "today" : ""}">
<span class="pc-ico">${Icons.svg(p.done ? "check" : "package")}</span>
<span class="pc-text"><span class="pc-name">${esc(p.name)}</span><span class="pc-status">${esc(p.done ? t("Delivered") : p.status || t("On the way"))}</span></span>
<span class="pc-when">${esc(when(p))}</span></${p.url ? "a" : "div"}>`).join("");
return `${title(c.title)}<div class="pc">
<div class="pc-sum"><b class="num">${coming.length}</b> ${esc(coming.length === 1 ? t("parcel on the way") : t("parcels on the way"))}${today ? ` · <span class="pc-today">${esc(t("{0} today", today))}</span>` : ""}</div>
${rows ? `<div class="pc-list">${rows}</div>` : `<div class="empty">${t("No parcels on the way.")}</div>`}</div>`;
},
};
// ---------------------------------------------------------------- air quality
const AIR = {
carbon_dioxide: { k: "co2", l: "CO₂", lv: (v) => (v <= 800 ? 0 : v <= 1200 ? 1 : 2) },
pm25: { k: "pm25", l: "PM2.5", lv: (v) => (v <= 12 ? 0 : v <= 35 ? 1 : 2) },
pm10: { k: "pm10", l: "PM10", lv: (v) => (v <= 20 ? 0 : v <= 50 ? 1 : 2) },
volatile_organic_compounds: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
volatile_organic_compounds_parts: { k: "voc", l: "VOC", lv: (v) => (v <= 300 ? 0 : v <= 1000 ? 1 : 2) },
humidity: { k: "hum", l: "Humidity", lv: (v) => (v >= 40 && v <= 60 ? 0 : v >= 30 && v <= 70 ? 1 : 2) },
};
const AIR_WORDS = /\b(co2|carbon dioxide|pm2[.,]?5|pm10|voc|humidity|luchtvochtigheid|vochtigheid|air quality|luchtkwaliteit)\b/gi;
W.air = {
label: "Air quality", icon: "air", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Air quality" },
{ k: "entities", t: "entities", l: "Sensors (leave empty to find CO₂, fine dust and humidity per room)", domains: ["sensor"] },
],
entities: (c, ha) => (c.entities?.length ? c.entities : Object.values(ha?.states || {}).filter((s) => AIR[s.attributes.device_class]).map((s) => s.entity_id)),
render(c, { ha }) {
const reg = ha.reg;
const areaOf = {};
if (reg) for (const [a, ids] of Object.entries(reg.byArea)) for (const id of ids) areaOf[id] = reg.areas.find((x) => x.area_id === a)?.name;
const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state)));
// Without chosen sensors, only rooms count; with chosen ones, a sensor's name is its room when it has none.
const rooms = new Map();
for (const s of sensors) {
const room = areaOf[s.entity_id] || (c.entities?.length ? friendly(s).replace(AIR_WORDS, "").replace(/\s+/g, " ").trim() || friendly(s) : null);
if (!room) continue;
(rooms.get(room) || rooms.set(room, []).get(room)).push(s);
}
if (!rooms.size) return `${title(c.title)}<div class="empty">${t("No air quality sensors found. Add CO₂, fine dust or humidity sensors in the inspector.")}</div>`;
const names = ["Good", "Fair", "Poor"];
const rows = [...rooms.entries()].map(([room, ss]) => {
const m = ss.map((s) => { const d = AIR[s.attributes.device_class], v = Number(s.state); return { ...d, v, lvl: d.lv(v), s }; });
const worst = Math.max(...m.map((x) => x.lvl));
const co2 = m.find((x) => x.k === "co2")?.v, hum = m.find((x) => x.k === "hum")?.v, pm = m.find((x) => (x.k === "pm25" || x.k === "pm10") && x.lvl === 2);
const hint = co2 >= 1000 ? t("Open a window") : pm ? t("Air the room or turn on the purifier") : hum > 70 ? t("Too humid, air the room") : hum < 30 ? t("The air is dry") : "";
return `<div class="aq-row lvl${worst}"><span class="aq-dot" aria-hidden="true"></span>
<span class="aq-room"><b>${esc(room)}</b><span>${esc(hint || t(names[worst]))}</span></span>
<span class="aq-vals">${m.map((x) => `<button class="aq-val lvl${x.lvl} wbtn" data-act="more" data-e="${esc(x.s.entity_id)}" title="${esc(t(x.l))}: ${esc(t(names[x.lvl]))}"><small>${esc(t(x.l))}</small><span class="num">${esc(fmtNum(x.v))}${x.k === "hum" ? "%" : ""}</span></button>`).join("")}</span></div>`;
}).join("");
return `${title(c.title)}<div class="aq">${rows}</div>`;
},
};
// ---------------------------------------------------------------- water and gas
// Daily use from Home Assistant's long-term statistics of a meter (the same numbers the energy dashboard uses).
W.usage = {
label: "Water and gas", icon: "droplet", size: [4, 3],
fields: [
{ k: "entity", t: "entity", l: "Meter (water, gas or energy)", domains: ["sensor"] },
{ k: "title", t: "text", l: "Title", ph: "Entity name" },
{ k: "kind", t: "seg", l: "Type", o: ["water", "gas", "electricity"], d: "water" },
{ k: "days", t: "number", l: "Days to show", d: 7 },
],
render(c, { ha, cache }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a meter");
let unit = s.attributes.unit_of_measurement || "";
// Water reads better in litres than in thousandths of a cubic metre.
const liters = (c.kind || "water") === "water" && unit === "m³";
if (liters) unit = "L";
const ico = { water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet";
const head = `<div class="us-head"><span class="us-ico ${esc(c.kind || "water")}">${Icons.svg(ico)}</span><span class="name">${esc(c.title || friendly(s))}</span></div>`;
if (cache.error) return `${head}<div class="empty">${t("No statistics for this meter. It needs a state class (total or total_increasing).")}</div>`;
if (!cache.days) return `${head}<div class="empty">${t("Loading…")}</div>`;
const days = liters ? cache.days.map((d) => ({ ...d, v: Math.round(d.v * 1000) })) : cache.days;
const today = days[days.length - 1]?.v ?? 0, yest = days[days.length - 2]?.v;
const max = Math.max(...days.map((d) => d.v), 0.0001), avg = days.slice(0, -1).reduce((a, d) => a + d.v, 0) / Math.max(1, days.length - 1);
return `${head}<div class="us ${esc(c.kind || "water")}">
<div class="us-now"><span class="us-big num">${esc(fmtNum(today))}<small>${esc(unit)}</small></span>
<span class="us-cmp">${t("Today so far")}${yest != null ? ` · ${t("yesterday {0}", `${fmtNum(yest)} ${unit}`)}` : ""}</span></div>
<div class="us-bars" role="img" aria-label="${esc(days.map((d) => `${d.label}: ${fmtNum(d.v)} ${unit}`).join(", "))}">
${avg ? `<i class="us-avg" style="bottom:${(avg / max) * 100}%" title="${esc(t("Average"))}"></i>` : ""}
${days.map((d, i) => `<div class="us-day ${i === days.length - 1 ? "today" : ""}"><span class="us-bar" style="height:${Math.max(2, (d.v / max) * 100)}%"></span><span class="us-lbl">${esc(d.label)}</span></div>`).join("")}</div>
<div class="us-foot"><span>${t("{0} days", days.length)}: <b class="num">${esc(fmtNum(days.reduce((a, d) => a + d.v, 0)))} ${esc(unit)}</b></span><span>${t("Average {0}", `${fmtNum(avg)} ${unit}`)}</span></div>
</div>`;
},
async after(el, c, ctx) {
if (!ctx.ha.states[c.entity]) return;
const cache = ctx.cache, n = Math.max(3, Math.min(31, Number(c.days) || 7));
const key = `${c.entity}|${n}`;
if (cache.key === key && Date.now() - cache.at < 10 * 60 * 1000) return;
cache.key = key; cache.at = Date.now();
try {
const rows = await ctx.ha.statistics(c.entity, n);
cache.days = rows.map((r) => ({ v: Math.max(0, Number(r.change) || 0), label: new Date(r.start).toLocaleDateString(LOCALE, { weekday: "short" }) }));
cache.error = !rows.length;
} catch { cache.error = true; }
ctx.rerender(); // the card may have been redrawn while loading: draw whichever one is on screen now
},
};
// ---------------------------------------------------------------- solar forecast
// Forecast.Solar: the day totals come from its sensors, the hour by hour forecast from the energy API.
W.solar = {
label: "Solar forecast", icon: "solar", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Solar forecast" },
{ k: "today", t: "entity", l: "Production today (kWh)", domains: ["sensor"] },
{ k: "tomorrow", t: "entity", l: "Production tomorrow (kWh)", domains: ["sensor"] },
{ k: "now", t: "entity", l: "Power now (W)", domains: ["sensor"] },
{ k: "hours", t: "number", l: "Hours for the washer or dishwasher", d: 3 },
],
autofill: {
from: "today",
fill: (eid, ha) => {
const has = (e) => (ha.states[e] ? e : "");
return { tomorrow: has(eid.replace("today", "tomorrow")), now: has(eid.replace("energy_production_today", "power_production_now")) };
},
},
entities: (c) => [c.today, c.tomorrow, c.now].filter(Boolean),
render(c, { ha, cache }) {
const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow], now = ha.states[c.now];
if (!today && !tomorrow) return `${title(c.title)}${missing(c.today, "the production today sensor")}`;
const kwh = (s) => (s ? `${fmtNum(s.state)}<small>${esc(s.attributes.unit_of_measurement || "kWh")}</small>` : "–");
const hours = cache.hours || [];
const day0 = new Date(); day0.setHours(0, 0, 0, 0);
const todays = hours.filter((h) => h.t >= day0.getTime() && h.t < day0.getTime() + 86400000);
const shown = todays.filter((h) => h.wh > 0 || (new Date(h.t).getHours() >= 7 && new Date(h.t).getHours() <= 20));
const max = Math.max(...shown.map((h) => h.wh), 1), nowH = new Date().getHours();
// the best window to run an appliance: the most sun in N hours in a row, from now on
const n = Math.max(1, Math.min(6, Number(c.hours) || 3));
const ahead = hours.filter((h) => h.t >= Date.now() - 3600000);
let best = null;
for (let i = 0; i + n <= ahead.length; i++) {
const sum = ahead.slice(i, i + n).reduce((a, h) => a + h.wh, 0);
if (ahead[i + n - 1].t - ahead[i].t === (n - 1) * 3600000 && (!best || sum > best.sum)) best = { sum, from: ahead[i].t, to: ahead[i].t + n * 3600000 };
}
const hmT = (ms) => new Date(ms).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
return `${title(c.title)}<div class="sf">
<div class="sf-top"><div class="sf-main"><span class="sf-lbl">${t("Today")}</span><span class="sf-big num">${kwh(today)}</span></div>
${tomorrow ? `<div><span class="sf-lbl">${t("Tomorrow")}</span><span class="sf-mid num">${kwh(tomorrow)}</span></div>` : ""}
${now ? `<div><span class="sf-lbl">${t("Now")}</span><span class="sf-mid num">${esc(fmtState(now))}</span></div>` : ""}</div>
${shown.length ? `<div class="sf-bars">${shown.map((h) => { const hr = new Date(h.t).getHours(); return `<div class="sf-h ${hr === nowH ? "now" : ""} ${hr < nowH ? "past" : ""} ${best && h.t >= best.from && h.t < best.to ? "best" : ""}" title="${esc(`${hmT(h.t)}: ${fmtNum(h.wh / 1000)} kWh`)}"><i style="height:${Math.max(2, (h.wh / max) * 100)}%"></i>${hr % 3 === 0 ? `<span>${hr}</span>` : ""}</div>`; }).join("")}</div>`
: cache.loaded ? `<div class="muted small">${t("The hour by hour forecast isn’t available.")}</div>` : ""}
${best ? `<div class="sf-best">${Icons.svg("washer")}<span>${esc(t("Best time for the washer: {0}", `${dayLabel(best.from) === t("Today") ? "" : dayLabel(best.from) + " "}${hmT(best.from)}–${hmT(best.to)}`))}</span></div>` : ""}
</div>`;
},
async after(el, c, ctx) {
const cache = ctx.cache;
if (cache.at && Date.now() - cache.at < 30 * 60 * 1000) return;
cache.at = Date.now();
try {
const res = await ctx.ha.solarForecast();
const sum = {};
for (const entry of Object.values(res || {})) for (const [iso, wh] of Object.entries(entry.wh_hours || {})) { const tm = new Date(iso).getTime(); sum[tm] = (sum[tm] || 0) + Number(wh || 0); }
cache.hours = Object.entries(sum).map(([tm, wh]) => ({ t: Number(tm), wh })).sort((a, b) => a.t - b.t);
} catch { cache.hours = []; }
cache.loaded = true;
ctx.rerender();
},
};
// ---------------------------------------------------------------- garage and gate
/** Who changed an entity last, from the user on its context (matched to a person). */
function changedBy(s, ha) {
const uid = s?.context?.user_id;
if (!uid) return "";
const p = Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && x.attributes.user_id === uid);
return p ? friendly(p) : "";
}
W.gate = {
label: "Garage and gate", icon: "garage", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Garage door, gate or lock", domains: ["cover", "lock", "switch", "button", "input_boolean"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "kind", t: "seg", l: "Icon", o: ["garage", "gate", "door"], d: "garage" },
{ k: "confirm", t: "check", l: "Ask before opening or closing", d: true },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a garage door or gate");
const d = domainOf(c.entity), st0 = s.state;
const open = ["open", "opening", "unlocked", "unlocking", "on"].includes(st0), moving = ["opening", "closing", "locking", "unlocking"].includes(st0);
const name = c.name || friendly(s);
const label = d === "button" ? t("Press") : moving ? st(st0) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open");
const by = changedBy(s, ha);
const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage";
return `<div class="gt ${open ? "open" : ""} ${moving ? "moving" : ""}">
<div class="gt-head"><span class="name">${esc(name)}</span><span class="gt-state">${esc(open ? t("Open") : d === "button" ? "" : st(st0))}</span></div>
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">
<span class="gt-ring">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="gt-lbl">${esc(label)}</span></button>
<div class="gt-since">${esc(since)}</div></div>`;
},
};
// ---------------------------------------------------------------- sports (TeamTracker)
const isMatchDay = (s) => s && (s.state === "IN" || (s.attributes.date && dayLabel(s.attributes.date) === t("Today")));
W.sports = {
label: "Sports", icon: "trophy", size: [4, 3],
fields: [
{ k: "entity", t: "entity", l: "Team (TeamTracker sensor)", domains: ["sensor"] },
{ k: "matchDay", t: "check", l: "Only show on match days", d: false },
{ k: "venue", t: "check", l: "Show the venue and TV channel", d: true },
],
autoHide: (c, { ha }) => !!c.matchDay && !isMatchDay(ha.states[c.entity]),
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a TeamTracker sensor");
const a = s.attributes, state = String(s.state).toUpperCase();
if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `<div class="sp"><div class="sp-league">${esc(a.league || friendly(s))}</div><div class="empty">${state === "BYE" ? t("No match this round.") : t("No match planned.")}</div></div>`;
const team = { name: a.team_name, short: a.team_abbr, logo: a.team_logo, score: a.team_score, win: a.team_winner },
opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner };
const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp];
const side = (x) => `<div class="sp-team ${x.win ? "win" : ""}">${x.logo ? `<img src="${esc(x.logo)}" alt="">` : `<span class="sp-badge">${esc((x.short || x.name || "?").slice(0, 3))}</span>`}<span>${esc(x.name || x.short || "")}</span></div>`;
const kick = a.date ? new Date(a.date) : null;
let mid;
if (state === "PRE") mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span><span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}</span>`;
else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span><span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> ${esc(a.clock || "")}` : t("Full time")}</span>`;
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : "";
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div>
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div>
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`;
},
};
// ---------------------------------------------------------------- Formula One (F1 Sensor integration)
// Its sensors carry the season data from the Jolpica (Ergast) API in their attributes.
const F1_SESSIONS = [["first_practice", "Practice 1"], ["second_practice", "Practice 2"], ["third_practice", "Practice 3"], ["sprint_qualifying", "Sprint qualifying"], ["sprint", "Sprint"], ["qualifying", "Qualifying"], ["race", "Race"]];
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
W.f1 = {
label: "Formula One", icon: "flag", size: [4, 5],
fields: [
{ k: "next", t: "entity", l: "Next race", domains: ["sensor"] },
{ k: "drivers", t: "entity", l: "Driver standings", domains: ["sensor"] },
{ k: "teams", t: "entity", l: "Constructor standings", domains: ["sensor"] },
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
{ k: "top", t: "number", l: "Positions to show", d: 5 },
],
autofill: {
from: "next",
fill: (eid, ha) => {
const pre = eid.replace(/next_race.*$/, ""), has = (e) => (ha.states[e] ? e : "");
return { drivers: has(`${pre}driver_standings`), teams: has(`${pre}constructor_standings`), results: has(`${pre}last_race_results`) };
},
},
entities: (c) => [c.next, c.drivers, c.teams, c.results].filter(Boolean),
render(c, { ha, cache }) {
const next = ha.states[c.next];
if (!next && !c.drivers) return missing(c.next, "the next race sensor");
const a = next?.attributes || {};
const iso = (k) => a[`${k}_start`] || a[`${k}_date`] || a[`${k}_start_local`];
const raceAt = iso("race") ? new Date(iso("race")) : null;
let countdown = "";
if (raceAt && raceAt > Date.now()) {
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
}
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`;
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
const tab = cache.tab || c.show || "drivers", top = Math.max(1, Math.min(20, Number(c.top) || 5));
const tabs = [["drivers", "Drivers", c.drivers], ["teams", "Teams", c.teams], ["results", "Last race", c.results]].filter(([, , e]) => e && ha.states[e]);
const cur = tabs.find(([k]) => k === tab) || tabs[0];
let rows = "";
if (cur) {
const s = ha.states[cur[2]];
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
rows = list.slice(0, top).map((r) => {
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r);
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r);
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`;
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
}).join("");
}
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
return `<div class="f1">
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
},
};
W.text = {
label: "Text", icon: "text", size: [4, 2],
fields: [