Dashboard Studio 2.5.0: phone sizes Small, Medium, Large and Row with a phone design per card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 2.5.0
|
||||
|
||||
- **Phone sizes:** on a phone dashboard every card now comes in four sizes, like widgets on a phone: **Small** (half width, square), **Medium** (full width, two rows), **Large** (full width, four rows) and **Row** (full width, one line). Pick one under **Size on the phone** in the inspector, or drag the corner: it snaps to the nearest size.
|
||||
- **A phone design per size:** Small cards and Rows show what matters at a glance: an icon, the one figure or word that counts (the temperature, how many are home, when the bins go out, the score), the name, and one control where it helps, such as a switch, play and pause, + and −, open and close, or Clean now. Tap anywhere else for what the card normally does. A light as a Row is a fader you can drag to dim.
|
||||
- **Medium and Large** show the full card, tuned for two or four rows on a phone: the forecast drops its days, Formula One its standings and the washer its extra facts at Medium. Entity tiles, buttons and timers get a larger figure instead of empty space.
|
||||
- Turning on **Designed for phones** now gives each card the size that fits it best; new cards on a phone dashboard start at the right size too. Dashboards that aren't designed for phones are unchanged.
|
||||
|
||||
## 2.4.2
|
||||
|
||||
- **Litter box, only when needed:** the card now stays out of sight until the waste bin is full or something is wrong, and then says so (*Bin full*, or *Check the litter box*) with the alert in amber. Untick **Only show when the bin is full or something is wrong** to see it all the time. Existing litter box cards get the new behaviour.
|
||||
|
||||
@@ -276,7 +276,22 @@ In the builder, hidden widgets stay visible but faded, with a **Hidden now** lab
|
||||
|
||||
## Phone dashboards
|
||||
|
||||
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns.
|
||||
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become Small, the rest Medium, Large or a Row by their height, and nothing overlaps. Turning it off converts back to 12 columns.
|
||||
|
||||
### Phone sizes
|
||||
|
||||
On a phone dashboard every card comes in four sizes, like widgets on a phone. Pick one under **Size on the phone** in the inspector, or drag the card's corner: it snaps to the nearest size.
|
||||
|
||||
| Size | Space | What it shows |
|
||||
| --- | --- | --- |
|
||||
| **Small** | half the width, square | an icon, the one figure or word that counts, the name, and one control |
|
||||
| **Medium** | full width, two rows | the card itself, with what doesn't fit two rows left out |
|
||||
| **Large** | full width, four rows | the whole card |
|
||||
| **Row** | full width, one line | the icon and name, the figure, and one control |
|
||||
|
||||
Small cards and Rows show each card at a glance: a thermostat its target with − and +, a media player what's playing with play and pause, blinds how far open with open and close, a room its temperature with a switch for its lights, the bins when they go out, a match its score, a calendar the next event, a litter box what it's doing with **Clean now**. Tap anywhere else on the card for what it normally does, such as switching a light or opening the details. A light as a Row is a fader: drag across it to dim. Medium and Large show the full card; entity tiles, buttons and timers get a larger figure there instead of empty space.
|
||||
|
||||
Not every card has every size: a heading is a Row or Medium, photos and web pages have no Row, and scroll cards are Medium or Large. Cards inside a scroll or swipe card keep their own design.
|
||||
|
||||
On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and the screen switcher. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "2.4.2"
|
||||
version: "2.5.0"
|
||||
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
|
||||
|
||||
@@ -250,6 +250,25 @@
|
||||
return d;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ phone sizes
|
||||
// On a phone dashboard every card comes in four sizes, like widgets on a phone: Small (half width, square),
|
||||
// Medium and Large (full width, two and four rows) and Row (full width, one line). Each has its own design.
|
||||
const PHONE_SIZES = { s: [2, 2], m: [4, 2], l: [4, 4], row: [4, 1] };
|
||||
const phoneSizes = (type) => Widgets[type]?.phone?.sizes || Object.keys(PHONE_SIZES);
|
||||
/** A widget's phone size, from its width and height. */
|
||||
const phoneSizeOf = (w) => (w.w <= 2 ? "s" : w.h <= 1 ? "row" : w.h >= 4 ? "l" : "m");
|
||||
/** The size of this card nearest to a width and height. */
|
||||
function snapPhone(type, w, h) {
|
||||
const d = ([pw, ph]) => Math.abs(pw - w) * 2 + Math.abs(ph - h);
|
||||
const k = [...phoneSizes(type)].sort((a, b) => d(PHONE_SIZES[a]) - d(PHONE_SIZES[b]))[0];
|
||||
return { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] };
|
||||
}
|
||||
/** A card's size on a phone, from its size on a computer: small tiles Small, the rest by their height. */
|
||||
function phoneFit(type, w, h, cols = 12) {
|
||||
const k = w <= cols / 4 && h <= 3 ? "s" : h <= 1 ? "row" : h >= 4 ? "l" : "m";
|
||||
return phoneSizes(type).includes(k) ? { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] } : snapPhone(type, ...PHONE_SIZES[k]);
|
||||
}
|
||||
|
||||
function makeWidget(type, x = 0, y = 0) {
|
||||
const def = Widgets[type];
|
||||
const cfg = {};
|
||||
@@ -1070,8 +1089,12 @@
|
||||
for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) {
|
||||
if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop);
|
||||
}
|
||||
// phone dashboards: each card's design for its size (Small, Medium, Large or Row)
|
||||
const size = current?.mobile && !child ? phoneSizeOf(w) : null;
|
||||
for (const k of Object.keys(PHONE_SIZES)) el.classList.toggle(`ph-${k}`, size === k);
|
||||
ctx.phone = size;
|
||||
let html;
|
||||
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
||||
try { html = (size && Widgets.__phoneRender(w.type, w.cfg, ctx, size)) || def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
||||
const hiddenNow = editMode && !child && !isVisible(w);
|
||||
el.classList.toggle("cond-hidden", hiddenNow);
|
||||
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
|
||||
@@ -1143,7 +1166,7 @@
|
||||
const eid = b.dataset.e, type = SUGGEST[domainOf(eid)];
|
||||
const wd = makeWidget(type);
|
||||
if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid;
|
||||
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
|
||||
if (current.mobile) Object.assign(wd, phoneFit(type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols);
|
||||
Object.assign(wd, findSpot(wd.w, wd.h));
|
||||
mutate(() => tab().widgets.push(wd));
|
||||
$(".sg-list").innerHTML = draw();
|
||||
@@ -1310,7 +1333,8 @@
|
||||
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")}
|
||||
${def.container ? childrenHTML(w) : ""}
|
||||
<div class="section"><div class="section-title">${tr("Size and position")}</div>
|
||||
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
|
||||
${current.mobile ? fieldHTML({ k: "size", t: "seg", l: "Size on the phone", o: phoneSizes(w.type), labels: { s: tr("Small"), m: tr("Medium"), l: tr("Large"), row: tr("Row") } }, phoneSizeOf(w), "phsize") : ""}
|
||||
<div class="row">${(current.mobile ? [["x", "Column"], ["y", "Row"]] : [["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]]).map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
|
||||
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
|
||||
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
|
||||
${visibilityHTML(w)}
|
||||
@@ -1542,6 +1566,13 @@
|
||||
return;
|
||||
}
|
||||
if (path === "layout") { convertLayout(!!v); renderInspector(); return; }
|
||||
if (path === "phsize") {
|
||||
const w = selectedWidget(), [pw, ph] = PHONE_SIZES[v] || [];
|
||||
if (!w || !pw) return;
|
||||
mutate(() => { Object.assign(w, { w: pw, h: ph, x: Math.min(w.x, current.grid.cols - pw) }); resolveCollisions(w); });
|
||||
renderInspector();
|
||||
return;
|
||||
}
|
||||
if (path === "nav") {
|
||||
mutate(() => { current.nav[k] = v; });
|
||||
if (k === "awayTab" || k === "awayPerson") { awayState = null; if (isChange) renderInspector(); }
|
||||
@@ -1696,9 +1727,10 @@
|
||||
for (const t of current.tabs) {
|
||||
const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
for (const w of ws) {
|
||||
if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width
|
||||
w.w = w.w >= from / 3 ? 4 : 2;
|
||||
w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0;
|
||||
if (toMobile) { // phone: small tiles become Small, the rest Medium, Large or a Row by their height
|
||||
const x0 = w.x;
|
||||
Object.assign(w, phoneFit(w.type, w.w, w.h, from));
|
||||
w.x = w.w === 4 ? 0 : x0 * f >= 1.5 ? 2 : 0;
|
||||
} else {
|
||||
w.w = Math.max(1, Math.min(to, Math.round(w.w * f)));
|
||||
w.x = Math.min(to - w.w, Math.round(w.x * f));
|
||||
@@ -1734,7 +1766,7 @@
|
||||
|
||||
function addWidget(type) {
|
||||
const wd = makeWidget(type);
|
||||
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
|
||||
if (current.mobile) Object.assign(wd, phoneFit(wd.type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols);
|
||||
Object.assign(wd, findSpot(wd.w, wd.h));
|
||||
mutate(() => tab().widgets.push(wd));
|
||||
select(wd.id);
|
||||
@@ -1994,6 +2026,10 @@
|
||||
drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`;
|
||||
n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap))));
|
||||
n.h = Math.max(1, o.h + Math.round(dy / (rh + gap)));
|
||||
if (current.mobile) { // phone dashboards: the nearest of the card's sizes
|
||||
Object.assign(n, snapPhone(drag.w.type, o.w + (dx / (colW + gap)), o.h + (dy / (rh + gap))));
|
||||
n.x = Math.min(n.x, cols - n.w);
|
||||
}
|
||||
}
|
||||
placeEl(drag.ph, n);
|
||||
showGuides(n, drag.w);
|
||||
|
||||
@@ -302,7 +302,10 @@
|
||||
"Clean button": "Schoonmaakknop", "Auto clean switch": "Schakelaar automatisch schoonmaken", "Choose the litter box’s status entity in the inspector.": "Kies de status-entiteit van de kattenbak in de inspector.",
|
||||
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
|
||||
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
|
||||
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
|
||||
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje",
|
||||
"Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis",
|
||||
"Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}",
|
||||
"{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
|
||||
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
|
||||
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
|
||||
"Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is",
|
||||
|
||||
@@ -2056,3 +2056,89 @@ button.fader-input { cursor: pointer; }
|
||||
.lb-acts { margin-top: 6px; }
|
||||
}
|
||||
@container (min-height: 260px) and (min-width: 180px) { .lb-ring { display: grid; height: min(50%, 170px); } }
|
||||
|
||||
/* ================================================================ phone dashboards: Small cards and Rows
|
||||
Each card's summary: an icon, the one figure that matters, a name, and at most one control.
|
||||
A tap anywhere does what the card does (phc-tap lies under everything); controls sit on top. */
|
||||
.phc { position: relative; height: 100%; min-width: 0; color: var(--d-text); }
|
||||
.phc-tap { position: absolute; inset: calc(-1 * var(--c-pad)); z-index: 0; background: none; border-radius: inherit; }
|
||||
.phc-top, .phc-bottom, .phc-text, .phc-ico, .phc-art, .phc-val { position: relative; z-index: 1; pointer-events: none; }
|
||||
.phc-ctl { position: relative; z-index: 2; display: flex; align-items: center; gap: 6px; flex: none; }
|
||||
.phc-ico { display: flex; font-size: var(--c-ico); color: var(--d-text-2); }
|
||||
.phc.on .phc-ico { color: var(--phc-tint, var(--d-accent)); }
|
||||
.w.lit-lamp .phc .phc-ico, .w.lit-on .phc .phc-ico { color: inherit; }
|
||||
/* plain and outline surfaces keep the card's own background: the icon lights up instead */
|
||||
.surface-plain .w.lit-lamp .phc .phc-ico, .surface-outline .w.lit-lamp .phc .phc-ico { color: color-mix(in srgb, var(--lamp, #e3a12f) 70%, #b77a10); }
|
||||
.surface-plain .w.lit-on .phc .phc-ico, .surface-outline .w.lit-on .phc .phc-ico { color: var(--accent-w); }
|
||||
.phc-art { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; flex: none; }
|
||||
.phc-btn { width: var(--c-ctl); height: var(--c-ctl); border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 17px; flex: none; }
|
||||
.phc-btn.wide { width: auto; padding: 0 14px; border-radius: 99px; font-size: var(--c-fs-meta); font-weight: 600; }
|
||||
.phc-btn:disabled { opacity: .45; }
|
||||
.phc-btn.wide { display: inline-flex; gap: 6px; }
|
||||
.phc-s .phc-btn.wide { width: 34px; padding: 0; justify-content: center; }
|
||||
.phc-s .phc-btn.wide span { display: none; }
|
||||
.phc-bin { display: flex; }
|
||||
.phc-val { font-size: 30px; font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.phc-val small { font-size: .5em; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
|
||||
.phc-word { font-size: 21px; letter-spacing: -0.02em; }
|
||||
.phc-name { font-size: var(--c-fs-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.phc-sub { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.phc-meter { display: block; height: 4px; border-radius: 2px; background: var(--d-fill); margin: 5px 0 6px; overflow: hidden; }
|
||||
.phc-meter i { display: block; height: 100%; border-radius: inherit; background: var(--phc-tint, var(--d-accent)); }
|
||||
.tone-ok .phc-val { color: var(--ok); } .tone-ok .phc-meter i { background: var(--ok); }
|
||||
.tone-warn .phc-val { color: var(--warn); } .tone-warn .phc-meter i { background: var(--warn); }
|
||||
.tone-crit .phc-val { color: var(--crit); } .tone-crit .phc-meter i { background: var(--crit); }
|
||||
.tone-accent .phc-val { color: var(--d-accent); }
|
||||
.w.lit-lamp .phc .phc-val, .w.lit-on .phc .phc-val, .w.lit-lamp .phc-val small, .w.lit-lamp .phc-sub { color: inherit; }
|
||||
.phc-clock .time { font: inherit; letter-spacing: inherit; }
|
||||
.phc-clock .date { font-size: var(--c-fs-meta); color: var(--d-text-2); letter-spacing: 0; margin-top: 2px; }
|
||||
|
||||
/* Small (and Large): the icon and control on top, the figure and name at the bottom */
|
||||
.phc-s, .phc-l { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; }
|
||||
.phc-s .phc-top, .phc-l .phc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; min-height: var(--c-ctl); }
|
||||
.phc-s .phc-ctl .phc-btn { width: 34px; height: 34px; font-size: 16px; }
|
||||
.phc-s .phc-bottom, .phc-l .phc-bottom { display: flex; flex-direction: column; min-width: 0; }
|
||||
.phc-s .phc-sub, .phc-l .phc-sub { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.phc-s .phc-val + .phc-name, .phc-s .phc-meter + .phc-name { margin-top: 2px; }
|
||||
|
||||
/* Row: one line: icon, name and what it says, the figure, a control */
|
||||
.phc-row { display: flex; align-items: center; gap: 12px; }
|
||||
.phc-row .phc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.phc-row .phc-val { flex: none; max-width: 45%; font-size: 24px; }
|
||||
.phc-row .phc-word { font-size: 17px; }
|
||||
.phc-row .phc-art { width: 36px; height: 36px; }
|
||||
.phc-row .phc-clock .date { display: none; }
|
||||
.w.ph-row .w-body { padding-top: 0; padding-bottom: 0; justify-content: center; }
|
||||
|
||||
/* a light as a Row: the fader the group card uses, filling the card */
|
||||
.w.ph-row .w-body:has(.phc-light) { padding: 0; }
|
||||
.phc-light { height: 100%; }
|
||||
.phc-light .lf-row { height: 100%; grid-template-columns: auto minmax(0, 1fr) auto auto; padding: 0 10px 0 12px; border-radius: inherit; }
|
||||
.phc-light .lt-ico { position: relative; z-index: 2; }
|
||||
.phc-l .phc-ico { font-size: 34px; }
|
||||
.phc-l .phc-val { font-size: 64px; }
|
||||
.phc-l .phc-word { font-size: 34px; }
|
||||
.phc-l .phc-name { font-size: 18px; }
|
||||
.phc-l .phc-sub { font-size: var(--c-fs-body); }
|
||||
.phc-l .phc-meter { height: 6px; border-radius: 3px; }
|
||||
|
||||
/* Medium: the icon, name and control on one line, the figure large below */
|
||||
.phc-m { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; }
|
||||
.phc-m .phc-top { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.phc-m .phc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.phc-m .phc-bottom { display: flex; flex-direction: column; min-width: 0; }
|
||||
.phc-m .phc-val { font-size: 42px; }
|
||||
.phc-m .phc-word { font-size: 26px; }
|
||||
|
||||
/* Medium cards drawn in full: what doesn't fit two rows on a phone makes way */
|
||||
.w.ph-m .appl-fact ~ .appl-fact { display: none; }
|
||||
.w.ph-m .f1-tabs, .w.ph-m .f1-list { display: none; }
|
||||
.w.ph-m .fc-days { display: none; }
|
||||
.w.ph-m .graph .legend > span ~ span { display: none; }
|
||||
/* cards that draw edge to edge (camera, photos) still get their padding as a summary */
|
||||
.w-body.flush:has(> .phc) { padding: var(--c-pad); }
|
||||
.w.ph-row .w-body.flush:has(> .phc) { padding: 0 var(--c-pad); }
|
||||
/* nothing to show but a name (a button): the name, larger, in the middle */
|
||||
.phc-m:not(:has(.phc-bottom)) { justify-content: center; }
|
||||
.phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; }
|
||||
.phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; }
|
||||
|
||||
@@ -1926,6 +1926,8 @@
|
||||
}
|
||||
await cache.loading;
|
||||
const list = el.querySelector(".cal");
|
||||
// the phone's small versions show the next event from the cache: draw them again once it's there
|
||||
if (el.isConnected && !list && cache.drawnAt !== cache.at) { cache.drawnAt = cache.at; ctx.rerender(); }
|
||||
if (!el.isConnected || !list) return;
|
||||
if (cache.error) { list.innerHTML = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; return; }
|
||||
|
||||
@@ -2213,6 +2215,350 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ================================================================ phone versions
|
||||
// Phone dashboards give every card four sizes, like widgets on a phone: Small (half width, square),
|
||||
// Medium and Large (full width, two and four rows high) and Row (full width, one line).
|
||||
// Medium and Large draw the card itself (tuned for phones in style.css). Small and Row draw the card's
|
||||
// summary: an icon, a name, the one figure that matters and at most one control. A card can ask for its
|
||||
// own drawing at a size instead ("card"), or leave a size out when it has no sensible form there.
|
||||
//
|
||||
// A summary is { ico, name, value, unit, word, sub, tone, on, tint, meter, ctl, tap }:
|
||||
// value + unit a figure, drawn large (word: the same, but words, such as "Open" or "Cleaning…")
|
||||
// tone "ok" | "warn" | "crit" | "accent" colours the figure; on lights the card like a lit tile
|
||||
// meter 0–100, a thin bar under the figure (brightness, battery, a gauge)
|
||||
// ctl HTML for one control (a switch, a round button, two at most)
|
||||
// tap { act, e, v } for a tap anywhere else on the card
|
||||
const ico = (name) => Icons.svg(name);
|
||||
const numOf = (s, decimals) => (s && s.state !== "" && isFinite(Number(s.state)) ? fmtNum(s.state, decimals) : null);
|
||||
const sw = (eid, on, label) => `<button class="light-switch wbtn" role="switch" aria-checked="${!!on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(label)}"><span class="switch"></span></button>`;
|
||||
const rnd = (act, eid, icn, label, v, extra = "") => `<button class="phc-btn wbtn" data-act="${act}" ${eid ? `data-e="${esc(eid)}"` : ""} ${v != null ? `data-v="${esc(v)}"` : ""} ${extra} aria-label="${esc(label)}" title="${esc(label)}">${Icons.svg(icn)}</button>`;
|
||||
const more = (eid) => (eid ? { act: "more", e: eid } : null);
|
||||
|
||||
const SUMMARY = {
|
||||
entity(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const d = domainOf(c.entity), tap = c.tap === "auto" || !c.tap ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
|
||||
const n = numOf(s);
|
||||
return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, unit: n != null ? s.attributes.unit_of_measurement || "" : "",
|
||||
word: n == null ? fmtState(s) : "", sub: c.showChanged ? relTime(s.last_changed) : "",
|
||||
on: isOn(s), tint: domainOf(c.entity) === "light" ? lightTint(s) : "",
|
||||
tap: tap === "none" ? null : { act: tap === "toggle" ? "toggle" : "more", e: c.entity } };
|
||||
},
|
||||
value(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const v = Number(s.state), lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : "";
|
||||
const n = numOf(s, c.decimals);
|
||||
return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, word: n == null ? cap(s.state) : "", unit: c.unit || s.attributes.unit_of_measurement || "",
|
||||
tone: lvl, sub: lvl ? (lvl === "crit" ? t("Critical") : t("High")) : "", tap: more(c.entity) };
|
||||
},
|
||||
gauge(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100;
|
||||
const tone = !isFinite(v) ? "" : has(c.red) && v >= Number(c.red) ? "crit" : has(c.yellow) && v >= Number(c.yellow) ? "warn" : has(c.green) && v >= Number(c.green) ? "ok" : "";
|
||||
return { ico: icon(s), name: friendly(s, c.name), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "", tone,
|
||||
meter: isFinite(v) ? Math.max(0, Math.min(100, ((v - min) / (max - min || 1)) * 100)) : null, tap: more(c.entity) };
|
||||
},
|
||||
climate(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const a = s.attributes, step = Number(c.step) || 0.5;
|
||||
return { ico: icon(s), name: friendly(s, c.name), value: a.temperature != null ? fmtNum(a.temperature) : "–", unit: "°",
|
||||
sub: [a.current_temperature != null && t("Now {0}°", fmtNum(a.current_temperature)), st(s.state)].filter(Boolean).join(" · "),
|
||||
on: s.state !== "off", tap: more(c.entity),
|
||||
ctl: rnd("temp", c.entity, "minus", t("Lower to {0}°", (a.temperature ?? 0) - step), -step) + rnd("temp", c.entity, "plus", t("Raise to {0}°", (a.temperature ?? 0) + step), step) };
|
||||
},
|
||||
graph(c, { ha }) {
|
||||
const s = ha.states[(c.entities || [])[0]];
|
||||
if (!s) return null;
|
||||
const n = numOf(s);
|
||||
return { ico: ico("chart"), name: c.title || friendly(s), value: n, word: n == null ? fmtState(s) : "", unit: n != null ? s.attributes.unit_of_measurement || "" : "",
|
||||
sub: (c.entities || []).length > 1 ? (c.entities.slice(1).map((e) => ha.states[e] && fmtState(ha.states[e])).filter(Boolean).join(" · ")) : "", tap: more(c.entities[0]) };
|
||||
},
|
||||
clock(c, { ha }) {
|
||||
const s = c.weather && ha.states[c.weather];
|
||||
return { ico: ico("clock"), name: s ? `${st(s.state)} · ${s.attributes.temperature ?? "–"}°` : "",
|
||||
value: `<span class="phc-clock" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="0" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, false, c.date)}</span>`,
|
||||
tap: more(c.weather) };
|
||||
},
|
||||
weather(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
return { ico: ico(WX[s.state] || "cloud"), name: friendly(s, c.name), value: s.attributes.temperature ?? "–", unit: "°", sub: st(s.state), tap: more(c.entity) };
|
||||
},
|
||||
forecast(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const d = cache.daily?.[0];
|
||||
return { ico: ico(WX[s.state] || "cloud"), name: friendly(s), value: s.attributes.temperature ?? "–", unit: "°",
|
||||
sub: d ? t("High {0}° · Low {1}°", Math.round(d.temperature), Math.round(d.templow ?? d.temperature)) : st(s.state), tap: more(c.entity) };
|
||||
},
|
||||
list(c, { ha }) {
|
||||
const ids = (c.entities || []).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return null;
|
||||
const on = ids.filter((e) => isOn(ha.states[e]));
|
||||
return { ico: ico("list"), name: c.title || t("Devices"), value: on.length, unit: `/ ${ids.length}`, sub: on.length ? on.map((e) => friendly(ha.states[e])).join(", ") : t("All off"), on: on.length > 0 };
|
||||
},
|
||||
button: (c) => ({ ico: icon(null, c.icon || "sparkles"), name: c.label || t("Run"), tap: { act: "button" } }),
|
||||
media(c, { ha, cache }) {
|
||||
const ids = mediaIds(c).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return null;
|
||||
const sel = mediaSelected(ids, ha, cache), s = ha.states[sel], a = s.attributes;
|
||||
const off = ["off", "standby", "unavailable"].includes(s.state), playing = s.state === "playing";
|
||||
return { ico: icon(s), name: friendly(s), sub: off ? st(s.state) : [a.media_title, a.media_artist].filter(Boolean).join(" · ") || st(s.state),
|
||||
on: playing, tap: more(sel), art: c.art !== false && !off ? a.entity_picture : "",
|
||||
ctl: off ? rnd("svc", sel, "power", t("Turn on"), "media_player.turn_on") : rnd("svc", sel, playing ? "pause" : "play", playing ? t("Pause") : t("Play"), "media_player.media_play_pause") };
|
||||
},
|
||||
todo(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const open = (cache.items || []).filter((i) => i.status !== "completed");
|
||||
return { ico: ico("checkbox"), name: c.title || friendly(s), value: numOf(s) ?? open.length, sub: open.slice(0, 3).map((i) => i.summary).join(", ") || t("Nothing on the list."), tap: more(c.entity) };
|
||||
},
|
||||
people(c, { ha }) {
|
||||
const ids = W.people.entities(c, ha).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return null;
|
||||
const home = ids.filter((e) => ha.states[e].state === "home");
|
||||
return { ico: ico("people"), name: t("Home"), value: home.length, unit: `/ ${ids.length}`, on: home.length > 0,
|
||||
sub: home.length ? home.map((e) => friendly(ha.states[e]).split(" ")[0]).join(", ") : t("Nobody home") };
|
||||
},
|
||||
timer(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const a = s.attributes, dur = toSecs(a.duration), active = s.state === "active";
|
||||
const left = active && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur;
|
||||
const first = String(c.presets || "").split(/[,\s]+/).map(Number).find((m) => m > 0);
|
||||
return { ico: ico("timer"), name: friendly(s, c.name), on: active, sub: st(s.state),
|
||||
value: `<span ${active && a.finishes_at ? `data-countdown="${esc(a.finishes_at)}" data-dur="${dur}"` : ""}>${hms(left)}</span>`,
|
||||
ctl: active ? rnd("timer", c.entity, "pause", t("Pause"), "pause") : s.state === "paused" ? rnd("timer", c.entity, "play", t("Resume"), "start")
|
||||
: dur ? rnd("timer", c.entity, "play", t("Start"), "start") : first ? rnd("timer", c.entity, "play", `${first} ${t("min")}`, "start", `data-d="${first}"`) : "" };
|
||||
},
|
||||
covers(c, { ha }) {
|
||||
const ids = (c.entities || []).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return null;
|
||||
const pos = ids.map((e) => coverPos(ha.states[e])), avg = Math.round(pos.reduce((x, y) => x + y, 0) / pos.length);
|
||||
const one = ids.length === 1, s = ha.states[ids[0]], all = esc(ids.join(","));
|
||||
const moving = ids.find((e) => ["opening", "closing"].includes(ha.states[e].state));
|
||||
return { ico: ico("blinds"), name: c.title || (one ? friendly(s) : t("Blinds")), value: avg, unit: "%", meter: avg, on: avg > 0,
|
||||
sub: moving ? st(ha.states[moving].state) : one ? (avg >= 100 ? t("Open") : avg <= 0 ? t("Closed") : t("Partly open")) : t("{0} of {1} open", pos.filter((p) => p > 0).length, ids.length),
|
||||
tap: one ? more(ids[0]) : null,
|
||||
ctl: `<button class="phc-btn wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids="${all}" aria-label="${t("Open")}">${Icons.svg("arrow-up")}</button><button class="phc-btn wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids="${all}" aria-label="${t("Close")}">${Icons.svg("arrow-down")}</button>` };
|
||||
},
|
||||
alarm(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const armed = s.state.startsWith("armed"), word = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state);
|
||||
return { ico: ico("shield"), name: friendly(s), word, tone: s.state === "triggered" ? "crit" : armed ? "ok" : s.state === "pending" ? "warn" : "", on: armed, tap: more(c.entity) };
|
||||
},
|
||||
notifications(c, { ha }) {
|
||||
const notes = c.notes !== false ? Object.values(ha.notifications || {}) : [];
|
||||
const ups = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : [];
|
||||
const n = notes.length + ups.length;
|
||||
return { ico: ico("bell"), name: t("Notifications"), value: n, tone: n ? "accent" : "",
|
||||
sub: n ? [...ups.map((u) => u.attributes.title || friendly(u)), ...notes.map((x) => x.title || x.message)].slice(0, 2).join(" · ") : t("All caught up") };
|
||||
},
|
||||
energy(c, { ha }) {
|
||||
if (!c.solar && !c.grid && !c.battery) return null;
|
||||
const S = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const solar = Math.max(0, watts(S("solar")) ?? 0);
|
||||
let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid;
|
||||
let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt;
|
||||
const home = Math.max(0, watts(S("home")) ?? solar + Math.max(0, grid) + Math.max(0, -batt) - Math.max(0, -grid) - Math.max(0, batt));
|
||||
const [v, u] = fmtW(home).split(" ");
|
||||
return { ico: ico("bolt"), name: t("House"), value: v, unit: u,
|
||||
sub: [c.solar && t("Sun {0}", fmtW(solar)), c.grid && (grid < -1 ? t("Sending back {0}", fmtW(-grid)) : t("Grid {0}", fmtW(Math.max(0, grid))))].filter(Boolean).join(" · ") };
|
||||
},
|
||||
room(c, { ha }) {
|
||||
const area = ha.reg?.areas?.find((a) => a.area_id === c.area), ids = roomEntities(c, ha);
|
||||
if (!area && !ids.length) return null;
|
||||
const S = (e) => ha.states[e], name = c.name || area?.name || t("Room");
|
||||
const temp = [c.temperature, area?.temperature_entity_id].find((e) => e && S(e)) || ids.find((e) => domainOf(e) === "sensor" && S(e).attributes.device_class === "temperature");
|
||||
const lights = ids.filter((e) => domainOf(e) === "light" && !Array.isArray(S(e).attributes.entity_id)), on = lights.filter((e) => S(e).state === "on");
|
||||
const open = ids.filter((e) => domainOf(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(S(e).attributes.device_class) && S(e).state === "on");
|
||||
return { ico: icon(null, c.icon || "sofa"), name, value: temp ? numOf(S(temp)) : null, unit: temp ? "°" : "", on: on.length > 0, tone: open.length ? "warn" : "",
|
||||
sub: open.length ? t("{0} open", open.length) : lights.length ? (on.length ? t("{0} of {1} lights on", on.length, lights.length) : t("Lights off")) : "",
|
||||
ctl: lights.length ? `<button class="light-switch wbtn" role="switch" aria-checked="${on.length > 0}" data-act="svc-many" data-v="light.${on.length ? "turn_off" : "turn_on"}" data-ids="${esc((on.length ? on : lights).join(","))}" aria-label="${esc(on.length ? t("Turn off all lights in {0}", name) : t("Turn on all lights in {0}", name))}"><span class="switch"></span></button>` : "" };
|
||||
},
|
||||
vacuum(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const a = s.attributes, cleaning = s.state === "cleaning", batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
|
||||
return { ico: ico("vacuum"), name: friendly(s, c.name), word: cleaning ? t("Cleaning…") : s.state === "returning" ? t("Returning") : st(s.state),
|
||||
sub: isFinite(batt) ? t("Battery {0}%", Math.round(batt)) : "", meter: isFinite(batt) ? batt : null, on: cleaning, tone: s.state === "error" ? "crit" : "", tap: more(c.entity),
|
||||
ctl: rnd("svc", c.entity, cleaning ? "pause" : "play", cleaning ? t("Pause") : t("Start"), `vacuum.${cleaning ? "pause" : "start"}`) };
|
||||
},
|
||||
appliance(c, { ha }) {
|
||||
if (c.kind === "litterbox") {
|
||||
if (!c.status) return null;
|
||||
const { phase, word } = litterPhase(c, ha), v = ha.states[c.visits];
|
||||
return { ico: ico(phase === "attention" ? "shield" : "paw"), name: c.name || t("Litter box"), word, tone: phase === "attention" ? "warn" : phase === "cleaning" ? "accent" : "",
|
||||
sub: v && isFinite(Number(v.state)) ? `${Math.round(Number(v.state))} ${Number(v.state) === 1 ? t("visit") : t("visits")}` : "",
|
||||
ctl: c.cleanBtn && ha.states[c.cleanBtn] ? rnd("svc", c.cleanBtn, "sparkles", t("Clean now"), domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press", phase === "cleaning" ? "disabled" : "") : "" };
|
||||
}
|
||||
if (!c.status && !c.power && !c.remaining) return null;
|
||||
const phase = appliancePhase(c, ha), left = ["running", "paused"].includes(phase) ? remainingMinutes(c, ha) : null;
|
||||
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase];
|
||||
const ii = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
|
||||
return { ico: ico(phase === "done" ? "check" : ii), name: c.name || t("Appliance"), word: left != null ? fmtDuration(left) : label, sub: left != null ? label : "",
|
||||
on: ["running", "done"].includes(phase), tone: phase === "done" ? "ok" : phase === "attention" ? "warn" : phase === "error" ? "crit" : phase === "running" ? "accent" : "",
|
||||
meter: Number.isFinite(Number(ha.states[c.progress]?.state)) && phase === "running" ? Number(ha.states[c.progress].state) : null };
|
||||
},
|
||||
camera(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
return s ? { ico: ico("camera"), name: friendly(s), sub: t("Tap to watch"), tap: more(c.entity) } : null;
|
||||
},
|
||||
car(c, { ha }) {
|
||||
const S = (k) => (c[k] ? ha.states[c[k]] : null), level = S("level"), range = S("range"), lock = S("lock");
|
||||
if (!level && !range && !lock) return null;
|
||||
const pct = numOf(level), charging = c.kind !== "fuel" && (truthy(S("charging")) || (S("chargeSwitch")?.state === "on" && domainOf(c.chargeSwitch) === "switch"));
|
||||
const locked = lock?.state === "locked";
|
||||
return { ico: ico("car"), name: c.name || t("Car"), value: pct != null ? Math.round(Number(level.state)) : null, unit: pct != null ? "%" : "",
|
||||
meter: pct != null ? Number(level.state) : null, tone: pct != null && Number(level.state) <= 20 ? "warn" : charging ? "ok" : "", on: charging,
|
||||
sub: [charging && t("Charging"), range && `${fmtNum(range.state)} ${range.attributes.unit_of_measurement || "km"}`].filter(Boolean).join(" · "),
|
||||
ctl: lock ? rnd("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock")) : "" };
|
||||
},
|
||||
parcels(c, { ha }) {
|
||||
const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean);
|
||||
if (!list.length) return null;
|
||||
const coming = list.flatMap((s) => parcelsOf(s)).filter((p) => !p.done).sort((a, b) => (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15));
|
||||
const today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length;
|
||||
return { ico: ico("package"), name: c.title || t("Parcels"), value: coming.length, tone: today ? "accent" : "",
|
||||
sub: today ? t("{0} today", today) : coming[0] ? `${coming[0].name}${coming[0].date ? ` · ${dayLabel(coming[0].date)}` : ""}` : t("Nothing on the way"), tap: more(c.entities[0]) };
|
||||
},
|
||||
air(c, { ha }) {
|
||||
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)));
|
||||
if (!sensors.length) return null;
|
||||
const lv = sensors.map((s) => ({ s, lvl: AIR[s.attributes.device_class].lv(Number(s.state)) })).sort((a, b) => b.lvl - a.lvl), worst = lv[0];
|
||||
const co2 = sensors.find((s) => AIR[s.attributes.device_class].k === "co2");
|
||||
return { ico: ico("air"), name: c.title || t("Air quality"), word: t(["Good", "Fair", "Poor"][worst.lvl]), tone: ["ok", "warn", "crit"][worst.lvl],
|
||||
sub: worst.lvl ? friendly(worst.s) : co2 ? `CO₂ ${fmtState(co2)}` : "", tap: more(worst.s.entity_id) };
|
||||
},
|
||||
usage(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const liters = (c.kind || "water") === "water" && s.attributes.unit_of_measurement === "m³";
|
||||
const days = (cache.days || []).map((d) => (liters ? d.v * 1000 : d.v)), today = days[days.length - 1], yest = days[days.length - 2];
|
||||
const unit = liters ? "L" : s.attributes.unit_of_measurement || "";
|
||||
return { ico: ico({ water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet"), name: c.title || friendly(s),
|
||||
value: today != null ? fmtNum(today) : "–", unit, sub: yest != null ? t("yesterday {0}", `${fmtNum(yest)} ${unit}`) : t("Today so far"), tap: more(c.entity) };
|
||||
},
|
||||
solar(c, { ha }) {
|
||||
const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow];
|
||||
if (!today && !tomorrow) return null;
|
||||
const s = today || tomorrow;
|
||||
return { ico: ico("solar"), name: c.title || t("Solar forecast"), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "kWh",
|
||||
sub: today && tomorrow ? `${t("Tomorrow")} ${fmtNum(tomorrow.state)} ${tomorrow.attributes.unit_of_measurement || "kWh"}` : today ? t("Today") : t("Tomorrow"), tap: more(c.today || c.tomorrow) };
|
||||
},
|
||||
gate(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const d = domainOf(c.entity), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing", "locking", "unlocking"].includes(s.state);
|
||||
const name = c.name || friendly(s), label = d === "button" ? t("Press") : moving ? st(s.state) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open");
|
||||
const k = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage";
|
||||
return { ico: ico(open && c.kind === "door" ? "door-open" : k), name, word: d === "button" ? "" : open ? t("Open") : st(s.state), tone: open ? "warn" : "", on: open, sub: relTime(s.last_changed),
|
||||
// a round button with an arrow on a Small card, the word beside it in a Row
|
||||
ctl: `<button class="phc-btn wide wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">${Icons.svg(d === "button" ? "play" : d === "lock" ? (open ? "lock" : "unlock") : open ? "arrow-down" : "arrow-up")}<span>${esc(label)}</span></button>` };
|
||||
},
|
||||
sports(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const a = s.attributes, state = String(s.state).toUpperCase();
|
||||
if (!a.opponent_name) return { ico: ico("trophy"), name: a.team_name || friendly(s), sub: t("No match planned.") };
|
||||
const home = a.team_homeaway === "away" ? "opponent" : "team", away = home === "team" ? "opponent" : "team";
|
||||
const ab = (p) => a[`${p}_abbr`] || String(a[`${p}_name`] || "").slice(0, 3).toUpperCase();
|
||||
const kick = a.date ? new Date(a.date) : null;
|
||||
return { ico: ico("trophy"), name: `${ab(home)} – ${ab(away)}`, on: state === "IN", tone: state === "IN" ? "accent" : "",
|
||||
value: state === "PRE" ? (kick ? kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "–") : `${a[`${home}_score`] ?? 0}–${a[`${away}_score`] ?? 0}`,
|
||||
sub: state === "PRE" ? (kick ? dayLabel(kick) : "") : state === "IN" ? a.clock || t("Live") : t("Full time"), tap: more(c.entity) };
|
||||
},
|
||||
f1(c, { ha }) {
|
||||
const next = ha.states[c.next];
|
||||
if (!next) return null;
|
||||
const a = next.attributes, iso = a.race_start || a.race_date || a.race_start_local, at = iso ? new Date(iso) : null;
|
||||
let when = at ? dayLabel(at) : "";
|
||||
if (at && at > Date.now()) { const m = Math.round((at - Date.now()) / 60000), dd = Math.floor(m / 1440); when = dd ? t("in {0} d {1} h", dd, Math.floor((m % 1440) / 60)) : t("in {0} h {1} min", Math.floor(m / 60), m % 60); }
|
||||
return { ico: ico("flag"), name: a.race_name || a.raceName || t("Next race"), word: when, sub: [a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · "), tap: more(c.next) };
|
||||
},
|
||||
calendar(c, { ha, cache }) {
|
||||
if (!c.entities?.length) return null;
|
||||
const now = new Date(), events = [];
|
||||
for (const eid of c.entities) for (const ev of cache.res?.[eid]?.events || []) { const s = parseWhen(ev.start), e = parseWhen(ev.end); if (e >= now) events.push({ ...ev, s, allDay: typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date }); }
|
||||
events.sort((x, y) => x.s - y.s);
|
||||
const ev = events[0];
|
||||
// the next event as the name; its time today as the figure, else the day
|
||||
const today = ev && dayLabel(ev.s) === t("Today");
|
||||
return { ico: ico("calendar"), name: ev ? ev.summary : c.title || t("Upcoming"), tap: more(c.entities[0]),
|
||||
value: ev && today && !ev.allDay ? ev.s.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : null,
|
||||
word: ev ? (today && !ev.allDay ? "" : dayLabel(ev.s)) : cache.res ? t("Nothing planned") : t("Loading…"), sub: c.title || "" };
|
||||
},
|
||||
waste(c, { ha }) {
|
||||
const items = wasteItems(c, ha).filter((i) => i.days <= (Number(c.within) || 31));
|
||||
if (!items.length) return null;
|
||||
const first = items.filter((i) => i.days === items[0].days);
|
||||
return { ico: `<span class="phc-bin" style="color:${first[0].color}">${Icons.svg("bin")}</span>`, name: c.title || t("Waste collection"), word: whenLabel(first[0].days),
|
||||
sub: first.map((i) => i.name).join(" · "), tone: first[0].days <= 1 ? "warn" : "", tap: more(first[0].eid) };
|
||||
},
|
||||
};
|
||||
|
||||
/** Draw a summary as a Small, Medium or Large card, or a Row. */
|
||||
function phoneCard(sum, size) {
|
||||
const tap = sum.tap ? `<button class="phc-tap wbtn" data-act="${esc(sum.tap.act)}" ${sum.tap.e ? `data-e="${esc(sum.tap.e)}"` : ""} ${sum.tap.v != null ? `data-v="${esc(sum.tap.v)}"` : ""} aria-label="${esc(sum.name)}"></button>` : "";
|
||||
const fig = sum.value != null && sum.value !== ""
|
||||
? `<span class="phc-val num">${typeof sum.value === "string" && sum.value.startsWith("<") ? sum.value : esc(sum.value)}${sum.unit === "°" ? "°" : sum.unit ? `<small>${esc(sum.unit)}</small>` : ""}</span>`
|
||||
: sum.word ? `<span class="phc-val phc-word">${esc(sum.word)}</span>` : "";
|
||||
const meter = sum.meter != null ? `<span class="phc-meter" aria-hidden="true"><i style="width:${Math.max(0, Math.min(100, sum.meter)).toFixed(0)}%"></i></span>` : "";
|
||||
const art = sum.art ? `<img class="phc-art" src="${esc(sum.art)}" alt="">` : `<span class="phc-ico">${sum.ico || ""}</span>`;
|
||||
const cls = `phc phc-${size} ${sum.on ? "on" : ""} ${sum.tone ? `tone-${sum.tone}` : ""}`;
|
||||
const style = sum.tint ? ` style="--phc-tint:${esc(sum.tint)}"` : "";
|
||||
if (size === "row") {
|
||||
return `<div class="${cls}"${style}>${tap}${art}<span class="phc-text"><span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</span>
|
||||
${fig}${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>`;
|
||||
}
|
||||
if (size === "m") {
|
||||
return `<div class="${cls}"${style}>${tap}<div class="phc-top">${art}<span class="phc-text"><span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</span>${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>
|
||||
${fig || meter ? `<div class="phc-bottom">${fig}${meter}</div>` : ""}</div>`;
|
||||
}
|
||||
return `<div class="${cls}"${style}>${tap}<div class="phc-top">${art}${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>
|
||||
<div class="phc-bottom">${fig}${meter}<span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</div></div>`;
|
||||
}
|
||||
|
||||
// Which sizes each card has on a phone, and which it draws itself instead of as a summary.
|
||||
const PHONE = {
|
||||
heading: { sizes: ["row", "m"], row: "card", s: "card" },
|
||||
entity: { m: "sum", l: "sum" },
|
||||
button: { m: "sum", l: "sum" },
|
||||
timer: { m: "sum" },
|
||||
light: { s: "card" },
|
||||
text: { s: "card", row: "card" },
|
||||
image: { sizes: ["s", "m", "l"], s: "card" },
|
||||
iframe: { sizes: ["s", "m", "l"], s: "card" },
|
||||
camera: { s: "card" },
|
||||
hscroll: { sizes: ["m", "l"] },
|
||||
swipe: { sizes: ["s", "m", "l"], s: "card" },
|
||||
};
|
||||
for (const [k, def] of Object.entries(W)) def.phone = { sizes: ["s", "m", "l", "row"], ...(PHONE[k] || {}) };
|
||||
|
||||
// A light as one row: the fader the group card uses for each lamp, so you can still dim it.
|
||||
function lightRow(c, ha) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return null;
|
||||
const name = friendly(s, c.name);
|
||||
return `<div class="lt phc-light ${isOn(s) ? "on" : ""}">${fader(c.entity, s, name, `<button class="lt-ico wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
|
||||
<span class="lf-name">${esc(name)}</span><span class="lf-pct num">${esc(bigText(s, true))}</span>${sw(c.entity, isOn(s), name)}`, "lf-row")}</div>`;
|
||||
}
|
||||
|
||||
/** The phone version of a card at a size, or null to draw the card as it is. */
|
||||
W.__phoneRender = (type, c, ctx, size) => {
|
||||
const def = W[type];
|
||||
if (!def) return null;
|
||||
if ((size === "m" || size === "l") ? def.phone[size] !== "sum" : def.phone[size] === "card") return null;
|
||||
if (type === "light" && size === "row") return lightRow(c, ctx.ha);
|
||||
const sum = SUMMARY[type]?.(c, ctx);
|
||||
return sum ? phoneCard(sum, size) : null;
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
function beep() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user