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.")}