155 files
This commit is contained in:
+9
-7
@@ -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}>
|
||||
|
||||
+15
-5
@@ -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));
|
||||
}
|
||||
|
||||
+6
-4
@@ -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()]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user