184 files

This commit is contained in:
Home Assistant Version Control
2026-09-02 11:49:09 +00:00
parent 7b47edbf8d
commit 0c64e47969
184 changed files with 9608 additions and 11572 deletions
@@ -227,6 +227,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _triggerToState = "";
@state() private _triggerTargetChanges = "";
@state() private _triggerRuntimeHours = "";
@state() private _triggerRuntimeMaxSession = "";
// Comma-separated "running" states for the runtime trigger (#103) —
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
// tasks ship e.g. ["mowing"], and dropping it on save silently stops
@@ -245,6 +246,7 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _lastPerformed = "";
@state() private _nfcTagId = "";
@state() private _requireTagScan = false;
@state() private _allowSkip = true;
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
@@ -400,6 +402,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._requireTagScan = !!task.require_tag_scan;
this._allowSkip = task.allow_skip !== false;
this._readingUnit = task.reading_unit || "";
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
@@ -500,6 +503,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerToState = tc.trigger_to_state || "";
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
this._triggerRuntimeMaxSession = tc.trigger_runtime_max_session_seconds?.toString() || "";
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
if (tc.type === "compound") {
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
@@ -551,6 +555,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = "";
this._nfcTagId = "";
this._requireTagScan = false;
this._allowSkip = true;
this._readingUnit = "";
this._consumesParts = {};
this._responsibleUserId = null;
@@ -610,6 +615,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._triggerToState = "";
this._triggerTargetChanges = "";
this._triggerRuntimeHours = "";
this._triggerRuntimeMaxSession = "";
this._triggerOnStates = "";
this._compoundLogic = "AND";
this._compoundConditions = [];
@@ -1253,6 +1259,7 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.require_tag_scan = this._requireTagScan;
data.allow_skip = this._allowSkip;
data.reading_unit = this._readingUnit.trim() || null;
// Task phases (#139): always sent — null clears a removed cycle.
{
@@ -1366,6 +1373,8 @@ export class MaintenanceTaskDialog extends LitElement {
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
} else if (this._triggerType === "runtime") {
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
// #149: per-session cap (seconds) against sensors stuck ON.
if (this._triggerRuntimeMaxSession) { const v = parseInt(this._triggerRuntimeMaxSession, 10); if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v; }
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
}
@@ -2480,6 +2489,14 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._triggerRuntimeHours}
@input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
label="${t("runtime_max_session", L)}"
type="number"
step="1"
.value=${this._triggerRuntimeMaxSession}
@input=${(e: Event) => (this._triggerRuntimeMaxSession = (e.target as HTMLInputElement).value)}
></ms-textfield>
<div class="field-help">${t("runtime_max_session_help", L)}</div>
${this._renderOnStatesField({
value: this._triggerOnStates,
entityId: this._triggerEntityId,
@@ -2831,6 +2848,15 @@ export class MaintenanceTaskDialog extends LitElement {
<span>${t("require_tag_scan", L)}</span>
</label>
${this._requireTagScan ? html`<div class="field-help">${t("require_tag_scan_help", L)}</div>` : nothing}
<label class="req-option">
<input
type="checkbox"
.checked=${!this._allowSkip}
@change=${(e: Event) => (this._allowSkip = !(e.target as HTMLInputElement).checked)}
/>
<span>${t("disallow_skip", L)}</span>
</label>
${!this._allowSkip ? html`<div class="field-help">${t("disallow_skip_help", L)}</div>` : nothing}
<label class="toggle-row">
<input
type="checkbox"
@@ -600,11 +600,15 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
<ha-icon slot="start" icon="mdi:check"></ha-icon>
${t("complete", L) || "Complete"}
</ha-button>
<ha-button appearance="plain" variant="neutral" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
</ha-button>
<ha-button appearance="plain" variant="neutral" @click=${() => { this._showReset = true; }} .disabled=${this._busy}>
${task.allow_skip !== false
? html`
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
</ha-button>
`
: nothing}
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showReset = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:restart"></ha-icon>
${t("reset", L) || "Reset"}
</ha-button>
@@ -528,6 +528,7 @@
"groupby_area": "Podle oblasti",
"groupby_group": "Podle skupiny",
"groupby_user": "Podle uživatele",
"groupby_object": "Podle objektu",
"filter_label": "Filtr",
"user_label": "Uživatel",
"photo_label": "Fotografie",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Aktuálně platná počáteční hodnota: {value}",
"runtime_on_states": "Aktivní stavy",
"runtime_on_states_help": "Stavy počítané jako doba běhu — výchozí: on. Např. mowing, cleaning, printing. Při zvoleném atributu se porovnávají jeho hodnoty.",
"runtime_max_session": "Max. doba běhu relace (sekundy)",
"runtime_max_session_help": "Jeden běh se počítá nejvýše na tolik sekund — chrání před senzorem zaseknutým na ZAP (ztráta spojení, restart). Prázdné = bez limitu.",
"trend_approaching": "Blíží se k prahu",
"trend_easing": "Vzdaluje se od prahu",
"split_select_hint": "Vyberte úkol a zde se zobrazí podrobnosti",
"setups_target_new": "Vytvořit nový: {name}",
"schedule_preview_title": "Nejbližší termíny",
"schedule_preview_ontime": "Za předpokladu včasného dokončení.",
@@ -927,5 +933,7 @@
"history_add_past": "Přidat dřívější dokončení",
"require_tag_scan": "Dokončit pouze naskenováním štítku",
"require_tag_scan_help": "Důkaz přítomnosti: „Hotovo“ je všude blokováno, dokud nenaskenujete NFC štítek nebo QR kód na zařízení. Automatizace mohou službě complete předat 'via_tag_scan'.",
"disallow_skip": "Nepovolit přeskočení",
"disallow_skip_help": "Akce Přeskočit zmizí ze všech ploch a server odmítne přeskočení i z automatizací a hlasu.",
"require_tag_scan_hint": "Tento úkol lze dokončit pouze naskenováním jeho NFC štítku nebo QR kódu na zařízení — uložení zde bude odmítnuto."
}
@@ -530,6 +530,7 @@
"groupby_area": "Efter område",
"groupby_group": "Efter gruppe",
"groupby_user": "Efter bruger",
"groupby_object": "Efter objekt",
"filter_label": "Filter",
"user_label": "Bruger",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Aktuelt gældende startværdi: {value}",
"runtime_on_states": "Aktive tilstande",
"runtime_on_states_help": "Tilstande der tæller som driftstid — standard: on. F.eks. mowing, cleaning, printing. Med en valgt attribut sammenlignes dens værdier i stedet.",
"runtime_max_session": "Maks. sessionstid (sekunder)",
"runtime_max_session_help": "En enkelt kørsel tæller højst så mange sekunder — beskytter mod en sensor, der hænger på TÆNDT (tabt forbindelse, genstart). Tom = ingen grænse.",
"trend_approaching": "Nærmer sig tærsklen",
"trend_easing": "Bevæger sig væk fra tærsklen",
"split_select_hint": "Vælg en opgave for at se detaljerne her",
"setups_target_new": "Opret ny: {name}",
"schedule_preview_title": "Kommende datoer",
"schedule_preview_ontime": "Forudsat rettidig fuldførelse.",
@@ -927,5 +933,7 @@
"history_add_past": "Tilføj tidligere udførelse",
"require_tag_scan": "Kan kun udføres ved tag-scanning",
"require_tag_scan_help": "Tilstedeværelsesbevis: „Udført“ er blokeret overalt, indtil NFC-tagget eller QR-koden på selve tingen er scannet. Automatiseringer kan give 'via_tag_scan' til complete-tjenesten.",
"disallow_skip": "Tillad ikke at springe over",
"disallow_skip_help": "Handlingen Spring over forsvinder fra alle flader, og serveren afviser også spring fra automatiseringer og stemme.",
"require_tag_scan_hint": "Denne opgave udføres kun ved at scanne dens NFC-tag eller QR-kode på selve tingen — at gemme her vil blive afvist."
}
@@ -530,6 +530,7 @@
"groupby_area": "Nach Bereich",
"groupby_group": "Nach Gruppe",
"groupby_user": "Nach Verantwortlichem",
"groupby_object": "Nach Objekt",
"filter_label": "Filter",
"user_label": "Benutzer",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Aktuell wirksamer Startwert: {value}",
"runtime_on_states": "Aktive Zustände",
"runtime_on_states_help": "Zustände, die als Laufzeit zählen — Standard: on. Z. B. mowing, cleaning, printing. Bei gewähltem Attribut werden dessen Werte verglichen.",
"runtime_max_session": "Maximale Sitzungslaufzeit (Sekunden)",
"runtime_max_session_help": "Ein einzelner Lauf zählt höchstens so viele Sekunden — schützt vor Sensoren, die auf AN hängen bleiben (Verbindungsabriss, Neustart). Leer = keine Begrenzung.",
"trend_approaching": "Läuft auf die Schwelle zu",
"trend_easing": "Entfernt sich von der Schwelle",
"split_select_hint": "Wähle eine Aufgabe, um hier die Details zu sehen",
"setups_target_new": "Neu anlegen: {name}",
"schedule_preview_title": "Nächste Termine",
"schedule_preview_ontime": "Bei pünktlichem Abschluss.",
@@ -927,5 +933,7 @@
"history_add_past": "Vergangene Erledigung nachtragen",
"require_tag_scan": "Nur per Tag-Scan abschließbar",
"require_tag_scan_help": "Anwesenheitsnachweis: „Erledigt“ ist überall blockiert, bis das NFC-Tag oder der QR-Code am Gerät gescannt wurde. Automationen können 'via_tag_scan' an den complete-Dienst übergeben.",
"disallow_skip": "Überspringen nicht erlauben",
"disallow_skip_help": "Die Aktion Überspringen verschwindet von allen Oberflächen; auch Automationen und Sprachbefehle werden abgewiesen.",
"require_tag_scan_hint": "Diese Aufgabe kann nur per Scan des NFC-Tags oder QR-Codes am Gerät erledigt werden — Speichern hier wird abgelehnt."
}
@@ -536,6 +536,7 @@
"groupby_area": "By area",
"groupby_group": "By group",
"groupby_user": "By user",
"groupby_object": "By object",
"filter_label": "Filter",
"user_label": "User",
"photo_label": "Photo",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Currently effective start value: {value}",
"runtime_on_states": "Active states",
"runtime_on_states_help": "States that count as running — default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",
"runtime_max_session": "Max session runtime (seconds)",
"runtime_max_session_help": "A single run counts at most this many seconds — protects against a sensor stuck ON (lost connection, restart). Empty = no cap.",
"trend_approaching": "Heading toward the threshold",
"trend_easing": "Easing away from the threshold",
"split_select_hint": "Select a task to see its details here",
"setups_target_new": "Create new: {name}",
"schedule_preview_title": "Next dates",
"schedule_preview_ontime": "Assuming on-time completion.",
@@ -927,5 +933,7 @@
"history_add_past": "Add past completion",
"require_tag_scan": "Only complete by scanning the tag",
"require_tag_scan_help": "Proof of presence: Done is blocked on every surface until the NFC tag or QR code on the thing itself is scanned. Automations can pass 'via_tag_scan' to the complete service.",
"disallow_skip": "Don't allow skipping",
"disallow_skip_help": "The Skip action disappears from every surface, and the server refuses a skip from automations and voice too.",
"require_tag_scan_hint": "This task completes only by scanning its NFC tag or QR code on the thing itself — saving here will be refused."
}
@@ -528,6 +528,7 @@
"groupby_area": "Por área",
"groupby_group": "Por grupo",
"groupby_user": "Por usuario",
"groupby_object": "Por objeto",
"filter_label": "Filtro",
"user_label": "Usuario",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Valor inicial vigente: {value}",
"runtime_on_states": "Estados activos",
"runtime_on_states_help": "Estados que cuentan como en funcionamiento — predeterminado: on. P. ej. mowing, cleaning, printing. Con un atributo seleccionado se comparan sus valores.",
"runtime_max_session": "Duración máxima por sesión (segundos)",
"runtime_max_session_help": "Una sola ejecución cuenta como máximo estos segundos — protege contra un sensor atascado en ON (conexión perdida, reinicio). Vacío = sin límite.",
"trend_approaching": "Se acerca al umbral",
"trend_easing": "Se aleja del umbral",
"split_select_hint": "Selecciona una tarea para ver sus detalles aquí",
"setups_target_new": "Crear nuevo: {name}",
"schedule_preview_title": "Próximas fechas",
"schedule_preview_ontime": "Suponiendo finalización puntual.",
@@ -927,5 +933,7 @@
"history_add_past": "Añadir una finalización pasada",
"require_tag_scan": "Completar solo escaneando la etiqueta",
"require_tag_scan_help": "Prueba de presencia: «Hecho» queda bloqueado en todas partes hasta escanear la etiqueta NFC o el código QR del propio objeto. Las automatizaciones pueden pasar 'via_tag_scan' al servicio complete.",
"disallow_skip": "No permitir omitir",
"disallow_skip_help": "La acción Omitir desaparece de todas las superficies y el servidor también rechaza omisiones desde automatizaciones y voz.",
"require_tag_scan_hint": "Esta tarea solo se completa escaneando su etiqueta NFC o código QR en el propio objeto; guardar aquí será rechazado."
}
@@ -530,6 +530,7 @@
"groupby_area": "Alueen mukaan",
"groupby_group": "Ryhmän mukaan",
"groupby_user": "Käyttäjän mukaan",
"groupby_object": "Kohteen mukaan",
"filter_label": "Suodata",
"user_label": "Käyttäjä",
"photo_label": "Valokuva",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Nyt voimassa oleva aloitusarvo: {value}",
"runtime_on_states": "Aktiiviset tilat",
"runtime_on_states_help": "Tilat, jotka lasketaan käyntiajaksi — oletus: on. Esim. mowing, cleaning, printing. Jos attribuutti on valittu, verrataan sen arvoja.",
"runtime_max_session": "Istunnon enimmäiskesto (sekuntia)",
"runtime_max_session_help": "Yksi ajo lasketaan enintään näin moneksi sekunniksi — suojaa PÄÄLLÄ-tilaan jumittuneelta anturilta (katkennut yhteys, uudelleenkäynnistys). Tyhjä = ei rajaa.",
"trend_approaching": "Lähestyy raja-arvoa",
"trend_easing": "Loittonee raja-arvosta",
"split_select_hint": "Valitse tehtävä nähdäksesi sen tiedot tässä",
"setups_target_new": "Luo uusi: {name}",
"schedule_preview_title": "Seuraavat päivämäärät",
"schedule_preview_ontime": "Olettaen, että valmistuminen on ajallaan.",
@@ -927,5 +933,7 @@
"history_add_past": "Lisää aiempi suoritus",
"require_tag_scan": "Suoritus vain tunnisteen skannauksella",
"require_tag_scan_help": "Läsnäolotodistus: ”Valmis” on estetty kaikkialla, kunnes laitteen NFC-tunniste tai QR-koodi on skannattu. Automaatiot voivat välittää 'via_tag_scan' complete-palvelulle.",
"disallow_skip": "Estä ohittaminen",
"disallow_skip_help": "Ohita-toiminto katoaa kaikista näkymistä, ja palvelin hylkää ohituksen myös automaatioista ja puheesta.",
"require_tag_scan_hint": "Tämä tehtävä valmistuu vain skannaamalla sen NFC-tunniste tai QR-koodi itse laitteesta — tallennus tässä estetään."
}
@@ -528,6 +528,7 @@
"groupby_area": "Par zone",
"groupby_group": "Par groupe",
"groupby_user": "Par utilisateur",
"groupby_object": "Par objet",
"filter_label": "Filtre",
"user_label": "Utilisateur",
"photo_label": "Photo",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Valeur de départ actuellement en vigueur : {value}",
"runtime_on_states": "États actifs",
"runtime_on_states_help": "États comptés comme en fonctionnement — par défaut : on. P. ex. mowing, cleaning, printing. Si un attribut est sélectionné, ce sont ses valeurs qui sont comparées.",
"runtime_max_session": "Durée max. par session (secondes)",
"runtime_max_session_help": "Une seule exécution compte au plus ce nombre de secondes — protège contre un capteur bloqué sur ON (connexion perdue, redémarrage). Vide = pas de limite.",
"trend_approaching": "Se rapproche du seuil",
"trend_easing": "S'éloigne du seuil",
"split_select_hint": "Sélectionnez une tâche pour voir ses détails ici",
"setups_target_new": "Créer : {name}",
"schedule_preview_title": "Prochaines échéances",
"schedule_preview_ontime": "En supposant un achèvement ponctuel.",
@@ -927,5 +933,7 @@
"history_add_past": "Ajouter une réalisation passée",
"require_tag_scan": "Terminer uniquement en scannant le tag",
"require_tag_scan_help": "Preuve de présence : « Terminé » est bloqué partout tant que le tag NFC ou le QR code sur l'objet n'a pas été scanné. Les automatisations peuvent passer 'via_tag_scan' au service complete.",
"disallow_skip": "Ne pas autoriser à ignorer",
"disallow_skip_help": "L'action Ignorer disparaît de toutes les surfaces et le serveur refuse aussi un saut via les automatisations et la voix.",
"require_tag_scan_hint": "Cette tâche ne se termine qu'en scannant son tag NFC ou son QR code sur l'objet — l'enregistrement ici sera refusé."
}
@@ -530,6 +530,7 @@
"groupby_area": "क्षेत्र के अनुसार",
"groupby_group": "समूह के अनुसार",
"groupby_user": "उपयोगकर्ता के अनुसार",
"groupby_object": "वस्तु के अनुसार",
"filter_label": "फ़िल्टर",
"user_label": "उपयोगकर्ता",
"photo_label": "फ़ोटो",
@@ -834,6 +835,11 @@
"baseline_current_effective": "वर्तमान में प्रभावी प्रारंभिक मान: {value}",
"runtime_on_states": "सक्रिय अवस्थाएँ",
"runtime_on_states_help": "वे अवस्थाएँ जो चालू समय में गिनी जाती हैं — डिफ़ॉल्ट: on। जैसे mowing, cleaning, printing। विशेषता चुनी होने पर उसकी मानों की तुलना होती है।",
"runtime_max_session": "प्रति सत्र अधिकतम रनटाइम (सेकंड)",
"runtime_max_session_help": "एक रन अधिकतम इतने सेकंड गिना जाता है — ON पर अटके सेंसर (कनेक्शन टूटना, रीस्टार्ट) से बचाता है। खाली = कोई सीमा नहीं।",
"trend_approaching": "सीमा की ओर बढ़ रहा है",
"trend_easing": "सीमा से दूर जा रहा है",
"split_select_hint": "विवरण यहाँ देखने के लिए कोई कार्य चुनें",
"setups_target_new": "नया बनाएँ: {name}",
"schedule_preview_title": "अगली तिथियाँ",
"schedule_preview_ontime": "समय पर पूर्णता मानते हुए।",
@@ -927,5 +933,7 @@
"history_add_past": "पिछली पूर्णता जोड़ें",
"require_tag_scan": "केवल टैग स्कैन से पूर्ण करें",
"require_tag_scan_help": "उपस्थिति प्रमाण: जब तक वस्तु पर लगा NFC टैग या QR कोड स्कैन न हो, हर जगह “पूर्ण” अवरुद्ध रहता है। ऑटोमेशन complete सेवा को 'via_tag_scan' दे सकते हैं।",
"disallow_skip": "छोड़ने की अनुमति न दें",
"disallow_skip_help": "छोड़ें क्रिया सभी सतहों से हट जाती है, और सर्वर ऑटोमेशन व वॉयस से छोड़ने को भी अस्वीकार करता है।",
"require_tag_scan_hint": "यह कार्य केवल वस्तु पर लगे NFC टैग या QR कोड को स्कैन करके ही पूर्ण होता है — यहाँ सहेजना अस्वीकार होगा।"
}
@@ -536,6 +536,7 @@
"groupby_area": "Terület szerint",
"groupby_group": "Csoport szerint",
"groupby_user": "Felhasználó szerint",
"groupby_object": "Objektum szerint",
"filter_label": "Szűrő",
"user_label": "Felhasználó",
"photo_label": "Fénykép",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Jelenleg érvényes kezdőérték: {value}",
"runtime_on_states": "Aktív állapotok",
"runtime_on_states_help": "Működésként számító állapotok — alapértelmezés: on. Pl. mowing, cleaning, printing. Attribútum kiválasztásakor annak értékei számítanak.",
"runtime_max_session": "Max. munkamenet-futásidő (másodperc)",
"runtime_max_session_help": "Egy futás legfeljebb ennyi másodpercet számít — véd a BE állapotban beragadt érzékelő ellen (kapcsolatvesztés, újraindítás). Üres = nincs korlát.",
"trend_approaching": "Közeledik a küszöbhöz",
"trend_easing": "Távolodik a küszöbtől",
"split_select_hint": "Válassz egy feladatot, és itt jelennek meg a részletei",
"setups_target_new": "Új létrehozása: {name}",
"schedule_preview_title": "Következő dátumok",
"schedule_preview_ontime": "Időben történő elvégzést feltételezve.",
@@ -927,5 +933,7 @@
"history_add_past": "Korábbi elvégzés rögzítése",
"require_tag_scan": "Csak címke beolvasásával fejezhető be",
"require_tag_scan_help": "Jelenlét-igazolás: a „Kész” mindenhol blokkolva van, amíg be nem olvasod az eszközön lévő NFC-címkét vagy QR-kódot. Az automatizálások 'via_tag_scan' értéket adhatnak át a complete szolgáltatásnak.",
"disallow_skip": "Kihagyás tiltása",
"disallow_skip_help": "A Kihagyás művelet minden felületről eltűnik, és a szerver az automatizálásokból és hangból érkező kihagyást is elutasítja.",
"require_tag_scan_hint": "Ez a feladat csak az eszközön lévő NFC-címke vagy QR-kód beolvasásával fejezhető be — az itteni mentés el lesz utasítva."
}
@@ -528,6 +528,7 @@
"groupby_area": "Per area",
"groupby_group": "Per gruppo",
"groupby_user": "Per utente",
"groupby_object": "Per oggetto",
"filter_label": "Filtro",
"user_label": "Utente",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Valore iniziale attualmente in vigore: {value}",
"runtime_on_states": "Stati attivi",
"runtime_on_states_help": "Stati che contano come in funzione — predefinito: on. Ad es. mowing, cleaning, printing. Con un attributo selezionato vengono confrontati i suoi valori.",
"runtime_max_session": "Durata massima per sessione (secondi)",
"runtime_max_session_help": "Una singola esecuzione conta al massimo questi secondi — protegge da un sensore bloccato su ON (connessione persa, riavvio). Vuoto = nessun limite.",
"trend_approaching": "Si avvicina alla soglia",
"trend_easing": "Si allontana dalla soglia",
"split_select_hint": "Seleziona un'attività per vederne i dettagli qui",
"setups_target_new": "Crea nuovo: {name}",
"schedule_preview_title": "Prossime date",
"schedule_preview_ontime": "Presumendo un completamento puntuale.",
@@ -927,5 +933,7 @@
"history_add_past": "Aggiungi un completamento passato",
"require_tag_scan": "Completa solo scansionando il tag",
"require_tag_scan_help": "Prova di presenza: «Fatto» è bloccato ovunque finché non viene scansionato il tag NFC o il codice QR sull'oggetto. Le automazioni possono passare 'via_tag_scan' al servizio complete.",
"disallow_skip": "Non consentire di saltare",
"disallow_skip_help": "L'azione Salta scompare da tutte le superfici e il server rifiuta anche i salti da automazioni e voce.",
"require_tag_scan_hint": "Questa attività si completa solo scansionando il suo tag NFC o codice QR sull'oggetto — il salvataggio qui verrà rifiutato."
}
@@ -530,6 +530,7 @@
"groupby_area": "エリア別",
"groupby_group": "グループ別",
"groupby_user": "ユーザー別",
"groupby_object": "対象別",
"filter_label": "絞り込み",
"user_label": "ユーザー",
"photo_label": "写真",
@@ -834,6 +835,11 @@
"baseline_current_effective": "現在有効な開始値:{value}",
"runtime_on_states": "稼働中とみなす状態",
"runtime_on_states_help": "稼働時間として数える状態 — 既定値:on。例:mowing, cleaning, printing。属性を選択している場合はその値が比較されます。",
"runtime_max_session": "1回の最大稼働時間(秒)",
"runtime_max_session_help": "1回の稼働は最大この秒数までカウント——ONのまま固まったセンサー(接続切れ・再起動)への保護です。空欄=制限なし。",
"trend_approaching": "しきい値に近づいています",
"trend_easing": "しきい値から遠ざかっています",
"split_select_hint": "タスクを選択すると、ここに詳細が表示されます",
"setups_target_new": "新規作成:{name}",
"schedule_preview_title": "次回の予定日",
"schedule_preview_ontime": "期日どおりに完了した場合。",
@@ -927,5 +933,7 @@
"history_add_past": "過去の完了を追加",
"require_tag_scan": "タグのスキャンでのみ完了可能",
"require_tag_scan_help": "在席証明:対象物のNFCタグまたはQRコードをスキャンするまで、どの画面でも「完了」はブロックされます。オートメーションはcompleteサービスに 'via_tag_scan' を渡せます。",
"disallow_skip": "スキップを許可しない",
"disallow_skip_help": "スキップ操作はすべての画面から消え、オートメーションや音声からのスキップもサーバーが拒否します。",
"require_tag_scan_hint": "このタスクは対象物のNFCタグまたはQRコードをスキャンしないと完了できません。ここでの保存は拒否されます。"
}
@@ -536,6 +536,7 @@
"groupby_area": "구역별",
"groupby_group": "그룹별",
"groupby_user": "사용자별",
"groupby_object": "대상별",
"filter_label": "필터",
"user_label": "사용자",
"photo_label": "사진",
@@ -834,6 +835,11 @@
"baseline_current_effective": "현재 적용 중인 시작 값: {value}",
"runtime_on_states": "활성 상태",
"runtime_on_states_help": "가동 중으로 계산할 상태입니다 — 기본값: on. 예: mowing, cleaning, printing. 속성을 선택한 경우 해당 속성 값과 비교합니다.",
"runtime_max_session": "세션당 최대 가동 시간(초)",
"runtime_max_session_help": "한 번의 가동은 최대 이 초까지만 집계됩니다 — ON에 멈춘 센서(연결 끊김, 재시작)로부터 보호합니다. 비워두면 제한 없음.",
"trend_approaching": "임계값에 가까워지는 중",
"trend_easing": "임계값에서 멀어지는 중",
"split_select_hint": "작업을 선택하면 여기에 세부 정보가 표시됩니다",
"setups_target_new": "새로 만들기: {name}",
"schedule_preview_title": "다음 예정일",
"schedule_preview_ontime": "제때 완료한다고 가정한 일정입니다.",
@@ -927,5 +933,7 @@
"history_add_past": "지난 완료 추가",
"require_tag_scan": "태그 스캔으로만 완료 가능",
"require_tag_scan_help": "현장 확인: 기기의 NFC 태그나 QR 코드를 스캔할 때까지 모든 화면에서 '완료'가 차단됩니다. 자동화는 complete 서비스에 'via_tag_scan'을 전달할 수 있습니다.",
"disallow_skip": "건너뛰기 허용 안 함",
"disallow_skip_help": "건너뛰기 동작이 모든 화면에서 사라지며, 서버는 자동화와 음성의 건너뛰기도 거부합니다.",
"require_tag_scan_hint": "이 작업은 기기의 NFC 태그나 QR 코드를 스캔해야만 완료됩니다. 여기서 저장하면 거부됩니다."
}
@@ -530,6 +530,7 @@
"groupby_area": "Etter område",
"groupby_group": "Etter gruppe",
"groupby_user": "Etter bruker",
"groupby_object": "Etter objekt",
"filter_label": "Filter",
"user_label": "Bruker",
"photo_label": "Bilde",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Gjeldende startverdi: {value}",
"runtime_on_states": "Aktive tilstander",
"runtime_on_states_help": "Tilstander som teller som driftstid — standard: on. F.eks. mowing, cleaning, printing. Med et valgt attributt sammenlignes verdiene dets i stedet.",
"runtime_max_session": "Maks økt-varighet (sekunder)",
"runtime_max_session_help": "En enkelt kjøring teller høyst så mange sekunder — beskytter mot en sensor som henger på PÅ (mistet forbindelse, omstart). Tom = ingen grense.",
"trend_approaching": "Nærmer seg terskelen",
"trend_easing": "Beveger seg bort fra terskelen",
"split_select_hint": "Velg en oppgave for å se detaljene her",
"setups_target_new": "Opprett ny: {name}",
"schedule_preview_title": "Kommende datoer",
"schedule_preview_ontime": "Forutsatt fullføring i tide.",
@@ -927,5 +933,7 @@
"history_add_past": "Legg til tidligere fullføring",
"require_tag_scan": "Kan bare fullføres ved tag-skanning",
"require_tag_scan_help": "Tilstedeværelsesbevis: «Fullført» er blokkert overalt til NFC-taggen eller QR-koden på selve tingen er skannet. Automatiseringer kan sende 'via_tag_scan' til complete-tjenesten.",
"disallow_skip": "Ikke tillat å hoppe over",
"disallow_skip_help": "Handlingen Hopp over forsvinner fra alle flater, og serveren avviser også hopp fra automatiseringer og tale.",
"require_tag_scan_hint": "Denne oppgaven fullføres bare ved å skanne NFC-taggen eller QR-koden på selve tingen — lagring her vil bli avvist."
}
@@ -528,6 +528,7 @@
"groupby_area": "Per gebied",
"groupby_group": "Per groep",
"groupby_user": "Per gebruiker",
"groupby_object": "Per object",
"filter_label": "Filter",
"user_label": "Gebruiker",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Momenteel geldende startwaarde: {value}",
"runtime_on_states": "Actieve statussen",
"runtime_on_states_help": "Statussen die als draaitijd tellen — standaard: on. Bijv. mowing, cleaning, printing. Met een geselecteerd attribuut worden de waarden daarvan vergeleken.",
"runtime_max_session": "Maximale sessieduur (seconden)",
"runtime_max_session_help": "Eén run telt maximaal dit aantal seconden — beschermt tegen sensoren die op AAN blijven hangen (verbroken verbinding, herstart). Leeg = geen limiet.",
"trend_approaching": "Beweegt richting de drempel",
"trend_easing": "Beweegt weg van de drempel",
"split_select_hint": "Selecteer een taak om hier de details te zien",
"setups_target_new": "Nieuw aanmaken: {name}",
"schedule_preview_title": "Volgende data",
"schedule_preview_ontime": "Uitgaande van tijdige afronding.",
@@ -927,5 +933,7 @@
"history_add_past": "Eerdere afronding toevoegen",
"require_tag_scan": "Alleen af te ronden via tag-scan",
"require_tag_scan_help": "Aanwezigheidsbewijs: „Klaar” is overal geblokkeerd totdat de NFC-tag of QR-code op het apparaat zelf is gescand. Automatiseringen kunnen 'via_tag_scan' aan de complete-service meegeven.",
"disallow_skip": "Overslaan niet toestaan",
"disallow_skip_help": "De actie Overslaan verdwijnt overal, en de server weigert ook een skip via automatiseringen en spraak.",
"require_tag_scan_hint": "Deze taak wordt alleen afgerond door de NFC-tag of QR-code op het apparaat te scannen — opslaan hier wordt geweigerd."
}
@@ -528,6 +528,7 @@
"groupby_area": "Wg obszaru",
"groupby_group": "Wg grupy",
"groupby_user": "Wg użytkownika",
"groupby_object": "Według obiektu",
"filter_label": "Filtr",
"user_label": "Użytkownik",
"photo_label": "Zdjęcie",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Obecnie obowiązująca wartość początkowa: {value}",
"runtime_on_states": "Stany aktywne",
"runtime_on_states_help": "Stany liczone jako czas pracy — domyślnie: on. Np. mowing, cleaning, printing. Przy wybranym atrybucie porównywane są jego wartości.",
"runtime_max_session": "Maks. czas sesji (sekundy)",
"runtime_max_session_help": "Pojedynczy przebieg liczy się najwyżej tyle sekund — chroni przed czujnikiem zawieszonym na WŁ. (utrata połączenia, restart). Puste = bez limitu.",
"trend_approaching": "Zbliża się do progu",
"trend_easing": "Oddala się od progu",
"split_select_hint": "Wybierz zadanie, aby zobaczyć tutaj szczegóły",
"setups_target_new": "Utwórz nowy: {name}",
"schedule_preview_title": "Najbliższe terminy",
"schedule_preview_ontime": "Przy założeniu terminowego ukończenia.",
@@ -927,5 +933,7 @@
"history_add_past": "Dodaj wcześniejsze ukończenie",
"require_tag_scan": "Ukończenie tylko przez skan tagu",
"require_tag_scan_help": "Dowód obecności: „Gotowe” jest wszędzie zablokowane, dopóki nie zeskanujesz tagu NFC lub kodu QR na urządzeniu. Automatyzacje mogą przekazać 'via_tag_scan' do usługi complete.",
"disallow_skip": "Nie zezwalaj na pomijanie",
"disallow_skip_help": "Akcja Pomiń znika ze wszystkich widoków, a serwer odrzuca pominięcie także z automatyzacji i głosu.",
"require_tag_scan_hint": "To zadanie można ukończyć tylko skanując jego tag NFC lub kod QR na urządzeniu — zapis tutaj zostanie odrzucony."
}
@@ -536,6 +536,7 @@
"groupby_area": "Por área",
"groupby_group": "Por grupo",
"groupby_user": "Por usuário",
"groupby_object": "Por objeto",
"filter_label": "Filtro",
"user_label": "Usuário",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Valor inicial efetivo atual: {value}",
"runtime_on_states": "Estados ativos",
"runtime_on_states_help": "Estados que contam como em funcionamento — padrão: on. Ex.: mowing, cleaning, printing. Com um atributo selecionado, os valores dele são comparados.",
"runtime_max_session": "Duração máxima por sessão (segundos)",
"runtime_max_session_help": "Uma única execução conta no máximo esses segundos — protege contra um sensor travado em ON (conexão perdida, reinício). Vazio = sem limite.",
"trend_approaching": "Aproximando-se do limite",
"trend_easing": "Afastando-se do limite",
"split_select_hint": "Selecione uma tarefa para ver os detalhes aqui",
"setups_target_new": "Criar novo: {name}",
"schedule_preview_title": "Próximas datas",
"schedule_preview_ontime": "Supondo conclusão em dia.",
@@ -927,5 +933,7 @@
"history_add_past": "Adicionar conclusão passada",
"require_tag_scan": "Concluir apenas escaneando a etiqueta",
"require_tag_scan_help": "Prova de presença: “Concluído” fica bloqueado em toda parte até escanear a etiqueta NFC ou o código QR no próprio objeto. Automações podem passar 'via_tag_scan' ao serviço complete.",
"disallow_skip": "Não permitir pular",
"disallow_skip_help": "A ação Pular desaparece de todas as telas e o servidor também recusa pular via automações e voz.",
"require_tag_scan_hint": "Esta tarefa só é concluída escaneando sua etiqueta NFC ou código QR no próprio objeto — salvar aqui será recusado."
}
@@ -573,6 +573,7 @@
"groupby_area": "Por área",
"groupby_group": "Por grupo",
"groupby_user": "Por usuário",
"groupby_object": "Por objeto",
"unassigned": "Não atribuído",
"no_area": "Sem área",
"has_overdue": "Tarefas em atraso",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Valor inicial atualmente em vigor: {value}",
"runtime_on_states": "Estados ativos",
"runtime_on_states_help": "Estados que contam como em funcionamento — padrão: on. P. ex. mowing, cleaning, printing. Com um atributo selecionado, são comparados os valores dele.",
"runtime_max_session": "Duração máxima por sessão (segundos)",
"runtime_max_session_help": "Uma única execução conta no máximo estes segundos — protege contra um sensor preso em ON (ligação perdida, reinício). Vazio = sem limite.",
"trend_approaching": "A aproximar-se do limiar",
"trend_easing": "A afastar-se do limiar",
"split_select_hint": "Selecione uma tarefa para ver os detalhes aqui",
"setups_target_new": "Criar novo: {name}",
"schedule_preview_title": "Próximas datas",
"schedule_preview_ontime": "Assumindo conclusão pontual.",
@@ -927,5 +933,7 @@
"history_add_past": "Adicionar conclusão passada",
"require_tag_scan": "Concluir apenas lendo a etiqueta",
"require_tag_scan_help": "Prova de presença: «Concluído» fica bloqueado em todo o lado até ler a etiqueta NFC ou o código QR no próprio objeto. As automações podem passar 'via_tag_scan' ao serviço complete.",
"disallow_skip": "Não permitir ignorar",
"disallow_skip_help": "A ação Ignorar desaparece de todas as superfícies e o servidor também recusa ignorar via automações e voz.",
"require_tag_scan_hint": "Esta tarefa só é concluída lendo a sua etiqueta NFC ou código QR no próprio objeto — guardar aqui será recusado."
}
@@ -528,6 +528,7 @@
"groupby_area": "По области",
"groupby_group": "По группе",
"groupby_user": "По пользователю",
"groupby_object": "По объекту",
"filter_label": "Фильтр",
"user_label": "Пользователь",
"photo_label": "Фото",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Действующее начальное значение: {value}",
"runtime_on_states": "Активные состояния",
"runtime_on_states_help": "Состояния, засчитываемые как работа — по умолчанию: on. Напр. mowing, cleaning, printing. Если выбран атрибут, сравниваются его значения.",
"runtime_max_session": "Макс. время сеанса (секунды)",
"runtime_max_session_help": "Один запуск засчитывается не более чем на столько секунд — защита от датчика, зависшего во включённом состоянии (обрыв связи, перезапуск). Пусто = без ограничения.",
"trend_approaching": "Приближается к порогу",
"trend_easing": "Отдаляется от порога",
"split_select_hint": "Выберите задачу, чтобы увидеть здесь её детали",
"setups_target_new": "Создать новый: {name}",
"schedule_preview_title": "Ближайшие даты",
"schedule_preview_ontime": "При условии своевременного выполнения.",
@@ -927,5 +933,7 @@
"history_add_past": "Добавить прошлое выполнение",
"require_tag_scan": "Завершение только по скану метки",
"require_tag_scan_help": "Подтверждение присутствия: «Готово» заблокировано везде, пока не отсканирована NFC-метка или QR-код на самом устройстве. Автоматизации могут передавать 'via_tag_scan' в службу complete.",
"disallow_skip": "Запретить пропуск",
"disallow_skip_help": "Действие «Пропустить» исчезает со всех экранов, а сервер отклоняет пропуск из автоматизаций и голосовых команд.",
"require_tag_scan_hint": "Эта задача завершается только сканированием её NFC-метки или QR-кода на самом устройстве — сохранение здесь будет отклонено."
}
@@ -528,6 +528,7 @@
"groupby_area": "Per område",
"groupby_group": "Per grupp",
"groupby_user": "Per användare",
"groupby_object": "Efter objekt",
"filter_label": "Filter",
"user_label": "Användare",
"photo_label": "Foto",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Nu gällande startvärde: {value}",
"runtime_on_states": "Aktiva tillstånd",
"runtime_on_states_help": "Tillstånd som räknas som drifttid — standard: on. T.ex. mowing, cleaning, printing. Med ett valt attribut jämförs dess värden i stället.",
"runtime_max_session": "Max körtid per session (sekunder)",
"runtime_max_session_help": "En enskild körning räknas som högst så här många sekunder — skyddar mot en sensor som fastnat på PÅ (tappad anslutning, omstart). Tomt = ingen gräns.",
"trend_approaching": "Närmar sig tröskeln",
"trend_easing": "Rör sig bort från tröskeln",
"split_select_hint": "Välj en uppgift för att se detaljerna här",
"setups_target_new": "Skapa ny: {name}",
"schedule_preview_title": "Kommande datum",
"schedule_preview_ontime": "Förutsatt slutförande i tid.",
@@ -927,5 +933,7 @@
"history_add_past": "Lägg till tidigare slutförande",
"require_tag_scan": "Kan endast slutföras via tagg-skanning",
"require_tag_scan_help": "Närvarobevis: ”Klar” är blockerad överallt tills NFC-taggen eller QR-koden på själva saken har skannats. Automationer kan skicka 'via_tag_scan' till complete-tjänsten.",
"disallow_skip": "Tillåt inte att hoppa över",
"disallow_skip_help": "Åtgärden Hoppa över försvinner från alla ytor och servern nekar även hopp från automationer och röst.",
"require_tag_scan_hint": "Den här uppgiften slutförs endast genom att skanna dess NFC-tagg eller QR-kod på själva saken — att spara här kommer att nekas."
}
@@ -536,6 +536,7 @@
"groupby_area": "Alana göre",
"groupby_group": "Gruba göre",
"groupby_user": "Kullanıcıya göre",
"groupby_object": "Nesneye göre",
"filter_label": "Filtre",
"user_label": "Kullanıcı",
"photo_label": "Fotoğraf",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Şu anda geçerli başlangıç değeri: {value}",
"runtime_on_states": "Etkin durumlar",
"runtime_on_states_help": "Çalışıyor sayılan durumlar — varsayılan: on. Örn. mowing, cleaning, printing. Bir öznitelik seçiliyse onun değerleri eşleştirilir.",
"runtime_max_session": "Oturum başına azami çalışma süresi (saniye)",
"runtime_max_session_help": "Tek bir çalışma en fazla bu kadar saniye sayılır — AÇIK durumda takılı kalan sensöre (bağlantı kopması, yeniden başlatma) karşı korur. Boş = sınır yok.",
"trend_approaching": "Eşiğe yaklaşıyor",
"trend_easing": "Eşikten uzaklaşıyor",
"split_select_hint": "Ayrıntıları burada görmek için bir görev seçin",
"setups_target_new": "Yeni oluştur: {name}",
"schedule_preview_title": "Sonraki tarihler",
"schedule_preview_ontime": "Zamanında tamamlama varsayılarak.",
@@ -927,5 +933,7 @@
"history_add_past": "Geçmiş tamamlama ekle",
"require_tag_scan": "Yalnızca etiket taranarak tamamlanır",
"require_tag_scan_help": "Yerinde olma kanıtı: Cihazın üzerindeki NFC etiketi veya QR kodu taranana kadar 'Tamamlandı' her yerde engellenir. Otomasyonlar complete servisine 'via_tag_scan' iletebilir.",
"disallow_skip": "Atlamaya izin verme",
"disallow_skip_help": "Atla eylemi tüm yüzeylerden kaybolur; sunucu otomasyonlardan ve sesten gelen atlamayı da reddeder.",
"require_tag_scan_hint": "Bu görev yalnızca cihazın üzerindeki NFC etiketi veya QR kodu taranarak tamamlanır — buradan kaydetme reddedilecektir."
}
@@ -528,6 +528,7 @@
"groupby_area": "За зоною",
"groupby_group": "За групою",
"groupby_user": "За користувачем",
"groupby_object": "За об'єктом",
"filter_label": "Фільтр",
"user_label": "Користувач",
"photo_label": "Фото",
@@ -834,6 +835,11 @@
"baseline_current_effective": "Чинне початкове значення: {value}",
"runtime_on_states": "Активні стани",
"runtime_on_states_help": "Стани, що зараховуються як робота — типово: on. Напр. mowing, cleaning, printing. Якщо вибрано атрибут, порівнюються його значення.",
"runtime_max_session": "Макс. час сеансу (секунди)",
"runtime_max_session_help": "Один запуск зараховується щонайбільше на стільки секунд — захист від датчика, що завис в увімкненому стані (втрата зв'язку, перезапуск). Порожньо = без обмеження.",
"trend_approaching": "Наближається до порога",
"trend_easing": "Віддаляється від порога",
"split_select_hint": "Виберіть завдання, щоб побачити тут його деталі",
"setups_target_new": "Створити новий: {name}",
"schedule_preview_title": "Найближчі дати",
"schedule_preview_ontime": "За умови вчасного виконання.",
@@ -927,5 +933,7 @@
"history_add_past": "Додати минуле виконання",
"require_tag_scan": "Завершення лише скануванням мітки",
"require_tag_scan_help": "Підтвердження присутності: «Готово» заблоковано всюди, доки не відскановано NFC-мітку або QR-код на самому пристрої. Автоматизації можуть передати 'via_tag_scan' службі complete.",
"disallow_skip": "Заборонити пропуск",
"disallow_skip_help": "Дія «Пропустити» зникає з усіх поверхонь, а сервер відхиляє пропуск з автоматизацій і голосу.",
"require_tag_scan_hint": "Це завдання завершується лише скануванням його NFC-мітки або QR-коду на самому пристрої — збереження тут буде відхилено."
}
@@ -530,6 +530,7 @@
"groupby_area": "按区域分组",
"groupby_group": "按分组",
"groupby_user": "按负责人",
"groupby_object": "按对象",
"filter_label": "过滤器",
"user_label": "用户",
"photo_label": "照片",
@@ -834,6 +835,11 @@
"baseline_current_effective": "当前生效的起始读数:{value}",
"runtime_on_states": "活动状态",
"runtime_on_states_help": "计入运行时间的状态 — 默认:on。例如 mowing、cleaning、printing。选择了属性时将比较该属性的值。",
"runtime_max_session": "单次运行最长时间(秒)",
"runtime_max_session_help": "单次运行最多计入这么多秒——防止传感器卡在开启状态(连接丢失、重启)。留空 = 不限制。",
"trend_approaching": "正接近阈值",
"trend_easing": "正远离阈值",
"split_select_hint": "选择一个任务,在此查看详情",
"setups_target_new": "新建:{name}",
"schedule_preview_title": "下次日期",
"schedule_preview_ontime": "假设按时完成。",
@@ -927,5 +933,7 @@
"history_add_past": "补录过去的完成记录",
"require_tag_scan": "仅可通过扫描标签完成",
"require_tag_scan_help": "在场证明:在扫描设备上的 NFC 标签或二维码之前,所有界面的“完成”均被阻止。自动化可向 complete 服务传递 'via_tag_scan'。",
"disallow_skip": "不允许跳过",
"disallow_skip_help": "跳过操作将从所有界面消失,服务器也会拒绝来自自动化和语音的跳过。",
"require_tag_scan_hint": "此任务只能通过扫描设备上的 NFC 标签或二维码来完成——在此保存将被拒绝。"
}
@@ -75,7 +75,7 @@ import "./components/saved-views-dialog";
import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog";
import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
import { renderTriggerProgress, renderMiniSparkline, computeTrend } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
// The task-detail sub-view as a web component (light-DOM, panel styles apply).
@@ -112,7 +112,7 @@ interface PartsOverviewRow {
}
type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group";
type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count";
type GroupByMode = "none" | "area" | "group" | "user";
type GroupByMode = "none" | "area" | "group" | "user" | "object";
// Chart dimension constants for mini sparklines (overview)
@@ -125,6 +125,10 @@ export class MaintenanceSupporterPanel extends LitElement {
* The wide row grid then cannot hold labelled action buttons, so the
* compact (icon-only) form applies here too. Reflected for the CSS. */
@property({ type: Boolean, reflect: true }) public tight = false;
/** ≥1500px panel width (2026-09-01): master-detail split — the dashboard
* keeps the task list on the left and docks the task detail on the right
* (a mail client's preview pane). Reflected for the CSS. */
@property({ type: Boolean, reflect: true }) public split = false;
private _tightObserver: ResizeObserver | null = null;
@property({ attribute: false }) public panel: Record<string, unknown> = {};
@@ -340,6 +344,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// fragments. Below ~1000 the narrow layout is the honest choice;
// from there up the chips track keeps ≥150 px of slack.
if (w > 0) this.tight = w < 1000;
if (w > 0) this.split = w >= 1500;
});
this._tightObserver.observe(this);
}
@@ -358,7 +363,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = lsGet(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
if (savedGroup && ["none", "area", "group", "user", "object"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = lsGet(LS_KEYS.objectView);
@@ -822,6 +827,7 @@ export class MaintenanceSupporterPanel extends LitElement {
entry_id: obj.entry_id,
task_id: task.id,
object_name: obj.object.name,
allow_skip: task.allow_skip !== false,
task_name: task.name,
type: task.type,
schedule_type: task.schedule_type,
@@ -1023,7 +1029,7 @@ export class MaintenanceSupporterPanel extends LitElement {
if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) {
this._sortMode = f.sort_mode as SortMode;
}
if (["none", "area", "group", "user"].includes(f.group_by)) {
if (["none", "area", "group", "user", "object"].includes(f.group_by)) {
this._groupByMode = f.group_by as GroupByMode;
}
// Persist sort/group like the manual controls do, so they stick after reload.
@@ -1077,7 +1083,28 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
/** Master-detail split is active: wide panel, dashboard tab, no bulk. */
private _splitActive(): boolean {
return this.split && !this.narrow && !this.tight && this._view === "overview" && this._overviewTab === "dashboard" && !this._bulkMode;
}
private _showTask(entryId: string, taskId: string): void {
// Master-detail split (2026-09-01): same data prep, but no view switch
// and no history entry — the detail docks on the right like a mail
// client's preview pane. The detail's breadcrumb (showTaskView) still
// opens the full page.
if (this._splitActive()) {
this._selectedEntryId = entryId;
this._selectedTaskId = taskId;
this._activeTab = "overview";
this._historyFilter = null;
this._fetchFullHistory(entryId, taskId);
const task = this._getTask(entryId, taskId);
if (task?.trigger_config?.entity_id) {
this._fetchDetailStats(task.trigger_config.entity_id, this._isCounterEntity(task.trigger_config));
}
return;
}
this._pushPanelState("task", entryId, taskId);
this._view = "task";
this._selectedEntryId = entryId;
@@ -2496,6 +2523,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<option value="area" ?selected=${this._groupByMode === "area"}>${t("groupby_area", L)}</option>
${this._features.groups ? html`<option value="group" ?selected=${this._groupByMode === "group"}>${t("groupby_group", L)}</option>` : nothing}
<option value="user" ?selected=${this._groupByMode === "user"}>${t("groupby_user", L)}</option>
<option value="object" ?selected=${this._groupByMode === "object"}>${t("groupby_object", L)}</option>
</select>
</label>
${archivedCount > 0 ? html`
@@ -2541,7 +2569,20 @@ export class MaintenanceSupporterPanel extends LitElement {
`
: html`
${this._bulkMode ? this._renderBulkBar(rows, L) : nothing}
${this._groupByMode === "none"
${this._splitActive()
? html`
<div class="split-layout">
<div class="split-list">
${this._groupByMode === "none" ? this._renderTaskTable(rows) : this._renderGroupedTasks(rows, L)}
</div>
<div class="split-pane">
${this._selectedEntryId && this._selectedTaskId && this._getTask(this._selectedEntryId, this._selectedTaskId)
? this._renderTaskDetail()
: html`<div class="split-pane-empty"><ha-icon icon="mdi:cursor-default-click-outline"></ha-icon><p>${t("split_select_hint", L)}</p></div>`}
</div>
</div>
`
: this._groupByMode === "none"
? this._renderTaskTable(rows)
: this._renderGroupedTasks(rows, L)}
`}
@@ -2661,6 +2702,9 @@ export class MaintenanceSupporterPanel extends LitElement {
keys = [u || noneLabel];
} else if (this._groupByMode === "group") {
keys = row.group_names.length > 0 ? row.group_names : [noneLabel];
} else if (this._groupByMode === "object") {
// #150(3): group the dashboard by the owning object/device.
keys = [row.object_name];
}
for (const k of keys) {
if (!groups.has(k)) groups.set(k, []);
@@ -2675,6 +2719,7 @@ export class MaintenanceSupporterPanel extends LitElement {
});
const icon = this._groupByMode === "area" ? "mdi:map-marker-outline"
: this._groupByMode === "group" ? "mdi:folder-outline"
: this._groupByMode === "object" ? "mdi:cube-outline"
: "mdi:account-outline";
return html`
${sorted.map(([key, taskRows]) => html`
@@ -3220,7 +3265,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const bulkSelected = this._bulkMode && this._bulkSelected.has(this._bulkKey(row));
return html`
<div class="task-row${!row.enabled ? ' task-disabled' : ''}${bulkSelected ? ' bulk-selected' : ''}">
<div class="task-row${!row.enabled ? ' task-disabled' : ''}${bulkSelected ? ' bulk-selected' : ''}${this._splitActive() && row.entry_id === this._selectedEntryId && row.task_id === this._selectedTaskId ? ' selected' : ''}">
${this._bulkMode ? html`
<label class="cell bulk-check" @click=${(e: Event) => e.stopPropagation()}>
<input type="checkbox" .checked=${bulkSelected} @change=${() => this._toggleBulkRow(row)} />
@@ -3260,13 +3305,13 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<div class="days-bar"><div class="days-bar-fill${daysOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div></div>`
: nothing}
${row.trigger_config
? renderTriggerProgress(row)
? renderTriggerProgress(row, { trend: computeTrend(row, this._miniStatsData), lang: L })
: !hasDaysBar && row.trigger_active
? html`<span style="color:var(--maint-triggered-color);font-weight:600">⚡</span>`
: nothing}
${renderMiniSparkline(row, this._miniStatsData, this._lang)}
</span>
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id))}
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id), row.allow_skip)}
</div>
`;
}
@@ -3296,7 +3341,7 @@ export class MaintenanceSupporterPanel extends LitElement {
/** #145: row actions as HA buttons — labelled (accent/success + plain, the
* task-detail pairing) or, for "buttons_compact" on narrow screens,
* icon-only — or the classic icon pair when the household chose "icons". */
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void): TemplateResult {
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void, allowSkip = true): TemplateResult {
const style = this._actionStyle();
if (style === "buttons" || style === "buttons_compact") {
const iconOnly = style === "buttons_compact" && (this.narrow || this.tight);
@@ -3308,9 +3353,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</ha-button>
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</ha-icon-button>
${allowSkip ? html`
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</ha-icon-button>` : nothing}
</span>`;
}
return html`
@@ -3318,9 +3364,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
</ha-button>
<ha-button size="small" appearance="plain" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
</ha-button>
${allowSkip ? html`
<ha-button size="small" appearance="outlined" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
</ha-button>` : nothing}
</span>`;
}
return html`
@@ -3328,9 +3375,10 @@ export class MaintenanceSupporterPanel extends LitElement {
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
<ha-icon icon="mdi:check"></ha-icon>
</mwc-icon-button>
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>
${allowSkip ? html`
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
<ha-icon icon="mdi:skip-next"></ha-icon>
</mwc-icon-button>` : nothing}
</span>`;
}
@@ -3470,7 +3518,7 @@ export class MaintenanceSupporterPanel extends LitElement {
<span class="due-cell" @click=${() => this._showTask(obj.entry_id, task.id)}>
<span class="due-text">${formatDueDays(task.days_until_due, L)}</span>
${task.trigger_config
? renderTriggerProgress(task)
? renderTriggerProgress(task, { trend: computeTrend(task, this._miniStatsData), lang: L })
: nothing}
${renderMiniSparkline(task, this._miniStatsData, this._lang)}
</span>
@@ -3478,6 +3526,7 @@ export class MaintenanceSupporterPanel extends LitElement {
L,
() => this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled),
() => this._promptSkipTask(obj.entry_id, task.id),
task.allow_skip,
)}
</div>
`)}</div>`}
@@ -18,7 +18,7 @@
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.1",
"esbuild": "^0.28.2",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -51,9 +51,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -68,9 +68,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -85,9 +85,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -102,9 +102,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -119,9 +119,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -136,9 +136,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -153,9 +153,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -170,9 +170,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -187,9 +187,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -204,9 +204,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -221,9 +221,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -238,9 +238,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -255,9 +255,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -272,9 +272,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -289,9 +289,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -306,9 +306,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -323,9 +323,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -340,9 +340,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -357,9 +357,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -374,9 +374,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -391,9 +391,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -408,9 +408,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -425,9 +425,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -442,9 +442,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -459,9 +459,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -476,9 +476,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -3204,9 +3204,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3217,32 +3217,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
@@ -4773,12 +4773,12 @@
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -4791,9 +4791,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -15,7 +15,7 @@
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.1",
"esbuild": "^0.28.2",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -229,6 +229,51 @@ export const panelStyles = css`
}
}
.bulk-check { display: flex; align-items: center; justify-content: center; cursor: pointer; }
/* Master-detail split (>=1500px panel, 2026-09-01): list left, docked task
detail right - the width finally carries content instead of slack. Only
the dashboard tab uses it; below the threshold everything is unchanged. */
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(430px, 41%);
gap: 20px;
align-items: start;
}
.split-list { min-width: 0; }
/* Inside the split the list is ~60% wide — the type and chips columns are
redundant there (the docked detail names the type, assignee and labels),
and squeezed chips read as clutter. Drop both; the task name gets the
room and the list stays calm. */
.split-list .task-table { grid-template-columns: auto minmax(100px, 180px) minmax(120px, 1fr) 150px auto; }
.split-list .task-table.bulk { grid-template-columns: auto auto minmax(100px, 180px) minmax(120px, 1fr) 150px auto; }
.split-list .cell.type { display: none; }
.split-list .task-sub { display: none; }
.split-pane {
min-width: 0;
position: sticky;
top: 8px;
max-height: calc(100vh - 140px);
overflow-y: auto;
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 4px 16px 16px;
background: var(--card-background-color, #fff);
}
.split-pane-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 240px;
gap: 8px;
color: var(--secondary-text-color);
text-align: center;
}
.split-pane-empty ha-icon { --mdc-icon-size: 40px; opacity: 0.5; }
.task-row.selected {
background: rgba(var(--rgb-primary-color, 3, 169, 244), 0.1);
box-shadow: inset 3px 0 0 var(--primary-color);
}
.bulk-check input, .bulk-selectall input { width: 17px; height: 17px; cursor: pointer; accent-color: var(--primary-color); }
.task-row.bulk-selected { background: color-mix(in srgb, var(--primary-color) 12%, transparent); }
.bulk-bar {
@@ -1457,7 +1502,16 @@ export const panelStyles = css`
width: 100%;
}
:host([narrow]) .task-table { display: block; }
/* Shared column tracks across rows (the wide layout's #66 lesson, applied
to narrow 2026-09-01): with per-row grids the auto badge/actions columns
sized independently, so the object-name column started at a different X
in every row — an "OK" pill vs "Due Soon" + priority chevron made the
list read jittery. The LIST owns the four tracks, rows subgrid them. */
:host([narrow]) .task-table {
display: grid;
grid-template-columns: auto minmax(76px, 1fr) 100px auto;
column-gap: 8px;
}
:host([narrow]) .task-row {
/* Mobile: 4-column grid keeps due-cell + actions at deterministic
@@ -1469,10 +1523,9 @@ export const panelStyles = css`
Task-name spans the full top row (own row above), chips span the
full bottom row. */
display: grid;
grid-column: auto;
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
grid-column: 1 / -1;
grid-template-columns: subgrid;
grid-template-rows: auto auto auto;
column-gap: 8px;
row-gap: 4px;
padding: 12px;
}
@@ -1486,6 +1539,13 @@ export const panelStyles = css`
:host([narrow]) .cell-badges {
grid-column: 1;
grid-row: 2;
/* Stack extra badges (priority chevron, NFC, disabled) under the status
pill: in the shared-track table the widest badge ROW sized column 1
for every row, and a pill+chevron combo pushed Complete/Skip off the
right edge at phone widths (2026-09-01). */
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
:host([narrow]) .cell.object-name {
grid-column: 2;
@@ -1540,26 +1600,30 @@ export const panelStyles = css`
the wide subgrid overflowed its last column at that width even before
the buttons. Same rules as the :host([narrow]) block above / the
max-width:768px media mirror below, keyed on the panel's own width. */
:host([tight]:not([narrow])) .task-table { display: block; }
:host([tight]:not([narrow])) .task-table {
display: grid;
grid-template-columns: auto minmax(76px, 1fr) 100px auto;
column-gap: 8px;
}
:host([tight]:not([narrow])) .task-row {
display: grid;
grid-column: auto;
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
grid-column: 1 / -1;
grid-template-columns: subgrid;
grid-template-rows: auto auto auto;
column-gap: 8px;
row-gap: 4px;
padding: 12px;
min-width: 0;
}
:host([tight]:not([narrow])) .cell.type { display: none; }
:host([tight]:not([narrow])) .cell.task-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
:host([tight]:not([narrow])) .cell-badges { grid-column: 1; grid-row: 2; }
:host([tight]:not([narrow])) .cell-badges { grid-column: 1; grid-row: 2; flex-direction: column; align-items: flex-start; gap: 4px; }
:host([tight]:not([narrow])) .cell.object-name { grid-column: 2; grid-row: 2; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; }
:host([tight]:not([narrow])) .due-cell { grid-column: 3; grid-row: 2; align-items: flex-end; min-width: 0; }
:host([tight]:not([narrow])) .row-actions { grid-column: 4; grid-row: 2; }
:host([tight]:not([narrow])) .task-sub { grid-column: 1 / -1; grid-row: 3; font-size: 11px; gap: 6px; justify-content: flex-start; flex-wrap: wrap; }
:host([tight]:not([narrow])) .task-sub-empty { display: none; }
:host([tight]:not([narrow])) .mini-sparkline { width: 50px; }
:host([tight]:not([narrow])) .mini-sparkline { display: none; }
:host([tight]:not([narrow])) .trend-arrow { display: inline; }
:host([narrow]) .task-sub {
grid-column: 1 / -1;
grid-row: 3;
@@ -1569,7 +1633,22 @@ export const panelStyles = css`
flex-wrap: wrap;
}
:host([narrow]) .task-sub-empty { display: none; }
:host([narrow]) .mini-sparkline { width: 50px; }
:host([narrow]) .mini-sparkline { display: none; }
:host([narrow]) .trend-arrow { display: inline; }
/* Phone band (≤429px): with SHARED tracks the widest badge sizes column 1
for every row, and the fixed 100px due column + both action buttons no
longer fit — Skip fell off the right edge. Tighter floors, slimmer row
padding and a smaller Skip icon-button. Bars stay uniform per
breakpoint (100px above, 84px here). */
@media (max-width: 429px) {
:host([narrow]) .task-table {
grid-template-columns: auto minmax(64px, 1fr) 84px auto;
column-gap: 6px;
}
:host([narrow]) .task-row { padding: 12px 8px; }
:host([narrow]) .row-actions.compact ha-icon-button { --mdc-icon-button-size: 32px; --mdc-icon-size: 20px; }
}
:host([narrow]) .detail-header {
flex-direction: column;
@@ -1691,24 +1770,31 @@ export const panelStyles = css`
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
.filter-bar { flex-wrap: wrap; }
.filter-bar select { flex: 1; min-width: 0; }
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
/* Mirror the :host([narrow]) grid layout for narrow desktop windows —
incl. the shared-track table so rows align (2026-09-01). */
.task-table {
display: grid;
grid-template-columns: auto minmax(76px, 1fr) 100px auto;
column-gap: 8px;
}
.task-row {
display: grid;
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
grid-column: 1 / -1;
grid-template-columns: subgrid;
grid-template-rows: auto auto auto;
column-gap: 8px;
row-gap: 4px;
padding: 12px;
}
.cell.type { display: none; }
.cell.task-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
.cell-badges { grid-column: 1; grid-row: 2; }
.cell-badges { grid-column: 1; grid-row: 2; flex-direction: column; align-items: flex-start; gap: 4px; }
.cell.object-name { grid-column: 2; grid-row: 2; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; }
.due-cell { grid-column: 3; grid-row: 2; align-items: flex-end; min-width: 0; }
.row-actions { grid-column: 4; grid-row: 2; }
.task-sub { grid-column: 1 / -1; grid-row: 3; font-size: 11px; gap: 6px; justify-content: flex-start; flex-wrap: wrap; }
.task-sub-empty { display: none; }
.mini-sparkline { width: 50px; }
.mini-sparkline { display: none; }
.trend-arrow { display: inline; }
.detail-header { flex-direction: column; align-items: flex-start; }
.info-grid { grid-template-columns: 1fr; }
.history-filters-new { flex-direction: column; }
@@ -20,7 +20,59 @@ const MINI_SPARKLINE_W = 60;
const MINI_SPARKLINE_H = 20;
const MAX_MINI_POINTS = 30;
export function renderTriggerProgress(row: TaskRow | MaintenanceTask) {
export type TrendState = "approaching" | "stable" | "easing";
const TREND_GLYPHS: Record<TrendState, string> = { approaching: "\u2197", stable: "\u2192", easing: "\u2198" };
/** #150 follow-up: on narrow/tight rows the squeezed sparkline is unreadable
* — this reduces the same points to the one question the curve answered:
* is the value heading TOWARD the threshold, holding, or easing away?
* Direction is relative to the threshold (a dropping salt level and a
* rising runtime are both "approaching"). null = no arrow (no direction,
* compound, or fewer than two points). */
export function computeTrend(
row: TaskRow | MaintenanceTask,
miniStatsData: Map<string, StatisticsPoint[]>,
): TrendState | null {
const tc = row.trigger_config;
if (!tc?.entity_id) return null;
let toward: 1 | -1;
const type = tc.type || "threshold";
if (type === "threshold") {
if (tc.trigger_above != null) toward = 1;
else if (tc.trigger_below != null) toward = -1;
else return null; // discrete = / \u2260 levels have no gradient
} else if (type === "counter" || type === "runtime" || type === "state_change") {
toward = 1; // these accumulate toward their target
} else {
return null; // compound: conditions may point both ways
}
const statsPoints = miniStatsData.get(tc.entity_id) || [];
let points: { ts: number; val: number }[] =
statsPoints.length >= 2
? statsPoints.map((p) => ({ ts: p.ts, val: p.val }))
: (row.history || [])
.filter((h) => h.trigger_value != null)
.map((h) => ({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value as number }));
if (row.trigger_current_value != null) points = [...points, { ts: Date.now(), val: row.trigger_current_value }];
if (points.length < 2) return null;
points.sort((a, b) => a.ts - b.ts);
const vals = points.map((p) => p.val);
const range = Math.max(...vals) - Math.min(...vals);
const delta = vals[vals.length - 1] - vals[0];
// Significance is judged against the JOURNEY to the threshold, not the
// series' own noise band: a sensor crawling 50.0 -> 50.15 under a
// 60-threshold is flat, even though that move spans most of its range.
const target =
type === "threshold" ? (tc.trigger_above ?? tc.trigger_below)
: type === "counter" ? tc.trigger_target_value
: type === "runtime" ? tc.trigger_runtime_hours
: tc.trigger_target_changes;
const base = typeof target === "number" ? Math.max(Math.abs(target - vals[0]), range) : range;
if (base === 0 || Math.abs(delta) < base * (typeof target === "number" ? 0.05 : 0.15)) return "stable";
return Math.sign(delta) === toward ? "approaching" : "easing";
}
export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { trend?: TrendState | null; lang?: string }) {
const tc = row.trigger_config ?? null;
if (!tc) return nothing;
@@ -109,7 +161,9 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask) {
<div class="trigger-progress-bar">
<div class="trigger-progress-fill${triggerOverflow ? " overflow" : ""}" style="width:${pct}%;background:${barColor}"></div>
</div>
<span class="trigger-progress-label">${label}</span>
<span class="trigger-progress-label">${label}${opts?.trend
? html` <i class="trend-arrow trend-${opts.trend}" title="${t(`trend_${opts.trend}`, opts.lang ?? "en")}" aria-label="${t(`trend_${opts.trend}`, opts.lang ?? "en")}">${TREND_GLYPHS[opts.trend]}</i>`
: nothing}</span>
</div>
`;
}
@@ -131,7 +131,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
</div>
<div class="task-header-actions">
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
<ha-button appearance="plain" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>
${task.allow_skip !== false
? html`<ha-button appearance="outlined" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>`
: nothing}
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
${ctx.moreMenuOpen ? html`
@@ -908,8 +908,15 @@ export const sharedStyles = css`
display: flex;
flex-direction: column;
gap: 2px;
/* #150 follow-up (2026-08-31, maisun's mobile screenshot): the box used
to carry min-width: 90px — in the narrow grid the due column is
fit-content(100px) and right-aligned, so on cells narrower than 90px
the box overhung LEFT across the object name. It now tracks the cell
with no floor, and the label ellipsizes inside. */
align-self: stretch;
max-width: 100%;
min-width: 0;
width: 100%;
min-width: 90px;
}
.trigger-progress-bar {
@@ -930,8 +937,25 @@ export const sharedStyles = css`
font-size: 12px;
color: var(--secondary-text-color);
text-align: right;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* #150 follow-up: 3-state trend arrow — replaces the sparkline on
narrow/tight rows (panel-styles toggles visibility per regime). */
.trend-arrow {
display: none;
font-style: normal;
font-weight: 700;
margin-left: 2px;
}
.trend-approaching { color: var(--warning-color, #ff9800); }
.trend-stable { color: var(--secondary-text-color); }
.trend-easing { color: var(--success-color, #4caf50); }
/* Days progress bar (detail view) */
.days-progress {
margin: 8px 0 16px;
@@ -974,6 +998,11 @@ export const sharedStyles = css`
/* Mini-sparkline in overview rows */
.mini-sparkline {
width: 60px;
/* Same class of bug as the trigger-progress floor (#150 follow-up): in
due cells narrower than the fixed width the right-aligned SVG used to
overhang LEFT (phone-360 sweep). preserveAspectRatio="none" lets it
just squeeze. */
max-width: 100%;
height: 20px;
display: block;
margin-top: 2px;
@@ -68,6 +68,8 @@ export interface TriggerConfig {
trigger_to_state?: string | null;
trigger_target_changes?: number;
trigger_runtime_hours?: number;
/** #149: a single session books at most this many seconds. */
trigger_runtime_max_session_seconds?: number;
/** States (or attribute values) that count as "running" — default ["on"]. */
trigger_on_states?: string[];
compound_logic?: "AND" | "OR";
@@ -173,6 +175,8 @@ export interface MaintenanceTask {
name: string;
type: string; // "cleaning" | "inspection" | "replacement" | "calibration" | "service" | "reading" | "custom"
enabled: boolean;
/** #150: false = the skip lock — Skip hidden everywhere, server refuses. */
allow_skip?: boolean;
schedule_type: string; // "time_based" | "sensor_based" | "one_time" | "manual" | calendar kind
interval_days?: number | null;
interval_unit?: string; // "days" | "weeks" | "months" | "years"
@@ -509,6 +513,8 @@ export interface TaskRow {
archived: boolean;
history: HistoryEntry[];
enabled: boolean;
/** #150: false = skip locked for this task. */
allow_skip: boolean;
nfc_tag_id: string | null;
priority: string;
labels: string[];