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);
});
});