Dashboard Studio 2.6.0 and Haven 2.3.0: easier to use on a phone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:48:32 +01:00
parent 3b2cbbc96c
commit 1bca71ff7e
12 changed files with 365 additions and 37 deletions
+88 -17
View File
@@ -301,8 +301,8 @@
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) {
body = p(tr("This is Haven, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
body = p(tr("This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Homes and addresses")}</button>`;
} else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
@@ -325,7 +325,7 @@
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
const choice = await openModal(`<h3>${IN_APP ? "Haven" : tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
@@ -2238,15 +2238,34 @@
// ------------------------------------------------------------ dialogs
let modalClose = null;
// An open sheet is a step in the history, so going back (Android's back gesture in Haven, the browser's
// back button) closes it instead of leaving the app.
let modalInHistory = false, closingByBack = false, ownBacks = 0;
window.addEventListener("popstate", () => {
if (ownBacks) { ownBacks--; return; } // the step back a closing sheet took itself (another may have opened since)
if (!modalInHistory) return;
modalInHistory = false;
closingByBack = true;
modalClose?.(null);
closingByBack = false;
});
/** Haven's back gesture asks first: close what is open here (a sheet, the top bar) and say so. */
window.havenBack = () => {
if (modalClose) { modalClose(null); return true; }
if (document.body.classList.contains("peek")) { document.body.classList.remove("peek"); return true; }
return false;
};
function openModal(html, onClick) {
const card = $("#modal .modal-card");
card.innerHTML = html;
$("#modal").hidden = false;
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
const prevFocus = document.activeElement;
return new Promise((resolve) => {
modalClose = (result) => {
$("#modal").hidden = true;
modalClose = null;
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
prevFocus?.focus?.();
resolve(result);
};
@@ -2467,23 +2486,56 @@
});
}
/** The details of one entity: what it is now in words, a day's history for a sensor, the controls,
* and the technical details (its ID and attributes) folded away for whoever needs them. */
function moreInfo(eid) {
const s = ha.states[eid];
if (!s) return;
if (domainOf(eid) === "light") { lightPopup(eid); return; }
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div>
const d = domainOf(eid);
if (d === "light") { lightPopup(eid); return; }
if (d === "climate") { climatePopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
const actionLabel = d === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(d) ? tr("Run") : ["button", "input_button"].includes(d) ? tr("Press") : isOn(s) ? tr("Turn off") : tr("Turn on");
const numeric = ["sensor", "input_number", "number", "counter"].includes(d) && s.state !== "" && isFinite(Number(s.state));
// a few domains get their own buttons
const svc = (service, ico, label) => `<button class="btn" data-svc="${service}">${Icons.svg(ico)}${esc(label)}</button>`;
const extra = d === "cover" ? svc("cover.open_cover", "arrow-up", tr("Open")) + svc("cover.stop_cover", "stop", tr("Stop")) + svc("cover.close_cover", "arrow-down", tr("Close"))
: d === "media_player" ? svc("media_player.media_previous_track", "prev", tr("Previous track")) + svc("media_player.media_play_pause", s.state === "playing" ? "pause" : "play", s.state === "playing" ? tr("Pause") : tr("Play")) + svc("media_player.media_next_track", "next", tr("Next track"))
: d === "vacuum" ? svc(s.state === "cleaning" ? "vacuum.pause" : "vacuum.start", s.state === "cleaning" ? "pause" : "play", s.state === "cleaning" ? tr("Pause") : tr("Start")) + svc("vacuum.return_to_base", "home", tr("Dock")) : "";
const attrs = Object.entries(s.attributes).filter(([k]) => !["friendly_name", "icon", "entity_picture", "supported_features"].includes(k));
const done = openModal(`<div class="mi-head"><span class="mi-ico">${window.HAFmt.icon(s)}</span><h3>${esc(friendly(s))}</h3></div>
<div class="mi-state num">${esc(fmtState(s))}</div>
<div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div>
<table class="attr-table">${Object.entries(s.attributes).map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table>
${numeric ? `<div class="mi-graph" aria-label="${esc(tr("The last 24 hours"))}"><div class="muted small">${tr("Loading history…")}</div></div>` : ""}
${extra ? `<div class="mi-actions">${extra}</div>` : ""}
<details class="mi-more"><summary>${tr("Technical details")}</summary>
<table class="attr-table"><tr><td>${tr("Entity ID")}</td><td>${esc(eid)}</td></tr>${attrs.map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table></details>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`,
(e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "toggle") defaultAction(eid);
if (x) close();
const b = e.target.closest("[data-x], [data-svc]");
if (!b) return;
if (b.dataset.svc) { const [dom, service] = b.dataset.svc.split("."); ha.callService(dom, service, {}, eid); haptic(); return; }
if (b.dataset.x === "toggle") defaultAction(eid);
close();
});
if (numeric) drawDetailGraph(eid, done);
}
/** A day of a sensor's history as a line, with its lowest and highest value. */
async function drawDetailGraph(eid, done) {
let pts = [];
try { pts = ((await ha.history([eid], 24))[eid] || []).filter(([, v]) => isFinite(v)); } catch { /* shown as unavailable */ }
const box = $("#modal:not([hidden]) .mi-graph");
if (!box) return;
if (pts.length < 2) { box.innerHTML = `<div class="muted small">${tr("No history for the last 24 hours.")}</div>`; return; }
const W_ = 320, H = 90, t0 = Date.now() - 24 * 3600e3, t1 = Date.now();
const vs = pts.map(([, v]) => v), lo = Math.min(...vs), hi = Math.max(...vs), span = hi - lo || 1;
const x = (t) => ((Math.max(t, t0) - t0) / (t1 - t0)) * W_, y = (v) => H - 6 - ((v - lo) / span) * (H - 12);
const line = pts.map(([t, v], i) => `${i ? "L" : "M"}${x(t).toFixed(1)} ${y(v).toFixed(1)}`).join("") + `L${W_} ${y(vs[vs.length - 1]).toFixed(1)}`;
const unit = ha.states[eid]?.attributes.unit_of_measurement || "";
const f = (v) => `${Number(v).toLocaleString(LOCALE, { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ""}`;
box.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>
<div class="mi-graph-legend muted small"><span>${tr("24 hours ago")}</span><span>${esc(tr("Low {0} · high {1}", f(lo), f(hi)))}</span><span>${tr("Now")}</span></div>`;
}
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
@@ -2599,7 +2651,7 @@
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Homes and addresses")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
@@ -3003,11 +3055,23 @@
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
}
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// Scrolling down, the ⋯ button steps aside so it doesn't sit on a card; scrolling up brings it back.
let lastScrollY = 0;
document.addEventListener("scroll", (e) => {
if (!document.body.classList.contains("phone-ui")) return;
const el = e.target === document ? document.scrollingElement : e.target;
if (!el || el.closest?.(".w, #modal")) return; // a list inside a card, or a sheet: not the page
const y = el.scrollTop || 0;
if (Math.abs(y - lastScrollY) < 6) return;
document.body.classList.toggle("menu-away", y > lastScrollY && y > 40);
lastScrollY = y;
}, { capture: true, passive: true });
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
lsSet("ds.current", current.id);
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
activeTabId = lsGet(`ds.tab.${current.id}`);
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
undoStack.length = redoStack.length = 0;
@@ -3721,10 +3785,14 @@
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
let everConnected = false;
ha.onStatus = (ok) => {
const c = $("#conn");
if (ok) everConnected = true;
c.classList.toggle("ok", ok);
c.textContent = ok ? tr("Connected") : offlineText();
// the first moments after opening are connecting, not offline
c.classList.toggle("connecting", !ok && !everConnected && !lastLive);
c.textContent = ok ? tr("Connected") : !everConnected && !lastLive ? tr("Connecting…") : offlineText();
showOffline(!ok);
// An update restarts the app, which drops the connection: check right after reconnecting.
if (ok) checkVersion();
@@ -3824,9 +3892,12 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
// the builder is for bigger screens: a phone always opens on the dashboard
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
// On a phone: the one last used on this phone, else the first dashboard designed for phones.
const phone = phoneScreen.matches ? [lsGet("ds.currentPhone"), store.dashboards.find((d) => d.mobile)?.id] : [];
setCurrent([hashId, dev?.dashboard, home, ...phone, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);