155 files

This commit is contained in:
Home Assistant Version Control
2026-10-04 20:46:32 +00:00
parent a212615470
commit 0ff8c287a8
155 changed files with 19264 additions and 3148 deletions
@@ -11,7 +11,7 @@ import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { runWs } from "../helpers/ws-run";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { addDaysIso, haToday } from "../helpers/ha-time";
import { px } from "../renderers/chart-utils";
import type { HomeAssistant } from "../types";
@@ -332,16 +332,18 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* `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. */
* is honest: the battery is living on borrowed time. The server counts
* the days from HA's today — so does the date (the browser's day plus
* N × 24 h was a day off on a device in another zone). */
private _predictedDate(daysUntil: number): string {
return this._fmtDate(Date.now() + daysUntil * 864e5);
return formatDate(addDaysIso(haToday(), daysUntil), this._lang);
}
/** 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). */
/** The calendar date of an epoch (a detected swap), in the HA profile
* date format and time zone (#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 formatDate(isoDateLocal(new Date(epochMs)), this._lang);
return Number.isFinite(epochMs) ? formatDate(new Date(epochMs).toISOString(), this._lang) : "—";
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
@@ -4,9 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty, haTimeZoneHint } from "../styles";
import { runWs } from "../helpers/ws-run";
import { isoMinuteLocal } from "../helpers/calendar-bucket";
import { haNowMinute, stampMs } from "../helpers/ha-time";
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { unitPrice, type PartsCostMode } from "../helpers/parts-cost";
@@ -23,6 +23,15 @@ import { photoPickerStyles } from "./ms-photo-picker";
* One TS source (helpers/setting-ranges), pinned to Python. */
const USED_QTY_RANGE = PART_QTY_RANGE;
/** `task-completed`'s detail: which task, and whether the server remembered
* the completion so the opener may offer Undo. */
export interface TaskCompletedDetail {
entryId: string;
taskId: string;
taskName: string;
undo: boolean;
}
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@@ -97,7 +106,8 @@ export class MaintenanceCompleteDialog extends LitElement {
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@state() private _restockQty = "";
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" in
* HA local time — zone-less, the way the backend reads it; "" = 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. */
@@ -203,8 +213,10 @@ export class MaintenanceCompleteDialog extends LitElement {
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
// fails fast with a localized message instead of a WS roundtrip. The
// value is HA local time, so it is judged on HA's clock: read in the
// browser's zone, a moment just past was "future" east of HA.
if (stampMs(this._completedAt) > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
return;
}
@@ -270,14 +282,22 @@ export class MaintenanceCompleteDialog extends LitElement {
if (res === undefined) return;
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
// The opener offers Undo when the server remembered the completion for
// this person (task/undo, ten minutes, one level).
const detail: TaskCompletedDetail = {
entryId: this.entryId,
taskId: this.taskId,
taskName: this.taskName,
undo: (res as { undo?: boolean } | null)?.undo === true,
};
this.dispatchEvent(new CustomEvent<TaskCompletedDetail>("task-completed", { detail }));
}
/** #161 phase 2: one field per slot with the previous value as a hint;
* a value below the last one gets a warning (meters count up — a
* replaced meter or a typo), never a block. */
private _renderReadingField(slot: ReadingSlot, L: string) {
const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN;
const at = this._completedAt ? stampMs(this._completedAt) : NaN;
const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at);
const unit = slot.unit || this.readingUnit;
const raw = (this._readingValues[slot.id] ?? "").trim();
@@ -386,9 +406,12 @@ export class MaintenanceCompleteDialog extends LitElement {
this._photos.discardOrphans();
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
/** Seed the backdate field with HA's current minute (seconds zeroed) —
* the field means HA local time. The browser's minute was refused as
* "future" east of HA and stored hours early west of it (next_due a day
* early once that crossed midnight). */
private _pickCompletedAt(): void {
this._completedAt = isoMinuteLocal(new Date());
this._completedAt = `${haNowMinute()}:00`;
}
render() {
@@ -525,6 +548,7 @@ export class MaintenanceCompleteDialog extends LitElement {
.hass=${this.hass}
.lang=${L}
.value=${this._completedAt}
.helper=${haTimeZoneHint(L)}
@value-changed=${(e: CustomEvent) => (this._completedAt = e.detail.value as string)}
></ms-date-field>`
: html`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
@@ -9,9 +9,10 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf, formatNumber } from "../styles";
import { t, langOf, formatNumber, haTimeZoneHint } from "../styles";
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
import { runWs } from "../helpers/ws-run";
import { haWallTime } from "../helpers/ha-time";
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
@@ -245,15 +246,20 @@ export class MaintenanceHistoryEditDialog extends LitElement {
/** #170: remove the entry altogether (after a confirm). The backend
* re-anchors last_performed on what remains; photos stay in the object's
* documents, consumed parts are not restocked. */
* documents, and the parts the entry used go back to stock — the confirm
* names them. */
private async _delete(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
const L = this._lang;
const used = (this._originalSnapshot.used_parts || []).filter((u) => u.quantity > 0);
const parts = used.map((u) => `${formatNumber(u.quantity, L)}× ${u.name || u.part_id}`).join(", ");
// The confirm dialog, not the browser's native modal — this dialog is
// also mounted on dashboards (DRY audit 2026-09-26).
const confirmed = await confirmAction(this.hass, {
title: t("history_delete_entry", L),
message: t("history_delete_confirm", L),
message: parts
? `${t("history_delete_confirm_entry", L)} ${t("history_delete_returns_parts", L).replace("{parts}", parts)}`
: t("history_delete_confirm_entry", L),
confirmText: t("delete", L),
danger: true,
});
@@ -386,10 +392,14 @@ export class MaintenanceHistoryEditDialog extends LitElement {
.hass=${this.hass}
.lang=${L}
.label=${t("history_edit_timestamp", L)}
.value=${d.timestamp.slice(0, 19)}
.helper=${haTimeZoneHint(L)}
.value=${haWallTime(d.timestamp)}
@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.
// means HA local time — the backend reads it so. The field
// therefore shows HA's wall clock (an imported "+00:00" stamp's
// own digits were UTC) and names HA's zone when the history
// list shows another one (the profile's "local" zone).
const v = e.detail.value as string;
if (v) this._set("timestamp", v);
}}
@@ -15,7 +15,7 @@ import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
import { ToastTimer } from "../helpers/toast";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { haToday } from "../helpers/ha-time";
import { runWs } from "../helpers/ws-run";
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
@@ -2084,7 +2084,8 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
// Dated by HA's day, like every date inside the export.
const ts = haToday();
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2095,7 +2096,7 @@ export class MaintenanceSettingsView extends LitElement {
private async _exportSettings(): Promise<void> {
const result = await this._ws<{ data: string }>({ type: "maintenance_supporter/settings/export" });
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2109,7 +2110,7 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2121,7 +2122,7 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
this._showToast(t("settings_export_success", this._lang));
}
@@ -19,7 +19,7 @@ import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCo
import { runWs } from "../helpers/ws-run";
import { snoozedMessage, type SnoozeResult } from "../helpers/snooze";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { haToday } from "../helpers/ha-time";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
@@ -108,9 +108,11 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._showAdaptive = false;
this._skipReason = "";
this._postponeDate = "";
// Local calendar date — toISOString() is UTC and prefills YESTERDAY for
// users east of UTC before their morning (bug audit 2026-08-22).
this._resetDate = isoDateLocal(new Date());
// HA's calendar date — the server dates the reset and refuses one after
// ITS today; the browser's date is a day off around midnight on a device
// in another zone (and toISOString() was UTC, YESTERDAY for users east
// of UTC before their morning — bug audit 2026-08-22).
this._resetDate = haToday();
this._open = true;
await Promise.all([this._loadTask(), this._loadFeatures()]);
}