Updated Scheduler and Maintainance Apps

This commit is contained in:
2026-07-08 12:00:20 -04:00
parent fefc2c8b5c
commit 0f7585754d
227 changed files with 2877 additions and 958 deletions
@@ -105,6 +105,7 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
promptPostpone: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
@@ -162,21 +163,32 @@ describe("task-detail renderer", () => {
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
});
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => {
const calls: string[] = [];
const host = mount(task(), ctx({
moreMenuOpen: true,
closeMoreMenu: () => calls.push("close"),
deleteTask: () => calls.push("delete"),
promptPostpone: () => calls.push("postpone"),
snoozeTask: () => calls.push("snooze"),
printWorksheet: () => calls.push("worksheet"),
}));
const items = [...host.querySelectorAll(".popup-menu-item")];
expect(items.length).to.equal(6);
(items[3] as HTMLElement).click(); // snooze
(items[4] as HTMLElement).click(); // work sheet (v2.21)
(items[5] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
expect(items.length).to.equal(7);
(items[3] as HTMLElement).click(); // postpone
(items[4] as HTMLElement).click(); // snooze
(items[5] as HTMLElement).click(); // work sheet (v2.21)
(items[6] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]);
});
it("shows a postponed badge when the task has a due_override, and none otherwise", () => {
const plain = mount(task(), ctx());
expect(plain.querySelector(".postponed-badge")).to.be.null;
const postponed = mount(task({ due_override: "2026-06-20" }), ctx());
const badge = postponed.querySelector(".postponed-badge");
expect(badge, "postponed badge").to.exist;
expect(badge!.textContent).to.match(/2026|20/);
});
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
@@ -0,0 +1,184 @@
/** Tests for <maintenance-task-detail-view> — the task-detail sub-view as a
* web component (incremental step over the renderers/task-detail
* extraction). Pins the contract the component adds on top of the renderer:
* - registers and renders into LIGHT DOM (no own shadow root) so the
* panel's shadow-scoped styles keep matching
* - header / breadcrumb / actions come through the component boundary
* - callbacks in the passed TaskDetailContext still fire (Complete, tab
* switch) — props in, panel callbacks out
* - property changes re-render (new task object → new name in the DOM)
* - renders nothing until both `task` and `ctx` are set
*/
import { expect, fixture } from "@open-wc/testing";
import { html } from "lit";
import "../components/task-detail-view.js";
import type { MaintenanceTaskDetailView } from "../components/task-detail-view.js";
import type { TaskDetailContext } from "../renderers/task-detail.js";
import type { MaintenanceTask } from "../types";
import { createMockHass } from "./_test-utils.js";
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
return {
id: "t1",
name: "Filter Wechsel",
type: "cleaning",
enabled: true,
status: "due_soon",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
days_until_due: 3,
next_due: "2026-07-10",
last_performed: "2026-06-10",
times_performed: 2,
total_cost: 50,
average_duration: 20,
history: [],
checklist: [],
is_done: false,
archived: false,
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
const { hass } = createMockHass({
handler: () => ({ documents: [] }),
});
return {
lang: "en",
hass: hass as TaskDetailContext["hass"],
entryId: "entry1",
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
activeTab: "overview",
features: {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
},
currencySymbol: "€",
collapsedSections: new Set(),
costDurationToggle: "both",
suggestionDismissed: false,
sparkline: {
lang: "en",
detailStatsData: new Map(),
hasStatsService: false,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
},
history: {
lang: "en",
hass: hass as TaskDetailContext["hass"],
filter: null,
search: "",
currencySymbol: "€",
setFilter: () => undefined,
setSearch: () => undefined,
openEdit: () => undefined,
},
getUserName: () => null,
setActiveTab: () => undefined,
toggleSection: () => undefined,
setCostDurationToggle: () => undefined,
showTaskView: () => undefined,
showObject: () => undefined,
toggleMoreMenu: () => undefined,
closeMoreMenu: () => undefined,
openEdit: () => undefined,
openComplete: () => undefined,
promptSkip: () => undefined,
toggleArchive: () => undefined,
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
promptPostpone: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
applySuggestion: () => undefined,
reanalyze: () => undefined,
dismissSuggestion: () => undefined,
openSeasonalOverrides: () => undefined,
...overrides,
};
}
async function mount(t: MaintenanceTask, c: TaskDetailContext): Promise<MaintenanceTaskDetailView> {
const el = await fixture<MaintenanceTaskDetailView>(html`
<maintenance-task-detail-view .task=${t} .ctx=${c}></maintenance-task-detail-view>
`);
await el.updateComplete;
return el;
}
describe("maintenance-task-detail-view", () => {
it("is registered as a custom element", () => {
expect(customElements.get("maintenance-task-detail-view")).to.exist;
});
it("renders into light DOM so the panel's shadow-scoped styles keep applying", async () => {
const el = await mount(task(), ctx());
expect(el.shadowRoot, "no own shadow root").to.equal(null);
// The detail markup is queryable directly on the element (= light DOM).
expect(el.querySelector(".detail-section"), "detail section in light DOM").to.exist;
});
it("renders header, breadcrumb and status through the component boundary", async () => {
const el = await mount(task(), ctx());
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Filter Wechsel");
expect(el.querySelector(".object-name-breadcrumb")?.textContent).to.contain("Pool Pump");
expect(el.querySelector(".status-chip"), "status chip").to.exist;
expect(el.querySelector(".kpi-bar"), "KPI bar").to.exist;
});
it("routes the Complete action to the panel callback with the task", async () => {
let completed: MaintenanceTask | null = null;
const el = await mount(task(), ctx({ openComplete: (tk) => { completed = tk; } }));
const btn = [...el.querySelectorAll(".task-header-actions ha-button")]
.find((b) => b.textContent?.match(/complete/i)) as HTMLElement;
expect(btn, "complete button").to.exist;
btn.click();
expect(completed, "openComplete received the task").to.not.equal(null);
expect((completed as unknown as MaintenanceTask).id).to.equal("t1");
});
it("switches tabs via the panel-owned setActiveTab callback", async () => {
let tab = "";
const el = await mount(task(), ctx({ setActiveTab: (v) => { tab = v; } }));
const tabs = [...el.querySelectorAll(".tab-bar .tab")] as HTMLElement[];
expect(tabs.length).to.equal(2);
tabs[1].click();
expect(tab).to.equal("history");
});
it("re-renders when the task property changes", async () => {
const el = await mount(task(), ctx());
el.task = task({ name: "Pumpe entkalken" });
await el.updateComplete;
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Pumpe entkalken");
});
it("renders nothing until both task and ctx are set", async () => {
const el = await fixture<MaintenanceTaskDetailView>(html`
<maintenance-task-detail-view></maintenance-task-detail-view>
`);
await el.updateComplete;
expect(el.querySelector(".detail-section")).to.equal(null);
});
it("hides the more-menu for operators (read-only surface preserved)", async () => {
const el = await mount(task(), ctx({ isOperator: true }));
expect(el.querySelector(".more-menu-wrapper")).to.equal(null);
});
});
@@ -0,0 +1,106 @@
/**
* Lit tests for the seasonal-window + finite-series recurrence extras in
* <maintenance-task-dialog>. Pins hydration from the nested schedule and the
* outgoing `schedule` payload (backend: test_schedule.py / test_ws_io.py).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog recurrence extras (season / finite series)", () => {
it("hydrates season_months and a count end from the nested schedule", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
interval_days: 14, interval_unit: "days", warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5, 6], ends: { count: 6 } },
} as any);
await el.updateComplete;
expect((el as any)._seasonMonths).to.deep.equal([4, 5, 6]);
expect((el as any)._endsMode).to.equal("count");
expect((el as any)._endsCount).to.equal("6");
});
it("hydrates an until end", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Cure", type: "custom", schedule_type: "time_based",
interval_days: 30, warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 30, unit: "days", ends: { until: "2027-01-01" } },
} as any);
await el.updateComplete;
expect((el as any)._endsMode).to.equal("until");
expect((el as any)._endsUntil).to.equal("2027-01-01");
});
it("create sends season_months + ends on an interval task's nested schedule", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Mow";
(el as any)._scheduleType = "time_based";
(el as any)._intervalDays = "14";
(el as any)._intervalUnit = "days";
(el as any)._seasonMonths = [7, 4, 5];
(el as any)._endsMode = "count";
(el as any)._endsCount = "6";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "interval", season_months: [4, 5, 7], ends: { count: 6 } });
expect(msg.interval_days).to.equal(14); // flat fields still carry the cadence
});
it("attaches the extras to a calendar-kind schedule too", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Gutter";
(el as any)._scheduleType = "day_of_month";
(el as any)._domDay = "15";
(el as any)._seasonMonths = [10, 11];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15, season_months: [10, 11] });
});
it("clearing season/ends on edit sends an authoritative schedule without them", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
interval_days: 14, warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5], ends: { count: 3 } },
} as any);
(el as any)._seasonMonths = [];
(el as any)._endsMode = "never";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
expect(msg.schedule).to.deep.equal({ kind: "interval" }); // no season_months, no ends
});
it("shows the season chips + ends selector for recurring, not for one_time", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "time_based";
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(12);
expect(el.shadowRoot!.querySelector('select option[value="count"]')).to.exist;
(el as any)._scheduleType = "one_time";
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(0);
});
});
@@ -0,0 +1,37 @@
/** <maintenance-task-detail-view> — the task-detail sub-view as a web
* component. The incremental step over the renderers/task-detail
* extraction: the panel now hands the component a task + the
* TaskDetailContext instead of calling the render function inline, giving
* the sub-view a real element boundary (own update lifecycle, testable in
* isolation, future home for detail-only state).
*
* Deliberately renders into LIGHT DOM (createRenderRoot returns `this`):
* the element lives inside the panel's shadow root, so every selector in
* panel-styles.ts (.task-header, .kpi-bar, .popup-menu, …) keeps matching
* and every dialog-opening callback still runs against the panel's shadow
* root. Moving the ~1000 lines of detail CSS and the dialog ownership in
* here would be the big-bang rewrite this component exists to avoid.
*/
import { LitElement, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { renderTaskDetail, type TaskDetailContext } from "../renderers/task-detail";
import type { MaintenanceTask } from "../types";
export class MaintenanceTaskDetailView extends LitElement {
@property({ attribute: false }) public task?: MaintenanceTask;
@property({ attribute: false }) public ctx?: TaskDetailContext;
protected createRenderRoot(): HTMLElement {
return this;
}
protected render(): unknown {
if (!this.task || !this.ctx) return nothing;
return html`${renderTaskDetail(this.task, this.ctx)}`;
}
}
if (!customElements.get("maintenance-task-detail-view")) {
customElements.define("maintenance-task-detail-view", MaintenanceTaskDetailView);
}
@@ -88,6 +88,12 @@ function weekdayNames(lang?: string): string[] {
return Array.from({ length: 7 }, (_, i) => weekdayName(i, lang, "short"));
}
/** Short localized month names, indexed 0=Jan … 11=Dec. */
function monthNames(lang?: string): string[] {
const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1)));
}
export class MaintenanceTaskDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
@@ -122,6 +128,12 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _domLastDay = false;
@state() private _domBusiness = false;
@state() private _calOffset = "0";
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
// window and a finite-series end condition.
@state() private _seasonMonths: number[] = [];
@state() private _endsMode: "never" | "count" | "until" = "never";
@state() private _endsCount = "";
@state() private _endsUntil = "";
@state() private _notes = "";
@state() private _documentationUrl = "";
@state() private _customIcon = "";
@@ -245,6 +257,16 @@ export class MaintenanceTaskDialog extends LitElement {
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
this._calOffset = sched?.offset ? String(sched.offset) : "0";
// Recurrence extras — read from the nested schedule wherever it lives.
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
const ends = sched?.ends;
if (ends && typeof ends.count === "number") {
this._endsMode = "count"; this._endsCount = String(ends.count); this._endsUntil = "";
} else if (ends && typeof ends.until === "string") {
this._endsMode = "until"; this._endsUntil = ends.until; this._endsCount = "";
} else {
this._endsMode = "never"; this._endsCount = ""; this._endsUntil = "";
}
this._warningDays = task.warning_days.toString();
this._earliestCompletionDays =
task.earliest_completion_days != null ? String(task.earliest_completion_days) : "";
@@ -345,6 +367,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._domLastDay = false;
this._domBusiness = false;
this._calOffset = "0";
this._seasonMonths = [];
this._endsMode = "never";
this._endsCount = "";
this._endsUntil = "";
this._notes = "";
this._documentationUrl = "";
this._customIcon = "";
@@ -700,7 +726,7 @@ export class MaintenanceTaskDialog extends LitElement {
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
// Calendar kinds are sent as the nested schedule; the backend prefers
// it over the flat fields and clears any stale interval/due_date.
data.schedule = this._buildSchedule();
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
data.interval_days = null;
if (this._taskId) data.due_date = null;
} else {
@@ -710,6 +736,14 @@ export class MaintenanceTaskDialog extends LitElement {
data.interval_days = parseInt(this._intervalDays, 10);
data.interval_unit = this._intervalUnit;
data.interval_anchor = this._intervalAnchor;
// Season / finite-series ride a minimal nested schedule alongside the
// flat interval fields; the backend carries them onto the interval it
// derives from the flat fields (they can't be expressed flatly).
// Always send an authoritative nested schedule for interval tasks so
// an edit that clears season/ends actually clears them.
if (this._scheduleType === "time_based") {
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
}
} else if (this._taskId) {
data.interval_days = null;
data.interval_anchor = "completion";
@@ -1133,6 +1167,72 @@ export class MaintenanceTaskDialog extends LitElement {
return withOffset(schedule);
}
/** The season/finite-series extras to attach to a recurring schedule. Empty
* values are omitted, so a clean schedule stays clean (and, since the sent
* schedule is authoritative, an omitted extra clears a previously-set one). */
private _recurrenceExtras(): { season_months?: number[]; ends?: { count?: number; until?: string } } {
const extras: { season_months?: number[]; ends?: { count?: number; until?: string } } = {};
if (this._seasonMonths.length) extras.season_months = [...this._seasonMonths].sort((a, b) => a - b);
if (this._endsMode === "count") {
const n = parseInt(this._endsCount, 10);
if (n >= 1) extras.ends = { count: n };
} else if (this._endsMode === "until" && this._endsUntil) {
extras.ends = { until: this._endsUntil };
}
return extras;
}
private _toggleSeasonMonth(m: number): void {
this._seasonMonths = this._seasonMonths.includes(m)
? this._seasonMonths.filter((x) => x !== m)
: [...this._seasonMonths, m];
}
/** Seasonal window + finite-series end — shown for recurring (interval +
* calendar) kinds, where both apply. */
private _renderRecurrenceExtras() {
const L = this._lang;
const recurring = this._scheduleType === "time_based" || CALENDAR_KINDS.includes(this._scheduleType);
if (!recurring) return nothing;
const months = monthNames(L);
return html`
<label class="field-label">${t("season_window_label", L)}</label>
<div class="field-help">${t("season_window_hint", L)}</div>
<div class="weekday-chips season-chips">
${months.map((name, i) => html`
<button
type="button"
class="season-chip ${this._seasonMonths.includes(i + 1) ? "selected" : ""}"
@click=${() => this._toggleSeasonMonth(i + 1)}
>${name}</button>`)}
</div>
<label class="field-label">${t("series_end_label", L)}</label>
<div class="select-row">
<select .value=${this._endsMode}
@change=${(e: Event) => (this._endsMode = (e.target as HTMLSelectElement).value as "never" | "count" | "until")}>
<option value="never" ?selected=${this._endsMode === "never"}>${t("series_end_never", L)}</option>
<option value="count" ?selected=${this._endsMode === "count"}>${t("series_end_after_count", L)}</option>
<option value="until" ?selected=${this._endsMode === "until"}>${t("series_end_until", L)}</option>
</select>
</div>
${this._endsMode === "count" ? html`
<ms-textfield
label="${t("series_end_count_label", L)}"
type="number" min="1"
.value=${this._endsCount}
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
></ms-textfield>` : nothing}
${this._endsMode === "until" ? html`
<ms-textfield
label="${t("series_end_until_label", L)}"
type="date"
.value=${this._endsUntil}
@input=${(e: Event) => (this._endsUntil = (e.target as HTMLInputElement).value)}
></ms-textfield>` : nothing}
`;
}
/** Per-kind field groups for the calendar recurrence kinds. */
private _renderCalendarFields() {
const L = this._lang;
@@ -1415,6 +1515,7 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>
`
: nothing}
${this._renderRecurrenceExtras()}
<ms-textfield
label="${t("warning_days", L)}"
type="number"
@@ -1772,6 +1873,20 @@ export class MaintenanceTaskDialog extends LitElement {
color: var(--text-primary-color, #fff);
border-color: var(--primary-color, #03a9f4);
}
.season-chip {
padding: 6px 10px;
border: 1px solid var(--divider-color);
border-radius: 16px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
}
.season-chip.selected {
background: var(--primary-color, #03a9f4);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color, #03a9f4);
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Výňatek z manuálu",
"worksheet_pages": "strany",
"worksheet_printed": "Vytištěno",
"worksheet_never": "Nikdy"
"worksheet_never": "Nikdy",
"card_all_caught_up": "Vše hotovo — nic nevyžaduje pozornost",
"postpone": "Odložit",
"postpone_date_prompt": "Na jaké datum odložit tento termín?",
"postpone_date_label": "Nové datum",
"postponed": "Odloženo",
"postponed_to": "Odloženo na",
"season_window_label": "Sezónní okno (měsíce)",
"season_window_hint": "Termín jen ve vybraných měsících; data mimo sezónu se posunou na další aktivní měsíc. Žádné = celý rok.",
"series_end_label": "Končí",
"series_end_never": "Nikdy (opakuje se stále)",
"series_end_after_count": "Po počtu opakování",
"series_end_until": "K datu",
"series_end_count_label": "Počet opakování",
"series_end_until_label": "Datum konce"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Uddrag af manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Udskrevet",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Alt er klaret — intet kræver opmærksomhed",
"postpone": "Udskyd",
"postpone_date_prompt": "Udskyd denne forekomst til hvilken dato?",
"postpone_date_label": "Ny forfaldsdato",
"postponed": "Udskudt",
"postponed_to": "Udskudt til",
"season_window_label": "Sæsonvindue (måneder)",
"season_window_hint": "Kun forfalden i de valgte måneder; datoer uden for sæsonen rykkes til næste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldrig (gentages uendeligt)",
"series_end_after_count": "Efter et antal gange",
"series_end_until": "På en dato",
"series_end_count_label": "Antal gange",
"series_end_until_label": "Slutdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handbuch-Auszug",
"worksheet_pages": "Seiten",
"worksheet_printed": "Gedruckt",
"worksheet_never": "Nie"
"worksheet_never": "Nie",
"card_all_caught_up": "Alles erledigt — nichts braucht Aufmerksamkeit",
"postpone": "Verschieben",
"postpone_date_prompt": "Diesen Termin auf welches Datum verschieben?",
"postpone_date_label": "Neues Fälligkeitsdatum",
"postponed": "Verschoben",
"postponed_to": "Verschoben auf",
"season_window_label": "Saisonfenster (Monate)",
"season_window_hint": "Nur in den gewählten Monaten fällig; Termine außerhalb rollen in den nächsten aktiven Monat. Keine = ganzjährig.",
"series_end_label": "Endet",
"series_end_never": "Nie (wiederholt unbegrenzt)",
"series_end_after_count": "Nach Anzahl",
"series_end_until": "An einem Datum",
"series_end_count_label": "Anzahl",
"series_end_until_label": "Enddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Manual excerpt",
"worksheet_pages": "pages",
"worksheet_printed": "Printed",
"worksheet_never": "Never"
"worksheet_never": "Never",
"card_all_caught_up": "All caught up — nothing needs attention",
"postpone": "Postpone",
"postpone_date_prompt": "Postpone this occurrence to which date?",
"postpone_date_label": "New due date",
"postponed": "Postponed",
"postponed_to": "Postponed to",
"season_window_label": "Seasonal window (months)",
"season_window_hint": "Only due in the selected months; off-season dates roll to the next active month. None = all year.",
"series_end_label": "Ends",
"series_end_never": "Never (repeats indefinitely)",
"series_end_after_count": "After a number of times",
"series_end_until": "On a date",
"series_end_count_label": "Number of times",
"series_end_until_label": "End date"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extracto del manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impreso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Todo al día — nada requiere atención",
"postpone": "Posponer",
"postpone_date_prompt": "¿A qué fecha posponer esta vez?",
"postpone_date_label": "Nueva fecha de vencimiento",
"postponed": "Pospuesto",
"postponed_to": "Pospuesto hasta",
"season_window_label": "Ventana estacional (meses)",
"season_window_hint": "Solo vence en los meses seleccionados; las fechas fuera de temporada pasan al siguiente mes activo. Ninguno = todo el año.",
"series_end_label": "Termina",
"series_end_never": "Nunca (se repite indefinidamente)",
"series_end_after_count": "Tras un número de veces",
"series_end_until": "En una fecha",
"series_end_count_label": "Número de veces",
"series_end_until_label": "Fecha de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Ote käyttöohjeesta",
"worksheet_pages": "sivut",
"worksheet_printed": "Tulostettu",
"worksheet_never": "Ei koskaan"
"worksheet_never": "Ei koskaan",
"card_all_caught_up": "Kaikki kunnossa — mikään ei vaadi huomiota",
"postpone": "Lykkää",
"postpone_date_prompt": "Mihin päivään tämä kerta lykätään?",
"postpone_date_label": "Uusi eräpäivä",
"postponed": "Lykätty",
"postponed_to": "Lykätty päivään",
"season_window_label": "Kausi-ikkuna (kuukaudet)",
"season_window_hint": "Erääntyy vain valittuina kuukausina; kauden ulkopuoliset päivät siirtyvät seuraavaan aktiiviseen kuukauteen. Ei mitään = koko vuosi.",
"series_end_label": "Päättyy",
"series_end_never": "Ei koskaan (toistuu loputtomasti)",
"series_end_after_count": "Tietyn kertamäärän jälkeen",
"series_end_until": "Päivämääränä",
"series_end_count_label": "Kertojen määrä",
"series_end_until_label": "Päättymispäivä"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extrait du manuel",
"worksheet_pages": "pages",
"worksheet_printed": "Imprimé",
"worksheet_never": "Jamais"
"worksheet_never": "Jamais",
"card_all_caught_up": "Tout est à jour — rien ne requiert d'attention",
"postpone": "Reporter",
"postpone_date_prompt": "Reporter cette échéance à quelle date ?",
"postpone_date_label": "Nouvelle date d’échéance",
"postponed": "Reporté",
"postponed_to": "Reporté au",
"season_window_label": "Fenêtre saisonnière (mois)",
"season_window_hint": "Dû uniquement les mois sélectionnés ; les dates hors saison passent au mois actif suivant. Aucun = toute lannée.",
"series_end_label": "Se termine",
"series_end_never": "Jamais (répète indéfiniment)",
"series_end_after_count": "Après un nombre de fois",
"series_end_until": "À une date",
"series_end_count_label": "Nombre de fois",
"series_end_until_label": "Date de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "मैनुअल अंश",
"worksheet_pages": "पृष्ठ",
"worksheet_printed": "मुद्रित",
"worksheet_never": "कभी नहीं"
"worksheet_never": "कभी नहीं",
"card_all_caught_up": "सब पूरा — किसी चीज़ पर ध्यान देने की ज़रूरत नहीं",
"postpone": "टालें",
"postpone_date_prompt": "इस बार को किस तारीख तक टालें?",
"postpone_date_label": "नई नियत तिथि",
"postponed": "टाल दिया गया",
"postponed_to": "तक टाला गया",
"season_window_label": "मौसमी अवधि (महीने)",
"season_window_hint": "केवल चुने गए महीनों में देय; ऑफ-सीज़न तिथियाँ अगले सक्रिय महीने में चली जाती हैं। कोई नहीं = पूरे वर्ष।",
"series_end_label": "समाप्त होता है",
"series_end_never": "कभी नहीं (अनिश्चित रूप से दोहराता है)",
"series_end_after_count": "कुछ बार के बाद",
"series_end_until": "एक तिथि पर",
"series_end_count_label": "कितनी बार",
"series_end_until_label": "समाप्ति तिथि"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Estratto del manuale",
"worksheet_pages": "pagine",
"worksheet_printed": "Stampato",
"worksheet_never": "Mai"
"worksheet_never": "Mai",
"card_all_caught_up": "Tutto in ordine — niente richiede attenzione",
"postpone": "Posticipa",
"postpone_date_prompt": "A quale data posticipare questa scadenza?",
"postpone_date_label": "Nuova data di scadenza",
"postponed": "Posticipato",
"postponed_to": "Posticipato al",
"season_window_label": "Finestra stagionale (mesi)",
"season_window_hint": "Scade solo nei mesi selezionati; le date fuori stagione passano al mese attivo successivo. Nessuno = tutto lanno.",
"series_end_label": "Termina",
"series_end_never": "Mai (ripete allinfinito)",
"series_end_after_count": "Dopo un numero di volte",
"series_end_until": "In una data",
"series_end_count_label": "Numero di volte",
"series_end_until_label": "Data di fine"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "マニュアル抜粋",
"worksheet_pages": "ページ",
"worksheet_printed": "印刷日",
"worksheet_never": "未実施"
"worksheet_never": "未実施",
"card_all_caught_up": "すべて完了 — 対応が必要なものはありません",
"postpone": "延期",
"postpone_date_prompt": "この回をどの日付に延期しますか?",
"postpone_date_label": "新しい期日",
"postponed": "延期済み",
"postponed_to": "延期先",
"season_window_label": "季節ウィンドウ(月)",
"season_window_hint": "選択した月のみ期限;シーズン外の日付は次の有効な月へ繰り越し。なし=通年。",
"series_end_label": "終了",
"series_end_never": "なし(無期限に繰り返す)",
"series_end_after_count": "回数の後",
"series_end_until": "日付で",
"series_end_count_label": "回数",
"series_end_until_label": "終了日"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag fra manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Skrevet ut",
"worksheet_never": "Aldri"
"worksheet_never": "Aldri",
"card_all_caught_up": "Alt er i orden — ingenting trenger oppmerksomhet",
"postpone": "Utsett",
"postpone_date_prompt": "Utsett denne forekomsten til hvilken dato?",
"postpone_date_label": "Ny forfallsdato",
"postponed": "Utsatt",
"postponed_to": "Utsatt til",
"season_window_label": "Sesongvindu (måneder)",
"season_window_hint": "Forfaller kun i valgte måneder; datoer utenfor sesongen flyttes til neste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldri (gjentas uendelig)",
"series_end_after_count": "Etter et antall ganger",
"series_end_until": "På en dato",
"series_end_count_label": "Antall ganger",
"series_end_until_label": "Sluttdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handleidingfragment",
"worksheet_pages": "pagina's",
"worksheet_printed": "Afgedrukt",
"worksheet_never": "Nooit"
"worksheet_never": "Nooit",
"card_all_caught_up": "Alles bijgewerkt — niets vraagt aandacht",
"postpone": "Uitstellen",
"postpone_date_prompt": "Deze keer uitstellen tot welke datum?",
"postpone_date_label": "Nieuwe vervaldatum",
"postponed": "Uitgesteld",
"postponed_to": "Uitgesteld tot",
"season_window_label": "Seizoensvenster (maanden)",
"season_window_hint": "Alleen verschuldigd in de gekozen maanden; data buiten het seizoen schuiven naar de volgende actieve maand. Geen = hele jaar.",
"series_end_label": "Eindigt",
"series_end_never": "Nooit (herhaalt onbeperkt)",
"series_end_after_count": "Na een aantal keer",
"series_end_until": "Op een datum",
"series_end_count_label": "Aantal keer",
"series_end_until_label": "Einddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Fragment instrukcji",
"worksheet_pages": "strony",
"worksheet_printed": "Wydrukowano",
"worksheet_never": "Nigdy"
"worksheet_never": "Nigdy",
"card_all_caught_up": "Wszystko zrobione — nic nie wymaga uwagi",
"postpone": "Odłóż",
"postpone_date_prompt": "Przełożyć ten termin na jaką datę?",
"postpone_date_label": "Nowa data",
"postponed": "Odłożono",
"postponed_to": "Odłożono do",
"season_window_label": "Okno sezonowe (miesiące)",
"season_window_hint": "Termin tylko w wybranych miesiącach; daty poza sezonem przechodzą na następny aktywny miesiąc. Brak = cały rok.",
"series_end_label": "Kończy się",
"series_end_never": "Nigdy (powtarza bez końca)",
"series_end_after_count": "Po liczbie razy",
"series_end_until": "W dniu",
"series_end_count_label": "Liczba razy",
"series_end_until_label": "Data końcowa"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Excerto do manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impresso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Tudo em dia — nada requer atenção",
"postpone": "Adiar",
"postpone_date_prompt": "Adiar esta ocorrência para qual data?",
"postpone_date_label": "Nova data de vencimento",
"postponed": "Adiado",
"postponed_to": "Adiado para",
"season_window_label": "Janela sazonal (meses)",
"season_window_hint": "Só vence nos meses selecionados; datas fora de época passam para o próximo mês ativo. Nenhum = todo o ano.",
"series_end_label": "Termina",
"series_end_never": "Nunca (repete indefinidamente)",
"series_end_after_count": "Após um número de vezes",
"series_end_until": "Numa data",
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Выдержка из руководства",
"worksheet_pages": "страницы",
"worksheet_printed": "Напечатано",
"worksheet_never": "Никогда"
"worksheet_never": "Никогда",
"card_all_caught_up": "Всё выполнено — ничего не требует внимания",
"postpone": "Отложить",
"postpone_date_prompt": "На какую дату отложить этот раз?",
"postpone_date_label": "Новый срок",
"postponed": "Отложено",
"postponed_to": "Отложено до",
"season_window_label": "Сезонное окно (месяцы)",
"season_window_hint": "Срок только в выбранные месяцы; даты вне сезона переносятся на следующий активный месяц. Нет = весь год.",
"series_end_label": "Заканчивается",
"series_end_never": "Никогда (повторяется бесконечно)",
"series_end_after_count": "После числа раз",
"series_end_until": "В дату",
"series_end_count_label": "Число раз",
"series_end_until_label": "Дата окончания"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag ur manualen",
"worksheet_pages": "sidor",
"worksheet_printed": "Utskriven",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Allt klart — inget behöver åtgärdas",
"postpone": "Skjut upp",
"postpone_date_prompt": "Skjut upp den här gången till vilket datum?",
"postpone_date_label": "Nytt förfallodatum",
"postponed": "Uppskjuten",
"postponed_to": "Uppskjuten till",
"season_window_label": "Säsongsfönster (månader)",
"season_window_hint": "Förfaller endast valda månader; datum utanför säsongen flyttas till nästa aktiva månad. Inga = hela året.",
"series_end_label": "Slutar",
"series_end_never": "Aldrig (upprepas i oändlighet)",
"series_end_after_count": "Efter ett antal gånger",
"series_end_until": "På ett datum",
"series_end_count_label": "Antal gånger",
"series_end_until_label": "Slutdatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Витяг з посібника",
"worksheet_pages": "сторінки",
"worksheet_printed": "Надруковано",
"worksheet_never": "Ніколи"
"worksheet_never": "Ніколи",
"card_all_caught_up": "Усе виконано — ніщо не потребує уваги",
"postpone": "Відкласти",
"postpone_date_prompt": "На яку дату відкласти цей раз?",
"postpone_date_label": "Новий термін",
"postponed": "Відкладено",
"postponed_to": "Відкладено до",
"season_window_label": "Сезонне вікно (місяці)",
"season_window_hint": "Термін лише у вибрані місяці; дати поза сезоном переносяться на наступний активний місяць. Немає = весь рік.",
"series_end_label": "Завершується",
"series_end_never": "Ніколи (повторюється безкінечно)",
"series_end_after_count": "Після кількості разів",
"series_end_until": "У дату",
"series_end_count_label": "Кількість разів",
"series_end_until_label": "Дата завершення"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "手册摘录",
"worksheet_pages": "页",
"worksheet_printed": "打印于",
"worksheet_never": "从未"
"worksheet_never": "从未",
"card_all_caught_up": "全部完成——没有需要处理的事项",
"postpone": "推迟",
"postpone_date_prompt": "将此次推迟到哪一天?",
"postpone_date_label": "新的到期日",
"postponed": "已推迟",
"postponed_to": "推迟至",
"season_window_label": "季节窗口(月份)",
"season_window_hint": "仅在所选月份到期;非当季日期顺延至下一个活动月份。无 = 全年。",
"series_end_label": "结束",
"series_end_never": "从不(无限重复)",
"series_end_after_count": "达到次数后",
"series_end_until": "在某日期",
"series_end_count_label": "次数",
"series_end_until_label": "结束日期"
}
@@ -249,10 +249,16 @@ export class MaintenanceSupporterCard extends LitElement {
</div>
</div>
${tasks.length === 0
? html`<div class="empty-card">
<div>${t("card_no_tasks_title", L)}</div>
<a class="empty-link" href="/maintenance-supporter">${t("card_no_tasks_cta", L)}</a>
</div>`
? this._objects.some((o) => o.tasks.length > 0)
? html`<div class="empty-card">
<!-- (#86) tasks exist but none match the filter (default:
actionable-only) "all caught up", NOT "no tasks yet". -->
<div class="all-caught-up"> ${t("card_all_caught_up", L)}</div>
</div>`
: html`<div class="empty-card">
<div>${t("card_no_tasks_title", L)}</div>
<a class="empty-link" href="/maintenance-supporter">${t("card_no_tasks_cta", L)}</a>
</div>`
: html`
<div class="task-list ${compact ? "compact" : ""}">
${tasks.map(
@@ -262,7 +268,16 @@ export class MaintenanceSupporterCard extends LitElement {
title="${t("open_task", L) || "Open task"}">
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
<div class="task-info">
<div class="task-name">${task.name}</div>
<div class="task-name">
${task.name}
${(task as any).due_override
? html`<ha-icon
class="postponed-icon"
icon="mdi:calendar-clock"
title="${t("postponed", L) || "Postponed"}"
></ha-icon>`
: nothing}
</div>
${!compact ? html`<div class="task-meta">${object_name} · ${t(task.type, L)}</div>` : nothing}
</div>
<div class="task-due">
@@ -366,6 +381,7 @@ export class MaintenanceSupporterCard extends LitElement {
font-size: 13px;
}
.empty-link:hover { text-decoration: underline; }
.all-caught-up { color: var(--success-color, #4caf50); font-weight: 500; }
.task-list { padding: 0 16px 16px; }
.task-item {
@@ -387,6 +403,7 @@ export class MaintenanceSupporterCard extends LitElement {
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.postponed-icon { --mdc-icon-size: 14px; color: var(--secondary-text-color); vertical-align: text-bottom; margin-inline-start: 4px; }
.task-meta { font-size: 12px; color: var(--secondary-text-color); }
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; }
@@ -200,15 +200,22 @@ const SMALL_SCREEN_CONDITION: ViewColumnsCondition = {
// truth shared with the panel KPI chips and the statistics WS endpoint — so the
// numbers can't drift. Rendered live via a markdown template instead of being
// counted client-side at generation time (which froze them until reload).
function kpiMarkdownCard(): CardConfig {
// (#86) Entity ids come registry-resolved from the statistics WS: the
// documented sensor.maintenance_supporter_<key> ids are NOT guaranteed —
// a user rename, a _2 collision suffix, or a pre-pinning install that
// registered localized ids all made the hardcoded templates read "unknown".
type SummaryEntityIds = Partial<Record<"overdue" | "triggered" | "due_soon" | "ok", string | null>>;
function kpiMarkdownCard(ids: SummaryEntityIds = {}): CardConfig {
const eid = (key: keyof SummaryEntityIds) => ids[key] || `sensor.maintenance_supporter_${key}`;
return {
type: "markdown",
text_only: true,
content: [
"🔴 **{{ states('sensor.maintenance_supporter_overdue') }}** overdue",
"⚡ **{{ states('sensor.maintenance_supporter_triggered') }}** triggered",
"🟡 **{{ states('sensor.maintenance_supporter_due_soon') }}** due soon",
"🟢 **{{ states('sensor.maintenance_supporter_ok') }}** ok",
`🔴 **{{ states('${eid("overdue")}') }}** overdue`,
`⚡ **{{ states('${eid("triggered")}') }}** triggered`,
`🟡 **{{ states('${eid("due_soon")}') }}** due soon`,
`🟢 **{{ states('${eid("ok")}') }}** ok`,
].join(" · "),
};
}
@@ -263,8 +270,8 @@ function emptyStateView(): ViewConfig {
};
}
function overviewView(): ViewConfig {
const kpiCard = kpiMarkdownCard();
function overviewView(summaryIds: SummaryEntityIds = {}): ViewConfig {
const kpiCard = kpiMarkdownCard(summaryIds);
return {
title: "Overview",
icon: "mdi:wrench-clock",
@@ -614,10 +621,19 @@ export class MaintenanceDashboardStrategy extends HTMLElement {
calendar: () => viewsByCalendar(objects),
};
// Registry-resolved summary-sensor ids (#86); tolerate older backends.
let summaryIds: SummaryEntityIds = {};
try {
const stats = await hass.connection.sendMessagePromise<{ summary_entity_ids?: SummaryEntityIds }>({
type: "maintenance_supporter/statistics",
});
summaryIds = stats.summary_entity_ids || {};
} catch { /* fall back to the documented default ids */ }
return {
title: "Maintenance",
views: [
overviewView(),
overviewView(summaryIds),
...(viewBuilders[groupBy] ?? viewBuilders.area)(),
],
};
@@ -57,7 +57,11 @@ import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderTaskDetail, renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
// The task-detail sub-view as a web component (light-DOM, panel styles apply).
// Side-effect import: type-only imports get tree-shaken and the element
// would never register.
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
type View = "overview" | "object" | "task" | "all_objects";
@@ -1392,6 +1396,38 @@ export class MaintenanceSupporterPanel extends LitElement {
this._resetTask(entryId, taskId, result.value || undefined);
}
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/postpone",
entry_id: entryId,
task_id: taskId,
until,
});
this._showToast(t("postponed", this._lang));
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
if (!dlg) return;
const result = await dlg.prompt({
title: t("postpone", this._lang),
message: t("postpone_date_prompt", this._lang),
confirmText: t("postpone", this._lang),
inputLabel: t("postpone_date_label", this._lang),
inputType: "date",
});
if (!result.confirmed || !result.value) return;
this._postponeTask(entryId, taskId, result.value);
}
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
this._actionLoading = true;
try {
@@ -2855,6 +2891,7 @@ export class MaintenanceSupporterPanel extends LitElement {
openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName),
duplicateTask: () => this._duplicateTask(entryId, taskId),
promptReset: () => this._promptResetTask(entryId, taskId),
promptPostpone: () => this._promptPostponeTask(entryId, taskId),
snoozeTask: () => this._snoozeTask(entryId, taskId),
printWorksheet: () => this._printTaskWorksheet(entryId, taskId),
deleteTask: () => this._deleteTask(entryId, taskId),
@@ -2869,7 +2906,10 @@ export class MaintenanceSupporterPanel extends LitElement {
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
if (!task) return html`<p>Task not found.</p>`;
return renderTaskDetail(task, this._taskDetailCtx());
return html`<maintenance-task-detail-view
.task=${task}
.ctx=${this._taskDetailCtx()}
></maintenance-task-detail-view>`;
}
/** v2.2.0: open the in-place history-edit dialog for the given entry.
@@ -304,6 +304,10 @@ export const panelStyles = css`
--mdc-icon-size: 26px;
}
/* Custom elements default to display:inline; the task-detail component
renders light-DOM and must behave like the block it wraps. */
maintenance-task-detail-view { display: block; }
.detail-section { padding: 16px 0; }
.detail-header {
@@ -589,9 +593,29 @@ export const panelStyles = css`
border-radius: 12px;
padding: 2px;
}
/* Inside .cell-badges the parent's gap does the spacing the badges' own
margin-left (meant for inline use, e.g. the detail header) would double
it and push the priority chevron out of line with the other badges. */
.cell-badges .nfc-badge,
.cell-badges .priority-badge {
margin-left: 0;
}
.priority-badge ha-icon {
--mdc-icon-size: 16px;
}
.postponed-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
margin-left: 8px;
background: var(--secondary-background-color, #e8e8e8);
color: var(--secondary-text-color);
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.postponed-badge ha-icon { --mdc-icon-size: 13px; }
.priority-high {
color: var(--error-color, #db4437);
}
@@ -61,6 +61,7 @@ export interface TaskDetailContext {
openQr: (taskName: string) => void;
duplicateTask: () => void;
promptReset: () => void;
promptPostpone: () => void;
snoozeTask: () => void;
/** v2.21: open the printable one-pager for this task. */
printWorksheet: () => void;
@@ -104,6 +105,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
<span class="status-chip ${statusClass}">${statusText}</span>
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
</span>` : nothing}
${renderUserBadge(task, ctx.getUserName)}
${task.nfc_tag_id
? html`<span class="nfc-badge" title="${t("nfc_tag_id", L)}: ${task.nfc_tag_id}"><ha-icon icon="mdi:nfc-variant"></ha-icon> NFC</span>`
@@ -131,6 +135,7 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
<div class="popup-menu-divider"></div>
@@ -740,11 +740,14 @@ export const sharedStyles = css`
transition: width 0.3s;
}
/* Trigger progress bar (overview rows) */
/* Trigger progress bar (overview rows). width:100% the due-cell doesn't
stretch its children (align-items: flex-end), so without it the bar
shrinks to its label and reads shorter than the days-bar in other rows. */
.trigger-progress {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
min-width: 90px;
}
@@ -133,6 +133,10 @@ export interface TaskSchedule {
business?: boolean;
/** (#83) shift the computed occurrence by ±N days (clamped ±15). */
offset?: number;
/** Seasonal active window — months (1..12) the task may be due in. */
season_months?: number[];
/** Finite-series end condition: after N completions and/or past a date. */
ends?: { count?: number; until?: string };
}
export interface MaintenanceTask {
@@ -182,6 +186,8 @@ export interface MaintenanceTask {
archived_reason?: string | null;
days_until_due?: number | null;
next_due?: string | null;
/** Per-occurrence postpone: the ISO date the current cycle was deferred to. */
due_override?: string | null;
trigger_active: boolean;
trigger_current_value?: number | null;
trigger_current_delta?: number | null;