189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -5,12 +5,14 @@ import { property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { live } from "lit/directives/live.js";
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
import { t, langOf } from "../styles";
import { t, langOf, personStyles } from "../styles";
import { signApiPath } from "../helpers/document-url";
import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache } from "../helpers/settings-cache";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
@@ -26,6 +28,8 @@ interface PersonNotifyTarget {
interface SettingsResponse {
features: AdvancedFeatures;
admin_panel_user_ids?: string[];
/** #169 follow-up: per-member avatar overrides. */
member_display?: Record<string, { initials?: string; color?: string }>;
operator_write_enabled?: boolean;
objects_table_columns?: string[];
/** v2.21: template-gallery curation — hidden template ids. */
@@ -73,6 +77,9 @@ interface SettingsResponse {
bundle_threshold: number;
reminder_lead_days: number[];
scope_view_id: string;
/** #165: your own notification rule — event-only delivery + extra-data template. */
event_only?: boolean;
extra_data?: string;
};
actions: {
complete_enabled: boolean;
@@ -117,6 +124,8 @@ interface VacationPreviewRow {
confidence: "deterministic" | "estimated" | "unpredictable";
events: Array<{ date: string; status: "due_soon" | "overdue" | "triggered_est" }>;
will_suppress: boolean;
/** The task's own skip rule (#150); absent on older backends = allowed. */
allow_skip?: boolean;
}
// Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is
@@ -269,11 +278,18 @@ export class MaintenanceSettingsView extends LitElement {
.replace("{n}", String(bn.devices));
const rule = (warn ? t("bn_above_floor", L).replace("{name}", "Battery Notes") : t("bn_floor_decides", L))
.replace("{floor}", String(floor));
// The integration name becomes the link, the rest stays text. ja/zh
// punctuate with the fullwidth colon — an ASCII-only split linked the
// whole sentence and then duplicated its last character (bug review
// 2026-09-04).
const cut = summary.search(/[::]/);
const linkText = cut < 0 ? summary : summary.slice(0, cut);
const rest = cut < 0 ? "" : summary.slice(cut);
return html`
<div class="bn-note${warn ? " warn" : ""}">
<ha-icon icon="${warn ? "mdi:alert-outline" : "mdi:battery-heart-variant"}"></ha-icon>
<span>
<a class="bn-link" href="/config/integrations/integration/battery_notes">${summary.split(":")[0]}</a>${summary.slice(summary.indexOf(":"))}
<a class="bn-link" href="/config/integrations/integration/battery_notes">${linkText}</a>${rest}
${bn.overrides.length
? html` · ${t("bn_overrides", L).replace("{n}", String(bn.overrides.length + bn.more))}
${bn.overrides.map((o, i) => html`${i ? " · " : " "}${o.device_id
@@ -286,6 +302,62 @@ export class MaintenanceSettingsView extends LitElement {
</div>`;
}
/** #169 follow-up: Settings → household members — the avatar each member
* gets in task lists (initials + a palette colour). Defaults come from
* the backend (name / user id); an override is stored per user id. */
private _renderMemberAvatars(L: string) {
if (!this._users.length) return nothing;
const overrides = this._settings?.member_display || {};
return html`
<div class="settings-section member-avatars" data-section="member_avatars">
<h3>${t("member_avatars", L)}</h3>
<p class="section-desc">${t("member_avatars_hint", L)}</p>
${this._users.map((u) => {
const person = personOf(u)!;
const override = overrides[u.id] || {};
return html`
<div class="member-avatar-row">
${renderPersonAvatar(person)}
<span class="member-avatar-name">${u.name}</span>
<input class="member-initials" type="text" maxlength="3" autocomplete="off"
aria-label="${t("member_initials", L)}"
placeholder="${person.initials}"
.value=${override.initials ?? ""}
@change=${(e: Event) => this._setMemberAvatar(u.id, { initials: (e.target as HTMLInputElement).value })} />
<span class="member-palette" role="radiogroup" aria-label="${t("member_color", L)}">
${AVATAR_PALETTE.map((color) => html`
<button type="button" class="member-swatch${person.color === color ? " selected" : ""}"
style="--person-color: ${color}" title=${color} aria-label=${color}
@click=${() => this._setMemberAvatar(u.id, { color })}></button>`)}
</span>
${override.initials || override.color
? html`<button class="ha-button secondary member-reset" @click=${() => this._setMemberAvatar(u.id, null)}>${t("member_avatar_reset", L)}</button>`
: nothing}
</div>`;
})}
</div>`;
}
/** Merge one member's override into the map and save; null resets the member. */
private async _setMemberAvatar(userId: string, patch: { initials?: string; color?: string } | null): Promise<void> {
const current = { ...(this._settings?.member_display || {}) };
if (patch === null) {
delete current[userId];
} else {
const next = { ...(current[userId] || {}), ...patch };
if ("initials" in patch && !(patch.initials || "").trim()) delete next.initials;
if (Object.keys(next).length) current[userId] = next;
else delete current[userId];
}
await this._updateSetting("member_display", current);
// The avatars come back resolved through users/list — refresh the cache.
try {
this._users = (await this._userService?.getUsers(true)) ?? this._users;
} catch {
/* keep the stale list; the next open reloads it */
}
}
private async _updateSetting(key: string, value: unknown): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -293,6 +365,9 @@ export class MaintenanceSettingsView extends LitElement {
settings: { [key]: value },
});
this._settings = result as SettingsResponse;
// The card / Lovelace dialogs read their copy through a page-wide
// cache — drop it so they follow the change without a reload.
invalidateSettingsCache();
this._showToast(t("settings_saved", this._lang));
this.dispatchEvent(new CustomEvent("settings-changed"));
} catch {
@@ -329,6 +404,23 @@ export class MaintenanceSettingsView extends LitElement {
setTimeout(() => { this._toast = ""; }, 3000);
}
/** Bounded integer settings: an out-of-range entry used to be dropped
* silently while the field kept showing it (bug review 2026-09-04). Now
* it is named in a toast and the field snaps back to the stored value;
* a server reject snaps back through `live()` like the selects. */
private _onBoundedIntChange(e: Event, key: string, min: number, max: number, current: number): void {
const input = e.target as HTMLInputElement;
const v = parseInt(input.value, 10);
if (Number.isInteger(v) && v >= min && v <= max) {
void this._updateSetting(key, v);
return;
}
this._showToast(
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
);
input.value = String(current);
}
private _downloadFile(content: string, filename: string, mime: string): void {
// Companion-app safe download (target=_blank + DOM + deferred revoke).
downloadTextFile(content, filename, mime);
@@ -345,6 +437,7 @@ export class MaintenanceSettingsView extends LitElement {
return html`
${this._renderFeatures(L)}
${this._renderPanelAccess(L)}
${this._renderMemberAvatars(L)}
${this._renderGeneral(L)}
${this._renderObjectsColumns(L)}
${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing}
@@ -632,28 +725,22 @@ export class MaintenanceSettingsView extends LitElement {
<h3>${t("settings_general", L)}</h3>
<label class="setting-row">
<span class="setting-label">${t("settings_default_warning", L)}</span>
<input type="number" min="0" max="365" .value=${String(g.default_warning_days)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
<input type="number" min="0" max="365" .value=${live(String(g.default_warning_days))}
@change=${(e: Event) =>
// 0 = no warning window (due soon only on the due date) — #145.
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
}} />
this._onBoundedIntChange(e, "default_warning_days", 0, 365, g.default_warning_days)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
<input type="number" min="1" max="90" .value=${String(g.default_consumable_threshold ?? 10)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (v >= 1 && v <= 90) this._updateSetting("default_consumable_threshold", v);
}} />
<input type="number" min="1" max="90" .value=${live(String(g.default_consumable_threshold ?? 10))}
@change=${(e: Event) =>
this._onBoundedIntChange(e, "default_consumable_threshold", 1, 90, g.default_consumable_threshold ?? 10)} />
</label>
<label class="setting-row">
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
<input type="number" min="1" max="90" .value=${String(g.battery_low_percent ?? 20)}
@change=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (v >= 1 && v <= 90) this._updateSetting("battery_low_percent", v);
}} />
<input type="number" min="1" max="90" .value=${live(String(g.battery_low_percent ?? 20))}
@change=${(e: Event) =>
this._onBoundedIntChange(e, "battery_low_percent", 1, 90, g.battery_low_percent ?? 20)} />
</label>
${this._renderBatteryNotesHint(L)}
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
@@ -884,6 +971,25 @@ export class MaintenanceSettingsView extends LitElement {
</label>
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
<div class="notify-rule">
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
<div class="setting-hint">${t("settings_notify_rule_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_event_only", L)}</span>
<input type="checkbox" .checked=${!!n.event_only}
@change=${(e: Event) => this._updateSetting("notify_event_only", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_notify_event_only_hint", L)}</div>
<label class="setting-row setting-row-block">
<span class="setting-label">${t("settings_notify_extra_data", L)}</span>
<textarea class="import-area notify-extra" .value=${live(n.extra_data || "")} maxlength="2000" spellcheck="false"
placeholder=${'{"category": "maintenance", "critical": {{ priority == "high" }}, "navigate_to": "{{ url }}"}'}
@change=${(e: Event) => this._updateSetting("notify_extra_data", (e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
<div class="setting-hint">${t("settings_notify_extra_data_hint", L)}</div>
</div>
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_actions", L)}</h4>
<label class="setting-row">
<span class="setting-label">${t("settings_action_complete", L)}</span>
@@ -1128,7 +1234,7 @@ export class MaintenanceSettingsView extends LitElement {
</div>
<div class="vac-preview-actions">
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
${row.kind === "time_based"
${row.kind === "time_based" && row.allow_skip !== false
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
: nothing}
<button class=${isExempt ? "vac-notify-on" : ""}
@@ -1761,7 +1867,7 @@ export class MaintenanceSettingsView extends LitElement {
// --- Styles ---
static styles = css`
static styles = [personStyles, css`
.bn-note {
display: flex; align-items: flex-start; gap: 10px;
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
@@ -1836,6 +1942,21 @@ export class MaintenanceSettingsView extends LitElement {
.notify-person-target.muted {
font-style: italic;
}
/* #169 follow-up: member avatar editor */
.member-avatar-row { display: flex; align-items: center; gap: 10px; padding: 6px 0 0; flex-wrap: wrap; }
.member-avatar-name { font-weight: 500; min-width: 120px; }
.member-initials {
width: 4.5em; padding: 4px 6px; text-align: center; text-transform: uppercase;
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
background: var(--card-background-color); color: var(--primary-text-color); font: inherit;
}
.member-palette { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.member-swatch {
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0;
background: var(--person-color); cursor: pointer;
}
.member-swatch.selected { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--card-background-color) inset; }
.member-reset { --ha-button-font-size: 12px; }
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
@@ -1927,6 +2048,8 @@ export class MaintenanceSettingsView extends LitElement {
.import-section { margin-top: 16px; }
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
.import-area {
width: 100%;
min-height: 120px;
@@ -2259,7 +2382,7 @@ export class MaintenanceSettingsView extends LitElement {
.qr-print-cell .qr-svg { max-width: 48mm; }
.qr-label { font-size: 9pt; }
}
`;
`];
}
customElements.define("maintenance-settings-view", MaintenanceSettingsView);