Files
ha-dashboard-studio/dashboard_studio/web/widgets.js
T
veenm 75ab27db10 Dashboard Studio 1.7.0: scroll and swipe cards, more themes, per-device dashboards
- Container cards: horizontal scroll (snap, arrows, keeps position) and
  swipe (dots, optional autoplay that survives redraws and respects
  reduced motion), with add/edit/reorder of the cards inside
- Nine new themes, contrast-checked, including High contrast; bright
  accents get dark text
- Choose which dashboard opens per device (optionally in kiosk mode)
  and per Home Assistant user, via /api/whoami and the ingress user
  headers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:21:24 +01:00

921 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Widget library. Each widget type declares:
* label, icon, size [w, h], fields (inspector schema), render(cfg, ctx) -> HTML
* optional: entities(cfg) -> ids it depends on, after(el, cfg, ctx), flush, transparent,
* lit(cfg, ctx) -> "lamp" | "on" | false, lamp(cfg, ctx) -> CSS color for a lit light
* Interactive elements are real <button>s carrying data-act / data-e, handled by app.js.
*/
(function () {
const Icons = window.Icons;
const { t, state: st, locale: LOCALE } = window.I18n;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const domainOf = (eid) => (eid || "").split(".")[0];
const ON_STATES = new Set(["on", "open", "opening", "unlocked", "playing", "home", "heat", "cool", "auto", "heat_cool", "dry", "fan_only", "cleaning"]);
const TOGGLE_DOMAINS = new Set(["light", "switch", "fan", "input_boolean", "automation", "cover", "lock", "media_player", "scene", "script", "button", "input_button", "siren", "humidifier", "vacuum", "climate"]);
const STATELESS = new Set(["scene", "script", "button", "input_button"]);
const isOn = (s) => !!s && ON_STATES.has(s.state);
const friendly = (s, override) => override || s?.attributes?.friendly_name || s?.entity_id || "";
// ------------------------------------------------------------ icons
const DOMAIN_ICON = {
light: "bulb", switch: "plug", fan: "fan", input_boolean: "toggle", automation: "robot", cover: "window",
media_player: "speaker", climate: "thermometer", scene: "sparkles", script: "script", camera: "camera",
person: "person", device_tracker: "pin", vacuum: "vacuum", button: "button", input_button: "button",
sun: "sun", alarm_control_panel: "shield", weather: "cloud-sun", input_number: "number", number: "number", counter: "number",
};
const CLASS_ICON = {
temperature: "thermometer", humidity: "droplet", moisture: "droplet", power: "bolt", energy: "bolt", voltage: "bolt",
current: "bolt", battery: "battery", illuminance: "sun", pressure: "gauge", gas: "flame", water: "droplet",
distance: "road", speed: "wind", wind_speed: "wind", co2: "pulse", pm25: "fog", aqi: "fog",
};
const BINARY_ICON = { door: ["door", "door-open"], garage_door: ["garage", "garage"], window: ["window", "window"], opening: ["door", "door-open"], motion: ["person", "person"], occupancy: ["person", "person"], presence: ["home", "home"], smoke: ["flame", "flame"], moisture: ["droplet", "droplet"], plug: ["cable", "cable"], battery_charging: ["battery", "bolt"], lock: ["lock", "unlock"] };
const WX = { "clear-night": "moon", cloudy: "cloud", exceptional: "shield", fog: "fog", hail: "snow", lightning: "storm", "lightning-rainy": "storm", partlycloudy: "cloud-sun", pouring: "rain", rainy: "rain", snowy: "snow", "snowy-rainy": "snow", sunny: "sun", windy: "wind", "windy-variant": "wind" };
function iconName(s) {
if (!s) return "pulse";
const d = domainOf(s.entity_id), dc = s.attributes.device_class;
if (d === "binary_sensor") return (BINARY_ICON[dc] || ["pulse", "pulse"])[isOn(s) ? 1 : 0];
if (d === "sensor") return CLASS_ICON[dc] || "pulse";
if (d === "lock") return s.state === "locked" ? "lock" : "unlock";
if (d === "cover" && dc === "garage") return "garage";
if (d === "weather") return WX[s.state] || "cloud";
if (d === "media_player" && s.state === "playing") return "music";
return DOMAIN_ICON[d] || "pulse";
}
/** An override can be an icon name from the set or any text, such as an emoji. */
function icon(s, override) {
if (override) return Icons.has(override) ? Icons.svg(override) : `<span class="glyph">${esc(override)}</span>`;
return Icons.svg(iconName(s));
}
// ------------------------------------------------------------ formatting
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
const BINARY_LABELS = { door: ["Closed", "Open"], window: ["Closed", "Open"], garage_door: ["Closed", "Open"], opening: ["Closed", "Open"], motion: ["Clear", "Motion"], occupancy: ["Clear", "Occupied"], moisture: ["Dry", "Wet"], smoke: ["Clear", "Smoke"], presence: ["Away", "Home"], plug: ["Unplugged", "Plugged in"], battery_charging: ["Not charging", "Charging"] };
function fmtNum(v, decimals) {
const n = Number(v);
if (!isFinite(n)) return v;
if (decimals !== "" && decimals != null && !isNaN(decimals)) {
const d = Math.max(0, Math.min(6, Number(decimals)));
return n.toLocaleString(LOCALE, { minimumFractionDigits: d, maximumFractionDigits: d });
}
return n.toLocaleString(LOCALE, { maximumFractionDigits: Math.abs(n) >= 100 ? 0 : 1 });
}
function fmtState(s) {
if (!s) return t("Unavailable");
const d = domainOf(s.entity_id), a = s.attributes;
if (s.state === "unavailable" || s.state === "unknown") return st(s.state);
if (d === "binary_sensor") return t((BINARY_LABELS[a.device_class] || ["Off", "On"])[isOn(s) ? 1 : 0]);
if (d === "light") return s.state === "on" ? (a.brightness ? `${Math.round(a.brightness / 2.55)}%` : t("On")) : t("Off");
if (d === "climate") return `${st(s.state)}${a.temperature != null ? " · " + a.temperature + "°" : ""}`;
if (d === "scene") return t("Scene");
if (d === "script") return s.state === "on" ? t("Running") : t("Script");
if (d === "media_player" && a.media_title) return `${st(s.state)} · ${a.media_title}`;
if (d === "weather") return `${st(s.state)} · ${a.temperature}°`;
if (d === "device_tracker" || d === "person") return s.state === "home" ? t("Home") : s.state === "not_home" ? t("Away") : st(s.state);
if (a.unit_of_measurement && !isNaN(Number(s.state))) return `${fmtNum(s.state)} ${a.unit_of_measurement}`;
return st(s.state);
}
const relTime = (iso) => {
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
if (sec < 60) return t("just now");
if (sec < 3600) return t("{0} min ago", Math.floor(sec / 60));
if (sec < 86400) return t("{0} h ago", Math.floor(sec / 3600));
return t("{0} d ago", Math.floor(sec / 86400));
};
const missing = (c, what = "an entity") => `<div class="empty">${c ? t("Can’t find {0}.", esc(c)) + "<br>" + t("Check the entity ID in the inspector.") : t("Choose {0} in the inspector.", t(what))}</div>`;
const title = (txt) => (txt ? `<div class="wt">${esc(txt)}</div>` : "");
const has = (x) => x !== "" && x != null && isFinite(Number(x));
function lampColor(s) {
const rgb = s?.attributes?.rgb_color;
return s?.state === "on" && Array.isArray(rgb) ? `rgb(${rgb.join(",")})` : null;
}
const litKind = (s) => (!isOn(s) ? false : domainOf(s.entity_id) === "light" ? "lamp" : "on");
// ------------------------------------------------------------ widgets
const W = {};
W.heading = {
label: "Heading", icon: "heading", size: [6, 1], transparent: true,
fields: [
{ k: "text", t: "text", l: "Text", d: "Welcome home" },
{ k: "subtitle", t: "text", l: "Subtitle", d: "" },
{ k: "size", t: "number", l: "Font size (px)", d: 30 },
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
],
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
<h2 class="h-main" style="font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
};
W.entity = {
label: "Entity tile", icon: "tile", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Entity" },
{ k: "name", t: "text", l: "Name" , ph: "Entity name" },
{ k: "icon", t: "icon", l: "Icon" },
{ k: "tap", t: "seg", l: "Tap action", o: ["auto", "toggle", "more-info", "none"], d: "auto" },
{ k: "showChanged", t: "check", l: "Show when it last changed", d: false },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const d = domainOf(c.entity);
const tap = c.tap === "auto" ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
const name = friendly(s, c.name), state = fmtState(s) + (c.showChanged ? " · " + relTime(s.last_changed) : "");
const inner = `<span class="badge">${icon(s, c.icon)}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(state)}</span></span>`;
if (tap === "none") return `<div class="tile">${inner}</div>`;
const pressed = tap === "toggle" && !STATELESS.has(d) ? ` aria-pressed="${isOn(s)}"` : "";
return `<button class="tile wbtn" data-act="${tap === "toggle" ? "toggle" : "more"}" data-e="${esc(c.entity)}"${pressed}>${inner}</button>`;
},
lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
};
W.value = {
label: "Big value", icon: "number", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number", "counter"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "unit", t: "text", l: "Unit", ph: "From entity" },
{ k: "decimals", t: "number", l: "Decimals", d: "" },
{ k: "warn", t: "number", l: "Warn above", d: "" },
{ k: "crit", t: "number", l: "Critical above", d: "" },
{ k: "icon", t: "icon", l: "Icon" },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const v = Number(s.state);
const lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : "";
const unit = c.unit || s.attributes.unit_of_measurement || "";
return `<button class="value wbtn ${lvl}" data-act="more" data-e="${esc(c.entity)}">
<span class="value-top"><span class="wt">${esc(friendly(s, c.name))}</span><span class="value-ico">${icon(s, c.icon)}</span></span>
<span class="big num">${esc(isFinite(v) ? fmtNum(s.state, c.decimals) : cap(s.state))}<span class="unit">${esc(unit)}</span></span>
${lvl ? `<span class="level">${lvl === "crit" ? t("Critical") : t("High")}</span>` : ""}</button>`;
},
};
function arc(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
const [x0, y0] = p(a0), [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
W.gauge = {
label: "Gauge", icon: "gauge", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "min", t: "number", l: "Minimum", d: 0 },
{ k: "max", t: "number", l: "Maximum", d: 100 },
{ k: "green", t: "number", l: "Green from", d: "" },
{ k: "yellow", t: "number", l: "Yellow from", d: "" },
{ k: "red", t: "number", l: "Red from", d: "" },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100;
const frac = isFinite(v) ? Math.max(0, Math.min(1, (v - min) / (max - min || 1))) : 0;
let color = "var(--d-text)";
if (isFinite(v)) {
if (has(c.red) && v >= Number(c.red)) color = "var(--crit)";
else if (has(c.yellow) && v >= Number(c.yellow)) color = "var(--warn)";
else if (has(c.green) && v >= Number(c.green)) color = "var(--ok)";
}
const a0 = Math.PI * 0.75, span = Math.PI * 1.5;
const unit = s.attributes.unit_of_measurement || "";
return `<button class="gauge wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(friendly(s, c.name))}, ${esc(fmtState(s))}">
<svg viewBox="0 0 100 82" aria-hidden="true">
<path d="${arc(50, 50, 40, a0, a0 + span)}" stroke="var(--d-fill)" stroke-width="8" fill="none" stroke-linecap="round"/>
${frac > 0.001 ? `<path d="${arc(50, 50, 40, a0, a0 + span * frac)}" stroke="${color}" stroke-width="8" fill="none" stroke-linecap="round"/>` : ""}
<text x="50" y="53" text-anchor="middle" class="num" font-size="20" font-weight="700" fill="currentColor">${esc(isFinite(v) ? fmtNum(v) : "–")}</text>
<text x="50" y="67" text-anchor="middle" font-size="9" fill="var(--d-text-2)">${esc(unit)}</text>
</svg><span class="wt">${esc(friendly(s, c.name))}</span></button>`;
},
};
W.light = {
label: "Light", icon: "bulb", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a light");
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
const name = friendly(s, c.name);
return `<div class="light">
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
<span class="badge">${icon(s)}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
},
lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
};
W.climate = {
label: "Thermostat", icon: "thermometer", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Thermostat", domains: ["climate"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "step", t: "number", l: "Step", d: 0.5 },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a thermostat");
const a = s.attributes, step = Number(c.step) || 0.5, e = esc(c.entity);
return `<div class="climate">
<div class="wt">${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°</div>
<div class="temps">
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${-step}" aria-label="${t("Lower to {0}°", esc((a.temperature ?? 0) - step))}">${Icons.svg("minus")}</button>
<div class="target num" aria-live="polite">${esc(a.temperature ?? "–")}°</div>
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${step}" aria-label="${t("Raise to {0}°", esc((a.temperature ?? 0) + step))}">${Icons.svg("plus")}</button>
</div>
<div class="chips" role="group" aria-label="${t("Mode")}">${(a.hvac_modes || []).map((m) => `<button class="chip wbtn" aria-pressed="${m === s.state}" data-act="hvac" data-e="${e}" data-v="${esc(m)}">${esc(st(m))}</button>`).join("")}</div></div>`;
},
};
W.graph = {
label: "History graph", icon: "chart", size: [6, 3],
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "entities", t: "entities", l: "Sensors", domains: ["sensor", "input_number", "number"] },
{ k: "hours", t: "number", l: "Hours to show", d: 24 },
{ k: "fill", t: "check", l: "Fill under the line", d: true },
],
entities: (c) => c.entities || [],
render(c) {
if (!c.entities?.length) return `<div class="empty">${t("Add one or more sensors in the inspector.")}</div>`;
return `<div class="graph">${title(c.title)}<div class="legend"></div><div class="plot"><div class="empty">${t("Loading history…")}</div></div></div>`;
},
async after(el, c, ctx) {
if (!c.entities?.length) return;
const hours = Number(c.hours) || 24;
const key = c.entities.join(",") + "|" + hours;
const cache = ctx.cache;
if (cache.key !== key || Date.now() - cache.at > 10 * 60 * 1000) {
cache.key = key; cache.at = Date.now();
cache.loading = ctx.ha.history(c.entities, hours).catch(() => ({})).then((s) => (cache.series = s));
}
// a re-render while the fetch is in flight must wait for the same request
await cache.loading;
if (!el.isConnected) return;
for (const eid of c.entities) {
const s = ctx.ha.states[eid];
if (!s || !cache.series[eid]) continue;
const pts = cache.series[eid], last = pts[pts.length - 1];
const t = new Date(s.last_updated).getTime();
if (!last || t > last[0]) pts.push([t, Number(s.state)]);
const cutoff = Date.now() - hours * 3600e3;
while (pts.length > 2 && pts[0][0] < cutoff) pts.shift();
}
drawGraph(el, c, ctx);
},
};
// Series differ by dash pattern as well as color, so color is never the only cue.
const SERIES = [["var(--d-accent)", ""], ["var(--warn)", "6 4"], ["var(--ok)", "2 3"], ["var(--crit)", "10 3 2 3"]];
function drawGraph(el, c, ctx) {
const plot = el.querySelector(".plot"), legend = el.querySelector(".legend");
if (!plot) return;
const W_ = plot.clientWidth, H = plot.clientHeight;
const series = c.entities.map((eid) => ({ eid, pts: (ctx.cache.series || {})[eid] || [] })).filter((s) => s.pts.length);
legend.innerHTML = c.entities.map((eid, i) => {
const s = ctx.ha.states[eid], [col, dash] = SERIES[i % SERIES.length];
return `<span><svg width="18" height="8" aria-hidden="true"><line x1="1" y1="4" x2="17" y2="4" stroke="${col}" stroke-width="2.5" stroke-dasharray="${dash}" stroke-linecap="round"/></svg>${esc(friendly(s))} <b class="num">${esc(s ? fmtState(s) : "–")}</b></span>`;
}).join("");
if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
const hours = Number(c.hours) || 24;
const t1 = Date.now(), t0 = t1 - hours * 3600e3;
let lo = Infinity, hi = -Infinity;
series.forEach((s) => s.pts.forEach(([, v]) => { if (isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } }));
if (!isFinite(lo)) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
if (hi - lo < 1e-6) { hi += 1; lo -= 1; }
const pad = (hi - lo) * 0.1; lo -= pad; hi += pad;
const L = 36, B = 16;
const x = (t) => L + ((t - t0) / (t1 - t0)) * (W_ - L);
const y = (v) => (1 - (v - lo) / (hi - lo)) * (H - B);
const paths = series.map((s) => {
const [col, dash] = SERIES[c.entities.indexOf(s.eid) % SERIES.length];
const pts = s.pts.filter(([, v]) => isFinite(v));
if (!pts.length) return "";
let d = "";
pts.forEach(([t, v], j) => { d += `${j ? "L" : "M"}${x(Math.max(t, t0)).toFixed(1)} ${y(v).toFixed(1)}`; });
d += `L${x(t1).toFixed(1)} ${y(pts[pts.length - 1][1]).toFixed(1)}`;
const area = c.fill && series.length === 1 ? `<path d="${d}L${x(t1).toFixed(1)} ${H - B}L${x(Math.max(pts[0][0], t0)).toFixed(1)} ${H - B}Z" fill="${col}" opacity=".12"/>` : "";
return `${area}<path d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round" stroke-linecap="round"/>`;
}).join("");
const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f);
plot.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" role="img" aria-label="${t("History for the last {0} hours", hours)}">
${ticks.map((v) => `<line x1="${L}" x2="${W_}" y1="${y(v)}" y2="${y(v)}" stroke="var(--d-separator)"/><text class="axis" x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${fmtNum(v)}</text>`).join("")}
${paths}
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
}
W.clock = {
label: "Clock", icon: "clock", size: [3, 2],
fields: [
{ k: "h24", t: "check", l: "24-hour time", d: true },
{ k: "seconds", t: "check", l: "Show seconds", d: false },
{ k: "date", t: "check", l: "Show date", d: true },
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
],
entities: (c) => (c.weather ? [c.weather] : []),
render(c, { ha }) {
const s = c.weather && ha.states[c.weather];
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
return `<div class="clock"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div>`;
},
async after(el, c, ctx) {
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
const cache = ctx.cache;
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
cache.key = c.weather; cache.at = Date.now();
cache.loading = ctx.ha.forecast(c.weather, "daily").then((f) => (cache.today = f[0])).catch(() => (cache.today = null));
}
await cache.loading;
const hl = el.querySelector("[data-hl]");
const f = cache.today;
if (hl && f && f.temperature != null) hl.textContent = f.templow != null ? t("High {0}° · Low {1}°", Math.round(f.temperature), Math.round(f.templow)) : t("High {0}°", Math.round(f.temperature));
},
};
function clockHTML(h24, sec, date) {
const d = new Date();
const time = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: sec ? "2-digit" : undefined, hour12: !h24 });
return `<div class="time num">${time}</div>${date ? `<div class="date">${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}</div>` : ""}`;
}
W.weather = {
label: "Weather", icon: "cloud-sun", size: [4, 2],
fields: [{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a weather entity");
const a = s.attributes;
return `<button class="weather wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
<span class="wx-text"><span class="wx-temp num">${esc(a.temperature ?? "–")}°</span><span class="wt">${esc(friendly(s, c.name))} · ${esc(st(s.state))}</span>
<span class="wt">${t("Humidity")} ${esc(a.humidity ?? "–")}% · ${t("Wind")} ${esc(a.wind_speed ?? "–")} ${esc(a.wind_speed_unit || "km/h")}</span></span></button>`;
},
};
W.list = {
label: "Entity list", icon: "list", size: [4, 4],
fields: [{ k: "title", t: "text", l: "Title", d: "Devices" }, { k: "entities", t: "entities", l: "Entities" }],
entities: (c) => c.entities || [],
render(c, { ha }) {
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add entities in the inspector.")}</div>`;
const rows = c.entities.map((eid) => {
const s = ha.states[eid];
if (!s) return `<div class="list-row"><span class="row-ico">${Icons.svg("pulse")}</span><span class="grow muted">${esc(eid)}</span></div>`;
const sw = ["light", "switch", "fan", "input_boolean", "automation"].includes(domainOf(eid));
// Switches belong in list rows, so this is the one place we draw them.
return sw
? `<button class="list-row wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="switch"></span></button>`
: `<button class="list-row wbtn" data-act="more" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="st">${esc(fmtState(s))}</span></button>`;
}).join("");
return `${title(c.title)}<div class="list">${rows}</div>`;
},
};
W.button = {
label: "Button", icon: "button", size: [2, 2],
fields: [
{ k: "label", t: "text", l: "Label", d: "Run" },
{ k: "icon", t: "icon", l: "Icon", d: "sparkles" },
{ k: "entity", t: "entity", l: "Target entity (optional)" },
{ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", d: "" },
{ k: "data", t: "json", l: "Action data (JSON)", d: "" },
],
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
};
// ---------------------------------------------------------------- media (one or more players)
const mediaIds = (c) => (c.entities?.length ? c.entities : c.entity ? [c.entity] : []);
const ACTIVE = ["playing", "paused", "buffering", "on", "idle"];
/** Which player the card shows: the one picked, else whatever is playing, else the first that's on. */
function mediaSelected(ids, ha, cache) {
if (cache.sel && ids.includes(cache.sel)) return cache.sel;
return ids.find((e) => ha.states[e]?.state === "playing") || ids.find((e) => ha.states[e]?.state === "paused") || ids.find((e) => ACTIVE.includes(ha.states[e]?.state)) || ids[0];
}
W.media = {
label: "Media players", icon: "music", size: [4, 3],
fields: [
{ k: "entities", t: "entities", l: "Media players", domains: ["media_player"] },
{ k: "art", t: "check", l: "Show album art", d: true },
{ k: "hideOff", t: "check", l: "Hide players that are off from the switcher", d: false },
],
entities: mediaIds,
render(c, { ha, cache }) {
let ids = mediaIds(c).filter((e) => ha.states[e]);
if (!ids.length) return missing(mediaIds(c)[0], "a media player");
const sel = mediaSelected(ids, ha, cache);
if (c.hideOff) ids = ids.filter((e) => e === sel || ha.states[e].state !== "off");
const s = ha.states[sel], a = s.attributes, e = esc(sel);
const playing = s.state === "playing", off = ["off", "standby", "unavailable"].includes(s.state);
const bars = `<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>`;
const switcher = ids.length > 1 ? `<div class="mp-switch" role="tablist" aria-label="${t("Media players")}">${ids.map((id) => {
const p = ha.states[id], on = p.state === "playing";
return `<button class="chip wbtn ${on ? "is-playing" : ""}" role="tab" aria-selected="${id === sel}" data-act="mp-select" data-v="${esc(id)}" aria-label="${esc(friendly(p))}, ${esc(st(p.state))}">${on ? bars : ""}${esc(friendly(p))}</button>`;
}).join("")}</div>` : "";
const pic = c.art !== false && a.entity_picture && !off ? `<img class="mp-art" src="${esc(a.entity_picture)}" alt="">` : `<span class="mp-art ph" aria-hidden="true">${icon(s)}</span>`;
const title = off ? st(s.state) : a.media_title || a.app_name || a.source || st(s.state);
const sub = off ? "" : [a.media_artist, a.media_album_name].filter(Boolean).join(" · ") || (a.media_title ? "" : st(s.state));
const controls = off
? `<button class="car-act wbtn" data-act="svc" data-e="${e}" data-v="media_player.turn_on">${Icons.svg("power")}<span>${t("Turn on")}</span></button>`
: `<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${esc(t("Volume"))} ${esc(friendly(s))}">` : ""}`;
return `<div class="media mp">${switcher}
<div class="mp-now">${pic}<div class="mp-meta">
${ids.length === 1 ? `<div class="wt">${esc(friendly(s))}</div>` : ""}
<div class="title">${esc(title)}</div>${sub ? `<div class="wt">${esc(sub)}</div>` : ""}</div></div>
<div class="ctrls">${controls}</div></div>`;
},
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
};
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
// Works with any integration: a status entity, and/or a power sensor above a threshold,
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
function appliancePhase(c, ha) {
const status = String(ha.states[c.status]?.state || "").toLowerCase();
if (status && !["unavailable", "unknown"].includes(status)) {
if (DONE_RE.test(status)) return "done";
if (PAUSE_RE.test(status)) return "paused";
if (RUNNING_RE.test(status)) return "running";
}
const power = Number(ha.states[c.power]?.state);
if (c.power && isFinite(power) && power > (Number(c.threshold) || 5)) return "running";
const left = remainingMinutes(c, ha);
if (!c.status && !c.power && left > 0) return "running";
return "off";
}
function remainingMinutes(c, ha) {
const s = ha.states[c.remaining];
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}T/.test(s.state)) return Math.max(0, Math.round((new Date(s.state) - Date.now()) / 60000));
const n = Number(s.state);
if (!isFinite(n)) return null;
const unit = String(s.attributes.unit_of_measurement || "min").toLowerCase();
return Math.round(unit.startsWith("h") ? n * 60 : unit === "s" ? n / 60 : n);
}
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
W.appliance = {
label: "Appliance", icon: "washer", size: [4, 2],
fields: [
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
],
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
render(c, { ha }) {
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
const phase = appliancePhase(c, ha);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
const prog = Number(ha.states[c.progress]?.state);
const program = ha.states[c.program]?.state;
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
return `<div class="appl phase-${phase}">
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
<span class="appl-status">${esc(label)}</span></div>
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
</div>`;
},
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
};
W.camera = {
label: "Camera", icon: "camera", size: [6, 4], flush: true,
fields: [{ k: "entity", t: "entity", l: "Camera", domains: ["camera"] }, { k: "refresh", t: "number", l: "Refresh every (seconds)", d: 10 }, { k: "showName", t: "check", l: "Show name", d: true }],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return `<div class="pad">${missing(c.entity, "a camera")}</div>`;
return `<button class="cam-btn wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} camera", friendly(s)))}"><img class="cam" data-cam="${esc(c.entity)}" data-refresh="${Number(c.refresh) || 10}" src="api/camera/${encodeURIComponent(c.entity)}?t=${Date.now()}" alt=""></button>
${c.showName ? `<div class="cam-label">${esc(friendly(s))}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
W.car = {
label: "Car", icon: "car", size: [6, 3],
fields: [
{ k: "name", t: "text", l: "Name", d: "My car" },
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
{ k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" },
{ k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] },
{ k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] },
{ k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] },
{ k: "power", t: "entity", l: "Charging power", domains: ["sensor"] },
{ k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] },
{ k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] },
{ k: "lock", t: "entity", l: "Doors", domains: ["lock"] },
{ k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] },
{ k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] },
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
render(c, { ha }) {
const st = (k) => (c[k] ? ha.states[c[k]] : null);
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
const power = st("power"), plug = st("plug"), limit = st("limit");
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
const plugged = charging || truthy(plug);
const low = pct != null && pct <= 20;
let status;
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
else if (plugged) status = t("Plugged in");
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked");
else status = "";
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const bar = pct == null ? "" : `
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
<span class="car-fill" style="width:${pct}%"></span>
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
</div>`;
const actions = [];
if (lock) {
const locked = lock.state === "locked";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
}
if (clim) {
const on = isOn(clim) || clim.state === "on";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
}
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
const odo = st("odometer");
return `<div class="car ${c.image ? "has-img" : ""}">
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
<div class="car-main">
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
${low ? `<span class="level">${t("Low")}</span>` : ""}
</div>
${bar}
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
</div>`;
},
};
W.text = {
label: "Text", icon: "text", size: [4, 2],
fields: [
{ k: "text", t: "textarea", l: "Text — use {{ sensor.x }} for live values", d: "Inside it’s {{ sensor.living_room_temperature }}." },
{ k: "size", t: "number", l: "Font size (px)", d: 17 },
],
entities: (c) => [...String(c.text || "").matchAll(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g)].map((m) => m[1]),
render(c, { ha }) {
const html = esc(c.text).replace(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g, (_, eid) => `<b>${esc(fmtState(ha.states[eid]))}</b>`);
return `<div class="text-w" style="font-size:${Math.max(13, Number(c.size) || 17)}px">${html}</div>`;
},
};
// ---------------------------------------------------------------- photos
// One image, a slideshow of URLs, or a slideshow of Immich albums (proxied by the server).
const urlList = (c) => String(c.urls ?? c.url ?? "").split(/\s*\n\s*/).map((u) => u.trim()).filter(Boolean);
W.image = {
label: "Photos", icon: "image", size: [4, 3], flush: true,
fields: [
{ k: "source", t: "seg", l: "Source", o: ["links", "immich"], d: "links" },
{ k: "urls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.source !== "immich" },
{ k: "albums", t: "albums", l: "Immich albums", when: (c) => c.source === "immich" },
{ k: "interval", t: "number", l: "Change photo every (seconds)", d: 20 },
{ k: "shuffle", t: "check", l: "Shuffle", d: true },
{ k: "fit", t: "seg", l: "Fit", o: ["cover", "contain"], d: "cover" },
{ k: "showDate", t: "check", l: "Show when the photo was taken", d: false, when: (c) => c.source === "immich" },
{ k: "alt", t: "text", l: "Description (for screen readers)" },
],
render(c) {
const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector.");
if (empty) return `<div class="pad"><div class="empty">${empty}</div></div>`;
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
},
async after(el, c, ctx) {
const cache = ctx.cache;
clearTimeout(cache.timer);
const box = el.querySelector(".photos");
if (!box) return;
const msg = box.querySelector(".ph-msg");
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]);
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
cache.key = key; cache.at = Date.now(); cache.pos = 0;
cache.loading = (async () => {
if (c.source !== "immich") return urlList(c).map((src) => ({ src }));
const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
})().then((items) => {
if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
cache.items = items; cache.error = null;
}).catch((e) => { cache.items = []; cache.error = e.message; });
}
await cache.loading;
if (!el.isConnected) return;
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; return; }
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
let front = 0;
const show = () => {
if (!el.isConnected) return;
const item = cache.items[cache.pos % cache.items.length];
cache.pos++;
const next = layers[1 - front];
next.onload = () => {
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
msg.innerHTML = "";
dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
};
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
next.src = item.src;
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000);
};
show();
},
};
// ---------------------------------------------------------------- calendar
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const parseWhen = (v) => (typeof v === "string" && !v.includes("T") ? new Date(`${v}T00:00:00`) : new Date(v?.dateTime || v?.date || v));
const hm = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
W.calendar = {
label: "Calendar", icon: "calendar", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Upcoming" },
{ k: "entities", t: "entities", l: "Calendars", domains: ["calendar"] },
{ k: "days", t: "number", l: "Days ahead", d: 7 },
{ k: "max", t: "number", l: "Most events to show", d: 12 },
{ k: "showCalendar", t: "check", l: "Show which calendar", d: true },
],
entities: (c) => c.entities || [],
render(c) {
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add one or more calendars in the inspector.")}</div>`;
return `${title(c.title)}<div class="cal"><div class="empty">${t("Loading events…")}</div></div>`;
},
async after(el, c, ctx) {
if (!c.entities?.length) return;
const days = Math.max(1, Number(c.days) || 7);
const key = c.entities.join(",") + "|" + days;
const cache = ctx.cache;
if (cache.key !== key || Date.now() - cache.at > 5 * 60 * 1000) {
cache.key = key; cache.at = Date.now();
const start = new Date(); start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + days * 86400e3);
cache.loading = ctx.ha.calendarEvents(c.entities, start, end)
.then((res) => { cache.res = res || {}; cache.error = null; })
.catch((e) => { cache.res = {}; cache.error = e.message; });
}
await cache.loading;
const list = el.querySelector(".cal");
if (!el.isConnected || !list) return;
if (cache.error) { list.innerHTML = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; return; }
const now = new Date();
const events = [];
c.entities.forEach((eid, i) => {
for (const ev of cache.res[eid]?.events || []) {
const allDay = typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date;
const s = parseWhen(ev.start), e = parseWhen(ev.end);
if (e < now) continue;
events.push({ ...ev, s, e, allDay, cal: friendly(ctx.ha.states[eid]), color: CAL_COLORS[i % CAL_COLORS.length] });
}
});
events.sort((a, b) => a.s - b.s || b.allDay - a.allDay);
const shown = events.slice(0, Math.max(1, Number(c.max) || 12));
if (!shown.length) { list.innerHTML = `<div class="empty">${t("Nothing planned for the next {0} days.", days)}</div>`; return; }
const today = new Date(); today.setHours(0, 0, 0, 0);
const label = (d) => {
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400e3);
return diff <= 0 ? t("Today") : diff === 1 ? t("Tomorrow") : d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" });
};
let html = "", lastDay = "";
const multi = c.entities.length > 1 && c.showCalendar;
for (const ev of shown) {
const day = ev.s < today ? today : ev.s;
if (dayKey(day) !== lastDay) { lastDay = dayKey(day); html += `<div class="cal-day">${esc(label(day))}</div>`; }
const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`;
const live = !ev.allDay && ev.s <= now && ev.e > now;
html += `<div class="cal-ev ${live ? "live" : ""}"><span class="cal-bar" style="background:${ev.color}"></span>
<span class="cal-when num">${esc(when)}</span>
<span class="cal-what"><span class="cal-sum">${esc(ev.summary || t("Busy"))}</span>${ev.location || multi ? `<span class="cal-sub">${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}</span>` : ""}</span>
${live ? `<span class="cal-now">${t("Now")}</span>` : ""}</div>`;
}
list.innerHTML = html;
},
};
W.iframe = {
label: "Web page", icon: "globe", size: [6, 4], flush: true,
fields: [{ k: "url", t: "text", l: "URL" }],
render: (c) => (c.url ? `<iframe class="frame" src="${esc(c.url)}" loading="lazy" title="${t("Embedded page")}"></iframe>` : `<div class="pad"><div class="empty">${t("Add a URL in the inspector.")}</div></div>`),
};
// ---------------------------------------------------------------- waste (Afvalwijzer)
// Reads the per-type sensors of the Afvalwijzer integration: state is the next pickup
// date (ISO, or a timestamp), with days_until_collection_date and is_collection_date_*.
const WASTE_TYPES = {
gft: ["Organic", "#4CAF50"], papier: ["Paper", "#2196F3"], pmd: ["PMD", "#800000"], plastic: ["Plastic", "#FF9800"],
restafval: ["Residual waste", "#9E9E9E"], restafvalzakken: ["Residual waste bags", "#9E9E9E"], restwagen: ["Residual waste", "#9E9E9E"],
textiel: ["Textile", "#00695C"], grofvuil: ["Bulky waste", "#795548"], kca: ["Hazardous waste", "#F44336"], luiers: ["Nappies", "#FFA000"],
glas: ["Glass", "#009688"], snoeiafval: ["Pruning waste", "#2E7D32"], tuinafval: ["Garden waste", "#2E7D32"], kerstbomen: ["Christmas trees", "#1B5E20"],
best_tas: ["BEST bag", "#6D4C41"], milieubus: ["Hazardous waste van", "#F44336"], grip: ["GRIP", "#607D8B"], maas: ["Maas", "#607D8B"],
};
const WASTE_KEYS = Object.keys(WASTE_TYPES).sort((a, b) => b.length - a.length);
const isWasteSensor = (s) => !!s && s.entity_id.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in s.attributes;
const wasteKey = (eid) => WASTE_KEYS.find((k) => eid.endsWith(`_${k}`)) || eid.split("_").pop();
function wasteItems(c, ha) {
const ids = c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => isWasteSensor(ha.states[e]));
const today = new Date(); today.setHours(0, 0, 0, 0);
const out = [];
for (const eid of ids) {
const s = ha.states[eid];
if (!s) continue;
const raw = s.state;
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (isNaN(date)) continue; // "geen" or unavailable: no pickup planned
date.setHours(0, 0, 0, 0);
const days = s.attributes.days_until_collection_date ?? Math.round((date - today) / 86400e3);
if (days < 0) continue;
const key = wasteKey(eid), known = WASTE_TYPES[key];
out.push({ eid, key, days, date, name: known ? t(known[0]) : friendly(s).replace(/^afvalwijzer\s*/i, "") || cap(key), color: known?.[1] || "var(--d-text-2)" });
}
return out.sort((a, b) => a.days - b.days || a.name.localeCompare(b.name));
}
const whenLabel = (d) => (d === 0 ? t("Today") : d === 1 ? t("Tomorrow") : d === 2 ? t("Day after tomorrow") : t("In {0} days", d));
const shortDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
W.waste = {
label: "Waste collection", icon: "bin", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Waste collection" },
{ k: "layout", t: "seg", l: "Layout", o: ["next", "list"], d: "next" },
{ k: "entities", t: "entities", l: "Waste sensors (leave empty to find them automatically)", domains: ["sensor"] },
{ k: "max", t: "number", l: "Most pickups to show", d: 6 },
{ k: "within", t: "number", l: "Only pickups within (days)", d: 31 },
],
// With automatic discovery every Afvalwijzer sensor matters, so listen to all of them.
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("sensor.afvalwijzer_"))),
render(c, { ha }) {
const within = Number(c.within) || 31;
const items = wasteItems(c, ha).filter((i) => i.days <= within).slice(0, Math.max(1, Number(c.max) || 6));
if (!items.length) {
const any = c.entities?.length || Object.values(ha.states).some(isWasteSensor);
return `${title(c.title)}<div class="empty">${any ? t("No pickups in the next {0} days.", within) : t("No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.")}</div>`;
}
const dot = (i) => `<span class="bin" style="--bin:${i.color}" aria-hidden="true">${Icons.svg("bin")}</span>`;
let head = "", rest = items;
if (c.layout !== "list") {
const first = items.filter((i) => i.days === items[0].days);
rest = items.slice(first.length);
const soon = first[0].days <= 1;
head = `<div class="waste-next ${soon ? "soon" : ""}">
<div class="waste-bins">${first.map(dot).join("")}</div>
<div class="waste-next-text"><span class="wt">${t("Next pickup")}</span>
<span class="waste-when">${esc(whenLabel(first[0].days))}</span>
<span class="waste-types">${esc(first.map((i) => i.name).join(" · "))}</span>
${first[0].days === 1 ? `<span class="level">${t("Put the bin out tonight")}</span>` : ""}</div></div>`;
}
const rows = rest.map((i) => `<button class="list-row waste-row wbtn" data-act="more" data-e="${esc(i.eid)}">${dot(i)}
<span class="grow">${esc(i.name)}</span><span class="waste-day ${i.days <= 1 ? "soon" : ""}">${esc(whenLabel(i.days))}</span><span class="st">${esc(shortDate(i.date))}</span></button>`).join("");
return `${title(c.title)}${head}${rows ? `<div class="list waste-list">${rows}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- containers
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
// ctx.renderChildren fills them using the normal widget renderer.
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
W.hscroll = {
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".hs-track"), box = el.querySelector(".hs");
if (!track) return;
// Keep the scroll position when a card inside updates and everything is redrawn.
track.scrollLeft = ctx.cache.scroll || 0;
const edges = () => {
box.classList.toggle("at-start", track.scrollLeft < 4);
box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4);
};
track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true });
edges();
},
};
W.swipe = {
label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
{ k: "dots", t: "check", l: "Show page dots", d: true },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
const dots = c.dots !== false && kids.length > 1
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
return `<div class="sw ${dots ? "with-dots" : ""}"><div class="sw-track">${kids.map(slot).join("")}</div>${dots}</div>`;
},
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".sw-track");
if (!track) return;
const cache = ctx.cache, n = track.children.length;
const page = () => Math.round(track.scrollLeft / Math.max(1, track.clientWidth));
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page())));
track.scrollLeft = (Math.min(cache.page || 0, n - 1)) * track.clientWidth;
mark();
// Only settled positions count (scrollend), so an autoplay step in flight isn't overwritten.
track.addEventListener("scroll", mark, { passive: true });
const settle = () => { cache.page = page(); mark(); };
if ("onscrollend" in window) track.addEventListener("scrollend", settle);
else { let t0; track.addEventListener("scroll", () => { clearTimeout(t0); t0 = setTimeout(settle, 150); }, { passive: true }); }
// One timer per card that survives redraws (cards inside update often); it always
// works on the latest track, which each redraw records in the cache.
cache.track = track;
track.addEventListener("pointerdown", () => { cache.pausedUntil = Date.now() + 20000; }, { passive: true });
const every = Math.max(3, Number(c.autoplay) || 0) * 1000;
if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; }
if (cache.timer && cache.every === every) return;
clearInterval(cache.timer);
cache.every = every;
cache.timer = setInterval(() => {
const tr = cache.track;
if (!tr?.isConnected || Date.now() < (cache.pausedUntil || 0) || document.body.classList.contains("editing")) return;
const count = tr.children.length, next = ((cache.page || 0) + 1) % count;
cache.page = next; // a redraw mid-animation then lands on the new card, not the old one
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
tr.scrollTo({ left: next * tr.clientWidth, behavior: reduce ? "auto" : "smooth" });
}, every);
},
};
window.Widgets = W;
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
})();