187 files
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
/** Shared chart helpers: nice axis ticks and consistent number/date formats. */
|
||||
|
||||
import { formatDateShort, formatTimeOfDay, formatNumber } from "../styles";
|
||||
|
||||
/** SVG / CSS coordinate: one decimal, ALWAYS a dot — never a user-facing
|
||||
* number. The only place outside styles.ts allowed to call toFixed (tripwire
|
||||
* rule D); human-facing figures go through formatNumber. */
|
||||
export function px(n: number): string {
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
/** Round axis ticks covering [min, max] — the classic "nice numbers" loop.
|
||||
* Returns the tick values plus the (possibly widened) nice domain, so the
|
||||
* chart can plot against the same bounds the labels describe. */
|
||||
@@ -32,47 +41,37 @@ export function niceTicks(
|
||||
return { ticks, niceMin, niceMax };
|
||||
}
|
||||
|
||||
/** Compact, consistent number format: 1.2M / 88k / 730 / 7.5 / 0.42 */
|
||||
export function fmtNum(v: number): string {
|
||||
/** Compact, consistent number format: 1.2M / 88k / 730 / 7.5 / 0.42 —
|
||||
* decimal separator per the HA profile number format. */
|
||||
export function fmtNum(v: number, lang?: string): string {
|
||||
const a = Math.abs(v);
|
||||
if (a >= 1_000_000) return trimZero((v / 1_000_000).toFixed(a >= 10_000_000 ? 0 : 1)) + "M";
|
||||
if (a >= 10_000) return trimZero((v / 1000).toFixed(0)) + "k";
|
||||
if (a >= 1000) return trimZero((v / 1000).toFixed(1)) + "k";
|
||||
if (a >= 100) return v.toFixed(0);
|
||||
if (a >= 10) return trimZero(v.toFixed(1));
|
||||
if (a >= 1) return trimZero(v.toFixed(1));
|
||||
const max = (d: number) => ({ maximumFractionDigits: d });
|
||||
if (a >= 1_000_000) return formatNumber(v / 1_000_000, lang, max(a >= 10_000_000 ? 0 : 1)) + "M";
|
||||
if (a >= 10_000) return formatNumber(v / 1000, lang, max(0)) + "k";
|
||||
if (a >= 1000) return formatNumber(v / 1000, lang, max(1)) + "k";
|
||||
if (a >= 100) return formatNumber(v, lang, max(0));
|
||||
if (a >= 1) return formatNumber(v, lang, max(1));
|
||||
if (a === 0) return "0";
|
||||
return trimZero(v.toFixed(2));
|
||||
return formatNumber(v, lang, max(2));
|
||||
}
|
||||
|
||||
function trimZero(s: string): string {
|
||||
return s.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
||||
}
|
||||
|
||||
/** Full-precision value + unit for tooltips (locale-aware grouping). */
|
||||
/** Full-precision value + unit for tooltips (profile grouping/decimal). */
|
||||
export function fmtVal(v: number, unit: string, lang: string): string {
|
||||
const s = v.toLocaleString(lang, { maximumFractionDigits: Math.abs(v) >= 100 ? 0 : 1 });
|
||||
const s = formatNumber(v, lang, { maximumFractionDigits: Math.abs(v) >= 100 ? 0 : 1 });
|
||||
return unit ? `${s} ${unit}` : s;
|
||||
}
|
||||
|
||||
/** Short date tick: "3. Juli" / "Jul 3", with year appended when the plotted
|
||||
* range spans more than ~10 months (disambiguates "Jun … Feb" sequences). */
|
||||
export function fmtDateTick(ts: number, lang: string, withYear: boolean): string {
|
||||
const d = new Date(ts);
|
||||
const opts: Intl.DateTimeFormatOptions = withYear
|
||||
? { month: "short", day: "numeric", year: "2-digit" }
|
||||
: { month: "short", day: "numeric" };
|
||||
return d.toLocaleDateString(lang, opts);
|
||||
return formatDateShort(new Date(ts), lang, withYear);
|
||||
}
|
||||
|
||||
/** Date+time for crosshair/tooltip labels. */
|
||||
/** Date+time for crosshair/tooltip labels — the clock half follows the HA
|
||||
* profile time format (#163). */
|
||||
export function fmtDateTime(ts: number, lang: string): string {
|
||||
return new Date(ts).toLocaleDateString(lang, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const d = new Date(ts);
|
||||
return `${formatDateShort(d, lang)}, ${formatTimeOfDay(d, lang)}`;
|
||||
}
|
||||
|
||||
/** Whether date ticks need the year: the range crosses a calendar-year
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear } from "./chart-utils";
|
||||
import { t, formatCost } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear, px } from "./chart-utils";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
const COST_CHART_H = 200;
|
||||
@@ -20,6 +20,7 @@ export function renderCostDurationCard(
|
||||
lang: string,
|
||||
toggle: "cost" | "duration" | "both",
|
||||
setToggle: (val: "cost" | "duration" | "both") => void,
|
||||
currencySymbol: string,
|
||||
) {
|
||||
const completedEntries = task.history.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null));
|
||||
if (completedEntries.length < 2) return nothing;
|
||||
@@ -50,12 +51,12 @@ export function renderCostDurationCard(
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${renderHistoryChart(task, lang, toggle)}
|
||||
${renderHistoryChart(task, lang, toggle, currencySymbol)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both") {
|
||||
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 }))
|
||||
@@ -109,27 +110,27 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
|
||||
const y = costY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}" stroke="var(--divider-color)" stroke-width="1" opacity="0.55" />
|
||||
<text x="${PAD_L - 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" fill="var(--primary-color)" font-size="10.5">${fmtNum(v)}€</text>`;
|
||||
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}" stroke="var(--divider-color)" stroke-width="1" opacity="0.55" />
|
||||
<text x="${PAD_L - 6}" y="${px(y + 3.5)}" text-anchor="end" fill="var(--primary-color)" font-size="10.5">${fmtNum(v, lang)}${currencySymbol}</text>`;
|
||||
}) : nothing}
|
||||
${showDuration ? durAxis.ticks.map((v) => {
|
||||
const y = durY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`<text x="${W - PAD_R + 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v)}m</text>`;
|
||||
return svg`<text x="${W - PAD_R + 6}" y="${px(y + 3.5)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v, lang)}m</text>`;
|
||||
}) : nothing}
|
||||
|
||||
${showCost ? entries.filter((e) => e.cost > 0).map((e) => svg`
|
||||
<rect x="${(toX(e.ts) - barW / 2).toFixed(1)}" y="${costY(e.cost).toFixed(1)}" width="${barW.toFixed(1)}" height="${(plotB - costY(e.cost)).toFixed(1)}"
|
||||
<rect x="${px(toX(e.ts) - barW / 2)}" y="${px(costY(e.cost))}" width="${px(barW)}" height="${px(plotB - costY(e.cost))}"
|
||||
fill="var(--primary-color)" opacity="0.6" rx="2">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.cost.toLocaleString(lang)}€${e.duration ? ` · ${e.duration}m` : ""}</title>
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${formatCost(e.cost, currencySymbol, lang)}${e.duration ? ` · ${e.duration}m` : ""}</title>
|
||||
</rect>
|
||||
`) : nothing}
|
||||
${showDuration ? svg`
|
||||
<polyline points="${entries.map((e) => `${toX(e.ts).toFixed(1)},${durY(e.duration).toFixed(1)}`).join(" ")}"
|
||||
<polyline points="${entries.map((e) => `${px(toX(e.ts))},${px(durY(e.duration))}`).join(" ")}"
|
||||
fill="none" stroke="var(--accent-color, #ff9800)" stroke-width="2" stroke-linejoin="round" />
|
||||
${entries.map((e) => svg`
|
||||
<circle cx="${toX(e.ts).toFixed(1)}" cy="${durY(e.duration).toFixed(1)}" r="3.5" fill="var(--accent-color, #ff9800)">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.duration}m${e.cost ? ` · ${e.cost.toLocaleString(lang)}€` : ""}</title>
|
||||
<circle cx="${px(toX(e.ts))}" cy="${px(durY(e.duration))}" r="3.5" fill="var(--accent-color, #ff9800)">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.duration}m${e.cost ? ` · ${formatCost(e.cost, currencySymbol, lang)}` : ""}</title>
|
||||
</circle>
|
||||
`)}
|
||||
` : nothing}
|
||||
@@ -137,7 +138,7 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
|
||||
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
|
||||
${xTicks.map((ts, i) => {
|
||||
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
|
||||
return svg`<text x="${toX(ts).toFixed(1)}" y="${H - 6}" text-anchor="${anchor}" fill="var(--secondary-text-color)" font-size="10">${fmtDateTick(ts, lang, withYear)}</text>`;
|
||||
return svg`<text x="${px(toX(ts))}" y="${H - 6}" text-anchor="${anchor}" fill="var(--secondary-text-color)" font-size="10">${fmtDateTick(ts, lang, withYear)}</text>`;
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDateTime, STATUS_ICONS } from "../styles";
|
||||
import { t, formatDateTime, formatNumber, formatCost, STATUS_ICONS } from "../styles";
|
||||
import type { MaintenanceTask, HistoryEntry, HomeAssistant } from "../types";
|
||||
import "../components/history-photo";
|
||||
|
||||
@@ -114,13 +114,13 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
|
||||
? html`<maintenance-history-photo .hass=${ctx.hass} .docId=${entry.photo_doc_id}></maintenance-history-photo>`
|
||||
: nothing}
|
||||
<div class="history-details">
|
||||
${entry.cost != null ? html`<span>${t("cost", L)}: ${entry.cost.toFixed(2)} ${ctx.currencySymbol}</span>` : nothing}
|
||||
${entry.cost != null ? html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>` : nothing}
|
||||
${entry.duration != null ? html`<span>${t("duration", L)}: ${entry.duration} min</span>` : nothing}
|
||||
${entry.trigger_value != null ? html`<span>${t("trigger_val", L)}: ${entry.trigger_value}</span>` : nothing}
|
||||
${entry.reading_value != null
|
||||
? html`<span>${t("reading_label", L)}: ${entry.reading_value}${ctx.readingUnit ? ` ${ctx.readingUnit}` : ""}${(() => {
|
||||
const d = ctx.readingDelta?.(entry);
|
||||
return d == null ? "" : ` (${d >= 0 ? "+" : ""}${Number(d.toFixed(3))})`;
|
||||
return d == null ? "" : ` (${d >= 0 ? "+" : ""}${formatNumber(d, L, { maximumFractionDigits: 3 })})`;
|
||||
})()}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Sensor prediction section renderer. */
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDate, fireMoreInfo } from "../styles";
|
||||
import { t, formatDate, fireMoreInfo, formatNumber } from "../styles";
|
||||
import type { MaintenanceTask, AdvancedFeatures } from "../types";
|
||||
|
||||
export function renderPredictionSection(task: MaintenanceTask, lang: string, features: AdvancedFeatures) {
|
||||
@@ -34,7 +34,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
|
||||
<ha-svg-icon path="${trendIcon}"></ha-svg-icon>
|
||||
<span class="prediction-label">${t("degradation_trend", lang)}</span>
|
||||
<span class="prediction-value ${task.degradation_trend}">${t("trend_" + task.degradation_trend, lang)}</span>
|
||||
${task.degradation_rate != null ? html`<span class="prediction-rate">${task.degradation_rate > 0 ? "+" : ""}${Math.abs(task.degradation_rate) >= 10 ? Math.round(task.degradation_rate).toLocaleString() : task.degradation_rate.toFixed(1)} ${task.trigger_entity_info?.unit_of_measurement || ""}/${t("day_short", lang)}</span>` : nothing}
|
||||
${task.degradation_rate != null ? html`<span class="prediction-rate">${task.degradation_rate > 0 ? "+" : ""}${formatNumber(task.degradation_rate, lang, Math.abs(task.degradation_rate) >= 10 ? 0 : 1)} ${task.trigger_entity_info?.unit_of_measurement || ""}/${t("day_short", lang)}</span>` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
${hasThreshold ? html`
|
||||
@@ -51,7 +51,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
|
||||
<div class="prediction-item">
|
||||
<ha-svg-icon path="M15,13V5A3,3 0 0,0 12,2A3,3 0 0,0 9,5V13A5,5 0 0,0 7,17A5,5 0 0,0 12,22A5,5 0 0,0 17,17A5,5 0 0,0 15,13M12,4A1,1 0 0,1 13,5V8H11V5A1,1 0 0,1 12,4Z"></ha-svg-icon>
|
||||
<span class="prediction-label">${t("environmental_adjustment", lang)}</span>
|
||||
<span class="prediction-value">${task.environmental_factor!.toFixed(2)}x</span>
|
||||
<span class="prediction-value">${formatNumber(task.environmental_factor!, lang, 2)}x</span>
|
||||
${task.environmental_entity ? html`<span class="prediction-entity entity-link" @click=${(ev: Event) => fireMoreInfo(ev, task.environmental_entity!)}>${task.environmental_entity}</span>` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDate, formatDueDays } from "../styles";
|
||||
import { t, formatDate, formatDueDays, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import { daysProgress } from "../helpers/interval";
|
||||
import type { MaintenanceTask, TaskRow, StatisticsPoint } from "../types";
|
||||
|
||||
@@ -92,7 +93,7 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
const low = below ?? 0;
|
||||
const range = above - low || 1;
|
||||
pct = Math.min(100, Math.max(0, ((val - low) / range) * 100));
|
||||
label = `${val.toFixed(1)} / ${above} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(above, opts?.lang)} ${unit}`;
|
||||
} else if (below != null) {
|
||||
// Progress toward lower limit (inverted: lower is worse)
|
||||
// Use entity max, or 2x the threshold as a stable "safe" reference.
|
||||
@@ -101,12 +102,12 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
const high = entityMax ?? ((below * 2) || 100);
|
||||
const range = high - below || 1;
|
||||
pct = Math.min(100, Math.max(0, ((high - val) / range) * 100));
|
||||
label = `${val.toFixed(1)} / ${below} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(below, opts?.lang)} ${unit}`;
|
||||
} else if (tc.trigger_equals != null || tc.trigger_not_equals != null) {
|
||||
// Discrete = / ≠ levels have no meaningful gradient — binary bar,
|
||||
// same treatment as compound.
|
||||
const target = tc.trigger_equals != null ? `= ${tc.trigger_equals}` : `≠ ${tc.trigger_not_equals}`;
|
||||
label = `${val.toFixed(1)} (${target}${unit ? ` ${unit}` : ""})`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} (${target}${unit ? ` ${unit}` : ""})`;
|
||||
pct = row.trigger_active ? 100 : 0;
|
||||
} else {
|
||||
return nothing;
|
||||
@@ -129,19 +130,19 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
}
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${val.toFixed(1)} / ${target} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(target, opts?.lang)} ${unit}`;
|
||||
} else if (triggerType === "state_change") {
|
||||
const target = tc.trigger_target_changes || 1;
|
||||
const val = row.trigger_current_value ?? null;
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${Math.round(val)} / ${target}`;
|
||||
label = `${formatNumber(val, opts?.lang, 0)} / ${formatNumber(target, opts?.lang, 0)}`;
|
||||
} else if (triggerType === "runtime") {
|
||||
const target = tc.trigger_runtime_hours || 100;
|
||||
const val = row.trigger_current_value ?? null;
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${val.toFixed(1)}h / ${target}h`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)}h / ${formatNumber(target, opts?.lang)}h`;
|
||||
} else if (triggerType === "compound") {
|
||||
const logic = tc.compound_logic || (tc as any).operator || "AND";
|
||||
const condCount = tc.conditions?.length || 0;
|
||||
@@ -218,7 +219,7 @@ export function renderMiniSparkline(
|
||||
renderPoints = renderPoints.filter((_, i) => i % step === 0 || i === renderPoints.length - 1);
|
||||
}
|
||||
|
||||
const pts = renderPoints.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
|
||||
const pts = renderPoints.map((p) => `${px(toX(p.ts))},${px(toY(p.val))}`).join(" ");
|
||||
// Match the detail chart's semantics: an actively-triggered sensor tints red.
|
||||
const stroke = row.trigger_active
|
||||
? "var(--error-color, #f44336)"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Seasonal factor chart renderers. */
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { t, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import type { MaintenanceTask, AdvancedFeatures } from "../types";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
@@ -38,7 +39,7 @@ export function renderSeasonalCardCompact(task: MaintenanceTask, lang: string, f
|
||||
return html`
|
||||
<div class="seasonal-bar ${colorClass} ${isCurrentMonth ? 'current' : ''}"
|
||||
style="height: ${height}px"
|
||||
title="${months[i]}: ${factor.toFixed(2)}x">
|
||||
title="${months[i]}: ${formatNumber(factor, lang, 2)}x">
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@@ -82,7 +83,7 @@ function renderSeasonalChart(task: MaintenanceTask, lang: string) {
|
||||
${reason ? html`<span class="source-tag">${reason === "learned" ? t("seasonal_learned", lang) : t("seasonal_manual", lang)}</span>` : nothing}
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${t("chart_seasonal", lang)}">
|
||||
<line x1="0" y1="${baselineY.toFixed(1)}" x2="${W}" y2="${baselineY.toFixed(1)}"
|
||||
<line x1="0" y1="${px(baselineY)}" x2="${W}" y2="${px(baselineY)}"
|
||||
stroke="var(--divider-color)" stroke-width="1" stroke-dasharray="4,3" />
|
||||
${factors.map((f, i) => {
|
||||
const barH = (f / maxFactor) * chartH;
|
||||
@@ -95,8 +96,8 @@ function renderSeasonalChart(task: MaintenanceTask, lang: string) {
|
||||
? "var(--warning-color, #ff9800)"
|
||||
: "var(--secondary-text-color)";
|
||||
return svg`
|
||||
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||
width="${barInner.toFixed(1)}" height="${barH.toFixed(1)}"
|
||||
<rect x="${px(x)}" y="${px(y)}"
|
||||
width="${px(barInner)}" height="${px(barH)}"
|
||||
fill="${color}" opacity="${isCurrent ? 1 : 0.5}" rx="2" />
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
|
||||
import { t, formatDate, formatDateTime, formatRecurrence, formatNumber, formatCost } from "../styles";
|
||||
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
|
||||
import { renderTriggerSection, type SparklineContext } from "./sparkline";
|
||||
import { renderPredictionSection } from "./prediction";
|
||||
@@ -130,9 +130,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
}
|
||||
</div>
|
||||
<div class="task-header-actions">
|
||||
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
|
||||
<ha-button appearance="accent" variant="success" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
|
||||
${task.allow_skip !== false
|
||||
? html`<ha-button appearance="outlined" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>`
|
||||
? html`<ha-button appearance="outlined" variant="warning" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>`
|
||||
: nothing}
|
||||
<div class="more-menu-wrapper">
|
||||
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
|
||||
@@ -351,11 +351,11 @@ function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_cost", L)}</div>
|
||||
<div class="kpi-value">${avgCost.toFixed(0)} ${ctx.currencySymbol}</div>
|
||||
<div class="kpi-value">${formatCost(avgCost, ctx.currencySymbol, L, 0)}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_duration", L)}</div>
|
||||
<div class="kpi-value">${task.average_duration ? task.average_duration.toFixed(0) : "—"} min</div>
|
||||
<div class="kpi-value">${task.average_duration ? formatNumber(task.average_duration, L, 0) : "—"} min</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -422,7 +422,7 @@ function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
<span class="activity-icon">${getIcon(entry.type)}</span>
|
||||
<span class="activity-date">${formatDateTime(entry.timestamp, L)}</span>
|
||||
<span class="activity-note">${entry.notes || "—"}</span>
|
||||
${entry.cost ? html`<span class="activity-badge">${entry.cost.toFixed(0)}${ctx.currencySymbol}</span>` : nothing}
|
||||
${entry.cost ? html`<span class="activity-badge">${formatCost(entry.cost, ctx.currencySymbol, L, 0)}</span>` : nothing}
|
||||
${entry.duration ? html`<span class="activity-badge">${entry.duration}min</span>` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
@@ -471,7 +471,7 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="right-column">
|
||||
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v))}
|
||||
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v), ctx.currencySymbol)}
|
||||
</div>
|
||||
</div>
|
||||
${hasWeibullData
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Weibull reliability analysis renderers. */
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { t, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
export function renderWeibullSection(task: MaintenanceTask, lang: string) {
|
||||
@@ -51,7 +52,7 @@ function renderBetaBadge(beta: number, lang: string) {
|
||||
return html`
|
||||
<span class="beta-badge ${cls}">
|
||||
<ha-svg-icon path="${icon}"></ha-svg-icon>
|
||||
${t(key, lang)} (\u03B2=${beta.toFixed(2)})
|
||||
${t(key, lang)} (\u03B2=${formatNumber(beta, lang, 2)})
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -74,15 +75,15 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
points.push([x, y]);
|
||||
}
|
||||
|
||||
const polyline = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
|
||||
const polyline = points.map(([x, y]) => `${px(x)},${px(y)}`).join(" ");
|
||||
const areaPath = `M${PAD_L},${PAD_T + chartH} ` +
|
||||
points.map(([x, y]) => `L${x.toFixed(1)},${y.toFixed(1)}`).join(" ") +
|
||||
` L${points[N][0].toFixed(1)},${PAD_T + chartH} Z`;
|
||||
points.map(([x, y]) => `L${px(x)},${px(y)}`).join(" ") +
|
||||
` L${px(points[N][0])},${PAD_T + chartH} Z`;
|
||||
|
||||
const curX = PAD_L + (currentInterval / maxT) * chartW;
|
||||
const curCdf = 1.0 - Math.exp(-Math.pow(currentInterval / eta, beta));
|
||||
const curY = PAD_T + chartH - curCdf * chartH;
|
||||
const reliability = ((1.0 - curCdf) * 100).toFixed(0);
|
||||
const reliability = formatNumber((1.0 - curCdf) * 100, lang, 0);
|
||||
|
||||
const recX = PAD_L + (recommended / maxT) * chartW;
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
|
||||
@@ -93,10 +94,10 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
${yTicks.map(tick => {
|
||||
const y = PAD_T + chartH - tick * chartH;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
|
||||
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}"
|
||||
stroke="var(--divider-color)" stroke-width="0.5" stroke-dasharray="${tick === 0.5 ? '4,3' : nothing}" />
|
||||
<text x="${PAD_L - 4}" y="${(y + 3).toFixed(1)}" fill="var(--secondary-text-color)"
|
||||
font-size="8" text-anchor="end">${(tick * 100).toFixed(0)}%</text>
|
||||
<text x="${PAD_L - 4}" y="${px(y + 3)}" fill="var(--secondary-text-color)"
|
||||
font-size="8" text-anchor="end">${formatNumber(tick * 100, lang, 0)}%</text>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -109,16 +110,16 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
stroke="var(--primary-color, #03a9f4)" stroke-width="2" />
|
||||
|
||||
${currentInterval > 0 ? svg`
|
||||
<line x1="${curX.toFixed(1)}" y1="${PAD_T}" x2="${curX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
|
||||
<line x1="${px(curX)}" y1="${PAD_T}" x2="${px(curX)}" y2="${px(PAD_T + chartH)}"
|
||||
stroke="var(--primary-color, #03a9f4)" stroke-width="1.5" stroke-dasharray="4,3" />
|
||||
<circle cx="${curX.toFixed(1)}" cy="${curY.toFixed(1)}" r="3"
|
||||
<circle cx="${px(curX)}" cy="${px(curY)}" r="3"
|
||||
fill="var(--primary-color, #03a9f4)" />
|
||||
<text x="${(curX + 4).toFixed(1)}" y="${(curY - 6).toFixed(1)}" fill="var(--primary-color, #03a9f4)"
|
||||
<text x="${px(curX + 4)}" y="${px(curY - 6)}" fill="var(--primary-color, #03a9f4)"
|
||||
font-size="9" font-weight="600">R=${reliability}%</text>
|
||||
` : nothing}
|
||||
|
||||
${recommended > 0 && recommended !== currentInterval ? svg`
|
||||
<line x1="${recX.toFixed(1)}" y1="${PAD_T}" x2="${recX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
|
||||
<line x1="${px(recX)}" y1="${PAD_T}" x2="${px(recX)}" y2="${px(PAD_T + chartH)}"
|
||||
stroke="var(--success-color, #4caf50)" stroke-width="1.5" stroke-dasharray="4,3" />
|
||||
` : nothing}
|
||||
|
||||
@@ -146,7 +147,7 @@ function renderWeibullInfo(analysis: NonNullable<MaintenanceTask["interval_analy
|
||||
${analysis.weibull_r_squared != null ? html`
|
||||
<div class="weibull-info-item">
|
||||
<span>${t("weibull_r_squared", lang)}</span>
|
||||
<span class="weibull-info-value">${analysis.weibull_r_squared!.toFixed(3)}</span>
|
||||
<span class="weibull-info-value">${formatNumber(analysis.weibull_r_squared!, lang, 3)}</span>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -174,9 +175,9 @@ function renderConfidenceInterval(analysis: NonNullable<MaintenanceTask["interva
|
||||
${t("confidence_interval", lang)}: ${rec} ${t("days", lang)} (${low}\u2013${high})
|
||||
</div>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="left:${fillLeft.toFixed(1)}%;width:${fillWidth.toFixed(1)}%"></div>
|
||||
${curPos >= 0 ? html`<div class="confidence-marker current" style="left:${curPos.toFixed(1)}%"></div>` : nothing}
|
||||
<div class="confidence-marker recommended" style="left:${recPos.toFixed(1)}%"></div>
|
||||
<div class="confidence-fill" style="left:${px(fillLeft)}%;width:${px(fillWidth)}%"></div>
|
||||
${curPos >= 0 ? html`<div class="confidence-marker current" style="left:${px(curPos)}%"></div>` : nothing}
|
||||
<div class="confidence-marker recommended" style="left:${px(recPos)}%"></div>
|
||||
</div>
|
||||
<div class="confidence-labels">
|
||||
<span class="confidence-text low">${t("confidence_conservative", lang)} (${low}${t("days", lang).charAt(0)})</span>
|
||||
|
||||
Reference in New Issue
Block a user