393 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-27 17:16:54 +00:00
1 parent b05b7a897e
commit 39d97ac2db
404 files changed
+54006 -11034

No files matched your search

@@ -32,12 +32,17 @@ import {
buildCalendarBuckets,
buildPastBuckets,
isoDateLocal,
pastHistoryGaps,
pastHistoryKey,
type CalendarEvent,
type HistoryEntryShape,
} from "./helpers/calendar-bucket";
import { calendarStyles } from "./calendar-styles";
import { syncLocaleFromHass, sharedStyles, currencySymbolOf, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatWeekday, formatMonth, langOf, formatCost, syncCurrencyDecimals} from "./styles";
import { syncLocaleFromHass, sharedStyles, currencySymbolOf, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatInterval, formatWeekday, formatMonth, langOf, formatCost, syncCurrencyDecimals} from "./styles";
import { registerCustomCard } from "./helpers/register-card";
import { loadHistoryEntryDraft } from "./helpers/history-draft";
import { canWrite } from "./helpers/permissions";
import { fetchSettingsOnce } from "./helpers/settings-cache";
import { openHistoryEditDialog, openTaskQuickActions } from "./dialog-mount";
import type {
HomeAssistant,
@@ -77,6 +82,11 @@ export class MaintenanceCalendarCard extends LitElement {
// 2+ configured object_filter values — restricts the card to this set.
private _configuredObjects: string[] = [];
@state() private _unsub: (() => void) | null = null;
/** Past mode: full histories of the tasks whose list payload (last 20
* entries) may not reach back over the whole window. */
@state() private _pastHistory: Record<string, HistoryEntryShape[]> = {};
private _pastHistorySig = "";
private _pastSeq = 0;
private _dataLoaded = false;
private _lastConnection: unknown = null;
@@ -128,8 +138,26 @@ export class MaintenanceCalendarCard extends LitElement {
return langOf(this.hass);
}
/** An entry edited or deleted anywhere on the page (the history-edit
* dialog's composed event reaches window). An edit of an entry OLDER than
* the listed window leaves the list payload — and so the refetch
* signature — untouched, so a fetched full history of that task is
* reloaded here (bug audit 2026-09-26 #2). */
private readonly _onHistorySaved = (ev: Event): void => {
const d = (ev as CustomEvent<{ entry_id?: string; task_id?: string }>).detail;
if (!d?.entry_id || !d.task_id || !(pastHistoryKey(d.entry_id, d.task_id) in this._pastHistory)) return;
this._pastHistorySig = "";
void this._loadPastHistories();
};
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("history-entry-saved", this._onHistorySaved);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("history-entry-saved", this._onHistorySaved);
if (this._unsub) {
try { this._unsub(); } catch { /* ignore */ }
this._unsub = null;
@@ -141,6 +169,9 @@ export class MaintenanceCalendarCard extends LitElement {
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
syncLocaleFromHass(this, changedProps);
if (this.hass && this._pastDays > 0 && (changedProps.has("_objects") || changedProps.has("_pastDays"))) {
void this._loadPastHistories();
}
if (changedProps.has("hass") && this.hass) {
if (!this._dataLoaded) {
this._dataLoaded = true;
@@ -177,6 +208,33 @@ export class MaintenanceCalendarCard extends LitElement {
}
}
/** Fetch the full history of every task `pastHistoryGaps` names (refetch
* only when that set or its entry counts change); only the newest round
* may land. */
private async _loadPastHistories(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const gaps = pastHistoryGaps(this._objects, today, this._pastDays || 30);
const sig = gaps.map((g) => g.sig).join("|");
if (sig === this._pastHistorySig) return;
this._pastHistorySig = sig;
const seq = ++this._pastSeq;
const results = await Promise.all(gaps.map(async (g) => {
try {
const r = await this.hass.connection.sendMessagePromise<{ history?: HistoryEntryShape[] }>({
type: "maintenance_supporter/task/history",
entry_id: g.entryId,
task_id: g.taskId,
});
return [g.key, r.history ?? []] as const;
} catch {
return null; // degrade to the listed window for this task
}
}));
if (seq !== this._pastSeq) return;
this._pastHistory = Object.fromEntries(results.filter((r): r is readonly [string, HistoryEntryShape[]] => r !== null));
}
private async _subscribe(): Promise<void> {
try {
const unsub = await this.hass.connection.subscribeMessage(
@@ -209,6 +267,10 @@ export class MaintenanceCalendarCard extends LitElement {
void this._openHistoryEntry(ev);
return;
}
this._openTask(ev);
}
private _openTask(ev: CalendarEvent): void {
if (openTaskQuickActions(ev.entry_id, ev.task_id)) return;
this.dispatchEvent(
new CustomEvent("ll-custom", {
@@ -226,6 +288,18 @@ export class MaintenanceCalendarCard extends LitElement {
/** Fetch the recorded entry and open the history-edit dialog directly
* (mirrors the strategy shim's ll-custom "edit-history" path). */
private async _openHistoryEntry(ev: CalendarEvent): Promise<void> {
// Editing a history entry is write tier: a household member without
// write access gets the task's quick actions (read view) instead of an
// editor whose Save the server refuses (bug audit 2026-09-27).
try {
const settings = await fetchSettingsOnce(this.hass);
if (!canWrite(this.hass?.user, settings.access)) {
this._openTask(ev);
return;
}
} catch {
/* settings unavailable — keep the editor; the server still decides */
}
try {
const draft = await loadHistoryEntryDraft(this.hass, ev.entry_id, ev.task_id, ev.history_timestamp!);
if (!draft) return;
@@ -294,7 +368,7 @@ export class MaintenanceCalendarCard extends LitElement {
today.setHours(0, 0, 0, 0);
const isPast = this._pastDays > 0;
const buckets = isPast
? buildPastBuckets(objects, today, this._pastDays, userFilter)
? buildPastBuckets(objects, today, this._pastDays, userFilter, this._pastHistory)
: buildCalendarBuckets(objects, today, this._windowDays, userFilter);
const todayIso = isoDateLocal(today);
@@ -312,11 +386,15 @@ export class MaintenanceCalendarCard extends LitElement {
const overdueLabel = ev.status === "overdue" && ev.days_until_due != null
? ` (${formatDueDays(ev.days_until_due, L)})`
: "";
// Days read "every N days" (the calendar's own wording, singular for
// 1); other units go through the shared interval formatter.
const recurEvery = ev.projected && ev.interval_days
? html`<span class="cal-event-recur">${
ev.interval_unit && ev.interval_unit !== "days"
? `${ev.interval_days} ${t("unit_" + ev.interval_unit, L)}`
: t("cal_every_n_days", L).replace("{n}", String(ev.interval_days))
? formatInterval(ev.interval_days, ev.interval_unit, L)
: ev.interval_days === 1
? t("cal_every_day", L)
: t("cal_every_n_days", L).replace("{n}", String(ev.interval_days))
}</span>`
: nothing;
const isSensor = ev.schedule_type === "sensor_based";