diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index b121c76..5a5ed84 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.4.1 + +- **Litter box, more compact:** the name gets the top line, the status sits beside **Clean** and the auto-clean switch, and today's figures are one short line (3 visits · 7 min · 4.6 kg), so nothing is cut off. One row high it all fits on a single line; tall cards keep the ring. On narrow cards the auto-clean switch makes way for **Clean**. + ## 2.4.0 - **Litter box:** the Appliance card has a new kind, **Litter box**, for self-cleaning litter boxes such as the ZEDAR K1200 on Tuya. It shows what the box is doing in words (Ready, Cleaning…, Emptying, Levelling, Sanitizing, Sleeping), today's visits, the time in the box today and your cat's weight, with a **Clean now** button and the auto-clean switch. A full waste bin or a fault shows in amber. Choose the box's *Status* sensor and the rest of the device fills in by itself. A litter box card never hides itself. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index d0a9f68..a4835d5 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -236,7 +236,7 @@ By default the card only shows while the appliance is running or has just finish #### Litter box -Set **Appliance** to **Litter box** for a self-cleaning litter box. Instead of a progress ring, the card says what the box is doing: *Ready*, *Cleaning…*, *Emptying*, *Levelling*, *Sanitizing* or *Sleeping*. Under that are today's visits, the time in the box today and your cat's weight. **Clean now** starts a cleaning cycle and the switch at the top turns auto clean on or off. The card never hides itself. +Set **Appliance** to **Litter box** for a self-cleaning litter box. The card is compact: it says what the box is doing (*Ready*, *Cleaning…*, *Emptying*, *Levelling*, *Sanitizing* or *Sleeping*) with **Clean** and the **Auto** clean switch beside it, and today's visits, the time in the box and your cat's weight in one line below. It fits in one row high, where it all goes on one line; tall cards add a ring on top. On narrow cards only **Clean** stays. The card never hides itself. **Tuya** (for example the ZEDAR K1200): choose the box's *Status* sensor and the card fills in the rest of the same device: *Excretion times (day)* as the visits, *Excretion duration* as the time in the box, *Cat weight*, the *Manual clean* button and the *Auto clean* switch. Sensors for a full waste bin or a fault become alerts, shown in amber. Not every box offers every sensor, and names can differ; you can always pick the entities by hand. Other brands work too, as long as there is a status sensor. diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index a4e3d44..34799e0 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.4.0" +version: "2.4.1" slug: dashboard_studio description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. url: https://git.melvanveen.nl/veenm/ha-dashboard-studio diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 20b8afd..de53836 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -302,7 +302,7 @@ "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", "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", "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", diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index bdf3c92..a1ae50d 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -1575,31 +1575,46 @@ body.in-haven #btn-new { display: none; } .appl-fact { font-size: var(--c-fs-meta); font-weight: 500; } .appl-big { font-size: clamp(22px, 17cqh, 34px); } -/* litter box: what it's doing in words, today's figures, clean now and auto clean */ -.lb .appl-text { gap: 4px; } -.lb .appl-main-text { flex-wrap: nowrap; overflow: hidden; } -.lb .appl-big { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.lb.phase-cleaning .appl-big { color: var(--d-accent); } -.lb.phase-attention .appl-big { color: var(--warn); } -.lb.phase-cleaning .appl-ico { color: var(--d-accent); } -.lb.phase-attention .appl-ico { color: var(--warn); } +/* litter box: compact; icon, name and controls on top, what it's doing, today's figures in one line */ +.lb { display: flex; align-items: stretch; gap: 16px; height: 100%; min-width: 0; } +.lb-ring { display: none; } .lb-ring > svg { transform: none; } .lb-spin { transform-origin: 50px 50px; animation: lb-spin 1.6s linear infinite; } @keyframes lb-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .lb-spin { animation: none; } } -.lb-figs { display: flex; gap: 18px; min-width: 0; } -.lb-fig { display: flex; flex-direction: column; min-width: 0; } -.lb-fig b { font-size: var(--c-fs-mid); font-weight: 400; line-height: 1.15; white-space: nowrap; } -.lb-s { display: none; } -.lb-fig span { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.lb-clean { flex: none; display: inline-flex; align-items: center; gap: 6px; height: var(--c-ctl); padding: 0 14px; border-radius: 99px; background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; } +.lb-body { flex: 1; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "ico name name" "state state acts" "figs figs figs" "warn warn warn"; + align-content: space-between; align-items: center; column-gap: 8px; row-gap: 2px; } +.lb-ico { grid-area: ico; display: flex; font-size: var(--c-ico); color: var(--d-text-2); } +.lb-name { grid-area: name; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lb-acts { grid-area: acts; display: flex; align-items: center; gap: 8px; } +.lb-state { grid-area: state; min-width: 0; font-size: clamp(20px, 16cqh, 32px); font-weight: 400; letter-spacing: -0.03em; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lb-figs { grid-area: figs; min-width: 0; display: flex; flex-wrap: wrap; column-gap: 12px; font-size: var(--c-fs-meta); color: var(--d-text-2); } +.lb-fig { white-space: nowrap; } +.lb-fig b { font-size: var(--c-fs-body); font-weight: 500; color: var(--d-text); } +.lb-warn { grid-area: warn; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-width: 0; } +.lb.phase-cleaning .lb-state, .lb.phase-cleaning .lb-ico, .lb.phase-cleaning .appl-ico { color: var(--d-accent); } +.lb.phase-attention .lb-state, .lb.phase-attention .lb-ico, .lb.phase-attention .appl-ico { color: var(--warn); } +.lb-clean { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: var(--c-ctl); min-width: var(--c-ctl); padding: 0 12px; border-radius: 99px; background: var(--d-fill); font-size: var(--c-fs-meta); font-weight: 600; } .lb-clean:disabled { opacity: .5; cursor: default; } @media (hover: hover) { .lb-clean:not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } } -.lb-auto { flex: none; display: inline-flex; align-items: center; gap: 8px; font-size: var(--c-fs-meta); color: var(--d-text-2); } -.lb-fig .unit { font-size: var(--c-fs-meta); } -@container (max-width: 240px) { .lb-fig:nth-child(3) { display: none; } } -@container (max-width: 400px) { .lb-figs { gap: 12px; } .lb-auto-l { display: none; } .lb-clean span { display: none; } .lb-clean { width: var(--c-ctl); padding: 0; justify-content: center; } } -@container (max-height: 180px) { .lb .appl-text { gap: 2px; } .lb-fig { flex-direction: row; align-items: baseline; gap: 5px; } .lb-fig b { font-size: var(--c-fs-body); } .lb-l { display: none; } .lb-s { display: inline; } } +.lb-auto { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: var(--c-fs-meta); color: var(--d-text-2); } +/* narrow: the controls as icons */ +@container (max-width: 320px) { .lb-clean { padding: 0; } .lb-clean span { display: none; } } +/* narrow: clean now stays, auto clean (a setting you rarely change) goes; the tiniest cards keep just the state */ +@container (max-width: 260px) { .lb-auto-l { display: none; } .lb-acts:has(.lb-clean) .lb-auto { display: none; } .lb-figs { column-gap: 8px; } } +@container (max-width: 150px) { .lb-acts { display: none; } .lb-ico { display: none; } .lb-body { column-gap: 0; } } +/* one row high: everything on one line */ +@container (max-height: 110px) { + .lb-body { grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto; grid-template-areas: "ico state figs acts"; align-content: center; column-gap: 12px; } + .lb-name, .lb-warn { display: none; } + .lb-state { font-size: var(--c-fs-mid); } + .lb-figs { line-height: 22px; height: 22px; overflow: hidden; } /* figures that don't fit wrap out of sight, whole */ +} +@container (max-height: 110px) and (max-width: 360px) { + .lb-figs { display: none; } + .lb-body { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "ico state acts"; } +} +@container (max-height: 110px) and (max-width: 280px) { .lb-acts:has(.lb-clean) .lb-auto { display: none; } } /* camera and photos: square label plates (above) */ @@ -2032,4 +2047,12 @@ button.fader-input { cursor: pointer; } .lt-colors { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; } .lt-colors .swatches { flex: 1; min-width: 0; margin: 0; justify-content: flex-start; } .lt-colors .swatch { width: 28px; height: 28px; } -@container (min-height: 260px) { .lb-figs { justify-content: center; } .lb-fig { align-items: center; } .lb .appl-main { flex-direction: column; } } +/* tall: a ring on top, the rest centred below */ +@container (min-height: 260px) { + .lb { flex-direction: column; align-items: center; justify-content: center; text-align: center; } + .lb-body { flex: none; width: 100%; grid-template-columns: minmax(0, 1fr); grid-template-areas: "name" "state" "figs" "acts" "warn"; justify-items: center; row-gap: 4px; } + .lb-ico { display: none; } + .lb-figs { justify-content: center; } + .lb-acts { margin-top: 6px; } +} +@container (min-height: 260px) and (min-width: 180px) { .lb-ring { display: grid; height: min(50%, 170px); } } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 046a5ac..d5e4181 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -1072,39 +1072,44 @@ return { phase: "ready", word: /^(standby|idle|ready|on|off|none|)$/.test(s) ? t("Ready") : st(s), alerts }; } + // Compact: icon, name and controls on top, what it's doing below, today's figures as one short line. + // One row high it all goes on one line; tall cards put a ring on top (see the container queries in style.css). function litterBox(c, ha) { if (!c.status) return `
${t("Choose the litter box’s status entity in the inspector.")}
`; const { phase, word, alerts } = litterPhase(c, ha); - // figures: visits and time today, and the cat's weight; the unit in grey - const fig = (eid, lbl, fmt, short = "") => { + const val = (eid) => { const s = ha.states[eid]; - if (!s || ["unknown", "unavailable"].includes(s.state)) return ""; - const n = Number(s.state), unit = s.attributes.unit_of_measurement || ""; - const v = fmt ? fmt(n, unit) : `${isFinite(n) ? n.toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(s.state)}${unit ? ` ${esc(unit)}` : ""}`; - return `
${v}${esc(lbl)}${short ? `${esc(short)}` : ""}
`; + return s && !["unknown", "unavailable"].includes(s.state) ? s : null; }; - // time today: Tuya reports it in seconds or minutes, shown as minutes - const mins = (n, unit) => { - if (!isFinite(n)) return "–"; - const m = Math.round(/^s/.test(unit) ? n / 60 : /^h/.test(unit) ? n * 60 : n); - return `${m} min`; - }; - const figs = [fig(c.visits, t("visits today"), (n) => (isFinite(n) ? String(Math.round(n)) : "–"), t("visits")), fig(c.timeToday, t("in the box today"), mins), fig(c.catWeight, t("cat’s weight"))].join(""); + const fig = (v, unit, title) => `${v} ${esc(unit)}`; + const visits = val(c.visits), time = val(c.timeToday), weight = val(c.catWeight); + const figs = [ + visits && isFinite(Number(visits.state)) && fig(Math.round(Number(visits.state)), Number(visits.state) === 1 ? t("visit") : t("visits"), t("Visits today")), + // time today: Tuya reports it in seconds or minutes, shown as minutes + time && isFinite(Number(time.state)) && (() => { + const n = Number(time.state), unit = String(time.attributes.unit_of_measurement || "min").toLowerCase(); + return fig(Math.round(unit.startsWith("s") ? n / 60 : unit.startsWith("h") ? n * 60 : n), t("min"), t("Time in the box today")); + })(), + weight && fig(isFinite(Number(weight.state)) ? Number(weight.state).toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(weight.state), weight.attributes.unit_of_measurement || "", t("Cat’s weight")), + ].filter(Boolean).join(""); const device = friendly(ha.states[c.status]).replace(/\s*(status|state)$/i, ""); const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt); const warn = alerts.map((e) => `${Icons.svg("shield")}${esc(cap(strip(friendly(ha.states[e]))))}`).join(""); const clean = c.cleanBtn && ha.states[c.cleanBtn] - ? `` : ""; + ? `` : ""; const auto = ha.states[c.autoClean]; - const autoSw = auto ? `${t("Auto clean")}` : ""; - return `
+ const autoSw = auto ? `${t("Auto")}` : ""; + const ico = phase === "attention" ? "shield" : "paw"; + return `
-
-
${esc(c.name || t("Litter box"))}${autoSw}
-
${esc(word)}
${clean}
+ ${Icons.svg(ico)}
+
+ + ${esc(c.name || t("Litter box"))} + ${clean || autoSw ? `
${autoSw}${clean}
` : ""} + ${esc(word)} ${figs ? `
${figs}
` : ""} - ${warn ? `
${warn}
` : ""} + ${warn ? `
${warn}
` : ""}
`; }