187 files

This commit is contained in:
Home Assistant Version Control
2026-09-03 17:54:35 +00:00
parent 16c9b4d7f4
commit d6413e19b5
187 changed files with 15542 additions and 2765 deletions
@@ -6,9 +6,10 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { describeWsError } from "../ws-errors";
import { px } from "../renderers/chart-utils";
import type { HomeAssistant } from "../types";
interface BatteryRow {
@@ -100,6 +101,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
updated(changed: Map<string, unknown>): void {
// #163: the forecast dates go through formatDate(), which reads the HA
// profile date format from a window singleton — feed it here too, so a
// stand-alone battery-fleet card (no panel / task card on the page to do
// it) still renders DD/MM vs MM/DD the way the profile says.
syncLocaleFromHass(this, changed);
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
@@ -257,9 +263,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
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 line = h.points.map(([t, v]) => `${px(x(t))},${px(y(v))}`).join(" ");
const vLast = h.points[h.points.length - 1][1];
const yTh = y(h.threshold).toFixed(1);
const yTh = px(y(h.threshold));
return html`<svg
class="bf-spark"
viewBox="0 0 ${W} ${H}"
@@ -272,9 +278,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
${projEnd !== null
? html`<line
class="bf-spark-proj"
x1=${x(tLast).toFixed(1)}
y1=${y(vLast).toFixed(1)}
x2=${x(projEnd).toFixed(1)}
x1=${px(x(tLast))}
y1=${px(y(vLast))}
x2=${px(x(projEnd))}
y2=${yTh}
></line>`
: nothing}
@@ -314,8 +320,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
return this._fmtDate(Date.now() + daysUntil * 864e5);
}
/** Local calendar date of an epoch, in the HA profile date format (#163 —
* a direct Intl.DateTimeFormat call here ignored the profile and showed
* 9/2/2026 to a DD/MM/YYYY user). */
private _fmtDate(epochMs: number): string {
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
const d = new Date(epochMs);
const pad = (n: number) => String(n).padStart(2, "0");
return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang);
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
@@ -8,7 +8,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
import { t, syncLocaleFromHass, DEFAULT_CURRENCY_SYMBOL, langOf, formatNumber, formatCost } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
@@ -50,10 +50,11 @@ export class MaintenanceBudgetSectionCard extends LitElement {
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
// Standalone card: nothing else feeds the profile number format in (#163).
syncLocaleFromHass(this, changedProps);
if (changedProps.has("hass") && this.hass && !this._loaded) {
this._loaded = true;
void this._load();
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
@@ -147,7 +148,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
<div class="track spent-only">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
<span class="track-numbers ok">${formatCost(track.spent, sym, L, 0)}</span>
</div>
</div>
`;
@@ -159,7 +160,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ${warn}">
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
${formatNumber(track.spent, L, 0)} / ${formatCost(track.budget, sym, L, 0)}
</span>
</div>
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
@@ -3,10 +3,11 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { t, nativeFieldStyles, formatCost } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-date-field";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -65,7 +66,7 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
/** #133: optional backdated completion moment (datetime-local value; "" = now). */
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
@state() private _completedAt = "";
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@@ -289,11 +290,11 @@ export class MaintenanceCompleteDialog extends LitElement {
if (this._cost.trim() !== "") return nothing;
const suggestion = this._partsCostSuggestion();
if (suggestion == null || suggestion <= 0) return nothing;
const amount = `${suggestion.toFixed(2)}${this.currencySymbol ? ` ${this.currencySymbol}` : ""}`;
const amount = formatCost(suggestion, this.currencySymbol, L);
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = suggestion.toFixed(2))}
@click=${() => (this._cost = String(Math.round(suggestion * 100) / 100))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
}
@@ -301,6 +302,13 @@ export class MaintenanceCompleteDialog extends LitElement {
this._open = false;
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
private _pickCompletedAt(): void {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
@@ -409,13 +417,21 @@ export class MaintenanceCompleteDialog extends LitElement {
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<div class="field">
<span class="field-label">${t("completed_at_optional", L)}</span>
<input type="datetime-local" class="field-input"
max=${new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)}
.value=${this._completedAt}
@change=${(e: Event) => (this._completedAt = (e.target as HTMLInputElement).value)} />
</label>
${this._completedAt
? html`<ms-date-field
kind="datetime"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._completedAt}
@value-changed=${(e: CustomEvent) => (this._completedAt = e.detail.value as string)}
></ms-date-field>`
: html`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
<ha-icon icon="mdi:calendar-clock"></ha-icon>${t("completed_at_pick", L)}
</button>`}
</div>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
${this._photoPreview
@@ -564,6 +580,26 @@ export class MaintenanceCompleteDialog extends LitElement {
width: fit-content;
}
.photo-pick:hover { border-color: var(--primary-color); }
/* #163: the backdate moment starts EMPTY (= now); the button seeds the
HA date+time picker with the current minute instead of the picker's
own 00:00 default, so a backdated completion never lands at midnight
by accident. */
.backdate-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
--mdc-icon-size: 18px;
}
.backdate-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
.photo-preview {
position: relative;
@@ -416,7 +416,7 @@ export class MaintenanceDocumentsSection extends LitElement {
const isFile = doc.kind === "file";
const cat = this._category_of(doc);
const meta = isFile
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size)}`
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size, L)}`
: t("doc_link_badge", L);
const thumb = this._thumbs[doc.id];
return html`
@@ -12,6 +12,7 @@ import { property, state } from "lit/decorators.js";
import { t, langOf } from "../styles";
import type { HomeAssistant } from "../types";
import { describeWsError } from "../ws-errors";
import "./ms-date-field";
export interface HistoryEntryDraft {
entry_id: string;
@@ -228,16 +229,20 @@ export class MaintenanceHistoryEditDialog extends LitElement {
<ha-icon icon="mdi:tag-outline"></ha-icon>
<span>${t(d.type, L) || d.type}</span>
</div>
<label>
<span>${t("history_edit_timestamp", L) || "Timestamp"}</span>
<input type="datetime-local"
.value=${d.timestamp.length >= 16 ? d.timestamp.slice(0, 16) : d.timestamp}
@change=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
// Re-add seconds if input drops them
this._set("timestamp", v.length === 16 ? `${v}:00` : v);
}} />
</label>
<ms-date-field
kind="datetime"
required
.hass=${this.hass}
.lang=${L}
.label=${t("history_edit_timestamp", L) || "Timestamp"}
.value=${d.timestamp.slice(0, 19)}
@value-changed=${(e: CustomEvent) => {
// Always "YYYY-MM-DDTHH:MM:SS" from the field; the naive value
// means local time, exactly as the datetime-local input did.
const v = e.detail.value as string;
if (v) this._set("timestamp", v);
}}
></ms-date-field>
<label>
<span>${t("notes_label", L)}</span>
<textarea
@@ -7,6 +7,7 @@ import { t, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import "./ms-textfield";
import "./ms-date-field";
export class MaintenanceObjectDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -168,18 +169,24 @@ export class MaintenanceObjectDialog extends LitElement {
@value-changed=${(e: CustomEvent) =>
(this._areaId = (e.detail.value as string) || "")}
></ha-area-picker>
<ms-textfield
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
label="${t("installation_date_optional", L)}"
type="date"
.value=${this._installationDate}
@input=${(e: Event) => (this._installationDate = (e.target as HTMLInputElement).value)}
></ms-textfield>
<ms-textfield
@value-changed=${(e: CustomEvent) => (this._installationDate = e.detail.value as string)}
></ms-date-field>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
label="${t("warranty_expiry_optional", L)}"
type="date"
.value=${this._warrantyExpiry}
@input=${(e: Event) => (this._warrantyExpiry = (e.target as HTMLInputElement).value)}
></ms-textfield>
@value-changed=${(e: CustomEvent) => (this._warrantyExpiry = e.detail.value as string)}
></ms-date-field>
<ha-form
.hass=${this.hass}
.data=${{ device: this._haDeviceId || undefined }}
@@ -15,7 +15,7 @@
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale, langOf } from "../styles";
import { t, ensureLocale, langOf, formatCost } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode.
@@ -360,7 +360,7 @@ export class MaintenancePartsSection extends LitElement {
${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>`
${formatCost(this._inventoryValue()!, this.currencySymbol, L)}</span>`
: nothing}
</h3>
${this.canWrite && !this._editing
@@ -11,6 +11,7 @@ import { downloadUrl } from "../helpers/download";
import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
import "./ms-date-field";
/** One household member and the notify services they actually resolve to.
* Empty `services` means no Companion device is linked, so their reminders
@@ -816,13 +817,25 @@ export class MaintenanceSettingsView extends LitElement {
${n.quiet_hours_enabled ? html`
<div class="setting-row sub-row">
<span class="setting-desc">${t("settings_quiet_start", L)}</span>
<input type="time" .value=${n.quiet_hours_start}
@change=${(e: Event) => this._updateSetting("quiet_hours_start", (e.target as HTMLInputElement).value)} />
<ms-date-field
kind="time"
required
.hass=${this.hass}
.lang=${L}
.value=${n.quiet_hours_start}
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_start", v); }}
></ms-date-field>
</div>
<div class="setting-row sub-row">
<span class="setting-desc">${t("settings_quiet_end", L)}</span>
<input type="time" .value=${n.quiet_hours_end}
@change=${(e: Event) => this._updateSetting("quiet_hours_end", (e.target as HTMLInputElement).value)} />
<ms-date-field
kind="time"
required
.hass=${this.hass}
.lang=${L}
.value=${n.quiet_hours_end}
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_end", v); }}
></ms-date-field>
</div>
` : nothing}
@@ -1000,16 +1013,28 @@ export class MaintenanceSettingsView extends LitElement {
</label>
<div class="vac-grid">
<label class="vac-field">
<div class="vac-field">
<span class="filter-label">${t("vacation_start", L)}</span>
<input type="date" .value=${this._vacStart}
@change=${(e: Event) => this._setVacationDate("start", (e.target as HTMLInputElement).value)} />
</label>
<label class="vac-field">
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._vacStart}
@value-changed=${(e: CustomEvent) => this._setVacationDate("start", e.detail.value as string)}
></ms-date-field>
</div>
<div class="vac-field">
<span class="filter-label">${t("vacation_end", L)}</span>
<input type="date" .value=${this._vacEnd}
@change=${(e: Event) => this._setVacationDate("end", (e.target as HTMLInputElement).value)} />
</label>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._vacEnd}
@value-changed=${(e: CustomEvent) => this._setVacationDate("end", e.detail.value as string)}
></ms-date-field>
</div>
<label class="vac-field">
<span class="filter-label">${t("vacation_buffer", L)}</span>
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
@@ -1843,9 +1868,12 @@ export class MaintenanceSettingsView extends LitElement {
.setting-row input[type="checkbox"] {
width: 18px; height: 18px; flex-shrink: 0;
}
.setting-row ms-date-field {
flex: 0 0 auto;
max-width: 60%;
}
.setting-row input[type="number"],
.setting-row input[type="text"],
.setting-row input[type="time"] {
.setting-row input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--divider-color, #e0e0e0);
@@ -166,10 +166,10 @@ export class MaintenanceStorageSectionCard extends LitElement {
render() {
if (!this._loaded || !this._summary) return nothing;
const s = this._summary;
if (s.document_count === 0) return nothing; // self-hide when unused
if (!s.document_count) return nothing; // self-hide when unused (or malformed)
const L = this._lang;
const rows = Object.entries(s.by_object)
const rows = Object.entries(s.by_object ?? {})
.filter(([, v]) => v.files > 0 || v.links > 0)
.map(([id, v]) => ({ id, name: this._nameFor(id), entry: this._entryFor(id), ...v }))
.sort((a, b) => b.bytes - a.bytes);
@@ -188,8 +188,8 @@ export class MaintenanceStorageSectionCard extends LitElement {
<span class="emoji">🗄️</span>
<span class="title-text">${t("doc_storage_title", L)}</span>
<span class="header-summary">
${formatBytes(s.total_bytes)}
${s.dedup_savings_bytes > 0 ? html`<span class="saved">−${formatBytes(s.dedup_savings_bytes)}</span>` : nothing}
${formatBytes(s.total_bytes, L)}
${s.dedup_savings_bytes > 0 ? html`<span class="saved">−${formatBytes(s.dedup_savings_bytes, L)}</span>` : nothing}
</span>
</button>
<button
@@ -207,7 +207,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
<div class="body">
<div class="totals">
<div class="stat">
<div class="stat-value">${formatBytes(s.total_bytes)}</div>
<div class="stat-value">${formatBytes(s.total_bytes, L)}</div>
<div class="stat-label">
<ha-icon icon="mdi:file-document-outline"></ha-icon> ${s.file_count}
<ha-icon icon="mdi:link-variant"></ha-icon> ${s.link_count}
@@ -215,7 +215,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
</div>
${s.dedup_savings_bytes > 0
? html`<div class="stat">
<div class="stat-value saved">−${formatBytes(s.dedup_savings_bytes)}</div>
<div class="stat-value saved">−${formatBytes(s.dedup_savings_bytes, L)}</div>
<div class="stat-label">${t("doc_storage_saved", L)}</div>
</div>`
: nothing}
@@ -275,7 +275,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
${r.files > 0 ? html`<ha-icon icon="mdi:file-document-outline"></ha-icon>${r.files}` : nothing}
${r.links > 0 ? html`<ha-icon icon="mdi:link-variant"></ha-icon>${r.links}` : nothing}
</span>
<span class="obj-size">${formatBytes(r.bytes)}</span>
<span class="obj-size">${formatBytes(r.bytes, L)}</span>
${eid ? html`<ha-icon class="obj-go" icon="mdi:chevron-right"></ha-icon>` : nothing}
</div>
`;
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName, langOf } from "../styles";
import { formatDate, t, weekdayName, monthName, langOf } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import {
@@ -15,6 +15,7 @@ import {
import { describeWsError } from "../ws-errors";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
import "./ms-date-field";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
const PRIORITY_KEYS = ["low", "normal", "high"];
@@ -129,8 +130,7 @@ function weekdayNames(lang?: string): string[] {
/** Short localized month names, indexed 0=Jan … 11=Dec. */
function monthNames(lang?: string): string[] {
const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1)));
return Array.from({ length: 12 }, (_, i) => monthName(i, lang, "short"));
}
export class MaintenanceTaskDialog extends LitElement {
@@ -1190,6 +1190,10 @@ export class MaintenanceTaskDialog extends LitElement {
return;
}
}
if (this._triggerType === "threshold" && this._thresholdLimitsOverlap()) {
this._error = t("trigger_hint_overlap", this._lang);
return;
}
this._loading = true;
this._error = "";
try {
@@ -2204,12 +2208,14 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
></ms-textfield>` : nothing}
${this._endsMode === "until" ? html`
<ms-textfield
<ms-date-field
kind="date"
.hass=${this.hass}
.lang=${L}
label="${t("series_end_until_label", L)}"
type="date"
.value=${this._endsUntil}
@input=${(e: Event) => (this._endsUntil = (e.target as HTMLInputElement).value)}
></ms-textfield>` : nothing}
@value-changed=${(e: CustomEvent) => (this._endsUntil = e.detail.value as string)}
></ms-date-field>` : nothing}
`;
}
@@ -2299,12 +2305,23 @@ export class MaintenanceTaskDialog extends LitElement {
* sensor's current reading (not from zero) and restarts after each
* completion. Renders nothing when there's no entity/state to read.
*/
/** #156: above/below are OR-ed server-side — `below > above` makes every
* reading a hit, so the trigger never clears (and never auto-completes). */
private _thresholdLimitsOverlap(): boolean {
const above = parseFloat(this._triggerAbove);
const below = parseFloat(this._triggerBelow);
return !isNaN(above) && !isNaN(below) && below > above;
}
private _renderTriggerLiveHint() {
if (this._triggerType === "compound") return nothing;
const overlap = this._triggerType === "threshold" && this._thresholdLimitsOverlap()
? html`<div class="trigger-live-hint warn">${t("trigger_hint_overlap", this._lang)}</div>`
: nothing;
const entityId = this._triggerEntityId || this._triggerEntityIds[0];
if (!entityId || !this.hass?.states) return nothing;
if (!entityId || !this.hass?.states) return overlap;
const st = this.hass.states[entityId];
if (!st) return nothing;
if (!st) return overlap;
const L = this._lang;
const unitAttr = st.attributes?.unit_of_measurement;
@@ -2362,8 +2379,8 @@ export class MaintenanceTaskDialog extends LitElement {
);
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
}
if (!parts.length) return nothing;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>`;
if (!parts.length) return overlap;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>${overlap}`;
}
private _renderTriggerTypeFields() {
@@ -2643,25 +2660,30 @@ export class MaintenanceTaskDialog extends LitElement {
</select>
</div>
${this.scheduleTimeEnabled ? html`
<ms-textfield
<ms-date-field
kind="time"
clearable
.hass=${this.hass}
.lang=${L}
label="${t("schedule_time_optional", L)}"
type="time"
.value=${this._scheduleTime}
helper="${t("schedule_time_help", L)}"
@input=${(e: Event) => (this._scheduleTime = (e.target as HTMLInputElement).value)}
></ms-textfield>
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
></ms-date-field>
` : nothing}
`
: nothing}
${this._renderCalendarFields()}
${this._scheduleType === "one_time"
? html`
<ms-textfield
<ms-date-field
kind="date"
.hass=${this.hass}
.lang=${L}
label="${t("due_date", L)}"
type="date"
.value=${this._dueDate}
@input=${(e: Event) => (this._dueDate = (e.target as HTMLInputElement).value)}
></ms-textfield>
@value-changed=${(e: CustomEvent) => (this._dueDate = e.detail.value as string)}
></ms-date-field>
`
: nothing}
${this._renderRecurrenceExtras()}
@@ -2707,12 +2729,15 @@ export class MaintenanceTaskDialog extends LitElement {
</label>
`)}
</div>
<ms-textfield
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
label="${t("last_performed_optional", L)}"
type="date"
.value=${this._lastPerformed}
@input=${(e: Event) => (this._lastPerformed = (e.target as HTMLInputElement).value)}
></ms-textfield>
@value-changed=${(e: CustomEvent) => (this._lastPerformed = e.detail.value as string)}
></ms-date-field>
<div class="select-row">
<label>${t("responsible_user", L)}</label>
<select
@@ -3131,6 +3156,11 @@ export class MaintenanceTaskDialog extends LitElement {
padding: 6px 10px;
margin: 4px 0;
}
.trigger-live-hint.warn {
color: var(--primary-text-color);
border-left-color: var(--warning-color, #ff9800);
background: rgba(255, 152, 0, 0.1);
}
.field-help a,
.link-button {
background: none;
@@ -228,7 +228,7 @@ export class MaintenanceTaskDocuments extends LitElement {
const isPdf = this._isPdf(doc);
const page = this._pageFor(doc);
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
const meta = isFile ? formatBytes(doc.size) : t("doc_link_badge", L);
const meta = isFile ? formatBytes(doc.size, L) : t("doc_link_badge", L);
return html`
<div class="tdoc-row">
<ha-icon class="tdoc-icon" icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}></ha-icon>
@@ -14,7 +14,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, formatNumber, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
@@ -22,6 +22,7 @@ import { phaseLabel } from "../helpers/phases";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
import "./ms-date-field";
import {
renderSeasonalCardCompact,
renderSeasonalCardExpanded,
@@ -466,7 +467,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</div>
<div class="stat">
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
<span class="stat-value">${totalCost.toFixed(2)}</span>
<span class="stat-value">${formatNumber(totalCost, L, 2)}</span>
</div>
<div class="stat">
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
@@ -501,7 +502,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
: nothing}
${entry.cost != null || entry.duration != null
? html`<div class="history-meta">
${entry.cost != null ? html`<span>💰 ${entry.cost.toFixed(2)}</span>` : nothing}
${entry.cost != null ? html`<span>💰 ${formatNumber(entry.cost, L, 2)}</span>` : nothing}
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
</div>`
: nothing}
@@ -582,8 +583,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
? html`
<div class="inline-form">
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
<input type="date" .value=${this._resetDate}
@input=${(e: Event) => { this._resetDate = (e.target as HTMLInputElement).value; }} />
<ms-date-field
kind="date"
.hass=${this.hass}
.lang=${L}
.value=${this._resetDate}
@value-changed=${(e: CustomEvent) => { this._resetDate = e.detail.value as string; }}
></ms-date-field>
<div class="inline-actions">
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
${t("cancel", L) || "Cancel"}
@@ -602,7 +608,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
</ha-button>
${task.allow_skip !== false
? html`
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-button appearance="outlined" variant="warning" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
${t("skip", L) || "Skip"}
</ha-button>
@@ -15,7 +15,7 @@
import { LitElement, html, css, svg, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../styles";
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear } from "../renderers/chart-utils";
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear, px } from "../renderers/chart-utils";
export interface ChartPoint {
ts: number;
@@ -170,18 +170,18 @@ export class MaintenanceTriggerChart extends LitElement {
const toX = (ts: number) => PAD_L + ((ts - tsMin) / tsSpan) * plotW;
const toY = (v: number) => PAD_T + (1 - (v - niceMin) / (niceMax - niceMin || 1)) * plotH;
const linePts = pts.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
const linePts = pts.map((p) => `${px(toX(p.ts))},${px(toY(p.val))}`).join(" ");
const areaPath =
`M${toX(pts[0].ts).toFixed(1)},${plotB} ` +
pts.map((p) => `L${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ") +
` L${toX(pts[pts.length - 1].ts).toFixed(1)},${plotB} Z`;
`M${px(toX(pts[0].ts))},${plotB} ` +
pts.map((p) => `L${px(toX(p.ts))},${px(toY(p.val))}`).join(" ") +
` L${px(toX(pts[pts.length - 1].ts))},${plotB} Z`;
// Optional min/max band behind the line.
let bandPath = "";
const band = pts.filter((p) => p.min != null && p.max != null);
if (band.length >= 2) {
const up = band.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.max!).toFixed(1)}`);
const dn = [...band].reverse().map((p) => `${toX(p.ts).toFixed(1)},${toY(p.min!).toFixed(1)}`);
const up = band.map((p) => `${px(toX(p.ts))},${px(toY(p.max!))}`);
const dn = [...band].reverse().map((p) => `${px(toX(p.ts))},${px(toY(p.min!))}`);
bandPath = `M${up[0]} ` + up.slice(1).map((x) => `L${x}`).join(" ") + ` L${dn.join(" L")} Z`;
}
@@ -190,11 +190,11 @@ export class MaintenanceTriggerChart extends LitElement {
const zones: { y: number; h: number; lineY: number; label: string; labelY: number }[] = [];
if (this.thresholdBelow != null) {
const zy = toY(this.thresholdBelow);
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow)}`, labelY: Math.min(plotB - 4, zy + 13) });
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow, L)}`, labelY: Math.min(plotB - 4, zy + 13) });
}
if (this.thresholdAbove != null) {
const zy = toY(this.thresholdAbove);
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove, L)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
}
const lastP = pts[pts.length - 1];
@@ -218,7 +218,7 @@ export class MaintenanceTriggerChart extends LitElement {
<defs>
<clipPath id="plot"><rect x="${PAD_L}" y="${PAD_T}" width="${plotW}" height="${plotH}" /></clipPath>
${zones.length
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}" />`)}</clipPath>`
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${px(z.y)}" width="${plotW}" height="${px(z.h)}" />`)}</clipPath>`
: nothing}
<!-- Diagonal hatch so the danger zone reads without relying on the
red tint alone (dark-theme contrast + colour-blind support). -->
@@ -232,13 +232,13 @@ export class MaintenanceTriggerChart extends LitElement {
const y = toY(v);
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
return svg`
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}"
stroke="var(--divider-color)" stroke-width="1" opacity="0.6" />
<text x="${PAD_L - 7}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" class="tick-label">${fmtNum(v)}</text>`;
<text x="${PAD_L - 7}" y="${px(y + 3.5)}" text-anchor="end" class="tick-label">${fmtNum(v, L)}</text>`;
})}
${zones.map(
(z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}"
(z) => svg`<rect x="${PAD_L}" y="${px(z.y)}" width="${plotW}" height="${px(z.h)}"
fill="url(#dangerHatch)" />`,
)}
@@ -253,27 +253,27 @@ export class MaintenanceTriggerChart extends LitElement {
${zones.map(
(z) => svg`
<line x1="${PAD_L}" y1="${z.lineY.toFixed(1)}" x2="${W - PAD_R}" y2="${z.lineY.toFixed(1)}"
<line x1="${PAD_L}" y1="${px(z.lineY)}" x2="${W - PAD_R}" y2="${px(z.lineY)}"
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
<text x="${W - PAD_R - 4}" y="${z.labelY.toFixed(1)}" text-anchor="end" class="zone-label">${z.label}</text>`,
<text x="${W - PAD_R - 4}" y="${px(z.labelY)}" text-anchor="end" class="zone-label">${z.label}</text>`,
)}
${this.targetValue != null
? svg`<line x1="${PAD_L}" y1="${toY(this.targetValue).toFixed(1)}" x2="${W - PAD_R}" y2="${toY(this.targetValue).toFixed(1)}"
? svg`<line x1="${PAD_L}" y1="${px(toY(this.targetValue))}" x2="${W - PAD_R}" y2="${px(toY(this.targetValue))}"
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
<text x="${W - PAD_R - 4}" y="${(toY(this.targetValue) - 5).toFixed(1)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue)} ${this.unit}</text>`
<text x="${W - PAD_R - 4}" y="${px(toY(this.targetValue) - 5)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue, L)} ${this.unit}</text>`
: nothing}
${this.projection && this.projection.length === 2
? svg`<line x1="${toX(this.projection[0].ts).toFixed(1)}" y1="${toY(this.projection[0].val).toFixed(1)}"
x2="${Math.min(toX(this.projection[1].ts), W - PAD_R).toFixed(1)}" y2="${toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))).toFixed(1)}"
? svg`<line x1="${px(toX(this.projection[0].ts))}" y1="${px(toY(this.projection[0].val))}"
x2="${px(Math.min(toX(this.projection[1].ts), W - PAD_R))}" y2="${px(toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))))}"
stroke="var(--warning-color, #ff9800)" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8" />`
: nothing}
${xTicks.map((ts, i) => {
const x = toX(ts);
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
return svg`<text x="${x.toFixed(1)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
return svg`<text x="${px(x)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
})}
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
@@ -287,19 +287,19 @@ export class MaintenanceTriggerChart extends LitElement {
? "var(--warning-color, #ff9800)"
: "var(--info-color, #2196f3)";
return svg`
<line x1="${x.toFixed(1)}" y1="${PAD_T}" x2="${x.toFixed(1)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
<rect x="${(x - 1.5).toFixed(1)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
<line x1="${px(x)}" y1="${PAD_T}" x2="${px(x)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
<rect x="${px(x - 1.5)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
<title>${fmtDateTime(e.ts, L)}</title>
</rect>`;
})}
${hover
? svg`
<line x1="${hover.x.toFixed(1)}" y1="${PAD_T}" x2="${hover.x.toFixed(1)}" y2="${plotB}"
<line x1="${px(hover.x)}" y1="${PAD_T}" x2="${px(hover.x)}" y2="${plotB}"
stroke="var(--secondary-text-color)" stroke-width="1" stroke-dasharray="3,3" opacity="0.7" />
<circle cx="${hover.x.toFixed(1)}" cy="${hover.y.toFixed(1)}" r="4.5" fill="var(--primary-color)"
<circle cx="${px(hover.x)}" cy="${px(hover.y)}" r="4.5" fill="var(--primary-color)"
stroke="var(--card-background-color, #fff)" stroke-width="2" />`
: svg`<circle cx="${toX(lastP.ts).toFixed(1)}" cy="${toY(lastP.val).toFixed(1)}" r="4" fill="var(--primary-color)"
: svg`<circle cx="${px(toX(lastP.ts))}" cy="${px(toY(lastP.val))}" r="4" fill="var(--primary-color)"
stroke="var(--card-background-color, #fff)" stroke-width="1.5" />`}
</svg>
${hover
@@ -311,7 +311,7 @@ export class MaintenanceTriggerChart extends LitElement {
<div class="hover-val">
${fmtVal(hover.p.val, this.unit, L)}
${hover.p.min != null && hover.p.max != null
? html`<span class="hover-range">(${fmtNum(hover.p.min)}–${fmtNum(hover.p.max)})</span>`
? html`<span class="hover-range">(${fmtNum(hover.p.min, L)}–${fmtNum(hover.p.max, L)})</span>`
: nothing}
</div>
</div>`
@@ -17,6 +17,7 @@ import { t, ensureLocale, langOf } from "../styles";
import { registerCustomCard } from "../helpers/register-card";
import { describeWsError } from "../ws-errors";
import { sectionCardSharedStyles } from "./section-card-shared-styles";
import "./ms-date-field";
import type { HomeAssistant } from "../types";
interface VacationState {
@@ -192,21 +193,33 @@ export class MaintenanceVacationSectionCard extends LitElement {
<div class="dates-row">
<div class="date-field">
<label>${t("vacation_start", L) || "Start"}</label>
<input type="date" .value=${this._localStart}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localStart = (e.target as HTMLInputElement).value;
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._localStart}
.disabled=${this._busy}
@value-changed=${(e: CustomEvent) => {
this._localStart = e.detail.value as string;
this._dirty = true;
}} />
}}
></ms-date-field>
</div>
<div class="date-field">
<label>${t("vacation_end", L) || "End"}</label>
<input type="date" .value=${this._localEnd}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localEnd = (e.target as HTMLInputElement).value;
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._localEnd}
.disabled=${this._busy}
@value-changed=${(e: CustomEvent) => {
this._localEnd = e.detail.value as string;
this._dirty = true;
}} />
}}
></ms-date-field>
</div>
<div class="date-field buffer">
<label>${t("vacation_buffer", L) || "Buffer days"}</label>