155 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-04 20:46:32 +00:00
1 parent a212615470
commit 0ff8c287a8
155 files changed
+19264 -3148

No files matched your search

@@ -1,6 +1,7 @@
/** Shared chart helpers: nice axis ticks and consistent number/date formats. */
import { formatDateShort, formatTimeOfDay, formatNumber } from "../styles";
import { displayTimeZone, ymdIn } from "../helpers/ha-time";
/** SVG / CSS coordinate: one decimal, ALWAYS a dot — never a user-facing
* number. The only place outside styles.ts allowed to call toFixed (tripwire
@@ -75,9 +76,12 @@ export function fmtDateTime(ts: number, lang: string): string {
}
/** Whether date ticks need the year: the range crosses a calendar-year
* boundary (a "Jun … Feb" sequence would otherwise read backwards). */
* boundary (a "Jun … Feb" sequence would otherwise read backwards) — in
* the zone the ticks are shown in. */
export function needsYear(tsMin: number, tsMax: number): boolean {
return new Date(tsMin).getFullYear() !== new Date(tsMax).getFullYear();
if (!Number.isFinite(tsMin) || !Number.isFinite(tsMax)) return false;
const zone = displayTimeZone();
return ymdIn(tsMin, zone).slice(0, 4) !== ymdIn(tsMax, zone).slice(0, 4);
}
/** Evenly-spaced x-axis tick timestamps (first/last inclusive). */
@@ -11,6 +11,7 @@ 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";
import { stampMs } from "../helpers/ha-time";
const COST_CHART_H = 200;
const PAD_T = 10;
@@ -63,7 +64,7 @@ export function renderCostDurationCard(
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both", currencySymbol: string) {
const entries = task.history
.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 }))
.map((h) => ({ ts: stampMs(h.timestamp), cost: entrySpend(h) ?? 0, duration: h.duration ?? 0 }))
.sort((a, b) => a.ts - b.ts);
if (entries.length < 2) return nothing;
@@ -20,6 +20,7 @@ import { renderRefChip } from "../helpers/reference";
import { entryReadingValues } from "../helpers/reading-slots";
import { matchesQuery } from "../helpers/search-match";
import { historyNoteText } from "../helpers/history-note";
import { stampMs } from "../helpers/ha-time";
/** v2.2.0: only "lifecycle + cost-bearing" entries are user-editable.
* Triggers are auto-generated by sensors and shouldn't be retroactively
@@ -34,9 +35,9 @@ export const EDITABLE_HISTORY_TYPES: readonly HistoryEntryType[] = ["completed",
* an unparsable) timestamp keep "later appended = newer". */
export function newestFirst<T extends { timestamp?: string | null }>(history: readonly T[] | null | undefined): T[] {
const ms = (e: T): number => {
const raw = e.timestamp ?? "";
// Date-only strings are local days (like formatDate) — not UTC midnight.
const v = Date.parse(raw.length === 10 ? `${raw}T00:00:00` : raw);
// A zone-less stamp (the history editor writes one) is HA local time,
// a date-only one HA's midnight — never UTC midnight.
const v = stampMs(e.timestamp);
return Number.isNaN(v) ? -Infinity : v;
};
return (history ?? [])
@@ -15,6 +15,7 @@ import { html, nothing } from "lit";
import { t, formatDate, formatDueDays, formatNumber } from "../styles";
import { px } from "./chart-utils";
import { daysProgress } from "../helpers/interval";
import { stampMs } from "../helpers/ha-time";
import type { MaintenanceTask, TaskRow, StatisticsPoint } from "../types";
const MINI_SPARKLINE_W = 60;
@@ -61,7 +62,7 @@ export function computeTrend(
? []
: (row.history || [])
.filter((h) => h.trigger_value != null)
.map((h) => ({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value as number }));
.map((h) => ({ ts: stampMs(h.timestamp), val: h.trigger_value as number }));
if (row.trigger_current_value != null) points = [...points, { ts: Date.now(), val: row.trigger_current_value }];
if (points.length < 2) return null;
points.sort((a, b) => a.ts - b.ts);
@@ -249,7 +250,7 @@ export function renderMiniSparkline(
if (!row.history) return nothing;
for (const h of row.history) {
if (h.trigger_value != null) {
points.push({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value });
points.push({ ts: stampMs(h.timestamp), val: h.trigger_value });
}
}
}
@@ -3,6 +3,7 @@
import { html, svg, nothing } from "lit";
import { t, formatNumber } from "../styles";
import { px } from "./chart-utils";
import { haToday } from "../helpers/ha-time";
import type { MaintenanceTask, AdvancedFeatures } from "../types";
const MONTH_KEYS = [
@@ -11,13 +12,18 @@ const MONTH_KEYS = [
"month_sep", "month_oct", "month_nov", "month_dec",
];
/** HA's current month (0 = Jan) — the one the backend applies the factor of. */
function haMonth(): number {
return Number(haToday().slice(5, 7)) - 1;
}
export function renderSeasonalCardCompact(task: MaintenanceTask, lang: string, features: AdvancedFeatures) {
if (!features.seasonal || !task.seasonal_factor || task.seasonal_factor === 1.0) {
return nothing;
}
const months = MONTH_KEYS.map(k => t(k, lang));
const currentMonth = new Date().getMonth();
const currentMonth = haMonth();
const realFactors = task.seasonal_factors || task.interval_analysis?.seasonal_factors || null;
const seasonalData = realFactors && realFactors.length === 12
@@ -66,7 +72,7 @@ function renderSeasonalChart(task: MaintenanceTask, lang: string) {
if (!factors || factors.length !== 12) return nothing;
const reason = task.interval_analysis?.seasonal_reason;
const currentMonth = new Date().getMonth();
const currentMonth = haMonth();
const W = 300, H = 100;
const PAD_T = 8, PAD_B = 4;
const chartH = H - PAD_T - PAD_B;
@@ -15,6 +15,7 @@ import { html, nothing } from "lit";
import { t, fireMoreInfo, formatDueDays } from "../styles";
import { fmtNum, fmtVal } from "./chart-utils";
import { dueDaysOf } from "./progress";
import { stampMs } from "../helpers/ha-time";
import "../components/trigger-chart";
import type { ChartPoint, ChartEvent } from "../components/trigger-chart";
import type { MaintenanceTask, TriggerConfig, StatisticsPoint } from "../types";
@@ -299,8 +300,8 @@ function counterBaseline(task: MaintenanceTask, rawPoints: ChartPoint[]): { valu
function lastServiceTs(task: MaintenanceTask): number | null {
const e = [...task.history]
.filter((h) => h.type === "completed" || h.type === "reset")
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
return e ? new Date(e.timestamp).getTime() : null;
.sort((a, b) => stampMs(b.timestamp) - stampMs(a.timestamp))[0];
return e ? stampMs(e.timestamp) : null;
}
/** "8,507 / 15,000 km · 57 %" header + progress bar (counter / state_change / runtime). */
@@ -351,7 +352,7 @@ function rawStatsPoints(task: MaintenanceTask, ctx: SparklineContext): ChartPoin
} else {
for (const h of task.history) {
if (h.trigger_value != null) {
points.push({ ts: new Date(h.timestamp).getTime(), val: h.trigger_value });
points.push({ ts: stampMs(h.timestamp), val: h.trigger_value });
}
}
}
@@ -472,7 +473,7 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
const events: ChartEvent[] = task.history
.filter((h) => ["completed", "skipped", "reset"].includes(h.type))
.map((h) => ({ ts: new Date(h.timestamp).getTime(), type: h.type }));
.map((h) => ({ ts: stampMs(h.timestamp), type: h.type }));
return html`
<maintenance-trigger-chart