Dashboard Studio 2.2.1: sports match centre, Import JSON back, no double entities

- Sports card: crests in team colours, position and record, countdown,
  win-probability bar, live clock and last play, venue
- Import JSON back in the builder's menu
- Fixed: picking an entity with the keyboard added it twice; existing
  duplicates are removed when dashboards load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 11:22:34 +01:00
parent a5823b4726
commit 05df0561b0
9 changed files with 101 additions and 34 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 2.2.1
- **Sports:** the card now looks like a match centre: crests in the teams' colours, league position and record, the time until kick-off, each side's chance to win as a bar, the last play while live, and the venue.
- **Import JSON…** is back in the builder's ⋯ menu (it is also under **Admin → Dashboards**).
- Fixed: choosing an entity with the keyboard (type, then Enter) added it twice to cards with a list of entities, such as media players and parcels. Lists that already have an entity twice are cleaned up.
## 2.2.0 ## 2.2.0
- **Robot vacuum:** the robot drawn from above beside its dock, placed by what it does (charging on the dock, cleaning with its path behind it, on its way back), the last clean, and maintenance bars for the filter and brushes. - **Robot vacuum:** the robot drawn from above beside its dock, placed by what it does (charging on the dock, cleaning with its path behind it, on its way back), the last clean, and maintenance bars for the filter and brushes.
+1 -1
View File
@@ -184,7 +184,7 @@ One big button for a garage door, gate or lock, which opens or closes it (or sto
### Sports ### Sports
For the TeamTracker integration: the next match with both teams, their logos, the kick-off time and the venue and TV channel; the live score with the match clock during the match; the final score afterwards. Turn on **Only show on match days** to hide the card on other days. For the TeamTracker integration, laid out like a match centre: both teams with their logos (or a crest in the team's own colours), their league position and record, and in the middle the kick-off time with how long until it starts, the live score with the match clock and the last play, or the final score. Under it, when TeamTracker has it, a bar with each side's chance to win and the chance of a draw, in the teams' colours; then the venue and town, and the TV channel at the top. Turn on **Only show on match days** to hide the card on other days. A small card keeps the teams, the time or score and the countdown.
### Formula One ### Formula One
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "2.2.0" version: "2.2.1"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+7 -1
View File
@@ -177,7 +177,13 @@ class MockHA:
entity("sensor.energy_production_tomorrow", "12.1", friendly_name="Estimated energy production - tomorrow", unit_of_measurement="kWh", device_class="energy"), entity("sensor.energy_production_tomorrow", "12.1", friendly_name="Estimated energy production - tomorrow", unit_of_measurement="kWh", device_class="energy"),
entity("sensor.power_production_now", "1240", friendly_name="Estimated power production - now", unit_of_measurement="W", device_class="power"), entity("sensor.power_production_now", "1240", friendly_name="Estimated power production - now", unit_of_measurement="W", device_class="power"),
entity("sensor.team_tracker_ajax", "PRE", friendly_name="Ajax", sport="soccer", league="Eredivisie", team_name="Ajax", team_abbr="AJA", team_homeaway="home", entity("sensor.team_tracker_ajax", "PRE", friendly_name="Ajax", sport="soccer", league="Eredivisie", team_name="Ajax", team_abbr="AJA", team_homeaway="home",
opponent_name="PSV", opponent_abbr="PSV", date=at(0, 20), kickoff_in="in a few hours", venue="Johan Cruijff ArenA", tv_network="ESPN"), opponent_name="PSV", opponent_abbr="PSV", date=at(0, 20), kickoff_in="in a few hours", venue="Johan Cruijff ArenA", location="Amsterdam", tv_network="ESPN",
team_colors=["#d2122e", "#ffffff"], opponent_colors=["#ed1c24", "#ffffff"], team_record="8-2-1", opponent_record="9-1-1", team_rank=2, opponent_rank=1,
team_win_probability=0.42, opponent_win_probability=0.33),
entity("sensor.team_tracker_feyenoord", "IN", friendly_name="Feyenoord", sport="soccer", league="Eredivisie", team_name="Feyenoord", team_abbr="FEY", team_homeaway="away",
opponent_name="FC Utrecht", opponent_abbr="UTR", date=at(0, 14, 30), venue="Stadion Galgenwaard", location="Utrecht", tv_network="ESPN 2",
team_colors=["#e2001a", "#000000"], opponent_colors=["#d40000", "#ffffff"], team_score=2, opponent_score=1, clock="63'", team_record="7-3-1", opponent_record="5-3-3",
team_rank=3, opponent_rank=6, last_play="Goal! Hadj Moussa scores for Feyenoord.", team_win_probability=0.71, opponent_win_probability=0.09),
entity("sensor.f1_next_race", at(5, 18), friendly_name="F1 Next Race", season="2026", round="21", race_name="São Paulo Grand Prix", entity("sensor.f1_next_race", at(5, 18), friendly_name="F1 Next Race", season="2026", round="21", race_name="São Paulo Grand Prix",
circuit_name="Autódromo José Carlos Pace", circuit_locality="São Paulo", circuit_country="Brazil", circuit_name="Autódromo José Carlos Pace", circuit_locality="São Paulo", circuit_country="Brazil",
first_practice_start=at(3, 15, 30), sprint_qualifying_start=at(3, 19, 30), sprint_start=at(4, 15), qualifying_start=at(4, 19), race_start=at(5, 18)), first_practice_start=at(3, 15, 30), sprint_qualifying_start=at(3, 19, 30), sprint_start=at(4, 15), qualifying_start=at(4, 19), race_start=at(5, 18)),
+11 -2
View File
@@ -220,6 +220,12 @@
// Bring dashboards saved by older versions up to the current shape. // Bring dashboards saved by older versions up to the current shape.
function migrate(d) { function migrate(d) {
// Before 2.2.1, picking an entity with the keyboard could add it twice: keep each entity once per list.
const dedupe = (ws) => (ws || []).forEach((w) => {
for (const [k, v] of Object.entries(w.cfg || {})) if (Array.isArray(v) && v.every((x) => typeof x === "string")) w.cfg[k] = [...new Set(v)];
dedupe(w.cfg?.children);
});
(d.tabs || []).forEach((tb) => dedupe(tb.widgets));
const t = d.theme || {}; const t = d.theme || {};
if (!t.preset) { if (!t.preset) {
t.preset = "Custom"; t.preset = "Custom";
@@ -1548,7 +1554,7 @@
} }
if (path === "guest") { if (path === "guest") {
const g = current.guest; const g = current.guest;
if (idx === "new") { if (!isChange || !v) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); } if (idx === "new") { if (!isChange || !v || (g[k] || []).includes(v)) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); }
else mutate(() => { if (idx != null) g[k][Number(idx)] = v; else g[k] = v; }, `guest|${k}|${idx ?? ""}`, { rerender: false }); else mutate(() => { if (idx != null) g[k][Number(idx)] = v; else g[k] = v; }, `guest|${k}|${idx ?? ""}`, { rerender: false });
checkGuest(true); checkGuest(true);
if (isChange || t.tagName === "BUTTON") renderInspector(); if (isChange || t.tagName === "BUTTON") renderInspector();
@@ -1572,6 +1578,7 @@
if (idx === "new") { if (idx === "new") {
if (!isChange || !v) return; if (!isChange || !v) return;
if ((w.cfg[k] || []).includes(v)) { t.value = ""; return; } // already in the list
mutate(() => { (w.cfg[k] ||= []).push(v); }, null); mutate(() => { (w.cfg[k] ||= []).push(v); }, null);
renderInspector(); renderInspector();
$(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus(); $(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus();
@@ -1628,6 +1635,8 @@
function onInspectorInput(e) { function onInspectorInput(e) {
const t = e.target; const t = e.target;
// A field the inspector just replaced (picking an entity redraws it) can still report its change: ignore it.
if (!t.isConnected) return;
if (t.matches?.("[data-haptics]")) { if (e.type === "change") { lsSet("ds.haptics", t.checked ? "1" : "0"); haptic(); } return; } if (t.matches?.("[data-haptics]")) { if (e.type === "change") { lsSet("ds.haptics", t.checked ? "1" : "0"); haptic(); } return; }
if (t.matches?.("[data-shortcut]")) { if (t.matches?.("[data-shortcut]")) {
if (e.type !== "change") return; if (e.type !== "change") return;
@@ -3343,7 +3352,7 @@
return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div> return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div>
<div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button> <div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button>
<button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button> <button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button>
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import file")}</button> <button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import JSON…")}</button>
<button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header> <button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
<div class="adm-cards">${cards}</div>`; <div class="adm-cards">${cards}</div>`;
}, },
+3
View File
@@ -773,6 +773,9 @@
"Moss": "Mos", "Moss": "Mos",
"Sakura": "Sakura", "Sakura": "Sakura",
"Concrete": "Beton", "Concrete": "Beton",
"Draw": "Gelijk",
"in {0} min": "over {0} min",
"Chance to win: {0} {1}%, draw {2}%, {3} {4}%": "Winstkans: {0} {1}%, gelijk {2}%, {3} {4}%",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+1
View File
@@ -53,6 +53,7 @@
<button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button> <button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button>
<button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button> <button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button>
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button> <button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
</div> </div>
</header> </header>
+32 -20
View File
@@ -1152,22 +1152,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@media (prefers-reduced-motion: reduce) { .gt.moving .gt-ring { animation: none; } } @media (prefers-reduced-motion: reduce) { .gt.moving .gt-ring { animation: none; } }
/* sports */ /* sports */
.sp { display: flex; flex-direction: column; gap: 8px; height: 100%; justify-content: center; min-height: 0; }
.sp-league { font-size: 12px; font-weight: 600; color: var(--d-text-2); text-align: center; text-transform: uppercase; letter-spacing: .04em; }
.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; }
.sp-team { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; text-align: center; }
.sp-team img { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; object-fit: contain; }
.sp-badge { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-weight: 700; font-size: 13px; }
.sp-team > span:last-child { font-size: 13px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sp-team.win > span:last-child { color: var(--ok); }
.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.sp-score { font-size: clamp(26px, 11cqmin, 40px); font-weight: 700; letter-spacing: -0.02em; }
.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; }
.sp-time { font-size: clamp(22px, 9cqmin, 32px); font-weight: 700; }
.sp-sub { font-size: 12px; color: var(--d-text-2); white-space: nowrap; }
.sp-live { color: var(--crit); }
.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 4px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; }
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Formula One */ /* Formula One */
@@ -1701,10 +1685,6 @@ body.in-haven #btn-new { display: none; }
@media (prefers-reduced-motion: reduce) { .gt.moving .gt-big { animation: none; } } @media (prefers-reduced-motion: reduce) { .gt.moving .gt-big { animation: none; } }
/* sports: sentence case, crests without a disc */ /* sports: sentence case, crests without a disc */
.sp-league { text-transform: none; letter-spacing: 0; font-size: var(--c-fs-meta); }
.sp-badge { background: transparent; box-shadow: inset 0 0 0 1.5px var(--d-separator); font-weight: 600; }
.sp-team > span:last-child { font-size: var(--c-fs-body); }
.sp-sub, .sp-extra { font-size: var(--c-fs-meta); }
/* Formula One: the next race on one flat step, no gradient */ /* Formula One: the next race on one flat step, no gradient */
@@ -1960,3 +1940,35 @@ body.guest .guest-hide { display: none; }
.inv-row.ended { opacity: .6; } .inv-row.ended { opacity: .6; }
.adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); } .adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); }
@media (max-width: 760px) { .inv-form { grid-template-columns: 1fr; } } @media (max-width: 760px) { .inv-form { grid-template-columns: 1fr; } }
/* sports: the match as a match centre shows it: crests in the teams' colours, the time or score in the middle */
.sp { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; justify-content: space-between; }
.sp-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
.sp-league { font-weight: 600; color: var(--d-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sp-tv { flex: none; }
.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; }
.sp-side { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; text-align: center; }
.sp-logo, .sp-crest { width: clamp(40px, 15cqmin, 64px); height: clamp(40px, 15cqmin, 64px); object-fit: contain; }
.sp-crest { height: auto; aspect-ratio: 40 / 46; }
.sp-name { font-size: var(--c-fs-body); font-weight: 600; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sp-meta { font-size: var(--c-fs-meta); color: var(--d-text-2); }
.sp.done .sp-side:not(.win) { opacity: .6; }
.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.sp-time, .sp-score { font-size: clamp(28px, 12cqmin, 46px); font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; }
.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; }
.sp-sub { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; }
.sp-until { font-size: var(--c-fs-meta); font-weight: 600; color: var(--d-accent); white-space: nowrap; }
.sp-live { color: var(--crit); }
.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 5px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; }
@media (prefers-reduced-motion: reduce) { .sp-live::before { animation: none; } }
.sp-odds { display: flex; flex-direction: column; gap: 4px; }
.sp-bar { display: flex; gap: 2px; height: 6px; border-radius: 3px; overflow: hidden; }
.sp-bar i { display: block; height: 100%; background: color-mix(in srgb, var(--d-text) 60%, transparent); }
.sp-bar i.d { background: var(--d-fill); }
.sp-odds-l { display: flex; justify-content: space-between; font-size: var(--c-fs-meta); color: var(--d-text-2); }
.sp-place, .sp-play { display: flex; align-items: center; gap: 6px; font-size: var(--c-fs-meta); color: var(--d-text-2); min-width: 0; }
.sp-place .i { font-size: 14px; flex: none; }
.sp-place span, .sp-play { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sp-play { color: var(--d-text); }
@container (max-height: 230px) { .sp-odds, .sp-place, .sp-play { display: none; } }
@container (max-height: 170px) { .sp-meta, .sp-head { display: none; } }
+39 -9
View File
@@ -1547,18 +1547,48 @@
if (!s) return missing(c.entity, "a TeamTracker sensor"); if (!s) return missing(c.entity, "a TeamTracker sensor");
const a = s.attributes, state = String(s.state).toUpperCase(); 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>`; 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 }, // TeamTracker gives each side its colours, record, league position and, often, its chance to win.
opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner }; const hues = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => String(x).trim()).map((x) => (x.startsWith("#") ? x : `#${x}`)).filter((x) => /^#[0-9a-f]{6}$/i.test(x));
const side = (p) => ({ name: a[`${p}_name`], short: a[`${p}_abbr`], logo: a[`${p}_logo`], score: a[`${p}_score`], win: a[`${p}_winner`],
colors: hues(a[`${p}_colors`]), record: a[`${p}_record`], rank: a[`${p}_rank`], prob: Number(a[`${p}_win_probability`]) });
const team = side("team"), opp = side("opponent");
const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp]; 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 light = (hex) => { const n = parseInt(hex.slice(1), 16); return 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255) > 160; };
const crest = (x) => {
if (x.logo) return `<img class="sp-logo" src="${esc(x.logo)}" alt="">`;
const c1 = x.colors[0] || "#8e8e93", c2 = x.colors[1] && x.colors[1].toLowerCase() !== c1.toLowerCase() ? x.colors[1] : light(c1) ? "#1c1c1e" : "#ffffff";
return `<svg class="sp-crest" viewBox="0 0 40 46" aria-hidden="true"><path d="M20 2 L37 8 V22 C37 33 29 41 20 44 C11 41 3 33 3 22 V8 Z" fill="${c1}" stroke="${c2}" stroke-width="2.5"/>
<text x="20" y="27" text-anchor="middle" font-size="10.5" font-weight="800" fill="${light(c1) ? "#1c1c1e" : "#ffffff"}">${esc((x.short || x.name || "?").slice(0, 3).toUpperCase())}</text></svg>`;
};
const meta = (x) => [x.rank ? `#${x.rank}` : "", x.record].filter(Boolean).join(" · ");
const sideHTML = (x) => `<div class="sp-side ${x.win ? "win" : ""}">${crest(x)}<span class="sp-name">${esc(x.name || x.short || "")}</span>${meta(x) ? `<span class="sp-meta num">${esc(meta(x))}</span>` : ""}</div>`;
const kick = a.date ? new Date(a.date) : null; const kick = a.date ? new Date(a.date) : null;
let mid; 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>`; if (state === "PRE") {
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>`; let until = "";
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : ""; if (kick && kick > Date.now()) {
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div> const m = Math.round((kick - Date.now()) / 60000), d = Math.floor(m / 1440);
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div> until = d ? t("in {0} d {1} h", d, Math.floor((m % 1440) / 60)) : m >= 60 ? t("in {0} h {1} min", Math.floor(m / 60), m % 60) : t("in {0} min", m);
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`; }
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) : "")}</span>${until ? `<span class="sp-until">${esc(until)}</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> <span class="num">${esc(a.clock || "")}</span>` : t("Full time")}</span>`;
// The chance of each result, as one bar in the teams' colours.
let odds = "";
if (isFinite(home.prob) && isFinite(away.prob) && home.prob + away.prob > 0) {
const draw = Math.max(0, 1 - home.prob - away.prob), pc = (v) => Math.round(v * 100);
const seg = (v, col, cls) => (v > 0.005 ? `<i class="${cls}" style="flex-basis:${(v * 100).toFixed(1)}%;${col ? `background:${col}` : ""}"></i>` : "");
odds = `<div class="sp-odds" aria-label="${esc(t("Chance to win: {0} {1}%, draw {2}%, {3} {4}%", home.name, pc(home.prob), pc(draw), away.name, pc(away.prob)))}">
<div class="sp-bar">${seg(home.prob, home.colors[0], "h")}${draw > 0.02 ? seg(draw, "", "d") : ""}${seg(away.prob, away.colors[0], "a")}</div>
<div class="sp-odds-l num"><span>${pc(home.prob)}%</span>${draw > 0.02 ? `<span>${esc(t("Draw"))} ${pc(draw)}%</span>` : ""}<span>${pc(away.prob)}%</span></div></div>`;
}
const place = c.venue !== false ? [a.venue, a.location].filter(Boolean).join(", ") : "";
return `<div class="sp ${state === "IN" ? "live" : ""} ${state === "POST" ? "done" : ""}">
<div class="sp-head"><span class="sp-league">${esc([a.league || a.sport, a.event_name && a.event_name !== a.league ? a.event_name : ""].filter(Boolean).join(" · "))}</span>${c.venue !== false && a.tv_network ? `<span class="sp-tv">${esc(a.tv_network)}</span>` : ""}</div>
<div class="sp-match">${sideHTML(home)}<div class="sp-mid">${mid}</div>${sideHTML(away)}</div>
${odds}
${state === "IN" && a.last_play ? `<div class="sp-play">${esc(a.last_play)}</div>` : place ? `<div class="sp-place">${Icons.svg("pin")}<span>${esc(place)}</span></div>` : ""}</div>`;
}, },
}; };