257 files
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user