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:
@@ -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