329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -66,7 +66,7 @@ export async function mountPanel(
objects: unknown[],
extraHandlers: Record<string, WsHandler> = {},
) {
const { hass, sent } = createMockHass({
const { hass, sent, subscriptions } = createMockHass({
handlers: {
"maintenance_supporter/objects": () => ({ objects }),
"maintenance_supporter/statistics": () => ({
@@ -97,7 +97,7 @@ export async function mountPanel(
await el.updateComplete;
await new Promise((r) => setTimeout(r, 10));
await el.updateComplete;
return { el, sent };
return { el, sent, subscriptions };
}
export function sr(el: HTMLElement): ShadowRoot {
@@ -97,7 +97,10 @@ export type WsHandler = (msg: SentMessage) => Promise<unknown> | unknown;
export interface CreateMockHassResult {
hass: {
language: string;
connection: { sendMessagePromise: (msg: SentMessage) => Promise<unknown> };
connection: {
sendMessagePromise: (msg: SentMessage) => Promise<unknown>;
subscribeMessage: (cb: (event: unknown) => void, msg: SentMessage) => Promise<() => void>;
};
callService: (
domain: string, service: string,
data?: Record<string, unknown>, target?: Record<string, unknown>,
@@ -107,6 +110,8 @@ export interface CreateMockHassResult {
};
sent: SentMessage[];
serviceCalls: ServiceCall[];
/** Captured subscribeMessage registrations — push events via `.push(ev)`. */
subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }>;
}
export interface CreateMockHassOptions {
@@ -157,15 +162,31 @@ export function createMockHass(opts: CreateMockHassOptions = {}): CreateMockHass
serviceCalls.push({ domain, service, data, target });
};
// Captured subscriptions: tests push events into components via
// `subscriptions.find(...).push(event)`.
const subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }> = [];
const subscribeMessage = async (
cb: (event: unknown) => void,
msg: SentMessage,
): Promise<() => void> => {
const entry = { msg, push: (event: unknown) => cb(event) };
subscriptions.push(entry);
return () => {
const i = subscriptions.indexOf(entry);
if (i >= 0) subscriptions.splice(i, 1);
};
};
return {
hass: {
language: opts.language ?? "en",
connection: { sendMessagePromise },
connection: { sendMessagePromise, subscribeMessage },
callService,
services: opts.services,
states: opts.states,
},
sent,
serviceCalls,
subscriptions,
};
}
@@ -38,6 +38,31 @@ async function mount(canWrite: boolean): Promise<MaintenancePartsSection> {
}
describe("parts-section", () => {
it("shows the inventory value only when a part has price AND tracked stock (#104)", async () => {
// PARTS as-is: p1 has stock but no cost, p2 no stock → chip hidden.
const bare = await mount(false);
expect(bare.shadowRoot!.querySelector(".inventory-value")).to.equal(null);
const priced: MaintenancePart[] = [
{ id: "p1", name: "Filter", cost: 12.5, stock: 3, is_low: false },
{ id: "p2", name: "Brush", cost: 4, stock: null, is_low: false }, // untracked → no contribution
{ id: "p3", name: "Seal", cost: null, stock: 5, is_low: false }, // unpriced → no contribution
];
const el = await fixture<MaintenancePartsSection>(html`
<maintenance-parts-section
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
.entryId=${"e1"}
.parts=${priced}
.currencySymbol=${"€"}
></maintenance-parts-section>
`);
await el.updateComplete;
const chip = el.shadowRoot!.querySelector(".inventory-value")!;
expect(chip, "value chip rendered").to.exist;
expect(chip.textContent).to.include("37.50");
expect(chip.textContent).to.include("€");
});
it("renders a row per part with stock badge, identifiers and location", async () => {
const el = await mount(false);
const rows = el.shadowRoot!.querySelectorAll(".part-row");
@@ -57,6 +57,9 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
objectManualDocs: [],
openManualDoc: () => {},
setChecklistItem: () => {},
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
@@ -212,6 +215,57 @@ describe("task-detail renderer", () => {
expect(host2.querySelector(".kpi-bar")).to.be.null;
});
it("object-manual row falls back to an attached manual when the URL is empty", () => {
let opened: unknown = null;
const host = mount(task(), ctx({
objectDocUrl: null,
objectManualDocs: [{ id: "d1", title: "Pump Handbook", kind: "file" }],
openManualDoc: (d) => { opened = d; },
}));
const link = [...host.querySelectorAll(".task-meta-link a")]
.find((a) => a.textContent?.includes("Pool Pump")) as HTMLElement | undefined;
expect(link, "fallback manual link rendered").to.exist;
expect(link!.getAttribute("title")).to.equal("Pump Handbook");
link!.click();
expect((opened as { id?: string })?.id).to.equal("d1");
});
it("the URL field still wins over attached manuals", () => {
const host = mount(task(), ctx({
objectDocUrl: "https://vendor.example/manual",
objectManualDocs: [{ id: "d1", title: "Pump Handbook", kind: "file" }],
}));
const link = [...host.querySelectorAll(".task-meta-link a")]
.find((a) => a.textContent?.includes("Pool Pump")) as HTMLElement | undefined;
expect(link, "manual row rendered").to.exist;
expect(link!.getAttribute("href")).to.equal("https://vendor.example/manual");
});
it("checklist ticks render from progress and fire setChecklistItem (#73)", () => {
const calls: Array<[string, boolean]> = [];
const host = mount(
task({ checklist: ["Drain", "Clean", "Refill"], checklist_progress: { Clean: true } }),
ctx({
features: {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: true, schedule_time: false, completion_actions: false,
},
setChecklistItem: (item, done) => calls.push([item, done]),
}),
);
const header = host.querySelector(".checklist-preview-header")!;
expect(header.textContent).to.include("1/3");
const boxes = [...host.querySelectorAll<HTMLInputElement>(".checklist-preview-list input")];
expect(boxes.length).to.equal(3);
expect(boxes[1].checked).to.be.true;
expect(boxes[0].checked).to.be.false;
expect(host.querySelectorAll(".checklist-preview-list li.checked").length).to.equal(1);
boxes[0].click();
expect(calls).to.deep.equal([["Drain", true]]);
});
it("KPI bar shows warning days and currency symbol", () => {
const host = mount(task(), ctx({ currencySymbol: "$" }));
const kpi = host.querySelector(".kpi-bar")!;
@@ -54,6 +54,9 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
objectManualDocs: [],
openManualDoc: () => {},
setChecklistItem: () => {},
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
@@ -18,10 +18,35 @@ interface BatteryRow {
level: number | null;
days_until: number | null;
available?: boolean;
/** Where the ~date comes from: "trend" (discharge regression) or "typical"
* (type-lifetime table). */
predicted_source?: "trend" | "typical";
prediction_confidence?: "medium" | "high" | null;
/** Charged, never bought — the row never feeds the shopping groupings and
* a ~date only appears when the discharge trend earned one. */
rechargeable?: boolean;
/** This battery's own low threshold (Battery Notes' configured value or
* the fleet floor, whichever is higher) — the level bar colors against
* it, not against a fixed 20 %. */
low_threshold?: number;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
}
/** 30 d downsampled level history per battery, for the roster sparklines.
* threshold = the same low threshold the trend forecast regresses toward,
* so the dotted projection ends exactly where the ~date comes from.
* jump = an upward step that looks like a swap nobody recorded in Battery
* Notes (the forecast still anchors on the dead battery's date) — with the
* device to call `battery_notes.set_battery_replaced` on. */
type HistorySeries = Record<
string,
{
points: [number, number][];
threshold: number;
jump?: { at: number; from: number; to: number; device_id: string };
}
>;
interface Overview {
available: boolean;
configured: boolean;
@@ -45,6 +70,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
@state() private _loading = false;
@state() private _marking = false;
@state() private _error = "";
@state() private _history: HistorySeries | null = null;
// Urgency by default (issue #123: "soon sat in the middle of the list");
// the choice is remembered per browser.
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
@state() private _typeFilter: string | null = null;
@state() private _recorded: string[] = [];
private _historyRequested = false;
private _localeReady = false;
private get _lang(): string {
@@ -139,20 +171,165 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
}
/** Lazy: the recorder-backed history is fetched once, when the roster is
* first expanded — most panel visits never open it. */
private _loadHistory = async (e: Event): Promise<void> => {
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
this._historyRequested = true;
try {
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
type: "maintenance_supporter/battery_fleet/overview_history",
});
this._history = res.series;
} catch {
this._history = null; // sparklines are an enhancement — rows render without them
}
};
/** Inline-SVG sparkline: 30 d level line, a faint threshold line, and —
* where the ~date comes from the discharge trend — a dotted projection
* from the last reading down to the threshold, so the date is visible
* instead of merely stated. */
private _sparkline(b: RosterRow) {
const h = this._history?.[b.entity_id];
if (!h || h.points.length < 2) return nothing;
const W = 110, H = 24, P = 2;
const t0 = h.points[0][0];
const tLast = h.points[h.points.length - 1][0];
const nowSec = Date.now() / 1000;
const projEnd =
b.status !== "low" && b.predicted_source === "trend" && b.days_until != null
? nowSec + b.days_until * 86400
: null;
const tMax = Math.max(tLast, projEnd ?? tLast);
const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P));
const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P);
const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const vLast = h.points[h.points.length - 1][1];
const yTh = y(h.threshold).toFixed(1);
return html`<svg
class="bf-spark"
viewBox="0 0 ${W} ${H}"
role="img"
aria-label=${t("battery_fleet_sparkline_hint", this._lang)}
>
<title>${t("battery_fleet_sparkline_hint", this._lang)}</title>
<line class="bf-spark-th" x1="0" y1=${yTh} x2=${W} y2=${yTh}></line>
<polyline class="bf-spark-line" points=${line}></polyline>
${projEnd !== null
? html`<line
class="bf-spark-proj"
x1=${x(tLast).toFixed(1)}
y1=${y(vLast).toFixed(1)}
x2=${x(projEnd).toFixed(1)}
y2=${yTh}
></line>`
: nothing}
</svg>`;
}
private static readonly _SORT_KEY = "ms_bf_roster_sort";
private static _storedSort(): "name" | "urgency" {
try {
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
return v === "name" ? "name" : "urgency";
} catch {
return "urgency";
}
}
private _setSort(mode: "name" | "urgency"): void {
this._rosterSort = mode;
try {
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
} catch {
// storage unavailable — the toggle still works for this visit
}
}
/** Urgency (the default, issue #123): low rows first — emptiest first —
* then the soonest forecast, dateless rows last. Name mode keeps the
* alphabetical lookup list. */
private _sortedRoster(rows: RosterRow[]): RosterRow[] {
const filtered = this._typeFilter === null ? rows : rows.filter((r) => r.battery_type === this._typeFilter);
if (this._rosterSort === "name") return filtered;
// Low rows rank far below everything and among themselves by LEVEL
// ascending (a 6 % battery before an 18 % one); the rest by days-until.
const rank = (r: RosterRow) => (r.status === "low" ? -1000 + (r.level ?? 101) / 101 : (r.days_until ?? Infinity));
return [...filtered].sort(
(a, b) => rank(a) - rank(b) || a.device_name.localeCompare(b.device_name),
);
}
/** The forecast as a date a person can plan with, not a day count.
* `days_until` comes from last-replaced + typical lifetime, so it is an
* estimate — the tilde in the template says so. Negative values (past the
* typical lifetime but not reported low yet) render as past dates, which
* is honest: the battery is living on borrowed time. */
private _predictedDate(daysUntil: number): string {
const when = new Date(Date.now() + daysUntil * 864e5);
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(when);
return this._fmtDate(Date.now() + daysUntil * 864e5);
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
.join(" · ");
private _fmtDate(epochMs: number): string {
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
* roster to the devices that need it — "which devices need those 4× AAA?"
* without scanning. Clicking the active chip clears the filter. */
private _shoppingLine(needs: Record<string, number>) {
return Object.entries(needs).map(
([type, qty]) => html`<button
class="bf-type-chip ${this._typeFilter === type ? "bf-type-chip-active" : ""}"
title=${t("battery_fleet_filter_type", this._lang)}
@click=${() => this._toggleTypeFilter(type)}
>
${qty}× ${type}
</button>`,
);
}
private _toggleTypeFilter(type: string): void {
this._typeFilter = this._typeFilter === type ? null : type;
if (this._typeFilter !== null) {
const details = this.shadowRoot?.querySelector<HTMLDetailsElement>("details.bf-roster");
if (details && !details.open) details.open = true; // fires toggle → history loads
}
}
/** One-click fix for a detected-but-unrecorded swap: record the DETECTED
* jump time in Battery Notes, so the forecast re-anchors on the real
* replacement instead of the dead battery's date. */
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.callService("battery_notes", "set_battery_replaced", {
device_id: jump.device_id,
datetime_replaced: new Date(jump.at * 1000).toISOString(),
});
this._recorded = [...this._recorded, entityId];
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
}
/** Purely visual level bar next to the number — scannable at a glance.
* Colored against the battery's OWN low threshold: red at/below it,
* amber inside a 20-point approach band, green above. */
private _levelBar(b: BatteryRow) {
const level = b.level;
if (level == null) return nothing;
const t = b.low_threshold ?? 20;
const cls = level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
return html`<span class="bf-bar" aria-hidden="true"
><span class="bf-bar-fill bf-bar-${cls}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
></span>`;
}
render() {
@@ -199,10 +376,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
: nothing}
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
<button
class="bf-mark"
title=${t("battery_fleet_mark_one", L)}
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
.disabled=${this._marking}
@click=${() => this._mark([b.entity_id])}
>
@@ -238,20 +421,57 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: nothing}
${ov.all?.length
? html`
<details class="bf-roster">
<details class="bf-roster" @toggle=${this._loadHistory}>
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
<div class="bf-roster-tools">
<button
class="bf-sort ${this._rosterSort === "urgency" ? "bf-sort-active" : ""}"
@click=${() => this._setSort("urgency")}
>
${t("battery_fleet_sort_urgency", L)}
</button>
<button
class="bf-sort ${this._rosterSort === "name" ? "bf-sort-active" : ""}"
@click=${() => this._setSort("name")}
>
${t("battery_fleet_sort_name", L)}
</button>
</div>
<div class="bf-rows">
${ov.all.map(
${this._sortedRoster(ov.all).map(
(b) => html`
<div class="bf-row">
<span class="bf-dev">${b.device_name}</span>
<span class="bf-status bf-${b.status}">${t("battery_fleet_status_" + b.status, L)}</span>
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
${b.rechargeable
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
></span>`
: nothing}
${this._sparkline(b)}
${this._levelBar(b)}
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
${(() => {
const jump = this._history?.[b.entity_id]?.jump;
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
return html`<button
class="bf-mark bf-jump"
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
.disabled=${this._marking}
@click=${() => this._recordJump(b.entity_id, jump)}
>
<ha-icon icon="mdi:calendar-sync"></ha-icon>
</button>`;
})()}
${b.days_until != null
? html`<span
class="bf-predicted"
title=${t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
title=${b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>~${this._predictedDate(b.days_until)}</span
>`
: nothing}
@@ -385,6 +605,102 @@ export class MaintenanceBatteryFleetSection extends LitElement {
color: var(--secondary-text-color);
font-size: 13px;
}
.bf-recharge {
color: var(--secondary-text-color);
display: inline-flex;
cursor: help;
}
.bf-recharge ha-icon {
--mdc-icon-size: 16px;
}
.bf-spark {
width: 110px;
height: 24px;
flex: 0 0 auto;
cursor: help;
}
/* On phones the row cannot fit name + chips + curve + bar + date: the
* decorations yield (the percentage still carries the number). */
@media (max-width: 640px) {
.bf-spark,
.bf-bar {
display: none;
}
}
.bf-spark-line {
fill: none;
stroke: var(--primary-color);
stroke-width: 1.5;
stroke-linejoin: round;
}
.bf-spark-proj {
stroke: var(--primary-color);
stroke-width: 1.2;
stroke-dasharray: 2 3;
opacity: 0.7;
}
.bf-spark-th {
stroke: var(--error-color, #f44336);
stroke-width: 1;
opacity: 0.35;
}
.bf-type-chip {
background: none;
border: 1px solid var(--divider-color);
border-radius: 10px;
padding: 1px 8px;
margin: 0 4px 2px 0;
font-size: 13px;
color: inherit;
cursor: pointer;
}
.bf-type-chip-active {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bf-bar {
width: 30px;
height: 6px;
border-radius: 3px;
background: var(--divider-color);
overflow: hidden;
flex: 0 0 auto;
}
.bf-bar-fill {
display: block;
height: 100%;
border-radius: 3px;
}
.bf-bar-good {
background: var(--success-color, #4caf50);
}
.bf-bar-warn {
background: var(--warning-color, #ff9800);
}
.bf-bar-bad {
background: var(--error-color, #f44336);
}
.bf-jump ha-icon {
color: var(--warning-color, #ff9800);
}
.bf-roster-tools {
display: flex;
gap: 6px;
margin: 8px 0 2px;
}
.bf-sort {
background: none;
border: 1px solid var(--divider-color);
border-radius: 12px;
padding: 2px 10px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.bf-sort-active {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bf-level {
font-size: 12px;
color: var(--error-color, #f44336);
@@ -448,6 +764,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
color: var(--secondary-text-color);
white-space: nowrap;
}
/* Trend-based dates (discharge regression) get a dotted underline — the
tooltip carries source + confidence. */
.bf-predicted.bf-trend {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);
@@ -52,6 +52,10 @@ export class MaintenanceCompleteDialog extends LitElement {
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
* prefill the dialog so nobody re-ticks what is already done. */
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
public open(): void {
if (this._open) return;
this._open = true;
@@ -59,7 +63,13 @@ export class MaintenanceCompleteDialog extends LitElement {
this._cost = "";
this._duration = "";
this._error = "";
this._checklistState = {};
// The dialog's own state is INDEX-keyed (historical shape, flows into the
// history entry as-is) — map the text-keyed in-cycle ticks onto indices.
this._checklistState = Object.fromEntries(
this.checklist
.map((item, i) => [String(i), !!this.checklistPrefill[item]] as const)
.filter(([, done]) => done),
);
this._feedback = "needed";
this._photoDocId = "";
this._photoPreview = "";
@@ -59,6 +59,7 @@ export class MaintenancePartsSection extends LitElement {
@property({ attribute: false }) public entryId!: string;
@property({ attribute: false }) public parts: MaintenancePart[] = [];
@property({ type: Boolean }) public canWrite = false;
@property({ attribute: false }) public currencySymbol = "€";
@state() private _editing: PartForm | null = null;
@state() private _busy = false;
@@ -335,6 +336,19 @@ export class MaintenancePartsSection extends LitElement {
`;
}
/** Inventory value = Σ unit cost × tracked stock (#104). Parts without a
* price or without tracked stock contribute nothing; null = no part has
* both, so the chip stays hidden rather than showing a misleading 0. */
private _inventoryValue(): number | null {
let sum = 0, any = false;
for (const p of this.parts) {
const cost = typeof p.cost === "number" ? p.cost : null;
const stock = typeof p.stock === "number" ? p.stock : null;
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
}
return any ? sum : null;
}
protected render() {
const L = this._lang;
if (!this.parts.length && !this.canWrite) return nothing;
@@ -343,6 +357,11 @@ export class MaintenancePartsSection extends LitElement {
<h3>
<ha-icon icon="mdi:package-variant"></ha-icon>
${t("parts_section", L)} (${this.parts.length})
${this._inventoryValue() !== null
? html`<span class="inventory-value" title=${t("parts_inventory_value", L)}
>${t("parts_inventory_value", L)}:
${this._inventoryValue()!.toFixed(2)}&nbsp;${this.currencySymbol}</span>`
: nothing}
</h3>
${this.canWrite && !this._editing
? html`<ha-button appearance="plain" @click=${() => this._openAdd()}>
@@ -361,6 +380,13 @@ export class MaintenancePartsSection extends LitElement {
display: block;
margin: 12px 0;
}
.inventory-value {
margin-left: 8px;
font-size: 0.75em;
font-weight: 400;
color: var(--secondary-text-color);
white-space: nowrap;
}
.section-head {
display: flex;
align-items: center;
@@ -716,6 +716,7 @@
"series_end_count_label": "Počet opakování",
"series_end_until_label": "Datum konce",
"parts_section": "Díly a spotřební materiál",
"parts_inventory_value": "Hodnota zásob",
"part_add": "Přidat díl",
"part_name": "Název",
"part_vendor": "Výrobce",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Brzy",
"battery_fleet_status_ok": "V pořádku",
"battery_fleet_predicted_on": "Očekáváno kolem {date}",
"battery_fleet_predicted_trend": "Předpověď z trendu vybíjení této baterie: přibližně {date} ({confidence})",
"battery_fleet_rechargeable": "Akumulátor: nabíjí se místo výměny — nikdy na nákupním seznamu",
"battery_fleet_sort_name": "Řadit podle názvu",
"battery_fleet_sort_urgency": "Řadit podle naléhavosti",
"battery_fleet_mark_recharged": "Označit jako nabitou",
"battery_fleet_sparkline_hint": "Stav baterie za posledních 30 dní — tečkovaně: projekce k prahu vybití",
"battery_fleet_filter_type": "Zobrazit pouze tento typ baterie",
"battery_fleet_record_replacement": "Stav poskočil kolem {date} — zaznamenat tuto výměnu do Battery Notes",
"battery_fleet_total": "Sledováno baterií: {n}",
"battery_fleet_setup_button": "Flotila baterií",
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Antal gange",
"series_end_until_label": "Slutdato",
"parts_section": "Dele & forbrugsvarer",
"parts_inventory_value": "Lagerværdi",
"part_add": "Tilføj del",
"part_name": "Navn",
"part_vendor": "Producent",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Snart",
"battery_fleet_status_ok": "I orden",
"battery_fleet_predicted_on": "Forventes omkring {date}",
"battery_fleet_predicted_trend": "Forudsagt ud fra batteriets afladningstendens: omkring {date} ({confidence})",
"battery_fleet_rechargeable": "Genopladeligt: oplad i stedet for at udskifte — aldrig på indkøbslisten",
"battery_fleet_sort_name": "Sortér efter navn",
"battery_fleet_sort_urgency": "Sortér efter hastende grad",
"battery_fleet_mark_recharged": "Markér som genopladet",
"battery_fleet_sparkline_hint": "Batteriniveau de seneste 30 dage — stiplet: fremskrivning ned til lav-tærsklen",
"battery_fleet_filter_type": "Vis kun denne batteritype",
"battery_fleet_record_replacement": "Niveauet sprang omkring {date} — registrér denne udskiftning i Battery Notes",
"battery_fleet_total": "{n} batterier overvåges",
"battery_fleet_setup_button": "Batteriflåde",
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Anzahl",
"series_end_until_label": "Enddatum",
"parts_section": "Teile & Verbrauchsmaterial",
"parts_inventory_value": "Lagerwert",
"part_add": "Teil hinzufügen",
"part_name": "Name",
"part_vendor": "Hersteller",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Bald",
"battery_fleet_status_ok": "In Ordnung",
"battery_fleet_predicted_on": "Voraussichtlich um {date}",
"battery_fleet_predicted_trend": "Aus dem Entladetrend dieser Batterie vorhergesagt: etwa {date} ({confidence})",
"battery_fleet_rechargeable": "Wiederaufladbar: laden statt ersetzen — nie auf der Einkaufsliste",
"battery_fleet_sort_name": "Nach Name sortieren",
"battery_fleet_sort_urgency": "Nach Dringlichkeit sortieren",
"battery_fleet_mark_recharged": "Als aufgeladen markieren",
"battery_fleet_sparkline_hint": "Batteriestand der letzten 30 Tage — gepunktet: Prognose bis zur Low-Schwelle",
"battery_fleet_filter_type": "Nur diesen Batterietyp anzeigen",
"battery_fleet_record_replacement": "Der Batteriestand ist um den {date} gesprungen — diesen Wechsel in Battery Notes nachtragen",
"battery_fleet_total": "{n} Batterien überwacht",
"battery_fleet_setup_button": "Batterie-Flotte",
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
@@ -722,6 +722,7 @@
"series_end_count_label": "Number of times",
"series_end_until_label": "End date",
"parts_section": "Parts & consumables",
"parts_inventory_value": "Inventory value",
"part_add": "Add part",
"part_name": "Name",
"part_vendor": "Manufacturer",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Soon",
"battery_fleet_status_ok": "Healthy",
"battery_fleet_predicted_on": "Expected around {date}",
"battery_fleet_predicted_trend": "Predicted from this battery's discharge trend: around {date} ({confidence})",
"battery_fleet_rechargeable": "Rechargeable: charge instead of replacing — never on the shopping list",
"battery_fleet_sort_name": "Sort by name",
"battery_fleet_sort_urgency": "Sort by urgency",
"battery_fleet_mark_recharged": "Mark as recharged",
"battery_fleet_sparkline_hint": "Battery level over the last 30 days — dotted: projected until the low threshold",
"battery_fleet_filter_type": "Show only this battery type",
"battery_fleet_record_replacement": "The level jumped around {date} — record this replacement in Battery Notes",
"battery_fleet_total": "{n} batteries tracked",
"battery_fleet_setup_button": "Battery fleet",
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Número de veces",
"series_end_until_label": "Fecha de fin",
"parts_section": "Piezas y consumibles",
"parts_inventory_value": "Valor del inventario",
"part_add": "Añadir pieza",
"part_name": "Nombre",
"part_vendor": "Fabricante",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Pronto",
"battery_fleet_status_ok": "Correcta",
"battery_fleet_predicted_on": "Previsto hacia {date}",
"battery_fleet_predicted_trend": "Predicción según la tendencia de descarga de esta pila: hacia {date} ({confidence})",
"battery_fleet_rechargeable": "Recargable: se recarga en lugar de sustituirse — nunca en la lista de compra",
"battery_fleet_sort_name": "Ordenar por nombre",
"battery_fleet_sort_urgency": "Ordenar por urgencia",
"battery_fleet_mark_recharged": "Marcar como recargada",
"battery_fleet_sparkline_hint": "Nivel de batería de los últimos 30 días — punteado: proyección hasta el umbral bajo",
"battery_fleet_filter_type": "Mostrar solo este tipo de pila",
"battery_fleet_record_replacement": "El nivel dio un salto hacia el {date} — registrar esta sustitución en Battery Notes",
"battery_fleet_total": "{n} baterías monitorizadas",
"battery_fleet_setup_button": "Flota de baterías",
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Kertojen määrä",
"series_end_until_label": "Päättymispäivä",
"parts_section": "Osat ja tarvikkeet",
"parts_inventory_value": "Varaston arvo",
"part_add": "Lisää osa",
"part_name": "Nimi",
"part_vendor": "Valmistaja",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Pian",
"battery_fleet_status_ok": "Kunnossa",
"battery_fleet_predicted_on": "Odotettavissa noin {date}",
"battery_fleet_predicted_trend": "Ennustettu tämän pariston purkautumistrendistä: noin {date} ({confidence})",
"battery_fleet_rechargeable": "Ladattava: lataa vaihtamisen sijaan — ei koskaan ostoslistalle",
"battery_fleet_sort_name": "Lajittele nimen mukaan",
"battery_fleet_sort_urgency": "Lajittele kiireellisyyden mukaan",
"battery_fleet_mark_recharged": "Merkitse ladatuksi",
"battery_fleet_sparkline_hint": "Akun varaustaso viimeisten 30 päivän ajalta — pisteviiva: ennuste alarajaan asti",
"battery_fleet_filter_type": "Näytä vain tämä paristotyyppi",
"battery_fleet_record_replacement": "Varaustaso hyppäsi noin {date} — kirjaa tämä vaihto Battery Notesiin",
"battery_fleet_total": "{n} paristoa seurannassa",
"battery_fleet_setup_button": "Paristokanta",
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Nombre de fois",
"series_end_until_label": "Date de fin",
"parts_section": "Pièces & consommables",
"parts_inventory_value": "Valeur du stock",
"part_add": "Ajouter une pièce",
"part_name": "Nom",
"part_vendor": "Fabricant",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Bientôt",
"battery_fleet_status_ok": "Bon état",
"battery_fleet_predicted_on": "Prévu vers {date}",
"battery_fleet_predicted_trend": "Prédit à partir de la tendance de décharge de cette pile : vers {date} ({confidence})",
"battery_fleet_rechargeable": "Rechargeable : à recharger plutôt qu'à remplacer — jamais sur la liste d'achats",
"battery_fleet_sort_name": "Trier par nom",
"battery_fleet_sort_urgency": "Trier par urgence",
"battery_fleet_mark_recharged": "Marquer comme rechargée",
"battery_fleet_sparkline_hint": "Niveau de batterie des 30 derniers jours — pointillé : projection jusqu'au seuil bas",
"battery_fleet_filter_type": "Afficher uniquement ce type de pile",
"battery_fleet_record_replacement": "Le niveau a bondi vers le {date} — enregistrer ce remplacement dans Battery Notes",
"battery_fleet_total": "{n} piles suivies",
"battery_fleet_setup_button": "Parc de piles",
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
@@ -716,6 +716,7 @@
"series_end_count_label": "कितनी बार",
"series_end_until_label": "समाप्ति तिथि",
"parts_section": "पुर्ज़े और उपभोग्य",
"parts_inventory_value": "इन्वेंट्री मूल्य",
"part_add": "पुर्ज़ा जोड़ें",
"part_name": "नाम",
"part_vendor": "निर्माता",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "जल्द",
"battery_fleet_status_ok": "ठीक",
"battery_fleet_predicted_on": "{date} के आसपास अपेक्षित",
"battery_fleet_predicted_trend": "इस बैटरी के डिस्चार्ज रुझान से अनुमानित: लगभग {date} ({confidence})",
"battery_fleet_rechargeable": "रिचार्जेबल: बदलने के बजाय चार्ज करें — खरीदारी सूची में कभी नहीं",
"battery_fleet_sort_name": "नाम के अनुसार क्रमबद्ध करें",
"battery_fleet_sort_urgency": "तात्कालिकता के अनुसार क्रमबद्ध करें",
"battery_fleet_mark_recharged": "रिचार्ज हो गई के रूप में चिह्नित करें",
"battery_fleet_sparkline_hint": "पिछले 30 दिनों का बैटरी स्तर — बिंदीदार: लो थ्रेशोल्ड तक का अनुमान",
"battery_fleet_filter_type": "केवल यही बैटरी प्रकार दिखाएँ",
"battery_fleet_record_replacement": "स्तर लगभग {date} को उछला — इस बदलाव को Battery Notes में दर्ज करें",
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
"battery_fleet_setup_button": "बैटरी फ्लीट",
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
@@ -722,6 +722,7 @@
"series_end_count_label": "Alkalmak száma",
"series_end_until_label": "Befejezés dátuma",
"parts_section": "Alkatrészek és fogyóeszközök",
"parts_inventory_value": "Készletérték",
"part_add": "Alkatrész hozzáadása",
"part_name": "Név",
"part_vendor": "Gyártó",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Hamarosan",
"battery_fleet_status_ok": "Rendben",
"battery_fleet_predicted_on": "Várhatóan {date} körül",
"battery_fleet_predicted_trend": "Az elem merülési trendjéből előrejelezve: kb. {date} ({confidence})",
"battery_fleet_rechargeable": "Újratölthető: cserélés helyett töltse fel — soha nem kerül a bevásárlólistára",
"battery_fleet_sort_name": "Rendezés név szerint",
"battery_fleet_sort_urgency": "Rendezés sürgősség szerint",
"battery_fleet_mark_recharged": "Megjelölés feltöltöttként",
"battery_fleet_sparkline_hint": "Akkumulátorszint az elmúlt 30 napban — pontozott: előrejelzés az alacsony küszöbig",
"battery_fleet_filter_type": "Csak ez az elemtípus megjelenítése",
"battery_fleet_record_replacement": "A szint {date} körül megugrott — rögzítse ezt a cserét a Battery Notes-ban",
"battery_fleet_total": "{n} elem követve",
"battery_fleet_setup_button": "Elemflotta",
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Numero di volte",
"series_end_until_label": "Data di fine",
"parts_section": "Ricambi e consumabili",
"parts_inventory_value": "Valore delle scorte",
"part_add": "Aggiungi ricambio",
"part_name": "Nome",
"part_vendor": "Produttore",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "A breve",
"battery_fleet_status_ok": "A posto",
"battery_fleet_predicted_on": "Previsto intorno al {date}",
"battery_fleet_predicted_trend": "Previsto dalla tendenza di scarica di questa batteria: intorno al {date} ({confidence})",
"battery_fleet_rechargeable": "Ricaricabile: da ricaricare anziché sostituire — mai nella lista della spesa",
"battery_fleet_sort_name": "Ordina per nome",
"battery_fleet_sort_urgency": "Ordina per urgenza",
"battery_fleet_mark_recharged": "Segna come ricaricata",
"battery_fleet_sparkline_hint": "Livello batteria degli ultimi 30 giorni — tratteggiato: proiezione fino alla soglia di batteria scarica",
"battery_fleet_filter_type": "Mostra solo questo tipo di batteria",
"battery_fleet_record_replacement": "Il livello è balzato intorno al {date} — registra questa sostituzione in Battery Notes",
"battery_fleet_total": "{n} batterie monitorate",
"battery_fleet_setup_button": "Parco batterie",
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
@@ -716,6 +716,7 @@
"series_end_count_label": "回数",
"series_end_until_label": "終了日",
"parts_section": "部品・消耗品",
"parts_inventory_value": "在庫金額",
"part_add": "部品を追加",
"part_name": "名前",
"part_vendor": "メーカー",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "まもなく",
"battery_fleet_status_ok": "良好",
"battery_fleet_predicted_on": "{date} 頃の見込み",
"battery_fleet_predicted_trend": "この電池の放電傾向から予測: {date} 頃 ({confidence})",
"battery_fleet_rechargeable": "充電式:交換ではなく充電——買い物リストには載りません",
"battery_fleet_sort_name": "名前順に並べ替え",
"battery_fleet_sort_urgency": "緊急度順に並べ替え",
"battery_fleet_mark_recharged": "充電済みにする",
"battery_fleet_sparkline_hint": "過去30日間のバッテリー残量——点線:低残量しきい値までの予測",
"battery_fleet_filter_type": "この電池タイプのみ表示",
"battery_fleet_record_replacement": "{date}頃に残量が急上昇——この交換をBattery Notesに記録する",
"battery_fleet_total": "{n} 個の電池を監視",
"battery_fleet_setup_button": "電池フリート",
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
@@ -722,6 +722,7 @@
"series_end_count_label": "횟수",
"series_end_until_label": "종료일",
"parts_section": "부품 및 소모품",
"parts_inventory_value": "재고 가치",
"part_add": "부품 추가",
"part_name": "이름",
"part_vendor": "제조사",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "곧",
"battery_fleet_status_ok": "정상",
"battery_fleet_predicted_on": "{date}쯤 예상",
"battery_fleet_predicted_trend": "이 배터리의 방전 추세로 예측: 약 {date} ({confidence})",
"battery_fleet_rechargeable": "충전식: 교체 대신 충전 — 쇼핑 목록에 오르지 않습니다",
"battery_fleet_sort_name": "이름순 정렬",
"battery_fleet_sort_urgency": "긴급도순 정렬",
"battery_fleet_mark_recharged": "충전 완료로 표시",
"battery_fleet_sparkline_hint": "지난 30일간의 배터리 잔량 — 점선: 낮음 임계값까지의 예측",
"battery_fleet_filter_type": "이 배터리 유형만 표시",
"battery_fleet_record_replacement": "{date}쯤 잔량이 급상승했습니다 — 이 교체를 Battery Notes에 기록",
"battery_fleet_total": "배터리 {n}개 추적 중",
"battery_fleet_setup_button": "배터리 플릿",
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Antall ganger",
"series_end_until_label": "Sluttdato",
"parts_section": "Deler & forbruksvarer",
"parts_inventory_value": "Lagerverdi",
"part_add": "Legg til del",
"part_name": "Navn",
"part_vendor": "Produsent",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Snart",
"battery_fleet_status_ok": "I orden",
"battery_fleet_predicted_on": "Forventes rundt {date}",
"battery_fleet_predicted_trend": "Forutsagt ut fra batteriets utladingstrend: rundt {date} ({confidence})",
"battery_fleet_rechargeable": "Oppladbart: lad i stedet for å bytte — aldri på handlelisten",
"battery_fleet_sort_name": "Sorter etter navn",
"battery_fleet_sort_urgency": "Sorter etter hastegrad",
"battery_fleet_mark_recharged": "Merk som oppladet",
"battery_fleet_sparkline_hint": "Batterinivå de siste 30 dagene — stiplet: fremskrevet ned til lavnivåterskelen",
"battery_fleet_filter_type": "Vis bare denne batteritypen",
"battery_fleet_record_replacement": "Nivået hoppet rundt {date} — registrer dette byttet i Battery Notes",
"battery_fleet_total": "{n} batterier spores",
"battery_fleet_setup_button": "Batteriflåte",
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Aantal keer",
"series_end_until_label": "Einddatum",
"parts_section": "Onderdelen & verbruiksartikelen",
"parts_inventory_value": "Voorraadwaarde",
"part_add": "Onderdeel toevoegen",
"part_name": "Naam",
"part_vendor": "Fabrikant",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Binnenkort",
"battery_fleet_status_ok": "In orde",
"battery_fleet_predicted_on": "Verwacht rond {date}",
"battery_fleet_predicted_trend": "Voorspeld uit de ontladingstrend van deze batterij: rond {date} ({confidence})",
"battery_fleet_rechargeable": "Oplaadbaar: opladen in plaats van vervangen — nooit op het boodschappenlijstje",
"battery_fleet_sort_name": "Sorteren op naam",
"battery_fleet_sort_urgency": "Sorteren op urgentie",
"battery_fleet_mark_recharged": "Markeren als opgeladen",
"battery_fleet_sparkline_hint": "Batterijniveau van de afgelopen 30 dagen — gestippeld: prognose tot de lage drempel",
"battery_fleet_filter_type": "Alleen dit batterijtype tonen",
"battery_fleet_record_replacement": "Het niveau maakte rond {date} een sprong — deze vervanging vastleggen in Battery Notes",
"battery_fleet_total": "{n} batterijen gevolgd",
"battery_fleet_setup_button": "Batterijvloot",
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Liczba razy",
"series_end_until_label": "Data końcowa",
"parts_section": "Części i materiały",
"parts_inventory_value": "Wartość zapasów",
"part_add": "Dodaj część",
"part_name": "Nazwa",
"part_vendor": "Producent",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Wkrótce",
"battery_fleet_status_ok": "W porządku",
"battery_fleet_predicted_on": "Przewidywane około {date}",
"battery_fleet_predicted_trend": "Prognoza na podstawie trendu rozładowania tej baterii: około {date} ({confidence})",
"battery_fleet_rechargeable": "Akumulator: ładowanie zamiast wymiany — nigdy na liście zakupów",
"battery_fleet_sort_name": "Sortuj według nazwy",
"battery_fleet_sort_urgency": "Sortuj według pilności",
"battery_fleet_mark_recharged": "Oznacz jako naładowaną",
"battery_fleet_sparkline_hint": "Poziom baterii z ostatnich 30 dni — kropkowana linia: prognoza do progu niskiego poziomu",
"battery_fleet_filter_type": "Pokaż tylko ten typ baterii",
"battery_fleet_record_replacement": "Poziom skoczył około {date} — zapisz tę wymianę w Battery Notes",
"battery_fleet_total": "Śledzone baterie: {n}",
"battery_fleet_setup_button": "Flota baterii",
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
@@ -722,6 +722,7 @@
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final",
"parts_section": "Peças e consumíveis",
"parts_inventory_value": "Valor do estoque",
"part_add": "Adicionar peça",
"part_name": "Nome",
"part_vendor": "Fabricante",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Em breve",
"battery_fleet_status_ok": "Em bom estado",
"battery_fleet_predicted_on": "Previsto por volta de {date}",
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta bateria: por volta de {date} ({confidence})",
"battery_fleet_rechargeable": "Recarregável: recarregue em vez de substituir — nunca na lista de compras",
"battery_fleet_sort_name": "Ordenar por nome",
"battery_fleet_sort_urgency": "Ordenar por urgência",
"battery_fleet_mark_recharged": "Marcar como recarregada",
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até o limite baixo",
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registrar esta substituição no Battery Notes",
"battery_fleet_total": "{n} baterias acompanhadas",
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final",
"parts_section": "Peças e consumíveis",
"parts_inventory_value": "Valor do stock",
"part_add": "Adicionar peça",
"part_name": "Nome",
"part_vendor": "Fabricante",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Em breve",
"battery_fleet_status_ok": "Em bom estado",
"battery_fleet_predicted_on": "Previsto por volta de {date}",
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta pilha: por volta de {date} ({confidence})",
"battery_fleet_rechargeable": "Recarregável: carrega-se em vez de se substituir — nunca na lista de compras",
"battery_fleet_sort_name": "Ordenar por nome",
"battery_fleet_sort_urgency": "Ordenar por urgência",
"battery_fleet_mark_recharged": "Marcar como recarregada",
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até ao limiar baixo",
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registar esta substituição no Battery Notes",
"battery_fleet_total": "{n} baterias monitorizadas",
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Число раз",
"series_end_until_label": "Дата окончания",
"parts_section": "Детали и расходники",
"parts_inventory_value": "Стоимость запасов",
"part_add": "Добавить деталь",
"part_name": "Название",
"part_vendor": "Производитель",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Скоро",
"battery_fleet_status_ok": "В норме",
"battery_fleet_predicted_on": "Ожидается примерно {date}",
"battery_fleet_predicted_trend": "Прогноз по тренду разряда этой батареи: примерно {date} ({confidence})",
"battery_fleet_rechargeable": "Аккумулятор: заряжается, а не заменяется — никогда не попадает в список покупок",
"battery_fleet_sort_name": "Сортировать по имени",
"battery_fleet_sort_urgency": "Сортировать по срочности",
"battery_fleet_mark_recharged": "Отметить как заряженную",
"battery_fleet_sparkline_hint": "Уровень заряда за последние 30 дней — пунктир: прогноз до порога разряда",
"battery_fleet_filter_type": "Показать только этот тип батарей",
"battery_fleet_record_replacement": "Уровень резко вырос примерно {date} — записать эту замену в Battery Notes",
"battery_fleet_total": "Отслеживается батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Antal gånger",
"series_end_until_label": "Slutdatum",
"parts_section": "Delar & förbrukning",
"parts_inventory_value": "Lagervärde",
"part_add": "Lägg till del",
"part_name": "Namn",
"part_vendor": "Tillverkare",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Snart",
"battery_fleet_status_ok": "I ordning",
"battery_fleet_predicted_on": "Väntas omkring {date}",
"battery_fleet_predicted_trend": "Förutspått utifrån batteriets urladdningstrend: omkring {date} ({confidence})",
"battery_fleet_rechargeable": "Uppladdningsbart: ladda i stället för att byta — aldrig på inköpslistan",
"battery_fleet_sort_name": "Sortera efter namn",
"battery_fleet_sort_urgency": "Sortera efter angelägenhet",
"battery_fleet_mark_recharged": "Markera som uppladdad",
"battery_fleet_sparkline_hint": "Batterinivå de senaste 30 dagarna — prickad: prognos ner till lågnivåtröskeln",
"battery_fleet_filter_type": "Visa endast denna batterityp",
"battery_fleet_record_replacement": "Nivån hoppade omkring {date} — registrera detta byte i Battery Notes",
"battery_fleet_total": "{n} batterier spåras",
"battery_fleet_setup_button": "Batteriflotta",
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
@@ -722,6 +722,7 @@
"series_end_count_label": "Tekrar sayısı",
"series_end_until_label": "Bitiş tarihi",
"parts_section": "Parçalar ve sarf malzemeleri",
"parts_inventory_value": "Stok değeri",
"part_add": "Parça ekle",
"part_name": "Ad",
"part_vendor": "Üretici",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Yakında",
"battery_fleet_status_ok": "İyi durumda",
"battery_fleet_predicted_on": "Yaklaşık {date} tarihinde bekleniyor",
"battery_fleet_predicted_trend": "Bu pilin deşarj eğiliminden tahmin edildi: yaklaşık {date} ({confidence})",
"battery_fleet_rechargeable": "Şarj edilebilir: değiştirmek yerine şarj edin — alışveriş listesine asla girmez",
"battery_fleet_sort_name": "Ada göre sırala",
"battery_fleet_sort_urgency": "Aciliyete göre sırala",
"battery_fleet_mark_recharged": "Şarj edildi olarak işaretle",
"battery_fleet_sparkline_hint": "Son 30 günün pil seviyesi — noktalı: düşük eşiğe kadar projeksiyon",
"battery_fleet_filter_type": "Yalnızca bu pil türünü göster",
"battery_fleet_record_replacement": "Seviye {date} civarında sıçradı — bu değişimi Battery Notes'a kaydet",
"battery_fleet_total": "{n} pil takip ediliyor",
"battery_fleet_setup_button": "Pil filosu",
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
@@ -716,6 +716,7 @@
"series_end_count_label": "Кількість разів",
"series_end_until_label": "Дата завершення",
"parts_section": "Деталі та витратні матеріали",
"parts_inventory_value": "Вартість запасів",
"part_add": "Додати деталь",
"part_name": "Назва",
"part_vendor": "Виробник",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "Незабаром",
"battery_fleet_status_ok": "У нормі",
"battery_fleet_predicted_on": "Очікується приблизно {date}",
"battery_fleet_predicted_trend": "Прогноз за трендом розряду цієї батареї: приблизно {date} ({confidence})",
"battery_fleet_rechargeable": "Акумулятор: заряджається, а не замінюється — ніколи не потрапляє до списку покупок",
"battery_fleet_sort_name": "Сортувати за назвою",
"battery_fleet_sort_urgency": "Сортувати за терміновістю",
"battery_fleet_mark_recharged": "Позначити як заряджену",
"battery_fleet_sparkline_hint": "Рівень заряду за останні 30 днів — пунктир: прогноз до порогу розряду",
"battery_fleet_filter_type": "Показати лише цей тип батарей",
"battery_fleet_record_replacement": "Рівень різко зріс приблизно {date} — записати цю заміну в Battery Notes",
"battery_fleet_total": "Відстежується батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
@@ -716,6 +716,7 @@
"series_end_count_label": "次数",
"series_end_until_label": "结束日期",
"parts_section": "配件与耗材",
"parts_inventory_value": "库存价值",
"part_add": "添加配件",
"part_name": "名称",
"part_vendor": "制造商",
@@ -820,6 +821,14 @@
"battery_fleet_status_soon": "即将",
"battery_fleet_status_ok": "正常",
"battery_fleet_predicted_on": "预计在 {date} 前后",
"battery_fleet_predicted_trend": "根据此电池的放电趋势预测:约 {date}({confidence})",
"battery_fleet_rechargeable": "可充电电池:充电即可,无需更换——不会出现在购物清单中",
"battery_fleet_sort_name": "按名称排序",
"battery_fleet_sort_urgency": "按紧急程度排序",
"battery_fleet_mark_recharged": "标记为已充电",
"battery_fleet_sparkline_hint": "过去 30 天的电池电量——虚线:外推至低电量阈值",
"battery_fleet_filter_type": "仅显示此电池类型",
"battery_fleet_record_replacement": "电量在 {date} 前后跳升——将此次更换记录到 Battery Notes",
"battery_fleet_total": "已跟踪 {n} 个电池",
"battery_fleet_setup_button": "电池群",
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
@@ -1,6 +1,7 @@
/** Maintenance Supporter Lovelace Card. */
import { LitElement, html, css, nothing } from "lit";
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import type {
@@ -274,10 +275,14 @@ export class MaintenanceSupporterCard extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const data = msg as { objects: MaintenanceObjectResponse[] };
this._objects = data.objects;
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
if (next !== null) this._objects = next;
},
{ type: "maintenance_supporter/subscribe" }
// deltas: only changed entries arrive — see helpers/subscription-merge.
{ type: "maintenance_supporter/subscribe", deltas: true }
);
// Detached mid-subscribe → drop the orphaned subscription.
if (!this.isConnected) {
@@ -2,6 +2,7 @@
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
@@ -29,6 +30,7 @@ import type {
StatisticsPoint,
SavedView,
SavedViewFilters,
ManualDocRef,
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
@@ -385,10 +387,18 @@ export class MaintenanceSupporterPanel extends LitElement {
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
// Battery Fleet availability (Battery Notes present + not yet set up).
// A data refresh means the open task's history may have grown (complete,
// history edit) — the truncated list payload can't tell, so refetch.
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
}
// Battery Fleet availability (batteries present + not yet set up).
// The slim status check, NOT the overview: the full overview runs the
// trend machinery server-side (one recorder regression per healthy
// battery on a cold cache) — far too expensive for hiding a button.
this.hass.connection
.sendMessagePromise<{ available: boolean; configured: boolean }>({
type: "maintenance_supporter/battery_fleet/overview",
type: "maintenance_supporter/battery_fleet/status",
})
.then((ov) => {
this._batteryFleetSetupAvailable = !!ov.available && !ov.configured;
@@ -591,10 +601,15 @@ export class MaintenanceSupporterPanel extends LitElement {
try {
const unsub = await this.hass.connection.subscribeMessage(
(msg: unknown) => {
const data = msg as { objects: MaintenanceObjectResponse[] };
this._objects = data.objects;
const next = mergeSubscriptionEvent(
this._objects,
msg as SubscriptionEvent<MaintenanceObjectResponse>,
);
if (next !== null) this._objects = next;
},
{ type: "maintenance_supporter/subscribe" }
// deltas: only entries whose rebuilt response actually changed —
// no-op timer waves send nothing, a real change ships one object.
{ type: "maintenance_supporter/subscribe", deltas: true }
);
// If the element was detached while the subscribe was in flight, drop the
// now-orphaned subscription instead of storing it on a dead component.
@@ -880,6 +895,9 @@ export class MaintenanceSupporterPanel extends LitElement {
this._activeTab = "overview";
this._historyFilter = null;
this._scrollContentToTop();
// Payload diet: list responses carry only the most recent history
// window — the detail's full timeline/charts load here, on demand.
this._fetchFullHistory(entryId, taskId);
// Lazy-load statistics for the task's trigger entity
const task = this._getTask(entryId, taskId);
@@ -1785,6 +1803,52 @@ export class MaintenanceSupporterPanel extends LitElement {
}
}
/** Open a manual-tagged document from the objects table / object header:
* web-links directly, stored files through a signed path (the same
* Companion-safe recipe as the documents section). */
private _openManualDoc(doc: ManualDocRef): void {
if (doc.kind !== "file") {
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
return;
}
// Open the tab synchronously (inside the click gesture) so it isn't
// popup-blocked, then point it at the freshly signed URL.
const win = window.open("about:blank", "_blank");
void this.hass.connection
.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
})
.then((signed) => {
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
})
.catch(() => win?.close());
}
/** #73: persist one checklist tick. Sends the FULL current state (the
* server replaces, not merges — idempotent) and reloads so the progress
* header and any other open surface agree. */
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
const obj = this._getObject(entryId);
const task = obj?.tasks.find((x) => x.id === taskId);
if (!task) return;
const state: Record<string, boolean> = {};
for (const step of task.checklist || []) {
const current = task.checklist_progress?.[step] ?? false;
state[step] = step === item ? done : current;
}
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/checklist_progress",
entry_id: entryId, task_id: taskId, checklist_state: state,
});
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
}
}
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
if (!dlg) return;
@@ -1801,6 +1865,8 @@ export class MaintenanceSupporterPanel extends LitElement {
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
// #73: ticks recorded during the cycle prefill the dialog's checklist.
dlg.checklistPrefill = tk?.checklist_progress || {};
dlg.requiredFields = tk?.required_completion_fields || [];
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
@@ -2765,13 +2831,22 @@ export class MaintenanceSupporterPanel extends LitElement {
const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—";
return html`<td class="oc-area_id">${area}</td>`;
}
case "documentation_url":
case "documentation_url": {
// Fallback: an UPLOADED manual (category "manual") is the object's
// manual just as much as the legacy URL field — an object with its
// handbook attached must not render "—" here (prod: Easee vs Epson).
const manualDoc = (o.manual_docs || [])[0];
return html`<td class="oc-documentation_url">${
isSafeHttpUrl(o.documentation_url)
? html`<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer"
@click=${(e: Event) => e.stopPropagation()}><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
: manualDoc
? html`<a href="#" title=${manualDoc.title}
@click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this._openManualDoc(manualDoc); }}
><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
}</td>`;
}
case "notes":
return html`<td class="oc-notes" title=${o.notes || ""}>${o.notes || "—"}</td>`;
case "task_count":
@@ -3085,7 +3160,16 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${t("documentation_url_label", L)}:
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
</p>`
: nothing}
: (o.manual_docs || []).length
? html`<p class="meta">${t("documentation_url_label", L)}:
${o.manual_docs!.slice(0, 3).map(
(m, i) => html`${i > 0 ? " · " : ""}<a href="#"
@click=${(e: Event) => { e.preventDefault(); this._openManualDoc(m); }}>${m.title}</a>`,
)}${o.manual_docs!.length > 3
? html` … +${o.manual_docs!.length - 3}`
: nothing}
</p>`
: nothing}
${o.installation_date ? html`<p class="meta">${t("installed", L)}: ${formatDate(o.installation_date, L)}</p>` : nothing}
${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing}
${o.notes
@@ -3157,6 +3241,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.entryId=${obj.entry_id}
.parts=${obj.parts || []}
.canWrite=${!isOperator}
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
</div>
@@ -3221,7 +3306,16 @@ export class MaintenanceSupporterPanel extends LitElement {
const task = this._selectedEntryId && this._selectedTaskId
? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId)
: undefined;
const readings = (task?.history || [])
// Payload diet: the summary's history is truncated to the recent window —
// the reading DELTAS must come from the full record (the oldest visible
// reading would otherwise lose or falsify its delta), which
// _fetchFullHistory loads for the open task.
const fh = this._fullHistory;
const fullHistory =
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task?.history || []).length
? fh.entries
: task?.history || [];
const readings = fullHistory
.filter((h) => h.reading_value != null)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return {
@@ -3256,6 +3350,9 @@ export class MaintenanceSupporterPanel extends LitElement {
taskId,
objectName: obj?.object.name || "",
objectDocUrl: obj?.object?.documentation_url ?? null,
objectManualDocs: obj?.object?.manual_docs ?? [],
openManualDoc: (doc) => this._openManualDoc(doc),
setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done),
isOperator: this._isOperator,
actionLoading: this._actionLoading,
moreMenuOpen: this._moreMenuOpen,
@@ -3293,12 +3390,37 @@ export class MaintenanceSupporterPanel extends LitElement {
};
}
/** Full history for the OPEN task (list payloads are truncated to the
* most recent window). null until loaded; a failure — e.g. an older
* backend without `task/history` — falls back to the truncated list. */
@state() private _fullHistory: { entryId: string; taskId: string; entries: HistoryEntry[] } | null = null;
private async _fetchFullHistory(entryId: string, taskId: string): Promise<void> {
try {
const res = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/history",
entry_id: entryId,
task_id: taskId,
})) as { history: HistoryEntry[] };
if (this._selectedEntryId === entryId && this._selectedTaskId === taskId) {
this._fullHistory = { entryId, taskId, entries: res.history || [] };
}
} catch {
this._fullHistory = null;
}
}
private _renderTaskDetail() {
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
if (!task) return html`<p>Task not found.</p>`;
const fh = this._fullHistory;
const detailTask =
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task.history || []).length
? { ...task, history: fh.entries }
: task;
return html`<maintenance-task-detail-view
.task=${task}
.task=${detailTask}
.ctx=${this._taskDetailCtx()}
></maintenance-task-detail-view>`;
}
@@ -31,14 +31,25 @@
* (all of which only matter once a dashboard is actually being generated).
*/
import { BUNDLE_VERSION } from "./helpers/bundle-version";
const STRATEGY_TYPE = "maintenance-supporter";
const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`;
const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor";
// Absolute URL of the full strategy bundle (served at STRATEGY_URL by the
// integration). The shim's only dependency, loaded on demand.
//
// Version-busted (issue #124): the bundle's chunk names are content-hashed
// and change every release, but browsers heuristically cache this entry
// (static serving sends no Cache-Control). A stale cached entry then imports
// chunk names the update deleted → 404 → the strategy dashboard dies until
// a hard refresh. The `?v=` makes the URL change with the release, so a
// fresh entry always pulls its matching chunks. BUNDLE_VERSION is inlined
// by esbuild — the built shim keeps its zero-import guarantee.
const BUNDLE_URL =
"/maintenance_supporter_strategy/maintenance-dashboard-strategy.js";
"/maintenance_supporter_strategy/maintenance-dashboard-strategy.js" +
`?v=${BUNDLE_VERSION}`;
let _bundle: Promise<unknown> | null = null;
function loadBundle(): Promise<unknown> {
@@ -9,10 +9,10 @@
"version": "0.1.0",
"dependencies": {
"pdfjs-dist": "^4.10.38",
"playwright": "1.61"
"playwright": "1.62"
},
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@open-wc/testing": "^5.0.0",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
@@ -864,25 +864,25 @@
}
},
"node_modules/@open-wc/semantic-dom-diff": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.1.tgz",
"integrity": "sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.21.0.tgz",
"integrity": "sha512-2hrNt9MWhz4kfuIWI6M6zyK+UJXd2ehjaP+nJ1shf9HZAperr+braPToTbRODx1oE6EvoVvTJGQlsCqHv7NKQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^4.3.1",
"@web/test-runner-commands": "^0.9.0"
"@web/test-runner-commands": "^1.0.0"
}
},
"node_modules/@open-wc/testing": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-4.0.0.tgz",
"integrity": "sha512-KI70O0CJEpBWs3jrTju4BFCy7V/d4tFfYWkg8pMzncsDhD7TYNHLw5cy+s1FHXIgVFetnMDhPpwlKIPvtTQW7w==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-5.0.0.tgz",
"integrity": "sha512-2IcM2py1wtz4pyTW5whhltP5L71TVqt0l0IeA2KqHILyxOzI77YnDc9drfN7ezl0+fZbGOXM6qXe4Y4o0R7xpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
"@open-wc/semantic-dom-diff": "^0.20.0",
"@open-wc/semantic-dom-diff": "^0.21.0",
"@open-wc/testing-helpers": "^3.0.0",
"@types/chai-dom": "^1.11.0",
"@types/sinon-chai": "^3.2.3",
@@ -2011,16 +2011,16 @@
}
},
"node_modules/@web/browser-logs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.1.tgz",
"integrity": "sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@web/config-loader": {
@@ -2064,56 +2064,9 @@
}
},
"node_modules/@web/dev-server-core": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.5.tgz",
"integrity": "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^2.1.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.13.0",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.2.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@web/dev-server-esbuild": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-2.0.0.tgz",
"integrity": "sha512-D4BPYj3jO3kTDmytKWYB97xIVpR/Mdpy+zOyY1rEpFfFAcM59LCmO76Y8hp78Ssm4+4c2Gvd27gBVLSqWTPN3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdn/browser-compat-data": "^4.0.0",
"@web/dev-server-core": "^1.0.0",
"esbuild": "^0.28.1",
"parse5": "^6.0.1",
"ua-parser-js": "^1.0.33"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server-esbuild/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.1.tgz",
"integrity": "sha512-hwps4+UoNDoAP5oM2wLuwWi7AB9drW6k1ybRx0eLSly966zqj3gRSHCJmtGtC0u77HdnlED4PDFSdJfrq2Oo3A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2140,15 +2093,18 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server-esbuild/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"node_modules/@web/dev-server-esbuild": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-2.0.0.tgz",
"integrity": "sha512-D4BPYj3jO3kTDmytKWYB97xIVpR/Mdpy+zOyY1rEpFfFAcM59LCmO76Y8hp78Ssm4+4c2Gvd27gBVLSqWTPN3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
"@mdn/browser-compat-data": "^4.0.0",
"@web/dev-server-core": "^1.0.0",
"esbuild": "^0.28.1",
"parse5": "^6.0.1",
"ua-parser-js": "^1.0.33"
},
"engines": {
"node": ">=22.0.0"
@@ -2172,98 +2128,10 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server-rollup/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server-rollup/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/dev-server/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/parse5-utils": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.1.tgz",
"integrity": "sha512-7rBVZEMGfrq2iPcAEwJ0KSNSvmA2a6jT2CK8/gyIOHgn4reg7bSSRbzyWIEYWyIkeRoYEukX/aW+nAeCgSSqhQ==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2271,7 +2139,7 @@
"parse5": "^6.0.1"
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner": {
@@ -2322,64 +2190,21 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-chrome/node_modules/@web/browser-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"node_modules/@web/test-runner-commands": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-1.0.1.tgz",
"integrity": "sha512-rvAvHJKlNzh5Tv8L2wGjnNX/hSkPmQx5eyKqeyExEvA7A2pVXiNYFstJzUGCD52TlAJSPF3BtW1yOwpf6vG7Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
"@web/test-runner-core": "^1.0.0",
"mkdirp": "^1.0.4"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-chrome/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-chrome/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-chrome/node_modules/@web/test-runner-core": {
"node_modules/@web/test-runner-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
@@ -2417,58 +2242,6 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-commands": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz",
"integrity": "sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@web/test-runner-core": "^0.13.0",
"mkdirp": "^1.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@web/test-runner-core": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz",
"integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^0.4.0",
"@web/dev-server-core": "^0.7.3",
"chokidar": "^4.0.1",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"internal-ip": "^6.2.0",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.2.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@web/test-runner-coverage-v8": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-1.0.0.tgz",
@@ -2486,101 +2259,6 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/browser-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/test-runner-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^1.0.0",
"@web/dev-server-core": "^1.0.0",
"chokidar": "^4.0.1",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"internal-ip": "^6.2.0",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.3.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-mocha": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-mocha/-/test-runner-mocha-1.0.0.tgz",
@@ -2594,101 +2272,6 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-mocha/node_modules/@web/browser-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-mocha/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-mocha/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-mocha/node_modules/@web/test-runner-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^1.0.0",
"@web/dev-server-core": "^1.0.0",
"chokidar": "^4.0.1",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"internal-ip": "^6.2.0",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.3.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-playwright": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-1.0.0.tgz",
@@ -2704,210 +2287,6 @@
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-playwright/node_modules/@web/browser-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-playwright/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-playwright/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner-playwright/node_modules/@web/test-runner-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^1.0.0",
"@web/dev-server-core": "^1.0.0",
"chokidar": "^4.0.1",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"internal-ip": "^6.2.0",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.3.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner/node_modules/@web/browser-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"errorstacks": "^2.4.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner/node_modules/@web/dev-server-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
"@web/parse5-utils": "^3.0.0",
"chokidar": "^4.0.1",
"clone": "^2.1.2",
"es-module-lexer": "^1.0.0",
"get-stream": "^6.0.0",
"is-stream": "^2.0.0",
"isbinaryfile": "^5.0.0",
"koa": "^2.16.1",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"lru-cache": "^8.0.4",
"mime-types": "^2.1.27",
"parse5": "^6.0.1",
"picomatch": "^2.3.2",
"ws": "^7.5.10"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner/node_modules/@web/parse5-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse5": "^6.0.1",
"parse5": "^6.0.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner/node_modules/@web/test-runner-commands": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-1.0.0.tgz",
"integrity": "sha512-8wXJkwvWWCc6GTPsdjbEN141kz+4MQPFjsId47hoibkY3FhkgeKJUm+cnwMYCIXHuWA0e7V+L2fmH087UPrXXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@web/test-runner-core": "^1.0.0",
"mkdirp": "^1.0.4"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@web/test-runner/node_modules/@web/test-runner-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^1.0.0",
"@web/dev-server-core": "^1.0.0",
"chokidar": "^4.0.1",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"internal-ip": "^6.2.0",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.3.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -5366,33 +4745,33 @@
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/portfinder": {
@@ -9,7 +9,7 @@
"test:watch": "web-test-runner --config web-test-runner.config.mjs --watch"
},
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@open-wc/testing": "^5.0.0",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
@@ -19,7 +19,7 @@
},
"dependencies": {
"pdfjs-dist": "^4.10.38",
"playwright": "1.61"
"playwright": "1.62"
},
"overrides": {
"esbuild": "$esbuild",
@@ -81,7 +81,10 @@ export const panelStyles = css`
flex-wrap: wrap;
gap: 8px;
padding: 4px 0 8px;
justify-content: flex-end;
/* Left-aligned on purpose: flex-end mimicked the pre-v2.37 look (buttons
trailing the filter row), but on wide desktops it strands the primary
"new task" action at the far right of an otherwise empty row. */
justify-content: flex-start;
}
:host([narrow]) .actions-bar {
@@ -1009,6 +1012,21 @@ export const panelStyles = css`
.checklist-preview-list li {
padding: 1px 0;
}
/* #73: interactive in-cycle ticks. */
.checklist-preview-list label {
display: inline-flex;
gap: 8px;
align-items: baseline;
cursor: pointer;
}
.checklist-preview-list input[type="checkbox"] {
accent-color: var(--primary-color);
cursor: pointer;
}
.checklist-preview-list li.checked label span {
text-decoration: line-through;
opacity: 0.6;
}
/* Recommendation Card */
.recommendation-card {
@@ -12,7 +12,7 @@
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
import { renderPredictionSection } from "./prediction";
import { renderWeibullSection } from "./weibull";
@@ -32,6 +32,13 @@ export interface TaskDetailContext {
objectName: string;
/** Parent object's documentation_url (raw; sanitised here). */
objectDocUrl: string | null | undefined;
/** Parent object's manual-tagged documents the fallback for the manual
* row when documentation_url is empty (same rule as the objects table). */
objectManualDocs: ManualDocRef[];
/** Opens a manual document (signed path / weblink) — panel-owned. */
openManualDoc: (doc: ManualDocRef) => void;
/** #73: persist one checklist tick (panel sends the full state via WS). */
setChecklistItem: (item: string, done: boolean) => void;
isOperator: boolean;
actionLoading: boolean;
moreMenuOpen: boolean;
@@ -178,22 +185,38 @@ function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDeta
`;
}
/** Read-only preview of the configured checklist steps so users can see
* the steps without having to open the Edit or Complete dialog. Only
* rendered when the Checklists feature is enabled and steps are set. */
/** Interactive checklist (#73): steps can be ticked off DURING the cycle
* without completing the task progress persists server-side (survives
* reloads, prefills the complete dialog) and resets when the task is
* completed or skipped. Only rendered when the Checklists feature is
* enabled and steps are set. */
function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
if (!ctx.features.checklists) return nothing;
const items = task.checklist || [];
if (items.length === 0) return nothing;
const L = ctx.lang;
const progress = task.checklist_progress || {};
const done = items.filter((item) => progress[item]).length;
return html`
<div class="checklist-preview-card">
<div class="checklist-preview-header">
<ha-icon icon="mdi:format-list-checks"></ha-icon>
<span>${t("checklist", L)} (${items.length})</span>
<span>${t("checklist", L)} (${done}/${items.length})</span>
</div>
<ol class="checklist-preview-list">
${items.map((item) => html`<li>${item}</li>`)}
${items.map((item) => html`
<li class=${progress[item] ? "checked" : ""}>
<label>
<input
type="checkbox"
.checked=${!!progress[item]}
@change=${(e: Event) =>
ctx.setChecklistItem(item, (e.target as HTMLInputElement).checked)}
/>
<span>${item}</span>
</label>
</li>
`)}
</ol>
</div>
`;
@@ -206,7 +229,10 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
const safeTaskUrl = isSafeHttpUrl(task.documentation_url)
? task.documentation_url : null;
const safeObjUrl = isSafeHttpUrl(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing;
// Same fallback rule as the objects table: an UPLOADED manual (category
// "manual") stands in when the object's URL field is empty.
const manualDoc = safeObjUrl ? null : (ctx.objectManualDocs || [])[0];
if (!task.notes && !safeTaskUrl && !safeObjUrl && !manualDoc) return nothing;
const L = ctx.lang;
return html`
<div class="task-meta-card">
@@ -227,6 +253,13 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
<ha-icon icon="mdi:book-open-variant"></ha-icon>
<a href="${safeObjUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_url_label", L)} (${ctx.objectName})</a>
</div>
` : manualDoc ? html`
<div class="task-meta-row task-meta-link">
<ha-icon icon="mdi:book-open-variant"></ha-icon>
<a href="#" title=${manualDoc.title}
@click=${(e: Event) => { e.preventDefault(); ctx.openManualDoc(manualDoc); }}
>${t("documentation_url_label", L)} (${ctx.objectName})</a>
</div>
` : nothing}
</div>
`;
@@ -31,6 +31,17 @@ export interface MaintenanceObject {
/** (roadmap P2) number of attached documents (files + web-links); drives the
* objects-table paperclip badge. Computed server-side, not persisted. */
document_count?: number;
/** Attached documents tagged as manuals the fallback for the "manual"
* column/header when documentation_url is unset. Computed server-side. */
manual_docs?: ManualDocRef[];
}
/** Slim reference to a manual-tagged document (subset of MaintenanceDocument). */
export interface ManualDocRef {
id: string;
title: string;
kind: string; // "file" | "weblink"
url?: string | null;
}
export interface TriggerConfig {
@@ -161,6 +172,9 @@ export interface MaintenanceTask {
notes?: string | null;
documentation_url?: string | null;
checklist?: string[];
/** #73: in-cycle ticks ({item text: bool}); persists server-side, resets on
* complete/skip. */
checklist_progress?: Record<string, boolean>;
labels?: string[];
assignee_pool?: string[];
rotation_strategy?: string | null;
@@ -184,7 +198,11 @@ export interface MaintenanceTask {
trigger_entity_infos?: TriggerEntityInfo[] | null;
/** Battery Fleet: the single aggregate task renders the battery section. */
battery_fleet_task?: boolean;
/** LIST payloads carry only the most recent window (payload diet) the
* task detail fetches the full record via `task/history`. */
history: HistoryEntry[];
/** Total entries that exist, including those beyond the list window. */
history_count?: number;
// Computed
status: string; // "ok" | "due_soon" | "overdue" | "triggered" | "archived"
/** True for a one-time task that has been completed (done; never re-arms). */