257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -10,6 +10,7 @@ import { html, svg, nothing } from "lit";
import { t, formatCost } from "../styles";
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear, px } from "./chart-utils";
import type { MaintenanceTask } from "../types";
import { entrySpend } from "../helpers/parts-cost";
const COST_CHART_H = 200;
const PAD_T = 10;
@@ -22,10 +23,12 @@ export function renderCostDurationCard(
setToggle: (val: "cost" | "duration" | "both") => void,
currencySymbol: string,
) {
const completedEntries = task.history.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null));
// Cost = what the entry counts (#104: parts booked when used, a purchase
// moved into stock) — the same number as the totals.
const completedEntries = task.history.filter((h) => h.type === "completed" && (entrySpend(h) != null || h.duration != null));
if (completedEntries.length < 2) return nothing;
const anyCost = completedEntries.some((h) => (h.cost ?? 0) > 0);
const anyCost = completedEntries.some((h) => (entrySpend(h) ?? 0) > 0);
const anyDuration = completedEntries.some((h) => (h.duration ?? 0) > 0);
if (!anyCost && !anyDuration) return nothing;
@@ -58,8 +61,8 @@ export function renderCostDurationCard(
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both", currencySymbol: string) {
const entries = task.history
.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null))
.map((h) => ({ ts: new Date(h.timestamp).getTime(), cost: h.cost ?? 0, duration: h.duration ?? 0 }))
.filter((h) => h.type === "completed" && (entrySpend(h) != null || h.duration != null))
.map((h) => ({ ts: new Date(h.timestamp).getTime(), cost: entrySpend(h) ?? 0, duration: h.duration ?? 0 }))
.sort((a, b) => a.ts - b.ts);
if (entries.length < 2) return nothing;
@@ -198,6 +198,11 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
const { compact = false, showRef = true, showBadges = true, showEdit = true } = opts;
const openEdit = ctx.openEdit;
const editable = showEdit && !!openEdit && EDITABLE_HISTORY_TYPES.includes(entry.type);
// #104: the parts this completion used, and whether their value counts
// (booked "when used") or is information; a purchase booked so is stock.
const partsCost = typeof entry.parts_cost === "number" ? entry.parts_cost : null;
const partsCounted = entry.cost_basis === "use";
const stockPurchase = partsCounted && !!entry.purchase;
return html`
<div class="history-entry${compact ? " compact" : ""}">
${compact
@@ -223,9 +228,16 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
${entry.notes ? html`<div>${historyNoteText(entry.notes, L)}</div>` : nothing}
${renderHistoryPhotos(ctx.hass, entry)}
${renderHistoryReadings(entry, ctx)}
${entry.cost != null || entry.duration != null || entry.trigger_value != null
${entry.cost != null || entry.duration != null || entry.trigger_value != null || partsCost != null
? html`<div class="history-details">
${entry.cost != null ? html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>` : nothing}
${entry.cost != null
? stockPurchase
? html`<span>${t("history_purchase_stock", L).replace("{amount}", formatCost(entry.cost, ctx.currencySymbol, L))}</span>`
: html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>`
: nothing}
${partsCost != null
? html`<span title=${t(partsCounted ? "history_parts_counted_hint" : "history_parts_info_hint", L)}>${t(partsCounted ? "history_parts_counted" : "history_parts_info", L).replace("{amount}", formatCost(partsCost, ctx.currencySymbol, L))}</span>`
: nothing}
${entry.duration != null ? html`<span>${t("duration", L)}: ${formatDuration(entry.duration, L)}</span>` : nothing}
${entry.trigger_value != null ? html`<span>${t("trigger_val", L)}: ${entry.trigger_value}</span>` : nothing}
</div>`
@@ -89,6 +89,12 @@ export function computeTrend(
return Math.sign(delta) === toward ? "approaching" : "easing";
}
/** Whole days for a due-date reading (days left, fractional): a date later
* today reads as today, one 30 hours away as 2 days. */
export function dueDaysOf(daysLeft: number): number {
return daysLeft > 0 ? Math.ceil(daysLeft) : Math.trunc(daysLeft) || 0;
}
export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { trend?: TrendState | null; lang?: string }) {
const tc = row.trigger_config ?? null;
if (!tc) return nothing;
@@ -176,6 +182,14 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
if (val == null) return nothing;
pct = Math.min(100, Math.max(0, (val / target) * 100));
label = `${formatNumber(val, opts?.lang, 1)}h / ${formatNumber(target, opts?.lang)}h`;
} else if (triggerType === "due_date") {
// The reading is the days left until the date the device reports: the
// bar fills over the last month before the lead time runs out.
const val = row.trigger_current_value ?? null;
if (val == null) return nothing;
const lead = tc.trigger_days_before ?? 0;
pct = val <= lead ? 100 : Math.max(0, 100 - ((val - lead) / 30) * 100);
label = formatDueDays(dueDaysOf(val), opts?.lang);
} else if (triggerType === "compound") {
const logic = tc.compound_logic || (tc as any).operator || "AND";
const condCount = tc.conditions?.length || 0;
@@ -12,8 +12,9 @@
*/
import { html, nothing } from "lit";
import { t, fireMoreInfo } from "../styles";
import { t, fireMoreInfo, formatDueDays } from "../styles";
import { fmtNum, fmtVal } from "./chart-utils";
import { dueDaysOf } from "./progress";
import "../components/trigger-chart";
import type { ChartPoint, ChartEvent } from "../components/trigger-chart";
import type { MaintenanceTask, TriggerConfig, StatisticsPoint } from "../types";
@@ -187,7 +188,7 @@ export function renderTriggerSection(task: MaintenanceTask, ctx: SparklineContex
: currentVal !== null && currentVal !== undefined
? html`
<div class="trigger-value-row">
<span class="trigger-current ${task.trigger_active ? "active" : ""}">${typeof currentVal === "number" ? fmtVal(currentVal, "", L) : currentVal}</span>
<span class="trigger-current ${task.trigger_active ? "active" : ""}">${triggerType === "due_date" && typeof currentVal === "number" ? formatDueDays(dueDaysOf(currentVal), L) : typeof currentVal === "number" ? fmtVal(currentVal, "", L) : currentVal}</span>
${unit ? html`<span class="trigger-unit">${unit}</span>` : nothing}
</div>
`
@@ -207,10 +208,13 @@ export function renderTriggerSection(task: MaintenanceTask, ctx: SparklineContex
${triggerType === "runtime" ? html`
${tc.trigger_runtime_hours != null ? html`<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("runtime_hours", L)}: ${tc.trigger_runtime_hours}h</span>` : nothing}
` : nothing}
${triggerType === "due_date" ? html`
<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("days_before", L)}: ${tc.trigger_days_before ?? 0}</span>
` : nothing}
${triggerType === "compound" ? html`
<span class="trigger-limit-item"><span class="dot warn" aria-hidden="true"></span> ${t("compound_logic", L)}: ${tc.compound_logic || (tc as any).operator || "AND"}</span>
${(tc.conditions || []).map((cond: any, i: number) => html`
<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${i + 1}. ${t(cond.type || "unknown", L)}: ${cond.entity_id ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, cond.entity_id)}>${cond.entity_id}</span>` : ""}</span>
<span class="trigger-limit-item"><span class="dot range" aria-hidden="true"></span> ${i + 1}. ${t(cond.type === "due_date" ? "trigger_type_due_date" : cond.type || "unknown", L)}: ${cond.entity_id ? html`<span class="entity-link" @click=${(ev: Event) => fireMoreInfo(ev, cond.entity_id)}>${cond.entity_id}</span>` : ""}</span>
`)}
` : nothing}
</div>
@@ -368,6 +372,8 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
if (!tc) return nothing;
const triggerType = tc.type || "threshold";
const entityId = tc.entity_id || "";
// A due date only counts down — no curve to tell.
if (triggerType === "due_date") return nothing;
let points = rawStatsPoints(task, ctx);
// #141 round 2: a state_change trigger on recorder history draws the
@@ -78,6 +78,8 @@ export interface TaskDetailContext {
openComplete: (task: MaintenanceTask) => void;
promptSkip: () => void;
toggleArchive: (archived: boolean) => void;
/** #193: pause (asks for an optional resume date) or resume this task. */
togglePause: (paused: boolean) => void;
openQr: (taskName: string) => void;
duplicateTask: () => void;
/** Forum #23: move the task to another object. */
@@ -128,6 +130,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
${renderStatusBadge(task, L, "chip")}
${task.paused && task.paused_until ? html`<span class="postponed-badge paused-until" title="${t("task_paused", L)}">
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>${t("paused_until_label", L)} ${formatDate(task.paused_until, L)}
</span>` : nothing}
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
</span>` : nothing}
@@ -161,6 +166,12 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
` : nothing}
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openQr(task.name); }}>${t("qr_code", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
${ctx.features.adaptive && task.adaptive_config?.enabled ? html`
<!-- The recommendation card carries Re-analyze too, but only
once a differing suggestion exists — the analysis it is
meant to trigger was unreachable before (audit 2026-09-28). -->
<div class="popup-menu-item reanalyze" @click=${() => { ctx.closeMoreMenu(); ctx.reanalyze(); }}>${t("reanalyze", L)}</div>
` : nothing}
${!isOperator ? html`
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => ctx.moveTask()}>${t("move_task", L)}</div>
@@ -172,6 +183,7 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
${!isOperator ? html`
${!task.archived ? html`<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.togglePause(!!task.paused); }}>${task.paused ? t("resume_task", L) : `${t("pause_task", L)}…`}</div>` : nothing}
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.toggleArchive(!!task.archived); }}>${task.archived ? t("unarchive", L) : t("archive", L)}</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>