329 files
This commit is contained in:
+333
-11
@@ -18,10 +18,35 @@ interface BatteryRow {
|
||||
level: number | null;
|
||||
days_until: number | null;
|
||||
available?: boolean;
|
||||
/** Where the ~date comes from: "trend" (discharge regression) or "typical"
|
||||
* (type-lifetime table). */
|
||||
predicted_source?: "trend" | "typical";
|
||||
prediction_confidence?: "medium" | "high" | null;
|
||||
/** Charged, never bought — the row never feeds the shopping groupings and
|
||||
* a ~date only appears when the discharge trend earned one. */
|
||||
rechargeable?: boolean;
|
||||
/** This battery's own low threshold (Battery Notes' configured value or
|
||||
* the fleet floor, whichever is higher) — the level bar colors against
|
||||
* it, not against a fixed 20 %. */
|
||||
low_threshold?: number;
|
||||
}
|
||||
interface RosterRow extends BatteryRow {
|
||||
status: "low" | "soon" | "ok";
|
||||
}
|
||||
/** 30 d downsampled level history per battery, for the roster sparklines.
|
||||
* threshold = the same low threshold the trend forecast regresses toward,
|
||||
* so the dotted projection ends exactly where the ~date comes from.
|
||||
* jump = an upward step that looks like a swap nobody recorded in Battery
|
||||
* Notes (the forecast still anchors on the dead battery's date) — with the
|
||||
* device to call `battery_notes.set_battery_replaced` on. */
|
||||
type HistorySeries = Record<
|
||||
string,
|
||||
{
|
||||
points: [number, number][];
|
||||
threshold: number;
|
||||
jump?: { at: number; from: number; to: number; device_id: string };
|
||||
}
|
||||
>;
|
||||
interface Overview {
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
@@ -45,6 +70,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
@state() private _loading = false;
|
||||
@state() private _marking = false;
|
||||
@state() private _error = "";
|
||||
@state() private _history: HistorySeries | null = null;
|
||||
// Urgency by default (issue #123: "soon sat in the middle of the list");
|
||||
// the choice is remembered per browser.
|
||||
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
|
||||
@state() private _typeFilter: string | null = null;
|
||||
@state() private _recorded: string[] = [];
|
||||
private _historyRequested = false;
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
@@ -139,20 +171,165 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy: the recorder-backed history is fetched once, when the roster is
|
||||
* first expanded — most panel visits never open it. */
|
||||
private _loadHistory = async (e: Event): Promise<void> => {
|
||||
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
|
||||
this._historyRequested = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
|
||||
type: "maintenance_supporter/battery_fleet/overview_history",
|
||||
});
|
||||
this._history = res.series;
|
||||
} catch {
|
||||
this._history = null; // sparklines are an enhancement — rows render without them
|
||||
}
|
||||
};
|
||||
|
||||
/** Inline-SVG sparkline: 30 d level line, a faint threshold line, and —
|
||||
* where the ~date comes from the discharge trend — a dotted projection
|
||||
* from the last reading down to the threshold, so the date is visible
|
||||
* instead of merely stated. */
|
||||
private _sparkline(b: RosterRow) {
|
||||
const h = this._history?.[b.entity_id];
|
||||
if (!h || h.points.length < 2) return nothing;
|
||||
const W = 110, H = 24, P = 2;
|
||||
const t0 = h.points[0][0];
|
||||
const tLast = h.points[h.points.length - 1][0];
|
||||
const nowSec = Date.now() / 1000;
|
||||
const projEnd =
|
||||
b.status !== "low" && b.predicted_source === "trend" && b.days_until != null
|
||||
? nowSec + b.days_until * 86400
|
||||
: null;
|
||||
const tMax = Math.max(tLast, projEnd ?? tLast);
|
||||
const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P));
|
||||
const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P);
|
||||
const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const vLast = h.points[h.points.length - 1][1];
|
||||
const yTh = y(h.threshold).toFixed(1);
|
||||
return html`<svg
|
||||
class="bf-spark"
|
||||
viewBox="0 0 ${W} ${H}"
|
||||
role="img"
|
||||
aria-label=${t("battery_fleet_sparkline_hint", this._lang)}
|
||||
>
|
||||
<title>${t("battery_fleet_sparkline_hint", this._lang)}</title>
|
||||
<line class="bf-spark-th" x1="0" y1=${yTh} x2=${W} y2=${yTh}></line>
|
||||
<polyline class="bf-spark-line" points=${line}></polyline>
|
||||
${projEnd !== null
|
||||
? html`<line
|
||||
class="bf-spark-proj"
|
||||
x1=${x(tLast).toFixed(1)}
|
||||
y1=${y(vLast).toFixed(1)}
|
||||
x2=${x(projEnd).toFixed(1)}
|
||||
y2=${yTh}
|
||||
></line>`
|
||||
: nothing}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
private static readonly _SORT_KEY = "ms_bf_roster_sort";
|
||||
|
||||
private static _storedSort(): "name" | "urgency" {
|
||||
try {
|
||||
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
|
||||
return v === "name" ? "name" : "urgency";
|
||||
} catch {
|
||||
return "urgency";
|
||||
}
|
||||
}
|
||||
|
||||
private _setSort(mode: "name" | "urgency"): void {
|
||||
this._rosterSort = mode;
|
||||
try {
|
||||
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
|
||||
} catch {
|
||||
// storage unavailable — the toggle still works for this visit
|
||||
}
|
||||
}
|
||||
|
||||
/** Urgency (the default, issue #123): low rows first — emptiest first —
|
||||
* then the soonest forecast, dateless rows last. Name mode keeps the
|
||||
* alphabetical lookup list. */
|
||||
private _sortedRoster(rows: RosterRow[]): RosterRow[] {
|
||||
const filtered = this._typeFilter === null ? rows : rows.filter((r) => r.battery_type === this._typeFilter);
|
||||
if (this._rosterSort === "name") return filtered;
|
||||
// Low rows rank far below everything and among themselves by LEVEL
|
||||
// ascending (a 6 % battery before an 18 % one); the rest by days-until.
|
||||
const rank = (r: RosterRow) => (r.status === "low" ? -1000 + (r.level ?? 101) / 101 : (r.days_until ?? Infinity));
|
||||
return [...filtered].sort(
|
||||
(a, b) => rank(a) - rank(b) || a.device_name.localeCompare(b.device_name),
|
||||
);
|
||||
}
|
||||
|
||||
/** The forecast as a date a person can plan with, not a day count.
|
||||
* `days_until` comes from last-replaced + typical lifetime, so it is an
|
||||
* estimate — the tilde in the template says so. Negative values (past the
|
||||
* typical lifetime but not reported low yet) render as past dates, which
|
||||
* is honest: the battery is living on borrowed time. */
|
||||
private _predictedDate(daysUntil: number): string {
|
||||
const when = new Date(Date.now() + daysUntil * 864e5);
|
||||
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(when);
|
||||
return this._fmtDate(Date.now() + daysUntil * 864e5);
|
||||
}
|
||||
|
||||
private _shoppingLine(needs: Record<string, number>): string {
|
||||
return Object.entries(needs)
|
||||
.map(([type, qty]) => `${qty}× ${type}`)
|
||||
.join(" · ");
|
||||
private _fmtDate(epochMs: number): string {
|
||||
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
|
||||
}
|
||||
|
||||
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
|
||||
* roster to the devices that need it — "which devices need those 4× AAA?"
|
||||
* without scanning. Clicking the active chip clears the filter. */
|
||||
private _shoppingLine(needs: Record<string, number>) {
|
||||
return Object.entries(needs).map(
|
||||
([type, qty]) => html`<button
|
||||
class="bf-type-chip ${this._typeFilter === type ? "bf-type-chip-active" : ""}"
|
||||
title=${t("battery_fleet_filter_type", this._lang)}
|
||||
@click=${() => this._toggleTypeFilter(type)}
|
||||
>
|
||||
${qty}× ${type}
|
||||
</button>`,
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleTypeFilter(type: string): void {
|
||||
this._typeFilter = this._typeFilter === type ? null : type;
|
||||
if (this._typeFilter !== null) {
|
||||
const details = this.shadowRoot?.querySelector<HTMLDetailsElement>("details.bf-roster");
|
||||
if (details && !details.open) details.open = true; // fires toggle → history loads
|
||||
}
|
||||
}
|
||||
|
||||
/** One-click fix for a detected-but-unrecorded swap: record the DETECTED
|
||||
* jump time in Battery Notes, so the forecast re-anchors on the real
|
||||
* replacement instead of the dead battery's date. */
|
||||
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.callService("battery_notes", "set_battery_replaced", {
|
||||
device_id: jump.device_id,
|
||||
datetime_replaced: new Date(jump.at * 1000).toISOString(),
|
||||
});
|
||||
this._recorded = [...this._recorded, entityId];
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Purely visual level bar next to the number — scannable at a glance.
|
||||
* Colored against the battery's OWN low threshold: red at/below it,
|
||||
* amber inside a 20-point approach band, green above. */
|
||||
private _levelBar(b: BatteryRow) {
|
||||
const level = b.level;
|
||||
if (level == null) return nothing;
|
||||
const t = b.low_threshold ?? 20;
|
||||
const cls = level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
|
||||
return html`<span class="bf-bar" aria-hidden="true"
|
||||
><span class="bf-bar-fill bf-bar-${cls}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
|
||||
></span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -199,10 +376,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
|
||||
: nothing}
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
|
||||
></span>`
|
||||
: nothing}
|
||||
${this._levelBar(b)}
|
||||
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
|
||||
<button
|
||||
class="bf-mark"
|
||||
title=${t("battery_fleet_mark_one", L)}
|
||||
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._mark([b.entity_id])}
|
||||
>
|
||||
@@ -238,20 +421,57 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
: nothing}
|
||||
${ov.all?.length
|
||||
? html`
|
||||
<details class="bf-roster">
|
||||
<details class="bf-roster" @toggle=${this._loadHistory}>
|
||||
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
|
||||
<div class="bf-roster-tools">
|
||||
<button
|
||||
class="bf-sort ${this._rosterSort === "urgency" ? "bf-sort-active" : ""}"
|
||||
@click=${() => this._setSort("urgency")}
|
||||
>
|
||||
${t("battery_fleet_sort_urgency", L)}
|
||||
</button>
|
||||
<button
|
||||
class="bf-sort ${this._rosterSort === "name" ? "bf-sort-active" : ""}"
|
||||
@click=${() => this._setSort("name")}
|
||||
>
|
||||
${t("battery_fleet_sort_name", L)}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bf-rows">
|
||||
${ov.all.map(
|
||||
${this._sortedRoster(ov.all).map(
|
||||
(b) => html`
|
||||
<div class="bf-row">
|
||||
<span class="bf-dev">${b.device_name}</span>
|
||||
<span class="bf-status bf-${b.status}">${t("battery_fleet_status_" + b.status, L)}</span>
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
|
||||
></span>`
|
||||
: nothing}
|
||||
${this._sparkline(b)}
|
||||
${this._levelBar(b)}
|
||||
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
|
||||
${(() => {
|
||||
const jump = this._history?.[b.entity_id]?.jump;
|
||||
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
|
||||
return html`<button
|
||||
class="bf-mark bf-jump"
|
||||
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._recordJump(b.entity_id, jump)}
|
||||
>
|
||||
<ha-icon icon="mdi:calendar-sync"></ha-icon>
|
||||
</button>`;
|
||||
})()}
|
||||
${b.days_until != null
|
||||
? html`<span
|
||||
class="bf-predicted"
|
||||
title=${t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
|
||||
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
|
||||
title=${b.predicted_source === "trend"
|
||||
? t("battery_fleet_predicted_trend", L)
|
||||
.replace("{date}", this._predictedDate(b.days_until))
|
||||
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
|
||||
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
|
||||
>~${this._predictedDate(b.days_until)}</span
|
||||
>`
|
||||
: nothing}
|
||||
@@ -385,6 +605,102 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.bf-recharge {
|
||||
color: var(--secondary-text-color);
|
||||
display: inline-flex;
|
||||
cursor: help;
|
||||
}
|
||||
.bf-recharge ha-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.bf-spark {
|
||||
width: 110px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
cursor: help;
|
||||
}
|
||||
/* On phones the row cannot fit name + chips + curve + bar + date: the
|
||||
* decorations yield (the percentage still carries the number). */
|
||||
@media (max-width: 640px) {
|
||||
.bf-spark,
|
||||
.bf-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.bf-spark-line {
|
||||
fill: none;
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.bf-spark-proj {
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 2 3;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bf-spark-th {
|
||||
stroke: var(--error-color, #f44336);
|
||||
stroke-width: 1;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.bf-type-chip {
|
||||
background: none;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
margin: 0 4px 2px 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bf-type-chip-active {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.bf-bar {
|
||||
width: 30px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--divider-color);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.bf-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.bf-bar-good {
|
||||
background: var(--success-color, #4caf50);
|
||||
}
|
||||
.bf-bar-warn {
|
||||
background: var(--warning-color, #ff9800);
|
||||
}
|
||||
.bf-bar-bad {
|
||||
background: var(--error-color, #f44336);
|
||||
}
|
||||
.bf-jump ha-icon {
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
.bf-roster-tools {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
.bf-sort {
|
||||
background: none;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bf-sort-active {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.bf-level {
|
||||
font-size: 12px;
|
||||
color: var(--error-color, #f44336);
|
||||
@@ -448,6 +764,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Trend-based dates (discharge regression) get a dotted underline — the
|
||||
tooltip carries source + confidence. */
|
||||
.bf-predicted.bf-trend {
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.bf-total {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -52,6 +52,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
* objects can carry the same part id, so part_id alone would merge pools. */
|
||||
@state() private _usedParts: Record<string, TaskPartLink> = {};
|
||||
|
||||
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
|
||||
* prefill the dialog so nobody re-ticks what is already done. */
|
||||
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
|
||||
|
||||
public open(): void {
|
||||
if (this._open) return;
|
||||
this._open = true;
|
||||
@@ -59,7 +63,13 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
this._cost = "";
|
||||
this._duration = "";
|
||||
this._error = "";
|
||||
this._checklistState = {};
|
||||
// The dialog's own state is INDEX-keyed (historical shape, flows into the
|
||||
// history entry as-is) — map the text-keyed in-cycle ticks onto indices.
|
||||
this._checklistState = Object.fromEntries(
|
||||
this.checklist
|
||||
.map((item, i) => [String(i), !!this.checklistPrefill[item]] as const)
|
||||
.filter(([, done]) => done),
|
||||
);
|
||||
this._feedback = "needed";
|
||||
this._photoDocId = "";
|
||||
this._photoPreview = "";
|
||||
|
||||
@@ -59,6 +59,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
@property({ attribute: false }) public entryId!: string;
|
||||
@property({ attribute: false }) public parts: MaintenancePart[] = [];
|
||||
@property({ type: Boolean }) public canWrite = false;
|
||||
@property({ attribute: false }) public currencySymbol = "€";
|
||||
|
||||
@state() private _editing: PartForm | null = null;
|
||||
@state() private _busy = false;
|
||||
@@ -335,6 +336,19 @@ export class MaintenancePartsSection extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Inventory value = Σ unit cost × tracked stock (#104). Parts without a
|
||||
* price or without tracked stock contribute nothing; null = no part has
|
||||
* both, so the chip stays hidden rather than showing a misleading 0. */
|
||||
private _inventoryValue(): number | null {
|
||||
let sum = 0, any = false;
|
||||
for (const p of this.parts) {
|
||||
const cost = typeof p.cost === "number" ? p.cost : null;
|
||||
const stock = typeof p.stock === "number" ? p.stock : null;
|
||||
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
|
||||
}
|
||||
return any ? sum : null;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const L = this._lang;
|
||||
if (!this.parts.length && !this.canWrite) return nothing;
|
||||
@@ -343,6 +357,11 @@ export class MaintenancePartsSection extends LitElement {
|
||||
<h3>
|
||||
<ha-icon icon="mdi:package-variant"></ha-icon>
|
||||
${t("parts_section", L)} (${this.parts.length})
|
||||
${this._inventoryValue() !== null
|
||||
? html`<span class="inventory-value" title=${t("parts_inventory_value", L)}
|
||||
>${t("parts_inventory_value", L)}:
|
||||
${this._inventoryValue()!.toFixed(2)} ${this.currencySymbol}</span>`
|
||||
: nothing}
|
||||
</h3>
|
||||
${this.canWrite && !this._editing
|
||||
? html`<ha-button appearance="plain" @click=${() => this._openAdd()}>
|
||||
@@ -361,6 +380,13 @@ export class MaintenancePartsSection extends LitElement {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.inventory-value {
|
||||
margin-left: 8px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user