diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index 18c62e5..bee70cc 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 - **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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index cc2681c..92f3a4a 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -184,7 +184,7 @@ One big button for a garage door, gate or lock, which opens or closes it (or sto ### 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 diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 16fe5ae..112bb63 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.2.0" +version: "2.2.1" slug: dashboard_studio 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 diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index dffc67c..51b13d6 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -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.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", - 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", 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)), diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 533419b..05d2c23 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -220,6 +220,12 @@ // Bring dashboards saved by older versions up to the current shape. 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 || {}; if (!t.preset) { t.preset = "Custom"; @@ -1548,7 +1554,7 @@ } if (path === "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 }); checkGuest(true); if (isChange || t.tagName === "BUTTON") renderInspector(); @@ -1572,6 +1578,7 @@ if (idx === "new") { if (!isChange || !v) return; + if ((w.cfg[k] || []).includes(v)) { t.value = ""; return; } // already in the list mutate(() => { (w.cfg[k] ||= []).push(v); }, null); renderInspector(); $(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus(); @@ -1628,6 +1635,8 @@ function onInspectorInput(e) { 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-shortcut]")) { if (e.type !== "change") return; @@ -3343,7 +3352,7 @@ return `

${tr("Dashboards")}

${tr("Build a dashboard, then choose under People who sees it.")}

- +
${cards}
`; }, diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 64720d8..e48fd65 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -773,6 +773,9 @@ "Moss": "Mos", "Sakura": "Sakura", "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", }; diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html index f052b4b..8830f23 100644 --- a/dashboard_studio/web/index.html +++ b/dashboard_studio/web/index.html @@ -53,6 +53,7 @@ + diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index f2b13c2..363efb5 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -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; } } /* 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 */ @@ -1701,10 +1685,6 @@ body.in-haven #btn-new { display: none; } @media (prefers-reduced-motion: reduce) { .gt.moving .gt-big { animation: none; } } /* 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 */ @@ -1960,3 +1940,35 @@ body.guest .guest-hide { display: none; } .inv-row.ended { opacity: .6; } .adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); } @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; } } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 5f1a431..f2b2843 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -1547,18 +1547,48 @@ 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 `
${esc(a.league || friendly(s))}
${state === "BYE" ? t("No match this round.") : t("No match planned.")}
`; - 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 }; + // TeamTracker gives each side its colours, record, league position and, often, its chance to win. + 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 side = (x) => `
${x.logo ? `` : `${esc((x.short || x.name || "?").slice(0, 3))}`}${esc(x.name || x.short || "")}
`; + 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 ``; + const c1 = x.colors[0] || "#8e8e93", c2 = x.colors[1] && x.colors[1].toLowerCase() !== c1.toLowerCase() ? x.colors[1] : light(c1) ? "#1c1c1e" : "#ffffff"; + return ``; + }; + const meta = (x) => [x.rank ? `#${x.rank}` : "", x.record].filter(Boolean).join(" · "); + const sideHTML = (x) => `
${crest(x)}${esc(x.name || x.short || "")}${meta(x) ? `${esc(meta(x))}` : ""}
`; const kick = a.date ? new Date(a.date) : null; let mid; - if (state === "PRE") mid = `${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}`; - else mid = `${esc(home.score ?? 0)}–${esc(away.score ?? 0)}${state === "IN" ? `${t("Live")} ${esc(a.clock || "")}` : t("Full time")}`; - const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : ""; - return `
${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}
-
${side(home)}
${mid}
${side(away)}
- ${extra ? `
${esc(extra)}
` : ""}${state === "IN" && a.last_play ? `
${esc(a.last_play)}
` : ""}
`; + if (state === "PRE") { + let until = ""; + if (kick && kick > Date.now()) { + const m = Math.round((kick - Date.now()) / 60000), d = Math.floor(m / 1440); + 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); + } + mid = `${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"} + ${esc(kick ? dayLabel(kick) : "")}${until ? `${esc(until)}` : ""}`; + } else mid = `${esc(home.score ?? 0)}–${esc(away.score ?? 0)} + ${state === "IN" ? `${t("Live")} ${esc(a.clock || "")}` : t("Full time")}`; + // 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 ? `` : ""); + odds = `
+
${seg(home.prob, home.colors[0], "h")}${draw > 0.02 ? seg(draw, "", "d") : ""}${seg(away.prob, away.colors[0], "a")}
+
${pc(home.prob)}%${draw > 0.02 ? `${esc(t("Draw"))} ${pc(draw)}%` : ""}${pc(away.prob)}%
`; + } + const place = c.venue !== false ? [a.venue, a.location].filter(Boolean).join(", ") : ""; + return `
+
${esc([a.league || a.sport, a.event_name && a.event_name !== a.league ? a.event_name : ""].filter(Boolean).join(" · "))}${c.venue !== false && a.tv_network ? `${esc(a.tv_network)}` : ""}
+
${sideHTML(home)}
${mid}
${sideHTML(away)}
+ ${odds} + ${state === "IN" && a.last_play ? `
${esc(a.last_play)}
` : place ? `
${Icons.svg("pin")}${esc(place)}
` : ""}
`; }, };