155 files

This commit is contained in:
Home Assistant Version Control
2026-10-04 20:46:32 +00:00
parent a212615470
commit 0ff8c287a8
155 changed files with 19264 additions and 3148 deletions
@@ -53,6 +53,7 @@ from .const import (
CONF_BUDGET_YEARLY,
CONF_GROUPS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_NOTIFY_SERVICE,
CONF_OBJECT,
CONF_PANEL_ENABLED,
CONF_SHOPPING_LIST_ENTITY,
@@ -1150,17 +1151,25 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
from .helpers.entity_rename import rewrite_object, rewrite_store, rewrite_tasks
# v2.67: the global shopping-list target is an entity reference too.
# v2.67: the global shopping-list target is an entity reference too —
# and so is a notify ENTITY picked as the notification target (a
# renamed one used to leave notifications to a repair message; a
# legacy notify service is no entity and never renamed).
gentry = get_global_entry(hass)
if gentry is not None and gentry.options.get(CONF_SHOPPING_LIST_ENTITY) == old_eid:
hass.config_entries.async_update_entry(
gentry, options={**gentry.options, CONF_SHOPPING_LIST_ENTITY: new_eid}
)
from .shopping_sync import SHOPPING_SYNC_KEY
if gentry is not None:
renamed = {
key: new_eid
for key in (CONF_SHOPPING_LIST_ENTITY, CONF_NOTIFY_SERVICE)
if gentry.options.get(key, gentry.data.get(key)) == old_eid
}
if renamed:
hass.config_entries.async_update_entry(gentry, options={**gentry.options, **renamed})
if CONF_SHOPPING_LIST_ENTITY in renamed:
from .shopping_sync import SHOPPING_SYNC_KEY
sync = hass.data.get(DOMAIN, {}).get(SHOPPING_SYNC_KEY)
if sync is not None:
await sync.async_handle_rename(old_eid, new_eid)
sync = hass.data.get(DOMAIN, {}).get(SHOPPING_SYNC_KEY)
if sync is not None:
await sync.async_handle_rename(old_eid, new_eid)
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
@@ -1709,7 +1709,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
dynamic: dict[str, Any],
static: dict[str, Any],
) -> None:
"""Put a task back the way it was before one voice action (undo).
"""Put a task back the way it was before one action (undo — by voice,
or the panel's and the card's Undo after Complete).
``dynamic`` maps Store fields to their earlier values (``None`` = the
field was absent); ``static`` does the same for the entry-level
@@ -1730,8 +1731,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
write_task(self.hass, self.entry, task_id, current)
# The undone completion must not swallow the real one that follows it
# as a double tap, nor hold the triggers in their post-completion
# cooldown.
# cooldown. That holds for a back-dated one too: undone and sent again
# with the same moment (to add the forgotten note), it was dropped as
# a "double submit" for 30 s.
self._recent_manual_completions.pop(task_id, None)
self._recent_backfills = {key: ts for key, ts in self._recent_backfills.items() if key[0] != task_id}
self._recently_completed.pop(task_id, None)
self._completion_cooldown.discard(task_id)
await self._store.async_save()
@@ -22,8 +22,8 @@ import {
MAX_OCCURRENCES_PER_TASK,
} from "../helpers/calendar-bucket";
const TODAY = new Date(2026, 4, 1); // 2026-05-01 local
const TODAY_ISO = "2026-05-01";
const TODAY = "2026-05-01"; // HA's today, as haToday() gives it
const TODAY_ISO = TODAY;
function addDays(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
@@ -341,10 +341,8 @@ describe("buildCalendarBuckets", () => {
// ── v2.2.0 — past-window bucketer ────────────────────────────────────────
function histEntry(daysAgo: number, type = "completed", over: Partial<any> = {}) {
const d = new Date(TODAY);
d.setDate(d.getDate() - daysAgo);
return {
timestamp: `${isoDateLocal(d)}T08:30:00`,
timestamp: `${addDays(TODAY_ISO, -daysAgo)}T08:30:00`,
type,
...over,
};
@@ -7,7 +7,7 @@
import { expect } from "@open-wc/testing";
import { warrantyStatus, WARRANTY_WARN_DAYS } from "../helpers/warranty";
const TODAY = new Date(2026, 5, 1); // 2026-06-01 local
const TODAY = "2026-06-01"; // HA's today, as haToday() gives it
describe("warrantyStatus (#67)", () => {
it("null / undefined / empty / invalid → none", () => {
@@ -11,7 +11,7 @@ import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
import { runWs } from "../helpers/ws-run";
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
import { fetchSettingsOnce } from "../helpers/settings-cache";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { addDaysIso, haToday } from "../helpers/ha-time";
import { px } from "../renderers/chart-utils";
import type { HomeAssistant } from "../types";
@@ -332,16 +332,18 @@ export class MaintenanceBatteryFleetSection extends LitElement {
* `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. */
* is honest: the battery is living on borrowed time. The server counts
* the days from HA's today — so does the date (the browser's day plus
* N × 24 h was a day off on a device in another zone). */
private _predictedDate(daysUntil: number): string {
return this._fmtDate(Date.now() + daysUntil * 864e5);
return formatDate(addDaysIso(haToday(), daysUntil), this._lang);
}
/** Local calendar date of an epoch, in the HA profile date format (#163 —
* a direct Intl.DateTimeFormat call here ignored the profile and showed
* 9/2/2026 to a DD/MM/YYYY user). */
/** The calendar date of an epoch (a detected swap), in the HA profile
* date format and time zone (#163 — a direct Intl.DateTimeFormat call
* here ignored the profile and showed 9/2/2026 to a DD/MM/YYYY user). */
private _fmtDate(epochMs: number): string {
return formatDate(isoDateLocal(new Date(epochMs)), this._lang);
return Number.isFinite(epochMs) ? formatDate(new Date(epochMs).toISOString(), this._lang) : "—";
}
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
@@ -4,9 +4,9 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty, haTimeZoneHint } from "../styles";
import { runWs } from "../helpers/ws-run";
import { isoMinuteLocal } from "../helpers/calendar-bucket";
import { haNowMinute, stampMs } from "../helpers/ha-time";
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { unitPrice, type PartsCostMode } from "../helpers/parts-cost";
@@ -23,6 +23,15 @@ import { photoPickerStyles } from "./ms-photo-picker";
* One TS source (helpers/setting-ranges), pinned to Python. */
const USED_QTY_RANGE = PART_QTY_RANGE;
/** `task-completed`'s detail: which task, and whether the server remembered
* the completion so the opener may offer Undo. */
export interface TaskCompletedDetail {
entryId: string;
taskId: string;
taskName: string;
undo: boolean;
}
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@@ -97,7 +106,8 @@ export class MaintenanceCompleteDialog extends LitElement {
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@state() private _restockQty = "";
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" in
* HA local time — zone-less, the way the backend reads it; "" = now). */
@state() private _completedAt = "";
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@@ -203,8 +213,10 @@ export class MaintenanceCompleteDialog extends LitElement {
}
if (this._completedAt) {
// Client-side guard mirrors the backend rule — a picked future moment
// fails fast with a localized message instead of a WS roundtrip.
if (new Date(this._completedAt).getTime() > Date.now()) {
// fails fast with a localized message instead of a WS roundtrip. The
// value is HA local time, so it is judged on HA's clock: read in the
// browser's zone, a moment just past was "future" east of HA.
if (stampMs(this._completedAt) > Date.now()) {
this._error = t("completed_at_future_error", this.lang);
return;
}
@@ -270,14 +282,22 @@ export class MaintenanceCompleteDialog extends LitElement {
if (res === undefined) return;
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
// The opener offers Undo when the server remembered the completion for
// this person (task/undo, ten minutes, one level).
const detail: TaskCompletedDetail = {
entryId: this.entryId,
taskId: this.taskId,
taskName: this.taskName,
undo: (res as { undo?: boolean } | null)?.undo === true,
};
this.dispatchEvent(new CustomEvent<TaskCompletedDetail>("task-completed", { detail }));
}
/** #161 phase 2: one field per slot with the previous value as a hint;
* a value below the last one gets a warning (meters count up — a
* replaced meter or a typo), never a block. */
private _renderReadingField(slot: ReadingSlot, L: string) {
const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN;
const at = this._completedAt ? stampMs(this._completedAt) : NaN;
const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at);
const unit = slot.unit || this.readingUnit;
const raw = (this._readingValues[slot.id] ?? "").trim();
@@ -386,9 +406,12 @@ export class MaintenanceCompleteDialog extends LitElement {
this._photos.discardOrphans();
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
/** Seed the backdate field with HA's current minute (seconds zeroed) —
* the field means HA local time. The browser's minute was refused as
* "future" east of HA and stored hours early west of it (next_due a day
* early once that crossed midnight). */
private _pickCompletedAt(): void {
this._completedAt = isoMinuteLocal(new Date());
this._completedAt = `${haNowMinute()}:00`;
}
render() {
@@ -525,6 +548,7 @@ export class MaintenanceCompleteDialog extends LitElement {
.hass=${this.hass}
.lang=${L}
.value=${this._completedAt}
.helper=${haTimeZoneHint(L)}
@value-changed=${(e: CustomEvent) => (this._completedAt = e.detail.value as string)}
></ms-date-field>`
: html`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
@@ -9,9 +9,10 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, langOf, formatNumber } from "../styles";
import { t, langOf, formatNumber, haTimeZoneHint } from "../styles";
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
import { runWs } from "../helpers/ws-run";
import { haWallTime } from "../helpers/ha-time";
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
@@ -245,15 +246,20 @@ export class MaintenanceHistoryEditDialog extends LitElement {
/** #170: remove the entry altogether (after a confirm). The backend
* re-anchors last_performed on what remains; photos stay in the object's
* documents, consumed parts are not restocked. */
* documents, and the parts the entry used go back to stock — the confirm
* names them. */
private async _delete(): Promise<void> {
if (!this._draft || !this._originalSnapshot) return;
const L = this._lang;
const used = (this._originalSnapshot.used_parts || []).filter((u) => u.quantity > 0);
const parts = used.map((u) => `${formatNumber(u.quantity, L)}× ${u.name || u.part_id}`).join(", ");
// The confirm dialog, not the browser's native modal — this dialog is
// also mounted on dashboards (DRY audit 2026-09-26).
const confirmed = await confirmAction(this.hass, {
title: t("history_delete_entry", L),
message: t("history_delete_confirm", L),
message: parts
? `${t("history_delete_confirm_entry", L)} ${t("history_delete_returns_parts", L).replace("{parts}", parts)}`
: t("history_delete_confirm_entry", L),
confirmText: t("delete", L),
danger: true,
});
@@ -386,10 +392,14 @@ export class MaintenanceHistoryEditDialog extends LitElement {
.hass=${this.hass}
.lang=${L}
.label=${t("history_edit_timestamp", L)}
.value=${d.timestamp.slice(0, 19)}
.helper=${haTimeZoneHint(L)}
.value=${haWallTime(d.timestamp)}
@value-changed=${(e: CustomEvent) => {
// Always "YYYY-MM-DDTHH:MM:SS" from the field; the naive value
// means local time, exactly as the datetime-local input did.
// means HA local time — the backend reads it so. The field
// therefore shows HA's wall clock (an imported "+00:00" stamp's
// own digits were UTC) and names HA's zone when the history
// list shows another one (the profile's "local" zone).
const v = e.detail.value as string;
if (v) this._set("timestamp", v);
}}
@@ -15,7 +15,7 @@ import { downloadTextFile } from "../helpers/download";
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
import { ToastTimer } from "../helpers/toast";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { haToday } from "../helpers/ha-time";
import { runWs } from "../helpers/ws-run";
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
@@ -2084,7 +2084,8 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
// Dated by HA's day, like every date inside the export.
const ts = haToday();
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2095,7 +2096,7 @@ export class MaintenanceSettingsView extends LitElement {
private async _exportSettings(): Promise<void> {
const result = await this._ws<{ data: string }>({ type: "maintenance_supporter/settings/export" });
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.data, `maintenance_settings_${ts}.json`, "application/json");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2109,7 +2110,7 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
this._showToast(t("settings_export_success", this._lang));
}
@@ -2121,7 +2122,7 @@ export class MaintenanceSettingsView extends LitElement {
...(ids ? { entry_ids: ids } : {}),
});
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
this._showToast(t("settings_export_success", this._lang));
}
@@ -19,7 +19,7 @@ import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCo
import { runWs } from "../helpers/ws-run";
import { snoozedMessage, type SnoozeResult } from "../helpers/snooze";
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { haToday } from "../helpers/ha-time";
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
import { phaseLabel } from "../helpers/phases";
import { renderNotesMarkdown } from "../helpers/notes-markdown";
@@ -108,9 +108,11 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._showAdaptive = false;
this._skipReason = "";
this._postponeDate = "";
// Local calendar date — toISOString() is UTC and prefills YESTERDAY for
// users east of UTC before their morning (bug audit 2026-08-22).
this._resetDate = isoDateLocal(new Date());
// HA's calendar date — the server dates the reset and refuses one after
// ITS today; the browser's date is a day off around midnight on a device
// in another zone (and toISOString() was UTC, YESTERDAY for users east
// of UTC before their morning — bug audit 2026-08-22).
this._resetDate = haToday();
this._open = true;
await Promise.all([this._loadTask(), this._loadFeatures()]);
}
@@ -102,8 +102,9 @@ function syncHass(el: HTMLElement & { hass?: HomeAssistant }): boolean {
// Same story for the profile date/time format (#163): on a strategy
// dashboard no panel or card may ever have fed hass.locale into the prefs
// singleton, so formatDate inside the dialog would fall back to the
// language default and ignore a DMY/MDY/YMD profile setting.
setProfilePrefs(hass.locale as Parameters<typeof setProfilePrefs>[0], hass.config?.country);
// language default and ignore a DMY/MDY/YMD profile setting. Likewise
// HA's time zone, which a back-dated completion's "now" runs on.
setProfilePrefs(hass.locale as Parameters<typeof setProfilePrefs>[0], hass.config?.country, hass.config?.time_zone);
return true;
}
@@ -18,6 +18,7 @@ import type { HistoryEntry, MaintenanceObjectResponse } from "../types";
import { entrySpend } from "./parts-cost";
import { intervalSpanDays } from "./interval";
import { statusRank } from "../status-constants";
import { addDaysIso, stampDate } from "./ha-time";
export const MAX_OCCURRENCES_PER_TASK = 5;
@@ -64,9 +65,10 @@ export interface CalendarDayBucket {
}
/**
* Format a Date as ISO YYYY-MM-DD using LOCAL time components — matches what
* the user sees on their wall clock, not UTC. The naive `.toISOString().slice(0,10)`
* approach silently shifts dates near midnight in non-UTC timezones.
* Format a Date as ISO YYYY-MM-DD using LOCAL time components — the
* BROWSER's calendar day of a local Date (the naive `.toISOString().slice(0,10)`
* is the UTC day). Never "today": next_due counts from HA's day, which a
* device in another zone does not share — that is helpers/ha-time haToday.
*/
export function isoDateLocal(d: Date): string {
const y = d.getFullYear();
@@ -75,34 +77,13 @@ export function isoDateLocal(d: Date): string {
return `${y}-${m}-${day}`;
}
/** The local wall-clock minute as "YYYY-MM-DDTHH:MM:00" (seconds zeroed) —
* the value shape of a datetime field; the date half is isoDateLocal. */
export function isoMinuteLocal(d: Date): string {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${isoDateLocal(d)}T${hh}:${mm}:00`;
}
/** Build the list of N consecutive ISO dates starting today (local). */
export function buildWindowDates(today: Date, windowDays: number): string[] {
/** The N consecutive ISO dates of a window starting at `today` ("YYYY-MM-DD"). */
export function buildWindowDates(today: string, windowDays: number): string[] {
const out: string[] = [];
for (let i = 0; i < windowDays; i++) {
const d = new Date(today);
d.setDate(d.getDate() + i);
d.setHours(0, 0, 0, 0);
out.push(isoDateLocal(d));
}
for (let i = 0; i < windowDays; i++) out.push(addDaysIso(today, i));
return out;
}
/** Add `days` to an ISO date (local), return new ISO date. */
function addDaysIso(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
date.setDate(date.getDate() + days);
return isoDateLocal(date);
}
/** Average recorded cost across history rows that HAVE a cost (mean of
* non-null `cost` values). Note this differs from the panel KPI, which divides
* total_cost by times_performed — completions without a recorded cost are
@@ -185,6 +166,8 @@ function projectTask(input: ProjectionInput): CalendarEvent[] {
const nextDue = task.next_due;
if (typeof nextDue !== "string" || !nextDue) return out;
const firstDate = nextDue.slice(0, 10); // strip time portion if any
// The projection steps a cursor from here — only from a real date shape.
if (!/^\d{4}-\d{2}-\d{2}$/.test(firstDate)) return out;
// First occurrence must be in window
if (firstDate >= windowStart && firstDate <= windowEnd) {
@@ -214,13 +197,15 @@ function projectTask(input: ProjectionInput): CalendarEvent[] {
* Main entry point: build the day-bucketed event list for the Calendar tab.
*
* @param objects The panel's loaded objects (with nested tasks).
* @param today "Today" as a Date (caller passes new Date() — easier for tests).
* @param today HA's today, "YYYY-MM-DD" (the caller passes haToday() —
* next_due and the overdue status count from that day,
* not from the browser's).
* @param windowDays Number of days to include (7 / 14 / 30).
* @param userFilter null/empty = all; otherwise filter tasks by responsible_user_id.
*/
export function buildCalendarBuckets(
objects: MaintenanceObjectResponse[],
today: Date,
today: string,
windowDays: number,
userFilter: string | null = null,
): CalendarDayBucket[] {
@@ -315,7 +300,7 @@ export function pastHistoryKey(entryId: string, taskId: string): string {
* so the card refetches only then. */
export function pastHistoryGaps(
objects: MaintenanceObjectResponse[],
today: Date,
today: string,
pastDays: number,
): Array<{ entryId: string; taskId: string; key: string; sig: string }> {
const windowStart = buildPastWindowDates(today, pastDays)[0];
@@ -325,8 +310,8 @@ export function pastHistoryGaps(
const listed = (task.history || []) as HistoryEntryShape[];
const count = task.history_count ?? listed.length;
if (count <= listed.length) continue;
const stamps = listed.map((h) => (typeof h?.timestamp === "string" ? h.timestamp : "")).filter(Boolean);
const oldest = stamps.length ? stamps.reduce((a, b) => (a < b ? a : b)).slice(0, 10) : "";
const days = listed.map((h) => stampDate(h?.timestamp)).filter((d): d is string => !!d);
const oldest = days.length ? days.reduce((a, b) => (a < b ? a : b)) : "";
if (oldest && oldest < windowStart) continue; // the window is fully listed
const key = pastHistoryKey(obj.entry_id, task.id);
out.push({ entryId: obj.entry_id, taskId: task.id, key, sig: `${key}:${count}:${contentHash(JSON.stringify(listed))}` });
@@ -346,15 +331,10 @@ function contentHash(text: string): string {
return (h >>> 0).toString(36);
}
/** Build a list of N consecutive ISO dates ending today (local). */
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
/** The N consecutive ISO dates of a window ending at `today` ("YYYY-MM-DD"). */
export function buildPastWindowDates(today: string, pastDays: number): string[] {
const out: string[] = [];
for (let i = pastDays - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
d.setHours(0, 0, 0, 0);
out.push(isoDateLocal(d));
}
for (let i = pastDays - 1; i >= 0; i--) out.push(addDaysIso(today, -i));
return out;
}
@@ -364,10 +344,12 @@ export function buildPastWindowDates(today: Date, pastDays: number): string[] {
* Each history entry whose timestamp falls inside the window becomes one
* CalendarEvent with `history_timestamp` set — the calendar card uses that
* timestamp as the stable key when dispatching an edit-history click event.
* An entry lands on its HA calendar day (helpers/ha-time stampDate): the
* first ten characters were the wrong day for an imported "+00:00" stamp.
*/
export function buildPastBuckets(
objects: MaintenanceObjectResponse[],
today: Date,
today: string,
pastDays: number,
userFilter: string | null = null,
/** Full histories fetched for the tasks `pastHistoryGaps` named, keyed by
@@ -390,8 +372,8 @@ export function buildPastBuckets(
const history = fullHistory[pastHistoryKey(entryId, task.id)] ?? ((task.history || []) as HistoryEntryShape[]);
for (const h of history) {
if (typeof h?.timestamp !== "string") continue;
const dateKey = h.timestamp.slice(0, 10); // YYYY-MM-DD
if (dateKey < windowStart || dateKey > windowEnd) continue;
const dateKey = stampDate(h.timestamp); // YYYY-MM-DD, HA's day
if (!dateKey || dateKey < windowStart || dateKey > windowEnd) continue;
const bucket = byDate.get(dateKey);
if (!bucket) continue;
const evType = h.type ?? "completed";
@@ -4,6 +4,8 @@
* deterministically without faking the system clock.
*/
import { daysBetweenIso, haToday } from "./ha-time";
export type WarrantyStatusKind = "valid" | "expiring" | "expired" | "none";
export interface WarrantyStatus {
@@ -21,20 +23,18 @@ export const WARRANTY_WARN_DAYS = 60;
* Classify an object's warranty expiry date relative to `today`.
*
* @param iso ISO `YYYY-MM-DD` warranty expiry date (null/undefined/""/invalid → "none").
* @param today Reference date; defaults to now. Injectable for deterministic tests.
* @param today Reference day "YYYY-MM-DD"; defaults to HA's today (the
* browser's date was a day off around midnight on a device in
* another zone). Injectable for deterministic tests.
*/
export function warrantyStatus(
iso: string | null | undefined,
today: Date = new Date(),
today: string = haToday(),
): WarrantyStatus {
if (!iso) return { kind: "none", days: null, date: null };
const exp = new Date(`${iso}T00:00:00`);
if (isNaN(exp.getTime())) return { kind: "none", days: null, date: null };
// Normalize both ends to local midnight so the difference is whole days
// regardless of the time-of-day component of `today`.
const t0 = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
const t1 = Date.UTC(exp.getFullYear(), exp.getMonth(), exp.getDate());
const days = Math.round((t1 - t0) / 86400000);
// Whole calendar days between two date-only strings — no time of day, no zone.
const days = daysBetweenIso(today, iso);
if (!Number.isFinite(days)) return { kind: "none", days: null, date: null };
if (days < 0) return { kind: "expired", days, date: iso };
if (days <= WARRANTY_WARN_DAYS) return { kind: "expiring", days, date: iso };
return { kind: "valid", days, date: iso };
@@ -14,7 +14,7 @@
*/
import type { MaintenanceTask } from "../types";
import { isoDateLocal } from "./calendar-bucket";
import { displayTimeZone, ymdIn } from "./ha-time";
export interface WorksheetLabels {
title: string; // "Work sheet"
@@ -47,14 +47,14 @@ export interface WorksheetExcerpt {
vendorBase?: string;
}
/** The printing day in the USER's calendar and date format. The raw
* `nowIso.slice(0, 10)` was the UTC day (`toISOString()`), so a sheet
* printed after midnight east of UTC — or in the evening west of it —
* carried the wrong date, and in ISO order regardless of the profile's
* date format (bug audit 2026-09-26). */
/** The printing day in the USER's calendar — the profile's time zone — and
* date format. The raw `nowIso.slice(0, 10)` was the UTC day
* (`toISOString()`), so a sheet printed after midnight east of UTC — or in
* the evening west of it — carried the wrong date, and in ISO order
* regardless of the profile's date format (bug audit 2026-09-26). */
function printedOn(nowIso: string, formatDate: (iso: string) => string): string {
const d = new Date(nowIso);
return Number.isNaN(d.getTime()) ? nowIso.slice(0, 10) : formatDate(isoDateLocal(d));
const ms = Date.parse(nowIso);
return Number.isNaN(ms) ? nowIso.slice(0, 10) : formatDate(ymdIn(ms, displayTimeZone()));
}
const esc = (v: unknown): string =>
@@ -210,6 +210,7 @@
"completed_at_optional": "Dokončeno dne (volitelné, prázdné = nyní)",
"completed_at_pick": "Nastavit datum a čas",
"completed_at_future_error": "Datum dokončení nesmí být v budoucnosti.",
"ha_time_zone_hint": "Čas v časovém pásmu Home Assistantu ({zone})",
"days": "dní",
"day": "den",
"today": "Dnes",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Budoucí okna",
"history_edit_title": "Upravit záznam historie",
"history_delete_entry": "Smazat záznam",
"history_delete_confirm": "Smazat tento záznam historie? Datum posledního provedení se vrátí k předchozímu dokončení; fotografie zůstanou u objektu a spotřebované díly se nevrátí na sklad.",
"history_delete_confirm_entry": "Smazat tento záznam historie? Datum posledního provedení se vrátí na předchozí; fotky zůstanou u objektu.",
"history_delete_returns_parts": "Použité díly se vrátí do zásoby: {parts}.",
"history_edit_timestamp": "Časové razítko",
"manufacturer": "Výrobce",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Načítání…",
"archive": "Archivovat",
"undo": "Zpět",
"task_completed_named": "Dokončeno: {task}",
"undo_done": "Dokončení vráceno zpět",
"undo_done_action_ran": "Dokončení vráceno zpět. Akce při dokončení už proběhla a nelze ji vrátit.",
"undo_nothing": "Není co vrátit. Vrátit jde jen vaše poslední dokončení, do 10 minut.",
"undo_changed": "Nelze vrátit. Úkol se mezitím změnil.",
"task_archived": "Úkol archivován",
"object_archived": "Objekt archivován",
"unarchive": "Obnovit z archivu",
@@ -212,6 +212,7 @@
"completed_at_optional": "Udført den (valgfrit, tomt = nu)",
"completed_at_pick": "Vælg dato og tidspunkt",
"completed_at_future_error": "Udførelsesdatoen må ikke ligge i fremtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidszone ({zone})",
"days": "dage",
"day": "dag",
"today": "I dag",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Kommende vinduer",
"history_edit_title": "Rediger historikpost",
"history_delete_entry": "Slet post",
"history_delete_confirm": "Slet denne historikpost? Datoen for seneste udførelse falder tilbage til den forrige; fotos bliver hos objektet, og forbrugte dele lægges ikke tilbage på lager.",
"history_delete_confirm_entry": "Slet denne historikpost? Datoen for sidste udførelse falder tilbage til den forrige; billeder bliver ved objektet.",
"history_delete_returns_parts": "De brugte dele går tilbage på lager: {parts}.",
"history_edit_timestamp": "Tidsstempel",
"manufacturer": "Producent",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Indlæser…",
"archive": "Arkivér",
"undo": "Fortryd",
"task_completed_named": "Fuldført: {task}",
"undo_done": "Udførelsen fortrudt",
"undo_done_action_ran": "Udførelsen fortrudt. Handlingen ved udførelse var allerede kørt og kan ikke fortrydes.",
"undo_nothing": "Intet at fortryde. Kun din egen seneste udførelse kan fortrydes, inden for 10 minutter.",
"undo_changed": "Kan ikke fortrydes. Opgaven er ændret siden.",
"task_archived": "Opgave arkiveret",
"object_archived": "Objekt arkiveret",
"unarchive": "Gendan",
@@ -212,6 +212,7 @@
"completed_at_optional": "Erledigt am (optional, leer = jetzt)",
"completed_at_pick": "Datum & Uhrzeit wählen",
"completed_at_future_error": "Das Erledigungsdatum darf nicht in der Zukunft liegen.",
"ha_time_zone_hint": "Uhrzeit in der Zeitzone von Home Assistant ({zone})",
"days": "Tage",
"day": "Tag",
"today": "Heute",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Zukünftige Fenster",
"history_edit_title": "Verlaufseintrag bearbeiten",
"history_delete_entry": "Eintrag löschen",
"history_delete_confirm": "Diesen Verlaufseintrag löschen? Das Datum der letzten Erledigung fällt auf die vorherige zurück; Fotos bleiben beim Objekt, verbrauchte Teile werden nicht zurückgebucht.",
"history_delete_confirm_entry": "Diesen Verlaufseintrag löschen? Das Datum der letzten Erledigung fällt auf die vorherige zurück; Fotos bleiben beim Objekt.",
"history_delete_returns_parts": "Die dabei verbrauchten Teile gehen zurück in den Bestand: {parts}.",
"history_edit_timestamp": "Zeitstempel",
"manufacturer": "Hersteller",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laden…",
"archive": "Archivieren",
"undo": "Rückgängig",
"task_completed_named": "Erledigt: {task}",
"undo_done": "Erledigung zurückgenommen",
"undo_done_action_ran": "Erledigung zurückgenommen. Die Aktion beim Erledigen war schon gelaufen und lässt sich nicht zurücknehmen.",
"undo_nothing": "Nichts zurückzunehmen. Nur deine eigene letzte Erledigung lässt sich zurücknehmen, innerhalb von 10 Minuten.",
"undo_changed": "Zurücknehmen nicht möglich. Die Aufgabe wurde inzwischen geändert.",
"task_archived": "Aufgabe archiviert",
"object_archived": "Objekt archiviert",
"unarchive": "Wiederherstellen",
@@ -212,6 +212,7 @@
"completed_at_optional": "Completed at (optional, empty = now)",
"completed_at_pick": "Set date & time",
"completed_at_future_error": "The completion date cannot be in the future.",
"ha_time_zone_hint": "Time in Home Assistant's time zone ({zone})",
"days": "days",
"day": "day",
"today": "Today",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Forward windows",
"history_edit_title": "Edit history entry",
"history_delete_entry": "Delete entry",
"history_delete_confirm": "Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object and consumed parts are not restocked.",
"history_delete_confirm_entry": "Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object.",
"history_delete_returns_parts": "The parts it used go back to stock: {parts}.",
"history_edit_timestamp": "Timestamp",
"manufacturer": "Manufacturer",
"model": "Model",
@@ -855,6 +857,11 @@
"loading": "Loading…",
"archive": "Archive",
"undo": "Undo",
"task_completed_named": "Completed: {task}",
"undo_done": "Completion undone",
"undo_done_action_ran": "Completion undone. Its completion action had already run and cannot be taken back.",
"undo_nothing": "Nothing to undo. Only your own last completion can be taken back, within 10 minutes.",
"undo_changed": "Can't undo. The task has changed since.",
"task_archived": "Task archived",
"object_archived": "Object archived",
"unarchive": "Unarchive",
@@ -210,6 +210,7 @@
"completed_at_optional": "Completado el (opcional, vacío = ahora)",
"completed_at_pick": "Elegir fecha y hora",
"completed_at_future_error": "La fecha de finalización no puede estar en el futuro.",
"ha_time_zone_hint": "Hora en la zona horaria de Home Assistant ({zone})",
"days": "días",
"day": "día",
"today": "Hoy",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Ventanas futuras",
"history_edit_title": "Editar entrada del historial",
"history_delete_entry": "Eliminar entrada",
"history_delete_confirm": "¿Eliminar esta entrada del historial? La fecha de última realización vuelve a la anterior; las fotos permanecen con el objeto y las piezas consumidas no se reponen.",
"history_delete_confirm_entry": "¿Eliminar esta entrada del historial? La fecha de la última realización vuelve a la anterior; las fotos se quedan con el objeto.",
"history_delete_returns_parts": "Las piezas usadas vuelven al stock: {parts}.",
"history_edit_timestamp": "Marca de tiempo",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -848,6 +850,11 @@
"loading": "Cargando…",
"archive": "Archivar",
"undo": "Deshacer",
"task_completed_named": "Completada: {task}",
"undo_done": "Realización deshecha",
"undo_done_action_ran": "Realización deshecha. La acción al completar ya se había ejecutado y no se puede deshacer.",
"undo_nothing": "No hay nada que deshacer. Solo puedes deshacer tu última realización, en un plazo de 10 minutos.",
"undo_changed": "No se puede deshacer. La tarea ha cambiado desde entonces.",
"task_archived": "Tarea archivada",
"object_archived": "Objeto archivado",
"unarchive": "Desarchivar",
@@ -212,6 +212,7 @@
"completed_at_optional": "Suoritettu (valinnainen, tyhjä = nyt)",
"completed_at_pick": "Aseta päivä ja aika",
"completed_at_future_error": "Suorituspäivä ei voi olla tulevaisuudessa.",
"ha_time_zone_hint": "Aika Home Assistantin aikavyöhykkeellä ({zone})",
"days": "päivää",
"day": "päivä",
"today": "Tänään",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Tulevat jaksot",
"history_edit_title": "Muokkaa historiamerkintää",
"history_delete_entry": "Poista merkintä",
"history_delete_confirm": "Poistetaanko tämä historiamerkintä? Viimeisimmän suorituksen päivä palautuu edelliseen; kuvat jäävät kohteelle eikä kulutettuja osia palauteta varastoon.",
"history_delete_confirm_entry": "Poistetaanko tämä historiamerkintä? Viimeisin suorituspäivä palaa edelliseen; kuvat jäävät kohteelle.",
"history_delete_returns_parts": "Käytetyt osat palautuvat varastoon: {parts}.",
"history_edit_timestamp": "Aikaleima",
"manufacturer": "Valmistaja",
"model": "Malli",
@@ -848,6 +850,11 @@
"loading": "Ladataan…",
"archive": "Arkistoi",
"undo": "Kumoa",
"task_completed_named": "Suoritettu: {task}",
"undo_done": "Suoritus peruttu",
"undo_done_action_ran": "Suoritus peruttu. Suorituksen toiminto oli jo ajettu, eikä sitä voi perua.",
"undo_nothing": "Ei mitään peruttavaa. Vain oman viimeisimmän suorituksen voi perua, 10 minuutin sisällä.",
"undo_changed": "Perua ei voi. Tehtävä on muuttunut sen jälkeen.",
"task_archived": "Tehtävä arkistoitu",
"object_archived": "Kohde arkistoitu",
"unarchive": "Palauta arkistosta",
@@ -210,6 +210,7 @@
"completed_at_optional": "Effectué le (optionnel, vide = maintenant)",
"completed_at_pick": "Choisir la date et l'heure",
"completed_at_future_error": "La date d'achèvement ne peut pas être dans le futur.",
"ha_time_zone_hint": "Heure dans le fuseau horaire de Home Assistant ({zone})",
"days": "jours",
"day": "jour",
"today": "Aujourd'hui",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Fenêtres à venir",
"history_edit_title": "Modifier l'entrée d'historique",
"history_delete_entry": "Supprimer l'entrée",
"history_delete_confirm": "Supprimer cette entrée d'historique ? La date de dernière réalisation revient à la précédente ; les photos restent avec l'objet et les pièces consommées ne sont pas remises en stock.",
"history_delete_confirm_entry": "Supprimer cette entrée d'historique ? La date de dernière réalisation revient à la précédente ; les photos restent avec l'objet.",
"history_delete_returns_parts": "Les pièces utilisées retournent en stock : {parts}.",
"history_edit_timestamp": "Horodatage",
"manufacturer": "Fabricant",
"model": "Modèle",
@@ -848,6 +850,11 @@
"loading": "Chargement…",
"archive": "Archiver",
"undo": "Annuler",
"task_completed_named": "Terminé : {task}",
"undo_done": "Réalisation annulée",
"undo_done_action_ran": "Réalisation annulée. L'action de fin s'était déjà exécutée et ne peut pas être annulée.",
"undo_nothing": "Rien à annuler. Seule votre dernière réalisation peut être annulée, dans les 10 minutes.",
"undo_changed": "Annulation impossible. La tâche a changé depuis.",
"task_archived": "Tâche archivée",
"object_archived": "Objet archivé",
"unarchive": "Désarchiver",
@@ -212,6 +212,7 @@
"completed_at_optional": "पूर्ण होने का समय (वैकल्पिक, खाली = अभी)",
"completed_at_pick": "तारीख और समय चुनें",
"completed_at_future_error": "पूर्णता की तारीख भविष्य में नहीं हो सकती।",
"ha_time_zone_hint": "Home Assistant के समय क्षेत्र का समय ({zone})",
"days": "दिन",
"day": "दिन",
"today": "आज",
@@ -556,7 +557,8 @@
"cal_forward_windows": "आगामी अवधियाँ",
"history_edit_title": "इतिहास प्रविष्टि संपादित करें",
"history_delete_entry": "प्रविष्टि हटाएँ",
"history_delete_confirm": "क्या यह इतिहास प्रविष्टि हटाएँ? अंतिम निष्पादन की तिथि पिछली पूर्णता पर लौट जाएगी; फ़ोटो ऑब्जेक्ट के पास रहेंगे और उपयोग किए गए पुर्जे स्टॉक में वापस नहीं जोड़े जाएँगे।",
"history_delete_confirm_entry": "यह इतिहास प्रविष्टि हटाएँ? अंतिम बार करने की तिथि पिछली पूर्णता पर लौट आएगी; फ़ोटो ऑब्जेक्ट के साथ रहेंगी।",
"history_delete_returns_parts": "इस्तेमाल किए गए पुर्ज़े स्टॉक में वापस जाएँगे: {parts}।",
"history_edit_timestamp": "टाइमस्टैम्प",
"manufacturer": "निर्माता",
"model": "मॉडल",
@@ -848,6 +850,11 @@
"loading": "लोड हो रहा है…",
"archive": "संग्रह करें",
"undo": "पूर्ववत करें",
"task_completed_named": "पूर्ण हुआ: {task}",
"undo_done": "पूर्णता वापस ली गई",
"undo_done_action_ran": "पूर्णता वापस ली गई। पूरा करने पर चलने वाली कार्रवाई पहले ही चल चुकी थी, उसे वापस नहीं लिया जा सकता।",
"undo_nothing": "वापस लेने को कुछ नहीं है। केवल आपकी अपनी पिछली पूर्णता 10 मिनट के भीतर वापस ली जा सकती है।",
"undo_changed": "वापस नहीं लिया जा सकता। तब से कार्य बदल चुका है।",
"task_archived": "कार्य संग्रहित",
"object_archived": "ऑब्जेक्ट संग्रहित",
"unarchive": "संग्रह से हटाएँ",
@@ -212,6 +212,7 @@
"completed_at_optional": "Elvégezve ekkor (opcionális, üres = most)",
"completed_at_pick": "Dátum és idő megadása",
"completed_at_future_error": "Az elvégzés dátuma nem lehet a jövőben.",
"ha_time_zone_hint": "Idő a Home Assistant időzónájában ({zone})",
"days": "nap",
"day": "nap",
"today": "Ma",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Jövőbeli időszakok",
"history_edit_title": "Előzménybejegyzés szerkesztése",
"history_delete_entry": "Bejegyzés törlése",
"history_delete_confirm": "Törli ezt az előzménybejegyzést? Az utolsó elvégzés dátuma az előzőre áll vissza; a fotók az objektumnál maradnak, a felhasznált alkatrészek nem kerülnek vissza a készletbe.",
"history_delete_confirm_entry": "Törlöd ezt az előzménybejegyzést? Az utolsó elvégzés dátuma visszaáll az előzőre; a fotók az objektumnál maradnak.",
"history_delete_returns_parts": "A felhasznált alkatrészek visszakerülnek a készletbe: {parts}.",
"history_edit_timestamp": "Időbélyeg",
"manufacturer": "Gyártó",
"model": "Modell",
@@ -855,6 +857,11 @@
"loading": "Betöltés…",
"archive": "Archiválás",
"undo": "Visszavonás",
"task_completed_named": "Elvégezve: {task}",
"undo_done": "Elvégzés visszavonva",
"undo_done_action_ran": "Elvégzés visszavonva. Az elvégzéskor futó művelet már lefutott, azt nem lehet visszavonni.",
"undo_nothing": "Nincs mit visszavonni. Csak a saját legutóbbi elvégzésed vonható vissza, 10 percen belül.",
"undo_changed": "Nem vonható vissza. A feladat azóta megváltozott.",
"task_archived": "Feladat archiválva",
"object_archived": "Objektum archiválva",
"unarchive": "Archiválás visszavonása",
@@ -210,6 +210,7 @@
"completed_at_optional": "Completato il (opzionale, vuoto = adesso)",
"completed_at_pick": "Imposta data e ora",
"completed_at_future_error": "La data di completamento non può essere nel futuro.",
"ha_time_zone_hint": "Ora nel fuso orario di Home Assistant ({zone})",
"days": "giorni",
"day": "giorno",
"today": "Oggi",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Finestre future",
"history_edit_title": "Modifica voce dello storico",
"history_delete_entry": "Elimina voce",
"history_delete_confirm": "Eliminare questa voce della cronologia? La data dell'ultima esecuzione torna al completamento precedente; le foto restano con l'oggetto e le parti consumate non vengono rimesse a magazzino.",
"history_delete_confirm_entry": "Eliminare questa voce della cronologia? La data dell'ultima esecuzione torna a quella precedente; le foto restano con l'oggetto.",
"history_delete_returns_parts": "I ricambi usati tornano in magazzino: {parts}.",
"history_edit_timestamp": "Data e ora",
"manufacturer": "Produttore",
"model": "Modello",
@@ -848,6 +850,11 @@
"loading": "Caricamento…",
"archive": "Archivia",
"undo": "Annulla",
"task_completed_named": "Completato: {task}",
"undo_done": "Completamento annullato",
"undo_done_action_ran": "Completamento annullato. L'azione al completamento era già stata eseguita e non si può annullare.",
"undo_nothing": "Niente da annullare. Si può annullare solo il tuo ultimo completamento, entro 10 minuti.",
"undo_changed": "Impossibile annullare. L'attività è cambiata nel frattempo.",
"task_archived": "Attività archiviata",
"object_archived": "Oggetto archiviato",
"unarchive": "Annulla archiviazione",
@@ -212,6 +212,7 @@
"completed_at_optional": "完了日時(任意・空欄 = 現在)",
"completed_at_pick": "日時を設定",
"completed_at_future_error": "完了日時に未来は指定できません。",
"ha_time_zone_hint": "Home Assistant のタイムゾーンの時刻({zone})",
"days": "日",
"day": "日",
"today": "今日",
@@ -556,7 +557,8 @@
"cal_forward_windows": "今後の期間",
"history_edit_title": "履歴項目を編集",
"history_delete_entry": "記録を削除",
"history_delete_confirm": "この履歴記録を削除しますか?最終実施日は前回の完了に戻ります。写真はオブジェクトに残り、消費した部品は在庫に戻りません。",
"history_delete_confirm_entry": "この履歴記録を削除しますか?最終実施日は前回の完了に戻ります。写真はオブジェクトに残ります。",
"history_delete_returns_parts": "使用した部品は在庫に戻ります:{parts}。",
"history_edit_timestamp": "日時",
"manufacturer": "メーカー",
"model": "型番",
@@ -848,6 +850,11 @@
"loading": "読み込み中…",
"archive": "アーカイブ",
"undo": "元に戻す",
"task_completed_named": "完了しました:{task}",
"undo_done": "完了を取り消しました",
"undo_done_action_ran": "完了を取り消しました。完了時のアクションは既に実行されており、取り消せません。",
"undo_nothing": "取り消せるものはありません。取り消せるのは自分の直近の完了だけで、10分以内です。",
"undo_changed": "取り消せません。その後タスクが変更されています。",
"task_archived": "タスクをアーカイブしました",
"object_archived": "オブジェクトをアーカイブしました",
"unarchive": "アーカイブ解除",
@@ -212,6 +212,7 @@
"completed_at_optional": "완료 시각 (선택, 비우면 지금)",
"completed_at_pick": "날짜 및 시간 설정",
"completed_at_future_error": "완료 날짜는 미래일 수 없습니다.",
"ha_time_zone_hint": "Home Assistant 시간대 기준 시간 ({zone})",
"days": "일",
"day": "일",
"today": "오늘",
@@ -556,7 +557,8 @@
"cal_forward_windows": "이후 기간",
"history_edit_title": "기록 항목 편집",
"history_delete_entry": "기록 삭제",
"history_delete_confirm": "이 기록을 삭제할까요? 마지막 수행일은 이전 완료로 되돌아가며, 사진은 개체에 남고 사용한 부품은 재고로 복구되지 않습니다.",
"history_delete_confirm_entry": "이 기록 항목을 삭제할까요? 마지막 수행 날짜가 이전 완료로 돌아갑니다. 사진은 객체에 남습니다.",
"history_delete_returns_parts": "사용한 부품은 재고로 돌아갑니다: {parts}.",
"history_edit_timestamp": "타임스탬프",
"manufacturer": "제조사",
"model": "모델",
@@ -855,6 +857,11 @@
"loading": "불러오는 중…",
"archive": "보관",
"undo": "실행 취소",
"task_completed_named": "완료됨: {task}",
"undo_done": "완료를 취소했습니다",
"undo_done_action_ran": "완료를 취소했습니다. 완료 시 동작은 이미 실행되어 되돌릴 수 없습니다.",
"undo_nothing": "취소할 항목이 없습니다. 자신의 마지막 완료만 10분 안에 취소할 수 있습니다.",
"undo_changed": "취소할 수 없습니다. 그 사이 작업이 변경되었습니다.",
"task_archived": "작업이 보관되었습니다",
"object_archived": "객체가 보관되었습니다",
"unarchive": "보관 해제",
@@ -212,6 +212,7 @@
"completed_at_optional": "Utført den (valgfritt, tomt = nå)",
"completed_at_pick": "Velg dato og klokkeslett",
"completed_at_future_error": "Fullføringsdatoen kan ikke ligge i fremtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidssone ({zone})",
"days": "dager",
"day": "dag",
"today": "I dag",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Fremtidige vinduer",
"history_edit_title": "Rediger historikkoppføring",
"history_delete_entry": "Slett oppføring",
"history_delete_confirm": "Slette denne historikkoppføringen? Datoen for siste utførelse faller tilbake til den forrige; bilder blir hos objektet, og forbrukte deler legges ikke tilbake på lager.",
"history_delete_confirm_entry": "Slette denne historikkoppføringen? Datoen for siste utførelse faller tilbake til den forrige; bilder blir værende på objektet.",
"history_delete_returns_parts": "De brukte delene går tilbake på lager: {parts}.",
"history_edit_timestamp": "Tidsstempel",
"manufacturer": "Produsent",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laster…",
"archive": "Arkivér",
"undo": "Angre",
"task_completed_named": "Fullført: {task}",
"undo_done": "Utførelsen angret",
"undo_done_action_ran": "Utførelsen angret. Handlingen ved utførelse hadde allerede kjørt og kan ikke angres.",
"undo_nothing": "Ingenting å angre. Bare din egen siste utførelse kan angres, innen 10 minutter.",
"undo_changed": "Kan ikke angres. Oppgaven er endret siden.",
"task_archived": "Oppgave arkivert",
"object_archived": "Objekt arkivert",
"unarchive": "Gjenopprett",
@@ -210,6 +210,7 @@
"completed_at_optional": "Voltooid op (optioneel, leeg = nu)",
"completed_at_pick": "Datum & tijd kiezen",
"completed_at_future_error": "De voltooiingsdatum mag niet in de toekomst liggen.",
"ha_time_zone_hint": "Tijd in de tijdzone van Home Assistant ({zone})",
"days": "dagen",
"day": "dag",
"today": "Vandaag",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Toekomstige vensters",
"history_edit_title": "Geschiedenisitem bewerken",
"history_delete_entry": "Vermelding verwijderen",
"history_delete_confirm": "Deze geschiedenisvermelding verwijderen? De datum van laatste uitvoering valt terug op de vorige voltooiing; foto's blijven bij het object en verbruikte onderdelen worden niet teruggeboekt.",
"history_delete_confirm_entry": "Deze geschiedenisregel verwijderen? De datum van de laatste uitvoering valt terug op de vorige; foto's blijven bij het object.",
"history_delete_returns_parts": "De gebruikte onderdelen gaan terug naar de voorraad: {parts}.",
"history_edit_timestamp": "Tijdstempel",
"manufacturer": "Fabrikant",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Laden…",
"archive": "Archiveren",
"undo": "Ongedaan maken",
"task_completed_named": "Voltooid: {task}",
"undo_done": "Afronding ongedaan gemaakt",
"undo_done_action_ran": "Afronding ongedaan gemaakt. De actie bij afronden was al uitgevoerd en kan niet worden teruggedraaid.",
"undo_nothing": "Niets om ongedaan te maken. Alleen je eigen laatste afronding kan worden teruggedraaid, binnen 10 minuten.",
"undo_changed": "Ongedaan maken kan niet. De taak is sindsdien gewijzigd.",
"task_archived": "Taak gearchiveerd",
"object_archived": "Object gearchiveerd",
"unarchive": "Dearchiveren",
@@ -210,6 +210,7 @@
"completed_at_optional": "Wykonano dnia (opcjonalne, puste = teraz)",
"completed_at_pick": "Ustaw datę i godzinę",
"completed_at_future_error": "Data wykonania nie może być w przyszłości.",
"ha_time_zone_hint": "Czas w strefie czasowej Home Assistant ({zone})",
"days": "dni",
"day": "dzień",
"today": "Dzisiaj",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Przyszłe okna",
"history_edit_title": "Edytuj wpis historii",
"history_delete_entry": "Usuń wpis",
"history_delete_confirm": "Usunąć ten wpis historii? Data ostatniego wykonania cofnie się do poprzedniego; zdjęcia pozostaną przy obiekcie, a zużyte części nie wrócą na stan.",
"history_delete_confirm_entry": "Usunąć ten wpis historii? Data ostatniego wykonania wróci do poprzedniej; zdjęcia zostaną przy obiekcie.",
"history_delete_returns_parts": "Zużyte części wrócą do magazynu: {parts}.",
"history_edit_timestamp": "Znacznik czasu",
"manufacturer": "Producent",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Ładowanie…",
"archive": "Archiwizuj",
"undo": "Cofnij",
"task_completed_named": "Wykonano: {task}",
"undo_done": "Wykonanie cofnięte",
"undo_done_action_ran": "Wykonanie cofnięte. Akcja przy wykonaniu już się odbyła i nie da się jej cofnąć.",
"undo_nothing": "Nie ma czego cofnąć. Cofnąć można tylko swoje ostatnie wykonanie, w ciągu 10 minut.",
"undo_changed": "Nie można cofnąć. Zadanie zmieniło się w międzyczasie.",
"task_archived": "Zadanie zarchiwizowane",
"object_archived": "Obiekt zarchiwizowany",
"unarchive": "Przywróć z archiwum",
@@ -212,6 +212,7 @@
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
"completed_at_pick": "Definir data e hora",
"completed_at_future_error": "A data de conclusão não pode estar no futuro.",
"ha_time_zone_hint": "Horário no fuso horário do Home Assistant ({zone})",
"days": "dias",
"day": "dia",
"today": "Hoje",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Janelas futuras",
"history_edit_title": "Editar entrada do histórico",
"history_delete_entry": "Excluir entrada",
"history_delete_confirm": "Excluir esta entrada do histórico? A data da última execução volta para a conclusão anterior; as fotos permanecem com o objeto e as peças consumidas não são repostas.",
"history_delete_confirm_entry": "Excluir esta entrada do histórico? A data da última realização volta para a anterior; as fotos continuam com o objeto.",
"history_delete_returns_parts": "As peças usadas voltam ao estoque: {parts}.",
"history_edit_timestamp": "Data e hora",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -855,6 +857,11 @@
"loading": "Carregando…",
"archive": "Arquivar",
"undo": "Desfazer",
"task_completed_named": "Concluída: {task}",
"undo_done": "Conclusão desfeita",
"undo_done_action_ran": "Conclusão desfeita. A ação de conclusão já tinha sido executada e não pode ser revertida.",
"undo_nothing": "Nada para desfazer. Só a sua última conclusão pode ser desfeita, em até 10 minutos.",
"undo_changed": "Não é possível desfazer. A tarefa mudou desde então.",
"task_archived": "Tarefa arquivada",
"object_archived": "Objeto arquivado",
"unarchive": "Desarquivar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
"completed_at_pick": "Definir data e hora",
"completed_at_future_error": "A data de conclusão não pode estar no futuro.",
"ha_time_zone_hint": "Hora no fuso horário do Home Assistant ({zone})",
"days": "dias",
"day": "dia",
"today": "Hoje",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Janelas futuras",
"history_edit_title": "Editar entrada do histórico",
"history_delete_entry": "Eliminar entrada",
"history_delete_confirm": "Eliminar esta entrada do histórico? A data da última execução recua para a conclusão anterior; as fotos ficam com o objeto e as peças consumidas não são repostas.",
"history_delete_confirm_entry": "Eliminar esta entrada do histórico? A data da última realização volta à anterior; as fotos ficam com o objeto.",
"history_delete_returns_parts": "As peças usadas voltam ao stock: {parts}.",
"history_edit_timestamp": "Carimbo de data/hora",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -848,6 +850,11 @@
"loading": "A carregar…",
"archive": "Arquivar",
"undo": "Desfazer",
"task_completed_named": "Concluída: {task}",
"undo_done": "Conclusão anulada",
"undo_done_action_ran": "Conclusão anulada. A ação de conclusão já tinha sido executada e não pode ser revertida.",
"undo_nothing": "Nada para anular. Só a sua última conclusão pode ser anulada, dentro de 10 minutos.",
"undo_changed": "Não é possível anular. A tarefa mudou entretanto.",
"task_archived": "Tarefa arquivada",
"object_archived": "Objeto arquivado",
"unarchive": "Desarquivar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Выполнено (необязательно, пусто = сейчас)",
"completed_at_pick": "Указать дату и время",
"completed_at_future_error": "Дата выполнения не может быть в будущем.",
"ha_time_zone_hint": "Время в часовом поясе Home Assistant ({zone})",
"days": "дней",
"day": "день",
"today": "Сегодня",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Будущие окна",
"history_edit_title": "Редактировать запись истории",
"history_delete_entry": "Удалить запись",
"history_delete_confirm": "Удалить эту запись истории? Дата последнего выполнения вернётся к предыдущему; фотографии останутся у объекта, а израсходованные детали не будут возвращены на склад.",
"history_delete_confirm_entry": "Удалить эту запись истории? Дата последнего выполнения вернётся к предыдущей; фото останутся у объекта.",
"history_delete_returns_parts": "Использованные запчасти вернутся на склад: {parts}.",
"history_edit_timestamp": "Метка времени",
"manufacturer": "Производитель",
"model": "Модель",
@@ -848,6 +850,11 @@
"loading": "Загрузка…",
"archive": "Архивировать",
"undo": "Отменить",
"task_completed_named": "Выполнено: {task}",
"undo_done": "Выполнение отменено",
"undo_done_action_ran": "Выполнение отменено. Действие при выполнении уже сработало, его нельзя отменить.",
"undo_nothing": "Нечего отменять. Можно отменить только своё последнее выполнение, в течение 10 минут.",
"undo_changed": "Отменить нельзя. Задача с тех пор изменилась.",
"task_archived": "Задача архивирована",
"object_archived": "Объект архивирован",
"unarchive": "Разархивировать",
@@ -210,6 +210,7 @@
"completed_at_optional": "Utförd den (valfritt, tomt = nu)",
"completed_at_pick": "Ange datum och tid",
"completed_at_future_error": "Slutförandedatumet får inte ligga i framtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidszon ({zone})",
"days": "dagar",
"day": "dag",
"today": "Idag",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Kommande fönster",
"history_edit_title": "Redigera historikpost",
"history_delete_entry": "Ta bort post",
"history_delete_confirm": "Ta bort den här historikposten? Datumet för senaste utförande faller tillbaka till det föregående; foton stannar hos objektet och förbrukade delar läggs inte tillbaka i lager.",
"history_delete_confirm_entry": "Ta bort den här historikposten? Datumet för senaste utförandet faller tillbaka på det föregående; foton finns kvar på objektet.",
"history_delete_returns_parts": "De använda delarna går tillbaka till lagret: {parts}.",
"history_edit_timestamp": "Tidsstämpel",
"manufacturer": "Tillverkare",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laddar…",
"archive": "Arkivera",
"undo": "Ångra",
"task_completed_named": "Slutförd: {task}",
"undo_done": "Utförandet ångrat",
"undo_done_action_ran": "Utförandet ångrat. Åtgärden vid utförande hade redan körts och kan inte ångras.",
"undo_nothing": "Inget att ångra. Bara ditt eget senaste utförande kan ångras, inom 10 minuter.",
"undo_changed": "Kan inte ångras. Uppgiften har ändrats sedan dess.",
"task_archived": "Uppgift arkiverad",
"object_archived": "Objekt arkiverat",
"unarchive": "Återställ",
@@ -212,6 +212,7 @@
"completed_at_optional": "Tamamlanma zamanı (isteğe bağlı, boş = şimdi)",
"completed_at_pick": "Tarih ve saat seç",
"completed_at_future_error": "Tamamlanma tarihi gelecekte olamaz.",
"ha_time_zone_hint": "Home Assistant saat dilimindeki saat ({zone})",
"days": "gün",
"day": "gün",
"today": "Bugün",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Gelecek dönemler",
"history_edit_title": "Geçmiş kaydını düzenle",
"history_delete_entry": "Kaydı sil",
"history_delete_confirm": "Bu geçmiş kaydı silinsin mi? Son yapılma tarihi bir önceki tamamlamaya döner; fotoğraflar nesnede kalır, kullanılan parçalar stoğa geri eklenmez.",
"history_delete_confirm_entry": "Bu geçmiş kaydı silinsin mi? Son yapılma tarihi bir önceki tamamlamaya geri döner; fotoğraflar nesnede kalır.",
"history_delete_returns_parts": "Kullanılan parçalar stoğa geri döner: {parts}.",
"history_edit_timestamp": "Zaman damgası",
"manufacturer": "Üretici",
"model": "Model",
@@ -855,6 +857,11 @@
"loading": "Yükleniyor…",
"archive": "Arşivle",
"undo": "Geri al",
"task_completed_named": "Tamamlandı: {task}",
"undo_done": "Tamamlama geri alındı",
"undo_done_action_ran": "Tamamlama geri alındı. Tamamlama eylemi zaten çalışmıştı ve geri alınamaz.",
"undo_nothing": "Geri alınacak bir şey yok. Yalnızca kendi son tamamlamanızı 10 dakika içinde geri alabilirsiniz.",
"undo_changed": "Geri alınamıyor. Görev o zamandan beri değişti.",
"task_archived": "Görev arşivlendi",
"object_archived": "Nesne arşivlendi",
"unarchive": "Arşivden çıkar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Виконано (необов'язково, порожньо = зараз)",
"completed_at_pick": "Вказати дату й час",
"completed_at_future_error": "Дата виконання не може бути в майбутньому.",
"ha_time_zone_hint": "Час у часовому поясі Home Assistant ({zone})",
"days": "днів",
"day": "день",
"today": "Сьогодні",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Майбутні вікна",
"history_edit_title": "Редагувати запис історії",
"history_delete_entry": "Видалити запис",
"history_delete_confirm": "Видалити цей запис історії? Дата останнього виконання повернеться до попереднього; фотографії залишаться в об'єкта, а витрачені деталі не повернуться на склад.",
"history_delete_confirm_entry": "Видалити цей запис історії? Дата останнього виконання повернеться до попередньої; фото залишаться в об'єкта.",
"history_delete_returns_parts": "Використані запчастини повернуться на склад: {parts}.",
"history_edit_timestamp": "Позначка часу",
"manufacturer": "Виробник",
"model": "Модель",
@@ -848,6 +850,11 @@
"loading": "Завантаження…",
"archive": "Архівувати",
"undo": "Скасувати",
"task_completed_named": "Виконано: {task}",
"undo_done": "Виконання скасовано",
"undo_done_action_ran": "Виконання скасовано. Дія під час виконання вже спрацювала, її не можна скасувати.",
"undo_nothing": "Нічого скасовувати. Можна скасувати лише своє останнє виконання, протягом 10 хвилин.",
"undo_changed": "Скасувати не можна. Завдання відтоді змінилося.",
"task_archived": "Завдання архівовано",
"object_archived": "Об'єкт архівовано",
"unarchive": "Розархівувати",
@@ -212,6 +212,7 @@
"completed_at_optional": "完成时间(可选,留空 = 现在)",
"completed_at_pick": "设置日期和时间",
"completed_at_future_error": "完成日期不能是未来时间。",
"ha_time_zone_hint": "Home Assistant 时区的时间({zone})",
"days": "天",
"day": "天",
"today": "今天",
@@ -556,7 +557,8 @@
"cal_forward_windows": "未来的窗口",
"history_edit_title": "编辑历史记录",
"history_delete_entry": "删除记录",
"history_delete_confirm": "删除此历史记录?上次完成日期将回退到前一次完成;照片仍保留在对象中,已消耗的配件不会退回库存。",
"history_delete_confirm_entry": "删除此历史记录?上次执行日期将退回到上一次完成;照片仍保留在对象中。",
"history_delete_returns_parts": "所用的配件将退回库存:{parts}。",
"history_edit_timestamp": "时间戳",
"manufacturer": "制造商",
"model": "型号",
@@ -848,6 +850,11 @@
"loading": "加载中…",
"archive": "归档",
"undo": "撤销",
"task_completed_named": "已完成:{task}",
"undo_done": "已撤销完成",
"undo_done_action_ran": "已撤销完成。完成时的操作已经执行,无法撤销。",
"undo_nothing": "没有可撤销的内容。只能在 10 分钟内撤销你自己最近一次完成。",
"undo_changed": "无法撤销。此后任务已发生变化。",
"task_archived": "任务已归档",
"object_archived": "对象已归档",
"unarchive": "取消归档",
@@ -31,12 +31,12 @@ import { property, state } from "lit/decorators.js";
import {
buildCalendarBuckets,
buildPastBuckets,
isoDateLocal,
pastHistoryGaps,
pastHistoryKey,
type CalendarEvent,
type HistoryEntryShape,
} from "./helpers/calendar-bucket";
import { haToday } from "./helpers/ha-time";
import { calendarStyles } from "./calendar-styles";
import { syncLocaleFromHass, sharedStyles, currencySymbolOf, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatInterval, formatWeekday, formatMonth, langOf, formatCost, syncCurrencyDecimals} from "./styles";
import { registerCustomCard } from "./helpers/register-card";
@@ -218,9 +218,7 @@ export class MaintenanceCalendarCard extends LitElement {
* only when that set or its entry counts change); only the newest round
* may land. */
private async _loadPastHistories(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const gaps = pastHistoryGaps(this._objects, today, this._pastDays || 30);
const gaps = pastHistoryGaps(this._objects, haToday(), this._pastDays || 30);
const sig = gaps.map((g) => g.sig).join("|");
if (sig === this._pastHistorySig) return;
this._pastHistorySig = sig;
@@ -370,14 +368,15 @@ export class MaintenanceCalendarCard extends LitElement {
? base.filter((o) => o.entry_id === filterEntryId)
: base;
const today = new Date();
today.setHours(0, 0, 0, 0);
// HA's today: next_due and the overdue status count from it, so the
// window and the Today marker must too — the browser's date is a day
// off around midnight on a device in another zone.
const todayIso = haToday();
const isPast = this._pastDays > 0;
const buckets = isPast
? buildPastBuckets(objects, today, this._pastDays, userFilter, this._pastHistory)
: buildCalendarBuckets(objects, today, this._windowDays, userFilter);
? buildPastBuckets(objects, todayIso, this._pastDays, userFilter, this._pastHistory)
: buildCalendarBuckets(objects, todayIso, this._windowDays, userFilter);
const todayIso = isoDateLocal(today);
// Year view AND past views collapse empty days — past windows can be
// sparse (most days have no maintenance recorded), so listing "no events"
// for 30 rows is noise.
@@ -6,6 +6,7 @@ import { hydrateObjects } from "./helpers/hydrate-objects";
import { property, state } from "lit/decorators.js";
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, langOf, currencySymbolOf, syncCurrencyDecimals} from "./styles";
import { openSignedDocument } from "./helpers/document-url";
import { isPhotoDocument } from "./helpers/document-categories";
import { isSafeHttpUrl } from "./helpers/url";
import { registerCustomCard } from "./helpers/register-card";
import { healCardRegistry } from "./helpers/registry-heal";
@@ -29,7 +30,8 @@ import { canWrite } from "./helpers/permissions";
import { ACTIONABLE_STATUSES, isActionableStatus, statusRank } from "./status-constants";
import "./maintenance-card-editor";
import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import type { MaintenanceCompleteDialog, TaskCompletedDetail } from "./components/complete-dialog";
import { offerUndoToast } from "./helpers/undo-completion";
// The Battery Fleet card ships in this globally-loaded bundle so it is
// available on every dashboard without another extra_module_url entry.
import "./components/battery-fleet-card";
@@ -46,6 +48,9 @@ interface CardDoc {
url?: string | null;
}
/** Document chips on one task row before the rest is counted ("+N"). */
const MAX_ROW_CHIPS = 2;
interface FlatTask {
entry_id: string;
object_name: string;
@@ -243,9 +248,12 @@ export class MaintenanceSupporterCard extends LitElement {
const res = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/list",
entry_id: entryId,
})) as { documents: Array<CardDoc & { task_ids?: string[] }> };
})) as { documents: Array<CardDoc & { task_ids?: string[]; tags?: string[] }> };
const byTask: Record<string, CardDoc[]> = {};
for (const doc of res.documents || []) {
// Completion photos are linked to their task too; a row of
// "photo-2026…" chips is no way to the manual.
if (isPhotoDocument(doc)) continue;
for (const taskId of doc.task_ids || []) {
(byTask[taskId] ||= []).push({ id: doc.id, title: doc.title, kind: doc.kind, url: doc.url });
}
@@ -268,6 +276,31 @@ export class MaintenanceSupporterCard extends LitElement {
return out;
}
/** A row's document chips: at most two, the rest as "+N" — not a button:
* a tap on it opens the task like the row does, where every document is
* listed (its tooltip names them). */
private _renderDocChips(entryId: string, task: MaintenanceTask) {
const docs = this._docsFor(entryId, task);
if (!docs.length) return nothing;
const rest = docs.slice(MAX_ROW_CHIPS);
return html`<div class="doc-chips">
${docs.slice(0, MAX_ROW_CHIPS).map((doc) => html`
<button
type="button"
class="doc-chip"
title="${doc.title}"
@click=${(e: Event) => { e.stopPropagation(); void this._openDoc(doc); }}
>
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
<span>${doc.title}</span>
</button>
`)}
${rest.length
? html`<span class="doc-chip doc-more" title="${rest.map((d) => d.title).join("\n")}">+${rest.length}</span>`
: nothing}
</div>`;
}
/** Open a chip: a web link directly, a stored file through a signed path
* (the same route the panel uses, so it works in the Companion app). */
private async _openDoc(doc: CardDoc): Promise<void> {
@@ -412,8 +445,13 @@ export class MaintenanceSupporterCard extends LitElement {
return tasks;
}
private _onCompleted = async (): Promise<void> => {
/** After a completion: reload, and — when the server remembered it for
* this person — Home Assistant's own toast offers Undo (the card has no
* toast of its own). */
private _onCompleted = async (e: CustomEvent<TaskCompletedDetail>): Promise<void> => {
await this._loadData();
const d = e.detail;
if (d?.undo) offerUndoToast(this, this.hass, d, this._lang, () => void this._loadData());
};
/** The row's Complete action — the SAME derivation the panel uses (phase
@@ -557,21 +595,7 @@ export class MaintenanceSupporterCard extends LitElement {
</div>`
: nothing}
</div>
${this._docsFor(entry_id, task).length
? html`<div class="doc-chips">
${this._docsFor(entry_id, task).map((doc) => html`
<button
type="button"
class="doc-chip"
title="${doc.title}"
@click=${(e: Event) => { e.stopPropagation(); void this._openDoc(doc); }}
>
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
<span>${doc.title}</span>
</button>
`)}
</div>`
: nothing}
${this._renderDocChips(entry_id, task)}
<div class="task-due">
${task.days_until_due !== null && task.days_until_due !== undefined
? task.days_until_due < 0
@@ -686,7 +710,9 @@ export class MaintenanceSupporterCard extends LitElement {
}
.empty-link:hover { text-decoration: underline; }
.all-caught-up { color: var(--success-color, #4caf50); font-weight: 500; }
.task-list { padding: 0 16px 16px; }
/* The list measures itself: a dashboard column on a wide screen needs
the narrow row layout as much as a phone does. */
.task-list { padding: 0 16px 16px; container: ms-tasks / inline-size; }
.task-item {
display: flex;
@@ -730,18 +756,24 @@ export class MaintenanceSupporterCard extends LitElement {
text-overflow: ellipsis;
}
/* One line of chips that shrinks before the name does; a chip's label
ends in an ellipsis (on the span — a flex button clips its text
without one). */
.doc-chips {
display: flex;
flex-wrap: wrap;
flex: 0 1 auto;
min-width: 0;
max-width: 40%;
gap: 4px;
margin-right: 6px;
max-width: 45%;
overflow: hidden;
}
.doc-chip {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
gap: 3px;
max-width: 14ch;
max-width: 22ch;
padding: 1px 6px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 10px;
@@ -750,12 +782,25 @@ export class MaintenanceSupporterCard extends LitElement {
font: inherit;
font-size: 11px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.doc-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.doc-chip:hover { color: var(--primary-color); border-color: var(--primary-color); }
.doc-chip ha-icon { --mdc-icon-size: 12px; width: 12px; height: 12px; }
.doc-chip ha-icon { --mdc-icon-size: 12px; width: 12px; height: 12px; flex: none; }
.doc-more { flex: none; }
/* Narrow (a phone, a dashboard column): the chips get a line of their
own under the task. Beside it, three of them left the task's name
one letter wide and broke its object and type over three lines. */
@container ms-tasks (max-width: 600px) {
.task-item { flex-wrap: wrap; row-gap: 2px; }
.doc-chips {
order: 1;
flex: 1 0 100%;
max-width: none;
box-sizing: border-box;
padding-inline-start: 20px;
}
}
/* nowrap: the due label is localized via formatDueDays ("5 d overdue",
"5 T überfällig") — without it a narrow phone card wraps that onto a
second line and the row grows taller. The name column ellipsizes
@@ -88,7 +88,8 @@ import "./components/parts-section";
import "./components/object-history-section";
import "./components/task-documents";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import type { MaintenanceCompleteDialog, TaskCompletedDetail } from "./components/complete-dialog";
import { undoCompletion } from "./helpers/undo-completion";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog";
import "./components/battery-fleet-section";
@@ -113,7 +114,8 @@ import type { MaintenanceGroupDialog } from "./components/group-dialog";
import "./components/saved-views-dialog";
import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog";
import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { buildCalendarBuckets, type CalendarEvent } from "./helpers/calendar-bucket";
import { haToday } from "./helpers/ha-time";
import { renderTriggerProgress, renderMiniSparkline, computeTrend } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
@@ -2909,7 +2911,8 @@ export class MaintenanceSupporterPanel extends LitElement {
}
return;
}
this._showToast(t("quick_complete_success", this._lang), "info");
if ((res as { undo?: boolean } | null)?.undo) this._offerUndo(entryId, taskId, t("quick_complete_success", this._lang));
else this._showToast(t("quick_complete_success", this._lang), "info");
// Silent success: the list must reflect the completion right away — the
// subscription delta may lag or be coalesced.
try { await this._loadData(); } catch { /* subscription will sync */ }
@@ -3125,6 +3128,24 @@ export class MaintenanceSupporterPanel extends LitElement {
try { await this._loadData(); } catch { /* subscription will sync */ }
};
/** A completion from the complete dialog: refresh, and offer Undo when the
* server remembered it for this person (helpers/undo-completion). */
private _onTaskCompleted = async (e: CustomEvent<TaskCompletedDetail>): Promise<void> => {
await this._onDialogEvent();
const d = e.detail;
if (d?.undo) this._offerUndo(d.entryId, d.taskId, t("task_completed_named", this._lang).replace("{task}", d.taskName));
};
private _offerUndo(entryId: string, taskId: string, message: string): void {
this._showUndoToast(message, () => void this._undoCompletion(entryId, taskId));
}
private async _undoCompletion(entryId: string, taskId: string): Promise<void> {
const res = await undoCompletion(this.hass, entryId, taskId, this._lang);
this._showToast(res.message, res.ok ? "info" : "error");
if (res.ok) await this._onDialogEvent();
}
// --- Render ---
render() {
@@ -3191,7 +3212,7 @@ export class MaintenanceSupporterPanel extends LitElement {
></maintenance-task-dialog>
<maintenance-complete-dialog
.hass=${this.hass}
@task-completed=${this._onDialogEvent}
@task-completed=${this._onTaskCompleted}
></maintenance-complete-dialog>
<maintenance-history-edit-dialog
.hass=${this.hass}
@@ -4271,7 +4292,7 @@ export class MaintenanceSupporterPanel extends LitElement {
esc(row.consumers.map((c) => `${c.object_name ?? ""}/${c.task_name ?? c.task_id}×${c.quantity}`).join(" | ")),
].join(","));
}
const ts = isoDateLocal(new Date());
const ts = haToday();
downloadTextFile(lines.join("\n"), `maintenance_parts_${ts}.csv`, "text/csv;charset=utf-8");
}
@@ -4279,7 +4300,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private async _exportObjectsCsv(): Promise<void> {
const result = await runWs<{ csv: string }>(this, { type: "maintenance_supporter/objects/csv" }, { onError: (m) => this._showToast(m) });
if (!result) return;
const ts = isoDateLocal(new Date());
const ts = haToday();
downloadTextFile(result.csv, `maintenance_objects_${ts}.csv`, "text/csv;charset=utf-8");
}
@@ -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
@@ -33,23 +33,29 @@ export { STATUS_COLORS, STATUS_ICONS } from "./status-constants";
// `from "./styles"` import keeps working.
import { ensureLocale, isLocaleLoaded, normLang, seedEnglish, t, type Translations } from "./helpers/locale-core";
import { primeBackendErrors } from "./helpers/backend-errors";
import { displayTimeZone, haTimeZone, minuteIn, stampMs, ymdIn } from "./helpers/ha-time";
export { ensureLocale, isLocaleLoaded, seedEnglish, setLocale, t } from "./helpers/locale-core";
seedEnglish(EN as Translations);
/** The per-`updated()` locale boot every top-level surface needs: date/time
* prefs follow the HA profile (#97), and the user's UI language is lazily
* fetched with a re-render once it arrives (EN is bundled, so strings read
* in English until then rather than as raw keys). Was copy-pasted across the
* panel and both cards (drift audit 2026-08). */
* prefs follow the HA profile (#97) — its time zone included — while
* schedule dates follow HA's own zone (helpers/ha-time), and the user's UI
* language is lazily fetched with a re-render once it arrives (EN is
* bundled, so strings read in English until then rather than as raw keys).
* Was copy-pasted across the panel and both cards (drift audit 2026-08). */
export function syncLocaleFromHass(
host: {
hass?: { language?: string; locale?: unknown; config?: { country?: string | null } };
hass?: { language?: string; locale?: unknown; config?: { country?: string | null; time_zone?: string | null } };
requestUpdate: () => void;
},
changedProps: Map<string, unknown>,
): void {
if (changedProps.has("hass")) {
setProfilePrefs(host.hass?.locale as Parameters<typeof setProfilePrefs>[0], host.hass?.config?.country);
setProfilePrefs(
host.hass?.locale as Parameters<typeof setProfilePrefs>[0],
host.hass?.config?.country,
host.hass?.config?.time_zone,
);
}
const lang = host.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
@@ -101,6 +107,11 @@ interface ProfilePrefs {
time?: string;
number?: string;
country?: string;
/** The profile "Time zone" option ("local" | "server") and
* hass.config.time_zone — read by helpers/ha-time (HA's clock for the
* schedule, the display zone for timestamps). */
timeZone?: string;
serverTimeZone?: string;
}
const _w = window as unknown as { __msDateTimePrefs?: ProfilePrefs };
const DT_PREFS: ProfilePrefs = (_w.__msDateTimePrefs ??= {});
@@ -124,18 +135,24 @@ export function syncCurrencyDecimals(src: { currency_decimals?: number | null }
if (src && src.currency_decimals !== undefined && src.currency_decimals !== null) setCurrencyDecimals(Number(src.currency_decimals));
}
/** Feed HA's per-user date/time/number formats into the formatters below.
* `country` is the SERVER's configured country (#140): with the profile
* date format left on "language" it regionalizes the language default —
* "en" + AU renders DD/MM/YYYY instead of the hard en-US mapping. */
/** Feed HA's per-user date/time/number formats and time zone option into
* the formatters below. `country` is the SERVER's configured country
* (#140): with the profile date format left on "language" it regionalizes
* the language default — "en" + AU renders DD/MM/YYYY instead of the hard
* en-US mapping. `serverTimeZone` is hass.config.time_zone, the clock every
* schedule date runs on (helpers/ha-time); it is kept even without a locale
* (a test mock, an old core). For both, undefined keeps the stored value. */
export function setProfilePrefs(
locale?: { date_format?: string; time_format?: string; number_format?: string },
locale?: { date_format?: string; time_format?: string; number_format?: string; time_zone?: string },
country?: string | null,
serverTimeZone?: string | null,
): void {
if (serverTimeZone !== undefined) DT_PREFS.serverTimeZone = serverTimeZone || undefined;
if (!locale) return;
DT_PREFS.date = locale.date_format;
DT_PREFS.time = locale.time_format;
DT_PREFS.number = locale.number_format;
DT_PREFS.timeZone = locale.time_zone;
if (country !== undefined) DT_PREFS.country = country || undefined;
}
@@ -217,56 +234,79 @@ function resolveLocale(lang?: string): string {
return base;
}
function _formatDateObj(d: Date, lang?: string): string {
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = String(d.getFullYear());
switch (DT_PREFS.date) {
case "DMY": return `${dd}/${mm}/${yyyy}`;
case "MDY": return `${mm}/${dd}/${yyyy}`;
case "YMD": return `${yyyy}-${mm}-${dd}`;
case "system":
return d.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
default: // "language" or unset — UI language, regionalized by the server country (#140)
return d.toLocaleDateString(resolveLocale(lang), { day: "2-digit", month: "2-digit", year: "numeric" });
/** The profile's numeric date of an instant, read in `timeZone` — the
* display zone for a timestamp, UTC for a date-only value pinned to UTC
* midnight. The explicit orders are built from formatToParts (ha-time
* ymdIn): getDate()/getMonth() read the BROWSER's zone whatever the
* profile says. */
function _formatDateObj(d: Date, lang: string | undefined, timeZone: string): string {
const order = DT_PREFS.date;
if (order === "DMY" || order === "MDY" || order === "YMD") {
const [yyyy, mm, dd] = ymdIn(d, timeZone).split("-");
return order === "DMY" ? `${dd}/${mm}/${yyyy}` : order === "MDY" ? `${mm}/${dd}/${yyyy}` : `${yyyy}-${mm}-${dd}`;
}
// "system" = the browser's locale; "language" or unset = the UI language,
// regionalized by the server country (#140).
const locale = order === "system" ? undefined : resolveLocale(lang);
return d.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric", timeZone });
}
/** Time of day honouring the HA profile time format (12/24h). Every
* clock-time string in the UI goes through here — chart crosshairs included
* (#163: a bare toLocaleDateString({hour, minute}) ignored the profile). */
/** Time of day of an instant honouring the HA profile time format (12/24h)
* and its time zone. Every clock-time string in the UI goes through here —
* chart crosshairs included (#163: a bare toLocaleDateString({hour,
* minute}) ignored the profile). */
export function formatTimeOfDay(d: Date, lang?: string): string {
const timeZone = displayTimeZone();
switch (DT_PREFS.time) {
case "12": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true });
case "24": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false });
case "system": return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
default: return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit" });
case "12": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true, timeZone });
case "24": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false, timeZone });
case "system": return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", timeZone });
default: return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", timeZone });
}
}
/** Format a date string (ISO) honouring the HA profile date format.
* Appends T00:00:00 to date-only strings so JS parses them as local time, not UTC. */
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
/** Format a date string (ISO) honouring the HA profile date format. A
* date-only value ("YYYY-MM-DD": next_due, warranty, …) has no zone and
* keeps its day whatever the zones say; a timestamp shows its day in the
* profile's time zone (helpers/ha-time — a zone-less one is HA local time). */
export function formatDate(iso: string | null | undefined, lang?: string): string {
if (!iso) return "—";
try {
const local = iso.includes("T") ? iso : iso + "T00:00:00";
return _formatDateObj(new Date(local), lang);
const day = DATE_ONLY.exec(iso);
if (day) return _formatDateObj(new Date(Date.UTC(+day[1], +day[2] - 1, +day[3])), lang, "UTC");
return _formatDateObj(new Date(stampMs(iso)), lang, displayTimeZone());
} catch {
return iso;
}
}
/** Format a datetime string (ISO) honouring the HA profile date/time formats. */
/** Format a datetime string (ISO) honouring the HA profile date/time formats
* and time zone (a zone-less value is HA local time; a date-only one has no
* time of day to show). */
export function formatDateTime(iso: string | null | undefined, lang?: string): string {
if (!iso) return "—";
if (DATE_ONLY.test(iso)) return formatDate(iso, lang);
try {
const d = new Date(iso);
return _formatDateObj(d, lang) + " " + formatTimeOfDay(d, lang);
const d = new Date(stampMs(iso));
return _formatDateObj(d, lang, displayTimeZone()) + " " + formatTimeOfDay(d, lang);
} catch {
return iso;
}
}
/** The hint under a field whose value is HA local time (a back-dated
* completion, a history entry's timestamp): it names HA's zone when the
* times shown everywhere else run on another clock — a device elsewhere
* with the profile's "local" zone — and is "" otherwise. Compared by the
* clock, not by name: "Europe/Berlin" and "Europe/Zurich" agree. */
export function haTimeZoneHint(lang?: string, at: number = Date.now()): string {
const zone = haTimeZone();
if (minuteIn(at, zone) === minuteIn(at, displayTimeZone())) return "";
return t("ha_time_zone_hint", lang).replace("{zone}", zone);
}
/** Format "days until due" in localized manner. */
export function formatDueDays(days: number | null | undefined, lang?: string): string {
if (days === null || days === undefined) return "—";
@@ -320,13 +360,12 @@ export function formatMonth(d: Date, lang?: string, style: "long" | "short" = "l
return monthName(d.getMonth(), lang, style);
}
/** Short day+month ("Jul 3" / "3. Juli"), with a 2-digit year when the
* caller's range needs disambiguating — chart axis ticks and tooltips. */
/** Short day+month of an instant ("Jul 3" / "3. Juli") in the profile's
* time zone, with a 2-digit year when the caller's range needs
* disambiguating — chart axis ticks and tooltips. */
export function formatDateShort(d: Date, lang?: string, withYear = false): string {
return d.toLocaleDateString(
resolveLocale(lang),
withYear ? { month: "short", day: "numeric", year: "2-digit" } : { month: "short", day: "numeric" },
);
const shape: Intl.DateTimeFormatOptions = withYear ? { month: "short", day: "numeric", year: "2-digit" } : { month: "short", day: "numeric" };
return d.toLocaleDateString(resolveLocale(lang), { ...shape, timeZone: displayTimeZone() });
}
/** Recurrence shape carried on the WS payload (see types.TaskSchedule). */
@@ -673,10 +673,13 @@ export interface HomeAssistant {
// date_format/time_format are HA's per-user PROFILE settings ("language" |
// "system" | "DMY" | "MDY" | "YMD" resp. "language" | "system" | "12" | "24")
// — issue #97: dates must follow them, not just the UI language.
locale?: { language: string; number_format?: string; date_format?: string; time_format?: string };
// time_zone is the profile's "Time zone": "local" (the browser's, HA's
// default) or "server" — the zone timestamps are SHOWN in.
locale?: { language: string; number_format?: string; date_format?: string; time_format?: string; time_zone?: string };
/** Server config — country (#140) feeds the "language"-default date
* format ("en" + AU → en-AU → DD/MM/YYYY). */
config?: { country?: string | null };
* format ("en" + AU → en-AU → DD/MM/YYYY); time_zone is HA's own zone,
* the one next_due / days_until_due count in (helpers/ha-time). */
config?: { country?: string | null; time_zone?: string | null };
localize(key: string, ...args: unknown[]): string;
user?: { id: string; name: string; is_admin: boolean; is_owner: boolean };
/** HA's registered panels; a custom panel carries its module URL under
@@ -25,6 +25,9 @@ export const WS_ERROR_CODE_KEYS: Record<string, string> = {
already_archived: "ws_err_already_archived",
already_paused: "ws_err_already_paused",
archived: "ws_err_archived",
// task/undo (2026-10): the person's last completion is another task / gone,
// or the task changed since.
changed_since: "undo_changed",
create_failed: "ws_err_create_failed",
duplicate_failed: "ws_err_duplicate_failed",
empty: "ws_err_empty",
@@ -55,6 +58,7 @@ export const WS_ERROR_CODE_KEYS: Record<string, string> = {
not_found: "ws_err_not_found",
not_loaded: "ws_err_not_loaded",
not_paused: "ws_err_not_paused",
nothing_to_undo: "undo_nothing",
replace_failed: "ws_err_replace_failed",
self_link_device: "ws_err_self_link_device",
storage_unavailable: "ws_err_storage_unavailable",
@@ -210,6 +210,7 @@
"completed_at_optional": "Dokončeno dne (volitelné, prázdné = nyní)",
"completed_at_pick": "Nastavit datum a čas",
"completed_at_future_error": "Datum dokončení nesmí být v budoucnosti.",
"ha_time_zone_hint": "Čas v časovém pásmu Home Assistantu ({zone})",
"days": "dní",
"day": "den",
"today": "Dnes",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Budoucí okna",
"history_edit_title": "Upravit záznam historie",
"history_delete_entry": "Smazat záznam",
"history_delete_confirm": "Smazat tento záznam historie? Datum posledního provedení se vrátí k předchozímu dokončení; fotografie zůstanou u objektu a spotřebované díly se nevrátí na sklad.",
"history_delete_confirm_entry": "Smazat tento záznam historie? Datum posledního provedení se vrátí na předchozí; fotky zůstanou u objektu.",
"history_delete_returns_parts": "Použité díly se vrátí do zásoby: {parts}.",
"history_edit_timestamp": "Časové razítko",
"manufacturer": "Výrobce",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Načítání…",
"archive": "Archivovat",
"undo": "Zpět",
"task_completed_named": "Dokončeno: {task}",
"undo_done": "Dokončení vráceno zpět",
"undo_done_action_ran": "Dokončení vráceno zpět. Akce při dokončení už proběhla a nelze ji vrátit.",
"undo_nothing": "Není co vrátit. Vrátit jde jen vaše poslední dokončení, do 10 minut.",
"undo_changed": "Nelze vrátit. Úkol se mezitím změnil.",
"task_archived": "Úkol archivován",
"object_archived": "Objekt archivován",
"unarchive": "Obnovit z archivu",
@@ -212,6 +212,7 @@
"completed_at_optional": "Udført den (valgfrit, tomt = nu)",
"completed_at_pick": "Vælg dato og tidspunkt",
"completed_at_future_error": "Udførelsesdatoen må ikke ligge i fremtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidszone ({zone})",
"days": "dage",
"day": "dag",
"today": "I dag",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Kommende vinduer",
"history_edit_title": "Rediger historikpost",
"history_delete_entry": "Slet post",
"history_delete_confirm": "Slet denne historikpost? Datoen for seneste udførelse falder tilbage til den forrige; fotos bliver hos objektet, og forbrugte dele lægges ikke tilbage på lager.",
"history_delete_confirm_entry": "Slet denne historikpost? Datoen for sidste udførelse falder tilbage til den forrige; billeder bliver ved objektet.",
"history_delete_returns_parts": "De brugte dele går tilbage på lager: {parts}.",
"history_edit_timestamp": "Tidsstempel",
"manufacturer": "Producent",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Indlæser…",
"archive": "Arkivér",
"undo": "Fortryd",
"task_completed_named": "Fuldført: {task}",
"undo_done": "Udførelsen fortrudt",
"undo_done_action_ran": "Udførelsen fortrudt. Handlingen ved udførelse var allerede kørt og kan ikke fortrydes.",
"undo_nothing": "Intet at fortryde. Kun din egen seneste udførelse kan fortrydes, inden for 10 minutter.",
"undo_changed": "Kan ikke fortrydes. Opgaven er ændret siden.",
"task_archived": "Opgave arkiveret",
"object_archived": "Objekt arkiveret",
"unarchive": "Gendan",
@@ -212,6 +212,7 @@
"completed_at_optional": "Erledigt am (optional, leer = jetzt)",
"completed_at_pick": "Datum & Uhrzeit wählen",
"completed_at_future_error": "Das Erledigungsdatum darf nicht in der Zukunft liegen.",
"ha_time_zone_hint": "Uhrzeit in der Zeitzone von Home Assistant ({zone})",
"days": "Tage",
"day": "Tag",
"today": "Heute",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Zukünftige Fenster",
"history_edit_title": "Verlaufseintrag bearbeiten",
"history_delete_entry": "Eintrag löschen",
"history_delete_confirm": "Diesen Verlaufseintrag löschen? Das Datum der letzten Erledigung fällt auf die vorherige zurück; Fotos bleiben beim Objekt, verbrauchte Teile werden nicht zurückgebucht.",
"history_delete_confirm_entry": "Diesen Verlaufseintrag löschen? Das Datum der letzten Erledigung fällt auf die vorherige zurück; Fotos bleiben beim Objekt.",
"history_delete_returns_parts": "Die dabei verbrauchten Teile gehen zurück in den Bestand: {parts}.",
"history_edit_timestamp": "Zeitstempel",
"manufacturer": "Hersteller",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laden…",
"archive": "Archivieren",
"undo": "Rückgängig",
"task_completed_named": "Erledigt: {task}",
"undo_done": "Erledigung zurückgenommen",
"undo_done_action_ran": "Erledigung zurückgenommen. Die Aktion beim Erledigen war schon gelaufen und lässt sich nicht zurücknehmen.",
"undo_nothing": "Nichts zurückzunehmen. Nur deine eigene letzte Erledigung lässt sich zurücknehmen, innerhalb von 10 Minuten.",
"undo_changed": "Zurücknehmen nicht möglich. Die Aufgabe wurde inzwischen geändert.",
"task_archived": "Aufgabe archiviert",
"object_archived": "Objekt archiviert",
"unarchive": "Wiederherstellen",
@@ -212,6 +212,7 @@
"completed_at_optional": "Completed at (optional, empty = now)",
"completed_at_pick": "Set date & time",
"completed_at_future_error": "The completion date cannot be in the future.",
"ha_time_zone_hint": "Time in Home Assistant's time zone ({zone})",
"days": "days",
"day": "day",
"today": "Today",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Forward windows",
"history_edit_title": "Edit history entry",
"history_delete_entry": "Delete entry",
"history_delete_confirm": "Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object and consumed parts are not restocked.",
"history_delete_confirm_entry": "Delete this history entry? The task's last-performed date falls back to the previous completion; photos stay with the object.",
"history_delete_returns_parts": "The parts it used go back to stock: {parts}.",
"history_edit_timestamp": "Timestamp",
"manufacturer": "Manufacturer",
"model": "Model",
@@ -855,6 +857,11 @@
"loading": "Loading…",
"archive": "Archive",
"undo": "Undo",
"task_completed_named": "Completed: {task}",
"undo_done": "Completion undone",
"undo_done_action_ran": "Completion undone. Its completion action had already run and cannot be taken back.",
"undo_nothing": "Nothing to undo. Only your own last completion can be taken back, within 10 minutes.",
"undo_changed": "Can't undo. The task has changed since.",
"task_archived": "Task archived",
"object_archived": "Object archived",
"unarchive": "Unarchive",
@@ -210,6 +210,7 @@
"completed_at_optional": "Completado el (opcional, vacío = ahora)",
"completed_at_pick": "Elegir fecha y hora",
"completed_at_future_error": "La fecha de finalización no puede estar en el futuro.",
"ha_time_zone_hint": "Hora en la zona horaria de Home Assistant ({zone})",
"days": "días",
"day": "día",
"today": "Hoy",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Ventanas futuras",
"history_edit_title": "Editar entrada del historial",
"history_delete_entry": "Eliminar entrada",
"history_delete_confirm": "¿Eliminar esta entrada del historial? La fecha de última realización vuelve a la anterior; las fotos permanecen con el objeto y las piezas consumidas no se reponen.",
"history_delete_confirm_entry": "¿Eliminar esta entrada del historial? La fecha de la última realización vuelve a la anterior; las fotos se quedan con el objeto.",
"history_delete_returns_parts": "Las piezas usadas vuelven al stock: {parts}.",
"history_edit_timestamp": "Marca de tiempo",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -848,6 +850,11 @@
"loading": "Cargando…",
"archive": "Archivar",
"undo": "Deshacer",
"task_completed_named": "Completada: {task}",
"undo_done": "Realización deshecha",
"undo_done_action_ran": "Realización deshecha. La acción al completar ya se había ejecutado y no se puede deshacer.",
"undo_nothing": "No hay nada que deshacer. Solo puedes deshacer tu última realización, en un plazo de 10 minutos.",
"undo_changed": "No se puede deshacer. La tarea ha cambiado desde entonces.",
"task_archived": "Tarea archivada",
"object_archived": "Objeto archivado",
"unarchive": "Desarchivar",
@@ -212,6 +212,7 @@
"completed_at_optional": "Suoritettu (valinnainen, tyhjä = nyt)",
"completed_at_pick": "Aseta päivä ja aika",
"completed_at_future_error": "Suorituspäivä ei voi olla tulevaisuudessa.",
"ha_time_zone_hint": "Aika Home Assistantin aikavyöhykkeellä ({zone})",
"days": "päivää",
"day": "päivä",
"today": "Tänään",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Tulevat jaksot",
"history_edit_title": "Muokkaa historiamerkintää",
"history_delete_entry": "Poista merkintä",
"history_delete_confirm": "Poistetaanko tämä historiamerkintä? Viimeisimmän suorituksen päivä palautuu edelliseen; kuvat jäävät kohteelle eikä kulutettuja osia palauteta varastoon.",
"history_delete_confirm_entry": "Poistetaanko tämä historiamerkintä? Viimeisin suorituspäivä palaa edelliseen; kuvat jäävät kohteelle.",
"history_delete_returns_parts": "Käytetyt osat palautuvat varastoon: {parts}.",
"history_edit_timestamp": "Aikaleima",
"manufacturer": "Valmistaja",
"model": "Malli",
@@ -848,6 +850,11 @@
"loading": "Ladataan…",
"archive": "Arkistoi",
"undo": "Kumoa",
"task_completed_named": "Suoritettu: {task}",
"undo_done": "Suoritus peruttu",
"undo_done_action_ran": "Suoritus peruttu. Suorituksen toiminto oli jo ajettu, eikä sitä voi perua.",
"undo_nothing": "Ei mitään peruttavaa. Vain oman viimeisimmän suorituksen voi perua, 10 minuutin sisällä.",
"undo_changed": "Perua ei voi. Tehtävä on muuttunut sen jälkeen.",
"task_archived": "Tehtävä arkistoitu",
"object_archived": "Kohde arkistoitu",
"unarchive": "Palauta arkistosta",
@@ -210,6 +210,7 @@
"completed_at_optional": "Effectué le (optionnel, vide = maintenant)",
"completed_at_pick": "Choisir la date et l'heure",
"completed_at_future_error": "La date d'achèvement ne peut pas être dans le futur.",
"ha_time_zone_hint": "Heure dans le fuseau horaire de Home Assistant ({zone})",
"days": "jours",
"day": "jour",
"today": "Aujourd'hui",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Fenêtres à venir",
"history_edit_title": "Modifier l'entrée d'historique",
"history_delete_entry": "Supprimer l'entrée",
"history_delete_confirm": "Supprimer cette entrée d'historique ? La date de dernière réalisation revient à la précédente ; les photos restent avec l'objet et les pièces consommées ne sont pas remises en stock.",
"history_delete_confirm_entry": "Supprimer cette entrée d'historique ? La date de dernière réalisation revient à la précédente ; les photos restent avec l'objet.",
"history_delete_returns_parts": "Les pièces utilisées retournent en stock : {parts}.",
"history_edit_timestamp": "Horodatage",
"manufacturer": "Fabricant",
"model": "Modèle",
@@ -848,6 +850,11 @@
"loading": "Chargement…",
"archive": "Archiver",
"undo": "Annuler",
"task_completed_named": "Terminé : {task}",
"undo_done": "Réalisation annulée",
"undo_done_action_ran": "Réalisation annulée. L'action de fin s'était déjà exécutée et ne peut pas être annulée.",
"undo_nothing": "Rien à annuler. Seule votre dernière réalisation peut être annulée, dans les 10 minutes.",
"undo_changed": "Annulation impossible. La tâche a changé depuis.",
"task_archived": "Tâche archivée",
"object_archived": "Objet archivé",
"unarchive": "Désarchiver",
@@ -212,6 +212,7 @@
"completed_at_optional": "पूर्ण होने का समय (वैकल्पिक, खाली = अभी)",
"completed_at_pick": "तारीख और समय चुनें",
"completed_at_future_error": "पूर्णता की तारीख भविष्य में नहीं हो सकती।",
"ha_time_zone_hint": "Home Assistant के समय क्षेत्र का समय ({zone})",
"days": "दिन",
"day": "दिन",
"today": "आज",
@@ -556,7 +557,8 @@
"cal_forward_windows": "आगामी अवधियाँ",
"history_edit_title": "इतिहास प्रविष्टि संपादित करें",
"history_delete_entry": "प्रविष्टि हटाएँ",
"history_delete_confirm": "क्या यह इतिहास प्रविष्टि हटाएँ? अंतिम निष्पादन की तिथि पिछली पूर्णता पर लौट जाएगी; फ़ोटो ऑब्जेक्ट के पास रहेंगे और उपयोग किए गए पुर्जे स्टॉक में वापस नहीं जोड़े जाएँगे।",
"history_delete_confirm_entry": "यह इतिहास प्रविष्टि हटाएँ? अंतिम बार करने की तिथि पिछली पूर्णता पर लौट आएगी; फ़ोटो ऑब्जेक्ट के साथ रहेंगी।",
"history_delete_returns_parts": "इस्तेमाल किए गए पुर्ज़े स्टॉक में वापस जाएँगे: {parts}।",
"history_edit_timestamp": "टाइमस्टैम्प",
"manufacturer": "निर्माता",
"model": "मॉडल",
@@ -848,6 +850,11 @@
"loading": "लोड हो रहा है…",
"archive": "संग्रह करें",
"undo": "पूर्ववत करें",
"task_completed_named": "पूर्ण हुआ: {task}",
"undo_done": "पूर्णता वापस ली गई",
"undo_done_action_ran": "पूर्णता वापस ली गई। पूरा करने पर चलने वाली कार्रवाई पहले ही चल चुकी थी, उसे वापस नहीं लिया जा सकता।",
"undo_nothing": "वापस लेने को कुछ नहीं है। केवल आपकी अपनी पिछली पूर्णता 10 मिनट के भीतर वापस ली जा सकती है।",
"undo_changed": "वापस नहीं लिया जा सकता। तब से कार्य बदल चुका है।",
"task_archived": "कार्य संग्रहित",
"object_archived": "ऑब्जेक्ट संग्रहित",
"unarchive": "संग्रह से हटाएँ",
@@ -212,6 +212,7 @@
"completed_at_optional": "Elvégezve ekkor (opcionális, üres = most)",
"completed_at_pick": "Dátum és idő megadása",
"completed_at_future_error": "Az elvégzés dátuma nem lehet a jövőben.",
"ha_time_zone_hint": "Idő a Home Assistant időzónájában ({zone})",
"days": "nap",
"day": "nap",
"today": "Ma",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Jövőbeli időszakok",
"history_edit_title": "Előzménybejegyzés szerkesztése",
"history_delete_entry": "Bejegyzés törlése",
"history_delete_confirm": "Törli ezt az előzménybejegyzést? Az utolsó elvégzés dátuma az előzőre áll vissza; a fotók az objektumnál maradnak, a felhasznált alkatrészek nem kerülnek vissza a készletbe.",
"history_delete_confirm_entry": "Törlöd ezt az előzménybejegyzést? Az utolsó elvégzés dátuma visszaáll az előzőre; a fotók az objektumnál maradnak.",
"history_delete_returns_parts": "A felhasznált alkatrészek visszakerülnek a készletbe: {parts}.",
"history_edit_timestamp": "Időbélyeg",
"manufacturer": "Gyártó",
"model": "Modell",
@@ -855,6 +857,11 @@
"loading": "Betöltés…",
"archive": "Archiválás",
"undo": "Visszavonás",
"task_completed_named": "Elvégezve: {task}",
"undo_done": "Elvégzés visszavonva",
"undo_done_action_ran": "Elvégzés visszavonva. Az elvégzéskor futó művelet már lefutott, azt nem lehet visszavonni.",
"undo_nothing": "Nincs mit visszavonni. Csak a saját legutóbbi elvégzésed vonható vissza, 10 percen belül.",
"undo_changed": "Nem vonható vissza. A feladat azóta megváltozott.",
"task_archived": "Feladat archiválva",
"object_archived": "Objektum archiválva",
"unarchive": "Archiválás visszavonása",
@@ -210,6 +210,7 @@
"completed_at_optional": "Completato il (opzionale, vuoto = adesso)",
"completed_at_pick": "Imposta data e ora",
"completed_at_future_error": "La data di completamento non può essere nel futuro.",
"ha_time_zone_hint": "Ora nel fuso orario di Home Assistant ({zone})",
"days": "giorni",
"day": "giorno",
"today": "Oggi",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Finestre future",
"history_edit_title": "Modifica voce dello storico",
"history_delete_entry": "Elimina voce",
"history_delete_confirm": "Eliminare questa voce della cronologia? La data dell'ultima esecuzione torna al completamento precedente; le foto restano con l'oggetto e le parti consumate non vengono rimesse a magazzino.",
"history_delete_confirm_entry": "Eliminare questa voce della cronologia? La data dell'ultima esecuzione torna a quella precedente; le foto restano con l'oggetto.",
"history_delete_returns_parts": "I ricambi usati tornano in magazzino: {parts}.",
"history_edit_timestamp": "Data e ora",
"manufacturer": "Produttore",
"model": "Modello",
@@ -848,6 +850,11 @@
"loading": "Caricamento…",
"archive": "Archivia",
"undo": "Annulla",
"task_completed_named": "Completato: {task}",
"undo_done": "Completamento annullato",
"undo_done_action_ran": "Completamento annullato. L'azione al completamento era già stata eseguita e non si può annullare.",
"undo_nothing": "Niente da annullare. Si può annullare solo il tuo ultimo completamento, entro 10 minuti.",
"undo_changed": "Impossibile annullare. L'attività è cambiata nel frattempo.",
"task_archived": "Attività archiviata",
"object_archived": "Oggetto archiviato",
"unarchive": "Annulla archiviazione",
@@ -212,6 +212,7 @@
"completed_at_optional": "完了日時(任意・空欄 = 現在)",
"completed_at_pick": "日時を設定",
"completed_at_future_error": "完了日時に未来は指定できません。",
"ha_time_zone_hint": "Home Assistant のタイムゾーンの時刻({zone})",
"days": "日",
"day": "日",
"today": "今日",
@@ -556,7 +557,8 @@
"cal_forward_windows": "今後の期間",
"history_edit_title": "履歴項目を編集",
"history_delete_entry": "記録を削除",
"history_delete_confirm": "この履歴記録を削除しますか?最終実施日は前回の完了に戻ります。写真はオブジェクトに残り、消費した部品は在庫に戻りません。",
"history_delete_confirm_entry": "この履歴記録を削除しますか?最終実施日は前回の完了に戻ります。写真はオブジェクトに残ります。",
"history_delete_returns_parts": "使用した部品は在庫に戻ります:{parts}。",
"history_edit_timestamp": "日時",
"manufacturer": "メーカー",
"model": "型番",
@@ -848,6 +850,11 @@
"loading": "読み込み中…",
"archive": "アーカイブ",
"undo": "元に戻す",
"task_completed_named": "完了しました:{task}",
"undo_done": "完了を取り消しました",
"undo_done_action_ran": "完了を取り消しました。完了時のアクションは既に実行されており、取り消せません。",
"undo_nothing": "取り消せるものはありません。取り消せるのは自分の直近の完了だけで、10分以内です。",
"undo_changed": "取り消せません。その後タスクが変更されています。",
"task_archived": "タスクをアーカイブしました",
"object_archived": "オブジェクトをアーカイブしました",
"unarchive": "アーカイブ解除",
@@ -212,6 +212,7 @@
"completed_at_optional": "완료 시각 (선택, 비우면 지금)",
"completed_at_pick": "날짜 및 시간 설정",
"completed_at_future_error": "완료 날짜는 미래일 수 없습니다.",
"ha_time_zone_hint": "Home Assistant 시간대 기준 시간 ({zone})",
"days": "일",
"day": "일",
"today": "오늘",
@@ -556,7 +557,8 @@
"cal_forward_windows": "이후 기간",
"history_edit_title": "기록 항목 편집",
"history_delete_entry": "기록 삭제",
"history_delete_confirm": "이 기록을 삭제할까요? 마지막 수행일은 이전 완료로 되돌아가며, 사진은 개체에 남고 사용한 부품은 재고로 복구되지 않습니다.",
"history_delete_confirm_entry": "이 기록 항목을 삭제할까요? 마지막 수행 날짜가 이전 완료로 돌아갑니다. 사진은 객체에 남습니다.",
"history_delete_returns_parts": "사용한 부품은 재고로 돌아갑니다: {parts}.",
"history_edit_timestamp": "타임스탬프",
"manufacturer": "제조사",
"model": "모델",
@@ -855,6 +857,11 @@
"loading": "불러오는 중…",
"archive": "보관",
"undo": "실행 취소",
"task_completed_named": "완료됨: {task}",
"undo_done": "완료를 취소했습니다",
"undo_done_action_ran": "완료를 취소했습니다. 완료 시 동작은 이미 실행되어 되돌릴 수 없습니다.",
"undo_nothing": "취소할 항목이 없습니다. 자신의 마지막 완료만 10분 안에 취소할 수 있습니다.",
"undo_changed": "취소할 수 없습니다. 그 사이 작업이 변경되었습니다.",
"task_archived": "작업이 보관되었습니다",
"object_archived": "객체가 보관되었습니다",
"unarchive": "보관 해제",
@@ -212,6 +212,7 @@
"completed_at_optional": "Utført den (valgfritt, tomt = nå)",
"completed_at_pick": "Velg dato og klokkeslett",
"completed_at_future_error": "Fullføringsdatoen kan ikke ligge i fremtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidssone ({zone})",
"days": "dager",
"day": "dag",
"today": "I dag",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Fremtidige vinduer",
"history_edit_title": "Rediger historikkoppføring",
"history_delete_entry": "Slett oppføring",
"history_delete_confirm": "Slette denne historikkoppføringen? Datoen for siste utførelse faller tilbake til den forrige; bilder blir hos objektet, og forbrukte deler legges ikke tilbake på lager.",
"history_delete_confirm_entry": "Slette denne historikkoppføringen? Datoen for siste utførelse faller tilbake til den forrige; bilder blir værende på objektet.",
"history_delete_returns_parts": "De brukte delene går tilbake på lager: {parts}.",
"history_edit_timestamp": "Tidsstempel",
"manufacturer": "Produsent",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laster…",
"archive": "Arkivér",
"undo": "Angre",
"task_completed_named": "Fullført: {task}",
"undo_done": "Utførelsen angret",
"undo_done_action_ran": "Utførelsen angret. Handlingen ved utførelse hadde allerede kjørt og kan ikke angres.",
"undo_nothing": "Ingenting å angre. Bare din egen siste utførelse kan angres, innen 10 minutter.",
"undo_changed": "Kan ikke angres. Oppgaven er endret siden.",
"task_archived": "Oppgave arkivert",
"object_archived": "Objekt arkivert",
"unarchive": "Gjenopprett",
@@ -210,6 +210,7 @@
"completed_at_optional": "Voltooid op (optioneel, leeg = nu)",
"completed_at_pick": "Datum & tijd kiezen",
"completed_at_future_error": "De voltooiingsdatum mag niet in de toekomst liggen.",
"ha_time_zone_hint": "Tijd in de tijdzone van Home Assistant ({zone})",
"days": "dagen",
"day": "dag",
"today": "Vandaag",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Toekomstige vensters",
"history_edit_title": "Geschiedenisitem bewerken",
"history_delete_entry": "Vermelding verwijderen",
"history_delete_confirm": "Deze geschiedenisvermelding verwijderen? De datum van laatste uitvoering valt terug op de vorige voltooiing; foto's blijven bij het object en verbruikte onderdelen worden niet teruggeboekt.",
"history_delete_confirm_entry": "Deze geschiedenisregel verwijderen? De datum van de laatste uitvoering valt terug op de vorige; foto's blijven bij het object.",
"history_delete_returns_parts": "De gebruikte onderdelen gaan terug naar de voorraad: {parts}.",
"history_edit_timestamp": "Tijdstempel",
"manufacturer": "Fabrikant",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Laden…",
"archive": "Archiveren",
"undo": "Ongedaan maken",
"task_completed_named": "Voltooid: {task}",
"undo_done": "Afronding ongedaan gemaakt",
"undo_done_action_ran": "Afronding ongedaan gemaakt. De actie bij afronden was al uitgevoerd en kan niet worden teruggedraaid.",
"undo_nothing": "Niets om ongedaan te maken. Alleen je eigen laatste afronding kan worden teruggedraaid, binnen 10 minuten.",
"undo_changed": "Ongedaan maken kan niet. De taak is sindsdien gewijzigd.",
"task_archived": "Taak gearchiveerd",
"object_archived": "Object gearchiveerd",
"unarchive": "Dearchiveren",
@@ -210,6 +210,7 @@
"completed_at_optional": "Wykonano dnia (opcjonalne, puste = teraz)",
"completed_at_pick": "Ustaw datę i godzinę",
"completed_at_future_error": "Data wykonania nie może być w przyszłości.",
"ha_time_zone_hint": "Czas w strefie czasowej Home Assistant ({zone})",
"days": "dni",
"day": "dzień",
"today": "Dzisiaj",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Przyszłe okna",
"history_edit_title": "Edytuj wpis historii",
"history_delete_entry": "Usuń wpis",
"history_delete_confirm": "Usunąć ten wpis historii? Data ostatniego wykonania cofnie się do poprzedniego; zdjęcia pozostaną przy obiekcie, a zużyte części nie wrócą na stan.",
"history_delete_confirm_entry": "Usunąć ten wpis historii? Data ostatniego wykonania wróci do poprzedniej; zdjęcia zostaną przy obiekcie.",
"history_delete_returns_parts": "Zużyte części wrócą do magazynu: {parts}.",
"history_edit_timestamp": "Znacznik czasu",
"manufacturer": "Producent",
"model": "Model",
@@ -848,6 +850,11 @@
"loading": "Ładowanie…",
"archive": "Archiwizuj",
"undo": "Cofnij",
"task_completed_named": "Wykonano: {task}",
"undo_done": "Wykonanie cofnięte",
"undo_done_action_ran": "Wykonanie cofnięte. Akcja przy wykonaniu już się odbyła i nie da się jej cofnąć.",
"undo_nothing": "Nie ma czego cofnąć. Cofnąć można tylko swoje ostatnie wykonanie, w ciągu 10 minut.",
"undo_changed": "Nie można cofnąć. Zadanie zmieniło się w międzyczasie.",
"task_archived": "Zadanie zarchiwizowane",
"object_archived": "Obiekt zarchiwizowany",
"unarchive": "Przywróć z archiwum",
@@ -212,6 +212,7 @@
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
"completed_at_pick": "Definir data e hora",
"completed_at_future_error": "A data de conclusão não pode estar no futuro.",
"ha_time_zone_hint": "Horário no fuso horário do Home Assistant ({zone})",
"days": "dias",
"day": "dia",
"today": "Hoje",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Janelas futuras",
"history_edit_title": "Editar entrada do histórico",
"history_delete_entry": "Excluir entrada",
"history_delete_confirm": "Excluir esta entrada do histórico? A data da última execução volta para a conclusão anterior; as fotos permanecem com o objeto e as peças consumidas não são repostas.",
"history_delete_confirm_entry": "Excluir esta entrada do histórico? A data da última realização volta para a anterior; as fotos continuam com o objeto.",
"history_delete_returns_parts": "As peças usadas voltam ao estoque: {parts}.",
"history_edit_timestamp": "Data e hora",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -855,6 +857,11 @@
"loading": "Carregando…",
"archive": "Arquivar",
"undo": "Desfazer",
"task_completed_named": "Concluída: {task}",
"undo_done": "Conclusão desfeita",
"undo_done_action_ran": "Conclusão desfeita. A ação de conclusão já tinha sido executada e não pode ser revertida.",
"undo_nothing": "Nada para desfazer. Só a sua última conclusão pode ser desfeita, em até 10 minutos.",
"undo_changed": "Não é possível desfazer. A tarefa mudou desde então.",
"task_archived": "Tarefa arquivada",
"object_archived": "Objeto arquivado",
"unarchive": "Desarquivar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Concluído em (opcional, vazio = agora)",
"completed_at_pick": "Definir data e hora",
"completed_at_future_error": "A data de conclusão não pode estar no futuro.",
"ha_time_zone_hint": "Hora no fuso horário do Home Assistant ({zone})",
"days": "dias",
"day": "dia",
"today": "Hoje",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Janelas futuras",
"history_edit_title": "Editar entrada do histórico",
"history_delete_entry": "Eliminar entrada",
"history_delete_confirm": "Eliminar esta entrada do histórico? A data da última execução recua para a conclusão anterior; as fotos ficam com o objeto e as peças consumidas não são repostas.",
"history_delete_confirm_entry": "Eliminar esta entrada do histórico? A data da última realização volta à anterior; as fotos ficam com o objeto.",
"history_delete_returns_parts": "As peças usadas voltam ao stock: {parts}.",
"history_edit_timestamp": "Carimbo de data/hora",
"manufacturer": "Fabricante",
"model": "Modelo",
@@ -848,6 +850,11 @@
"loading": "A carregar…",
"archive": "Arquivar",
"undo": "Desfazer",
"task_completed_named": "Concluída: {task}",
"undo_done": "Conclusão anulada",
"undo_done_action_ran": "Conclusão anulada. A ação de conclusão já tinha sido executada e não pode ser revertida.",
"undo_nothing": "Nada para anular. Só a sua última conclusão pode ser anulada, dentro de 10 minutos.",
"undo_changed": "Não é possível anular. A tarefa mudou entretanto.",
"task_archived": "Tarefa arquivada",
"object_archived": "Objeto arquivado",
"unarchive": "Desarquivar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Выполнено (необязательно, пусто = сейчас)",
"completed_at_pick": "Указать дату и время",
"completed_at_future_error": "Дата выполнения не может быть в будущем.",
"ha_time_zone_hint": "Время в часовом поясе Home Assistant ({zone})",
"days": "дней",
"day": "день",
"today": "Сегодня",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Будущие окна",
"history_edit_title": "Редактировать запись истории",
"history_delete_entry": "Удалить запись",
"history_delete_confirm": "Удалить эту запись истории? Дата последнего выполнения вернётся к предыдущему; фотографии останутся у объекта, а израсходованные детали не будут возвращены на склад.",
"history_delete_confirm_entry": "Удалить эту запись истории? Дата последнего выполнения вернётся к предыдущей; фото останутся у объекта.",
"history_delete_returns_parts": "Использованные запчасти вернутся на склад: {parts}.",
"history_edit_timestamp": "Метка времени",
"manufacturer": "Производитель",
"model": "Модель",
@@ -848,6 +850,11 @@
"loading": "Загрузка…",
"archive": "Архивировать",
"undo": "Отменить",
"task_completed_named": "Выполнено: {task}",
"undo_done": "Выполнение отменено",
"undo_done_action_ran": "Выполнение отменено. Действие при выполнении уже сработало, его нельзя отменить.",
"undo_nothing": "Нечего отменять. Можно отменить только своё последнее выполнение, в течение 10 минут.",
"undo_changed": "Отменить нельзя. Задача с тех пор изменилась.",
"task_archived": "Задача архивирована",
"object_archived": "Объект архивирован",
"unarchive": "Разархивировать",
@@ -210,6 +210,7 @@
"completed_at_optional": "Utförd den (valfritt, tomt = nu)",
"completed_at_pick": "Ange datum och tid",
"completed_at_future_error": "Slutförandedatumet får inte ligga i framtiden.",
"ha_time_zone_hint": "Tid i Home Assistants tidszon ({zone})",
"days": "dagar",
"day": "dag",
"today": "Idag",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Kommande fönster",
"history_edit_title": "Redigera historikpost",
"history_delete_entry": "Ta bort post",
"history_delete_confirm": "Ta bort den här historikposten? Datumet för senaste utförande faller tillbaka till det föregående; foton stannar hos objektet och förbrukade delar läggs inte tillbaka i lager.",
"history_delete_confirm_entry": "Ta bort den här historikposten? Datumet för senaste utförandet faller tillbaka på det föregående; foton finns kvar på objektet.",
"history_delete_returns_parts": "De använda delarna går tillbaka till lagret: {parts}.",
"history_edit_timestamp": "Tidsstämpel",
"manufacturer": "Tillverkare",
"model": "Modell",
@@ -848,6 +850,11 @@
"loading": "Laddar…",
"archive": "Arkivera",
"undo": "Ångra",
"task_completed_named": "Slutförd: {task}",
"undo_done": "Utförandet ångrat",
"undo_done_action_ran": "Utförandet ångrat. Åtgärden vid utförande hade redan körts och kan inte ångras.",
"undo_nothing": "Inget att ångra. Bara ditt eget senaste utförande kan ångras, inom 10 minuter.",
"undo_changed": "Kan inte ångras. Uppgiften har ändrats sedan dess.",
"task_archived": "Uppgift arkiverad",
"object_archived": "Objekt arkiverat",
"unarchive": "Återställ",
@@ -212,6 +212,7 @@
"completed_at_optional": "Tamamlanma zamanı (isteğe bağlı, boş = şimdi)",
"completed_at_pick": "Tarih ve saat seç",
"completed_at_future_error": "Tamamlanma tarihi gelecekte olamaz.",
"ha_time_zone_hint": "Home Assistant saat dilimindeki saat ({zone})",
"days": "gün",
"day": "gün",
"today": "Bugün",
@@ -556,7 +557,8 @@
"cal_forward_windows": "Gelecek dönemler",
"history_edit_title": "Geçmiş kaydını düzenle",
"history_delete_entry": "Kaydı sil",
"history_delete_confirm": "Bu geçmiş kaydı silinsin mi? Son yapılma tarihi bir önceki tamamlamaya döner; fotoğraflar nesnede kalır, kullanılan parçalar stoğa geri eklenmez.",
"history_delete_confirm_entry": "Bu geçmiş kaydı silinsin mi? Son yapılma tarihi bir önceki tamamlamaya geri döner; fotoğraflar nesnede kalır.",
"history_delete_returns_parts": "Kullanılan parçalar stoğa geri döner: {parts}.",
"history_edit_timestamp": "Zaman damgası",
"manufacturer": "Üretici",
"model": "Model",
@@ -855,6 +857,11 @@
"loading": "Yükleniyor…",
"archive": "Arşivle",
"undo": "Geri al",
"task_completed_named": "Tamamlandı: {task}",
"undo_done": "Tamamlama geri alındı",
"undo_done_action_ran": "Tamamlama geri alındı. Tamamlama eylemi zaten çalışmıştı ve geri alınamaz.",
"undo_nothing": "Geri alınacak bir şey yok. Yalnızca kendi son tamamlamanızı 10 dakika içinde geri alabilirsiniz.",
"undo_changed": "Geri alınamıyor. Görev o zamandan beri değişti.",
"task_archived": "Görev arşivlendi",
"object_archived": "Nesne arşivlendi",
"unarchive": "Arşivden çıkar",
@@ -210,6 +210,7 @@
"completed_at_optional": "Виконано (необов'язково, порожньо = зараз)",
"completed_at_pick": "Вказати дату й час",
"completed_at_future_error": "Дата виконання не може бути в майбутньому.",
"ha_time_zone_hint": "Час у часовому поясі Home Assistant ({zone})",
"days": "днів",
"day": "день",
"today": "Сьогодні",
@@ -554,7 +555,8 @@
"cal_forward_windows": "Майбутні вікна",
"history_edit_title": "Редагувати запис історії",
"history_delete_entry": "Видалити запис",
"history_delete_confirm": "Видалити цей запис історії? Дата останнього виконання повернеться до попереднього; фотографії залишаться в об'єкта, а витрачені деталі не повернуться на склад.",
"history_delete_confirm_entry": "Видалити цей запис історії? Дата останнього виконання повернеться до попередньої; фото залишаться в об'єкта.",
"history_delete_returns_parts": "Використані запчастини повернуться на склад: {parts}.",
"history_edit_timestamp": "Позначка часу",
"manufacturer": "Виробник",
"model": "Модель",
@@ -848,6 +850,11 @@
"loading": "Завантаження…",
"archive": "Архівувати",
"undo": "Скасувати",
"task_completed_named": "Виконано: {task}",
"undo_done": "Виконання скасовано",
"undo_done_action_ran": "Виконання скасовано. Дія під час виконання вже спрацювала, її не можна скасувати.",
"undo_nothing": "Нічого скасовувати. Можна скасувати лише своє останнє виконання, протягом 10 хвилин.",
"undo_changed": "Скасувати не можна. Завдання відтоді змінилося.",
"task_archived": "Завдання архівовано",
"object_archived": "Об'єкт архівовано",
"unarchive": "Розархівувати",
@@ -212,6 +212,7 @@
"completed_at_optional": "完成时间(可选,留空 = 现在)",
"completed_at_pick": "设置日期和时间",
"completed_at_future_error": "完成日期不能是未来时间。",
"ha_time_zone_hint": "Home Assistant 时区的时间({zone})",
"days": "天",
"day": "天",
"today": "今天",
@@ -556,7 +557,8 @@
"cal_forward_windows": "未来的窗口",
"history_edit_title": "编辑历史记录",
"history_delete_entry": "删除记录",
"history_delete_confirm": "删除此历史记录?上次完成日期将回退到前一次完成;照片仍保留在对象中,已消耗的配件不会退回库存。",
"history_delete_confirm_entry": "删除此历史记录?上次执行日期将退回到上一次完成;照片仍保留在对象中。",
"history_delete_returns_parts": "所用的配件将退回库存:{parts}。",
"history_edit_timestamp": "时间戳",
"manufacturer": "制造商",
"model": "型号",
@@ -848,6 +850,11 @@
"loading": "加载中…",
"archive": "归档",
"undo": "撤销",
"task_completed_named": "已完成:{task}",
"undo_done": "已撤销完成",
"undo_done_action_ran": "已撤销完成。完成时的操作已经执行,无法撤销。",
"undo_nothing": "没有可撤销的内容。只能在 10 分钟内撤销你自己最近一次完成。",
"undo_changed": "无法撤销。此后任务已发生变化。",
"task_archived": "任务已归档",
"object_archived": "对象已归档",
"unarchive": "取消归档",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
/*! maintenance_supporter frontend 2.98.0 */
import{e as y}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-FFPNBBZ6.js";import{d as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-QFIANZWP.js";import{a as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-MN2RZ73S.js";import{H as b,a as o,b as u,c as n,f as l,h as v,l as g,m as d,s as r,u as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var a=class extends v{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._objectNames={};this._objects=[];this._covered={};this._users=[];this._responsible="";this._forMinutes="0";this._localeReady=!1;this._userService=null;this._toggle=t=>{let i=new Set(this._selected);i.has(t)?i.delete(t):i.add(t),this._selected=i};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(t=>t.entity_id))};this._adopt=async()=>{if(this._selected.size===0||this._adopting)return;this._error="";let t=this._sensors.filter(e=>this._selected.has(e.entity_id)).map(e=>({entity_id:e.entity_id,name:e.name,entry_id:this._existingEntryFor(e)??void 0,object_name:this._effectiveName(e),device_id:e.device_id??void 0,part_id:e.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),i=await h(this,{type:"maintenance_supporter/problem_sensors/adopt",selections:t},{busy:e=>{this._adopting=e},onError:e=>{this._error=e}});i!==void 0&&(this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:i})),this._open=!1)}}get _lang(){return b(this.hass)}updated(t){t.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,f(this._lang).then(()=>this.requestUpdate()))}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._objectNames={},this._responsible="",this._forMinutes="0",this._userService?this._userService.updateHass(this.hass):this._userService=new y(this.hass);let[t,i,e]=await Promise.all([h(this,{type:"maintenance_supporter/problem_sensors/discover"},{onError:s=>{this._error=s}}),this._userService.getUsers().catch(()=>[]),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>({objects:[]}))]);this._loading=!1,t!==void 0&&(this._objects=(e?.objects||[]).filter(s=>!s.object.archived_at).map(s=>({entry_id:s.entry_id,name:s.object.name,tasks:(s.tasks||[]).length})),this._sensors=t?.sensors||[],this._covered=Object.fromEntries(this._sensors.map(s=>[s.entity_id,s.covered_by??null])),this._selected=new Set(this._sensors.filter(s=>!s.covered_by).map(s=>s.entity_id)),this._users=i)}_defaultName(t){return t.suggested_entry_id?t.suggested_object_name:t.candidate?.name??t.suggested_object_name}_effectiveName(t){return(this._objectNames[t.entity_id]??"").trim()||this._defaultName(t)}_existingEntryFor(t){let i=this._effectiveName(t),e=t.suggested_entry_id||t.target_entry_id||null;if(i===this._defaultName(t)&&e)return e;let s=this._objects.find(c=>c.name.trim().toLowerCase()===i.toLowerCase());return s?s.entry_id:null}_schedulePreview(){clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._preview()},350)}async _preview(){let t=this._sensors.map(e=>({entity_id:e.entity_id,name:e.name,entry_id:this._existingEntryFor(e)})),i=await h(this,{type:"maintenance_supporter/problem_sensors/preview",selections:t},{onError:()=>{}});i?.covered&&(this._covered={...this._covered,...i.covered})}_summary(t){let i=new Map;for(let e of this._sensors){if(!this._selected.has(e.entity_id))continue;let s=this._existingEntryFor(e),c=this._effectiveName(e),_=s??`new:${c.toLowerCase()}`,p=i.get(_)??{name:c,entry:s,adding:0};p.adding+=1,i.set(_,p)}return i.size===0?l:n`
<div class="summary">
<div class="summary-title">${r("adopt_summary_title",t)}</div>
${[...i.values()].map(e=>{if(!e.entry)return n`<div class="summary-line new">
${r("setups_new_object_count",t).replace("{name}",e.name).replace("{count}",String(e.adding))}
</div>`;let s=this._objects.find(c=>c.entry_id===e.entry)?.tasks??0;return n`<div class="summary-line">
${r("adopt_summary_existing",t).replace("{name}",e.name).replace("{before}",String(s)).replace("{after}",String(s+e.adding))}
</div>`})}
</div>
`}_close(){clearTimeout(this._previewTimer),this._open=!1}_targetLabel(t,i){if(t.candidate&&this._existingEntryFor(t)===t.candidate.entry_id){let e=t.candidate.reasons.map(s=>r(`setups_reason_${s}`,i)).join(", ");return r("setups_target_match",i).replace("{name}",t.candidate.name).replace("{reasons}",e)}return this._effectiveName(t)}render(){if(!this._open)return n``;let t=this._lang,i=this._sensors.length>0&&this._selected.size===this._sensors.length;return n`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${e=>e.stopPropagation()}>
<div class="title">${r("adopt_problem_title",t)}</div>
<div class="hint">${r("adopt_problem_hint",t)}</div>
${this._error?n`<div class="error">${this._error}</div>`:l}
${this._loading?n`<div class="loading">…</div>`:this._sensors.length===0?n`<div class="empty">${r("adopt_problem_none",t)}</div>`:n`
<label class="select-all">
<input
type="checkbox"
.checked=${i}
@change=${this._toggleAll}
/>
<span>${r("selected",t)}: ${this._selected.size} / ${this._sensors.length}</span>
</label>
<div class="row-sub adopt-object-hint">${r("adopt_object_hint",t)}</div>
<datalist id="adopt-object-names">
${this._objects.map(e=>n`<option value=${e.name}></option>`)}
</datalist>
<div class="list">
${this._sensors.map(e=>{let s=this._selected.has(e.entity_id),c=e.state==="on",_=[e.device_name,e.area_name].filter(Boolean).join(" \xB7 ");return n`
<label class="row">
<input
type="checkbox"
.checked=${s}
@change=${()=>this._toggle(e.entity_id)}
/>
<div class="row-main">
<div class="row-top">
<span class="row-name">${e.name}</span>
<span class="chip ${c?"chip-active":"chip-ok"}">
${c?r("adopt_problem_active",t):r("adopt_problem_ok",t)}
</span>
</div>
${_?n`<div class="row-sub">${_}</div>`:l}
${s?n`<div class="row-object" @click=${p=>p.stopPropagation()}>
<input
class="adopt-object"
list="adopt-object-names"
aria-label=${r("object",t)}
placeholder=${this._defaultName(e)}
.value=${this._objectNames[e.entity_id]??this._defaultName(e)}
@input=${p=>{this._objectNames={...this._objectNames,[e.entity_id]:p.target.value},this._schedulePreview()}}
/>
</div>`:l}
<div class="row-target">
→ ${this._targetLabel(e,t)}${this._existingEntryFor(e)?l:n` <span class="new-tag">${r("adopt_problem_new_object",t)}</span>`}
</div>
${this._covered[e.entity_id]?n`<div class="maybe">
${r("setups_maybe_covered",t).replace("{name}",this._covered[e.entity_id].name)}
</div>`:l}
${e.suggested_part_name?n`<div class="row-part">
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
${r("adopt_problem_part",t).replace("{name}",e.suggested_part_name)}
</div>`:l}
</div>
</label>
`})}
</div>
`}
${!this._loading&&this._sensors.length>0?this._summary(t):l}
${!this._loading&&this._sensors.length>0?n`
<label class="responsible">
<span>${r("for_at_least_minutes",t)}</span>
<input
class="for-input"
type="number"
min=${m[0]}
max=${m[1]}
.value=${this._forMinutes}
@input=${e=>this._forMinutes=e.target.value}
/>
</label>
<div class="for-hint">${r("adopt_for_minutes_hint",t)}</div>
`:l}
${!this._loading&&this._sensors.length>0&&this._users.length>0?n`
<label class="responsible">
<span>${r("adopt_problem_responsible",t)}</span>
<select
.value=${this._responsible}
@change=${e=>{this._responsible=e.target.value}}
>
<option value="" ?selected=${!this._responsible}>${r("no_user_assigned",t)}</option>
${this._users.map(e=>n`<option value=${e.id} ?selected=${e.id===this._responsible}>${e.name}</option>`)}
</select>
</label>
`:l}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>
${r("cancel",t)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size===0||this._adopting}
>
${r("adopt_problem_adopt",t)}
</ha-button>
</div>
</div>
</div>
`}};a.styles=u`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: min(360px, calc(100vw - 24px));
max-width: 560px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.loading,
.empty {
color: var(--secondary-text-color);
font-size: 14px;
padding: 12px 0;
}
.select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.select-all input {
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
cursor: pointer;
}
.row input {
margin-top: 2px;
cursor: pointer;
}
.row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.row-top {
display: flex;
align-items: center;
gap: 8px;
}
.row-name {
font-weight: 500;
font-size: 13px;
}
.row-sub {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-object { margin-top: 6px; }
.row-object input {
width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit;
border: 1px solid var(--divider-color); border-radius: 6px;
background: var(--card-background-color); color: var(--primary-text-color);
}
.adopt-object-hint { margin: 0 0 8px; }
.row-target {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-part {
color: var(--secondary-text-color);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.row-part ha-icon {
--mdc-icon-size: 14px;
}
.new-tag {
font-style: italic;
}
.maybe {
font-size: 11px;
color: var(--warning-color, #ff9800);
}
.summary {
font-size: 12px;
border-top: 1px dashed var(--divider-color);
padding-top: 6px;
}
.summary-title {
font-weight: 500;
margin-bottom: 2px;
}
.summary-line {
color: var(--primary-color);
}
.chip {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
white-space: nowrap;
}
.chip-active {
background: var(--error-color, #f44336);
color: #fff;
}
.chip-ok {
background: var(--divider-color);
color: var(--secondary-text-color);
}
.responsible {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
flex-wrap: wrap;
}
.for-input {
width: 70px;
background: var(--card-background-color);
color: var(--primary-text-color);
border: 1px solid var(--divider-color);
border-radius: 4px;
padding: 4px 6px;
}
.for-hint {
font-size: 11px;
color: var(--secondary-text-color);
margin: -4px 0 2px;
}
.responsible select {
flex: 1;
min-width: 140px;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid var(--divider-color);
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`,o([g({attribute:!1})],a.prototype,"hass",2),o([d()],a.prototype,"_open",2),o([d()],a.prototype,"_loading",2),o([d()],a.prototype,"_adopting",2),o([d()],a.prototype,"_error",2),o([d()],a.prototype,"_sensors",2),o([d()],a.prototype,"_selected",2),o([d()],a.prototype,"_objectNames",2),o([d()],a.prototype,"_objects",2),o([d()],a.prototype,"_covered",2),o([d()],a.prototype,"_users",2),o([d()],a.prototype,"_responsible",2),o([d()],a.prototype,"_forMinutes",2);customElements.get("maintenance-adopt-problem-sensors-dialog")||customElements.define("maintenance-adopt-problem-sensors-dialog",a);export{a as MaintenanceAdoptProblemSensorsDialog};
@@ -0,0 +1,279 @@
/*! maintenance_supporter frontend 2.98.0 */
import{a as X}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-WH65ZHYZ.js";import{a as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DY5FF6TM.js";import{f as E,g as M,h as z,i as Q,j as U,k as Y,l as q,m as G,n as R,o as A,p as W}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-S7XZBRRU.js";import{a as B}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-GGO5IKKD.js";import{e as P,f as K}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DLWOWMV7.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-4PKMHUVO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-NEWWPCTD.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-QKEXGIRG.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-OUHDI2FL.js";import{B as S,H as F,K as g,L as v,O as N,P as I,T as w,V as T,a as $,c as p,f as y,h as H,l as k,m as j,n as C,q as D,s as a,u as L,y as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";function J(b,i,t,s){let l=b.entries.filter(e=>e.type==="completed").sort((e,n)=>e.ts-n.ts),d=`${b.from?t.date(b.from):"\u2026"} \u2013 ${b.to?t.date(b.to):"\u2026"}`,r=b.totals,h=[[i.completions,t.number(r.completions)],[i.totalCost,t.cost(r.totalCost)],[i.avgCost,r.avgCost!=null?t.cost(r.avgCost):i.none],[i.totalTime,r.totalDuration?t.duration(r.totalDuration):i.none]].map(([e,n])=>`<div class="kpi"><div class="k">${o(e)}</div><div class="v">${o(n)}</div></div>`).join(""),u=b.byObject.map(e=>`<tr>
<td>${o(e.objectName)}</td>
<td class="num">${o(t.number(e.completions))}</td>
<td class="num">${o(t.cost(e.cost))}</td>
<td class="num">${o(t.share(e.share))}</td>
</tr>`).join(""),f=b.buckets.buckets.reduce((e,n)=>Math.max(e,n.cost),0),c=b.buckets.buckets.map(e=>{let n=f>0?Math.round(e.cost/f*100):0;return`<tr>
<td class="nowrap">${o(t.bucket(e))}</td>
<td class="num">${o(t.number(e.completions))}</td>
<td class="bar-cell">${n>0?`<span class="bar" style="width:${n}%"></span>`:""}</td>
<td class="num">${o(t.cost(e.cost))}</td>
</tr>`}).join(""),m=l.map(e=>{let n=[e.notes,e.completedBy?`${i.completedBy}: ${e.completedBy}`:null].filter(Boolean).join(" \xB7 "),x=e.phaseName?`${e.taskName} \xB7 ${e.phaseName}`:e.taskName;return`<tr>
<td class="nowrap">${o(t.date(e.timestamp))}</td>
<td>${o(e.objectName)}</td>
<td>${o(x)}</td>
<td class="num">${e.cost!=null?o(t.cost(e.cost)):o(i.none)}</td>
<td class="num">${e.duration!=null?o(t.duration(e.duration)):o(i.none)}</td>
<td class="notes">${o(n)||o(i.none)}</td>
</tr>`}).join(`
`);return`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${o(i.title)} \u2014 ${o(b.areaName)}</title>
<style>
/* Printable sheet: it opens as a blob in whatever viewer the OS supplies
(Companion = WebView, dark phones paint a dark default canvas), so the
document states its own light scheme and paints its background. */
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #fff; margin: 32px; }
h1 { font-size: 22px; margin: 0 0 2px; }
h2 { font-size: 15px; margin: 24px 0 8px; }
.sub { color: #666; margin: 0 0 16px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0 0 8px; }
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px 10px; }
.kpi .k { color: #777; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; }
.kpi .v { font-size: 16px; font-weight: 600; }
table { border-collapse: collapse; width: 100%; }
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #666; border-bottom: 2px solid #ccc; padding: 6px 8px; }
td { border-bottom: 1px solid #e5e5e5; padding: 6px 8px; vertical-align: top; }
td.num, th.num { text-align: right; white-space: nowrap; }
td.nowrap { white-space: nowrap; }
td.notes { color: #444; white-space: pre-wrap; }
td.bar-cell { width: 40%; vertical-align: middle; }
.bar { display: block; height: 10px; background: #9aa7b8; border-radius: 0 3px 3px 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
tfoot td { border-bottom: none; border-top: 2px solid #ccc; font-weight: 600; }
tr { break-inside: avoid; }
.cap-note { margin-top: 14px; color: #888; font-size: 11px; }
@media print { body { margin: 12mm; } }
</style>
</head>
<body>
<h1>${o(i.title)} \u2014 ${o(b.areaName)}</h1>
<p class="sub">${o(i.period)}: ${o(d)} \xB7 ${o(i.generated)} ${o(t.date(s))}</p>
<div class="kpis">${h}</div>
<h2>${o(i.costPerObject)}</h2>
<table>
<thead><tr>
<th>${o(i.colObject)}</th><th class="num">${o(i.completions)}</th>
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.share)}</th>
</tr></thead>
<tbody>${u||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
<tfoot><tr>
<td>${o(i.totalCost)}</td><td class="num">${o(t.number(r.completions))}</td>
<td class="num">${o(t.cost(r.totalCost))}</td><td></td>
</tr></tfoot>
</table>
<h2>${o(i.costPerBucket)}</h2>
<table>
<thead><tr>
<th>${o(i.period)}</th><th class="num">${o(i.completions)}</th>
<th></th><th class="num">${o(i.colCost)}</th>
</tr></thead>
<tbody>${c||`<tr><td colspan="4">${o(i.none)}</td></tr>`}</tbody>
</table>
<h2>${o(i.historyHeading)} (${o(t.number(l.length))})</h2>
<table>
<thead><tr>
<th>${o(i.colDate)}</th><th>${o(i.colObject)}</th><th>${o(i.colTask)}</th>
<th class="num">${o(i.colCost)}</th><th class="num">${o(i.colDuration)}</th><th>${o(i.colNotes)}</th>
</tr></thead>
<tbody>
${m||`<tr><td colspan="6">${o(i.none)}</td></tr>`}
</tbody>
<tfoot><tr>
<td colspan="3">${o(i.totalCost)}</td>
<td class="num">${o(t.cost(r.totalCost))}</td>
<td class="num">${r.totalDuration?o(t.duration(r.totalDuration)):""}</td><td></td>
</tr></tfoot>
</table>
${b.capped?`<p class="cap-note">${o(i.capNote)}</p>`:""}
</body>
</html>`}var Z=25,V=5,tt={completed:C.ok,missed:C.overdue,reset:C.paused,skipped:"var(--secondary-text-color)"},_=class extends H{constructor(){super(...arguments);this.areaKey="";this.objects=[];this.showArchived=!1;this.currencySymbol="";this.userName=()=>null;this._from="";this._to="";this._entryFilter="";this._taskQuery="";this._expanded=!1;this._printing=!1;this._histories=new X(this);this._localeReady=!1;this._filtersFor=null;this._memo=null}createRenderRoot(){return this}get _lang(){return F(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("areaKey")&&this._filtersFor!==this.areaKey){this._filtersFor=this.areaKey;let s=R();this._from=s.from,this._to=s.to,this._entryFilter="",this._taskQuery="",this._expanded=!1}if(t.has("objects")||t.has("areaKey")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let s=this._areaObjects().flatMap(l=>l.tasks.map(d=>({entryId:l.entry_id,task:d})));this._histories.sync(this.hass,s)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,L(this._lang).then(()=>this.requestUpdate()))}_areaObjects(){return z(this.objects,this.areaKey,this.showArchived).sort((t,s)=>t.object.name.localeCompare(s.object.name))}_entries(t){let s=[this.objects,this.areaKey,this.showArchived,this._histories.version];if(this._memo&&this._memo.deps.every((d,r)=>d===s[r]))return this._memo.entries;let l=Q(t,(d,r)=>this._histories.historyOf(d,r));return this._memo={deps:s,entries:l},l}_figures(){let t=this._areaObjects(),s=this._entries(t),l={from:this._from||null,to:this._to||null},d=U(s,{...l,entryId:this._entryFilter||null,taskQuery:this._taskQuery}),r=this._entryFilter?t.filter(h=>h.entry_id===this._entryFilter):t;return{objects:t,entries:s,filtered:d,totals:Y(d),buckets:G(d,l),byObject:q(d,r)}}_setRange(t){this._from=t.from,this._to=t.to,this._expanded=!1}_openObject(t){this.dispatchEvent(new CustomEvent("open-object",{detail:{entryId:t},bubbles:!0,composed:!0}))}_openTask(t,s){this.dispatchEvent(new CustomEvent("open-task",{detail:{entryId:t,taskId:s},bubbles:!0,composed:!0}))}_bucketLabel(t,s){return t.month==null?String(t.year):`${T(t.month,this._lang,s)} ${t.year}`}_countText(t,s,l){let d=this._lang;return l===1?a(s,d):a(t,d).replace("{n}",g(l,d))}async _print(){if(this._printing)return;let t=K();this._printing=!0;try{await this._histories.settled()}finally{this._printing=!1}let s=this._lang,l=this.currencySymbol,{filtered:d,totals:r,buckets:h,byObject:u}=this._figures(),f={title:a("area_report_title",s),generated:a("report_generated",s),period:a("area_report_period",s),completions:a("area_kpi_completions",s),totalCost:a("total_cost",s),avgCost:a("avg_cost",s),totalTime:a("area_kpi_total_time",s),costPerObject:a("area_cost_per_object",s),costPerBucket:h.unit==="month"?a("area_cost_per_month",s):a("area_cost_per_year",s),share:a("area_cost_share",s),historyHeading:a("area_history_section",s),colDate:a("date",s),colObject:a("object",s),colTask:a("task_name",s),colCost:a("cost",s),colDuration:a("duration",s),colNotes:a("notes_label",s),completedBy:a("completed_by",s),capNote:a("object_history_cap_note",s),none:"\u2014"},c=d.filter(n=>n.type==="completed"),m=d.map(n=>({...n,completedBy:n.completedBy?this.userName(n.completedBy):null,notes:n.notes?B(n.notes,s):null})),e=J({areaName:E(this.areaKey,this.hass?.areas,a("no_area",s)),from:this._from||S(c[c.length-1]?.timestamp)||null,to:this._to||O(),totals:r,byObject:u,buckets:h,entries:m,capped:this._histories.capped},f,{date:n=>N(n,s),cost:n=>v(n,l,s),duration:n=>w(n,s),bucket:n=>this._bucketLabel(n,"long"),share:n=>`${g(n*100,s,0)} %`,number:n=>g(n,s)},new Date().toISOString());P(e,t)}_renderRangeChips(t){let s=this._lang,l=O(),d=R(l),r=u=>this._from===u.from&&this._to===u.to,h=W(t,Number(l.slice(0,4))).slice(0,V);return p`
<div class="filter-chips area-range-chips">
<button class="filter-chip ${r(d)?"active":""}" @click=${()=>this._setRange(R())}>
${a("area_range_12m",s)}
</button>
${h.map(u=>p`
<button class="filter-chip ${r(A(u))?"active":""}" @click=${()=>this._setRange(A(u))}>${u}</button>
`)}
<button class="filter-chip ${!this._from&&!this._to?"active":""}" @click=${()=>this._setRange({from:"",to:""})}>
${a("area_range_all",s)}
</button>
</div>
`}_renderChart(t){let s=this._lang,l=this.currencySymbol,d=t.reduce((c,m)=>Math.max(c,m.cost),0),r=t.reduce((c,m)=>Math.min(c,m.cost),0);if(d<=0&&r>=0)return p`<p class="area-empty">${a("area_no_costs",s)}</p>`;let h=c=>Math.round(c/(d-r)*1e3)/10,u=Math.max(1,Math.ceil(t.length/12)),f=t.map((c,m)=>({b:c,i:m})).filter(({i:c})=>c%u===0);return p`
<div class="area-chart">
<div class="area-chart-max">${v(d,l,s)}</div>
<div class="area-chart-bars">
${r<0?p`<div class="area-chart-zero" style="bottom: ${h(-r)}%"></div>`:y}
${t.map(c=>{let m=`${this._bucketLabel(c,"long")}: ${v(c.cost,l,s)} \xB7 ${a("area_kpi_completions",s)}: ${g(c.completions,s)}`,e=c.cost<0?h(c.cost-r):h(-r);return p`<div class="area-bar" title=${m} aria-label=${m}>
${c.cost!==0?p`<div class="area-bar-fill ${c.cost<0?"credit":""}" style="height: ${h(Math.abs(c.cost))}%${e?`; bottom: ${e}%`:""}"></div>`:y}
</div>`})}
</div>
${r<0?p`<div class="area-chart-min">${v(r,l,s)}</div>`:y}
<div class="area-chart-axis ${u>1?"grouped":""}">
${f.map(({b:c,i:m})=>p`<span class="area-bar-label" style="flex-grow: ${Math.min(u,t.length-m)}">${c.month==null?String(c.year):p`${T(c.month,s,"short")}${c.month===0||m===0?p`<br />${c.year}`:y}`}</span>`)}
</div>
</div>
`}render(){if(!this.hass||!this.areaKey)return y;let t=this._lang,s=this.currencySymbol,{objects:l,entries:d,filtered:r,totals:h,buckets:u,byObject:f}=this._figures(),c=l.reduce((e,n)=>e+n.tasks.filter(x=>this.showArchived||!x.archived).length,0),m=this._expanded?r:r.slice(0,Z);return p`
<div class="detail-section area-view">
<div class="detail-header">
<h2 class="area-title">
<ha-icon .icon=${M(this.areaKey,this.hass.areas)}></ha-icon>
${E(this.areaKey,this.hass.areas,a("no_area",t))}
</h2>
<div class="action-buttons">
<ha-button appearance="plain" class="area-print" .disabled=${this._printing} @click=${()=>this._print()}>
<ha-icon icon="mdi:printer-outline"></ha-icon>
${this._printing?a("loading",t):a("area_report_print",t)}
</ha-button>
</div>
</div>
<p class="meta">
${this._countText("area_object_count","area_object_count_one",l.length)} ·
${this._countText("templates_task_count","templates_task_count_one",c)}
${this._histories.loading?p` · <span class="area-loading">${a("loading",t)}</span>`:y}
</p>
${this._renderRangeChips(d)}
<div class="filter-bar area-filters">
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${t}
.label=${a("date_from",t)}
.value=${this._from}
@value-changed=${e=>{this._from=e.detail.value}}
></ms-date-field>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${t}
.label=${a("date_to",t)}
.value=${this._to}
@value-changed=${e=>{this._to=e.detail.value}}
></ms-date-field>
<label class="filter-field">
<span class="filter-label">${a("object",t)}</span>
<select class="area-object-filter" .value=${this._entryFilter}
@change=${e=>{this._entryFilter=e.target.value}}>
<option value="" ?selected=${!this._entryFilter}>${a("all_objects",t)}</option>
${l.map(e=>p`<option value=${e.entry_id} ?selected=${e.entry_id===this._entryFilter}>${e.object.name}</option>`)}
</select>
</label>
<input
type="search"
class="search-input area-task-filter"
aria-label=${a("area_task_filter",t)}
placeholder=${a("area_task_filter",t)}
.value=${this._taskQuery}
@input=${e=>{this._taskQuery=e.target.value}}
/>
</div>
<div class="kpi-bar area-kpis">
<div class="kpi-card">
<div class="kpi-label">${a("area_kpi_completions",t)}</div>
<div class="kpi-value-large area-kpi-completions">${g(h.completions,t)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("total_cost",t)}</div>
<div class="kpi-value-large area-kpi-cost">${v(h.totalCost,s,t)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("avg_cost",t)}</div>
<div class="kpi-value-large">${h.avgCost!=null?v(h.avgCost,s,t):"\u2014"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${a("area_kpi_total_time",t)}</div>
<div class="kpi-value-large">${h.totalDuration?w(h.totalDuration,t):"\u2014"}</div>
</div>
</div>
<h3>${u.unit==="month"?a("area_cost_per_month",t):a("area_cost_per_year",t)}</h3>
${this._renderChart(u.buckets)}
<h3>${a("area_cost_per_object",t)}</h3>
<div class="objects-table-wrap">
<table class="objects-table area-object-table">
<thead>
<tr>
<th>${a("object",t)}</th>
<th class="num">${a("area_kpi_completions",t)}</th>
<th class="num">${a("cost",t)}</th>
<th class="num">${a("area_cost_share",t)}</th>
</tr>
</thead>
<tbody>
${f.map(e=>p`
<tr class="objects-table-row" @click=${()=>this._openObject(e.entryId)}>
<td><span class="objects-table-name">${e.objectName}</span></td>
<td class="num">${g(e.completions,t)}</td>
<td class="num">${v(e.cost,s,t)}</td>
<td class="num">
<span class="area-share"><span class="area-share-fill" style="width: ${Math.round(e.share*100)}%"></span></span>
${g(e.share*100,t,0)} %
</td>
</tr>
`)}
</tbody>
</table>
</div>
<h3>
${a("area_history_section",t)}
<span class="area-count">${g(r.length,t)}</span>
</h3>
${r.length===0?p`<p class="area-empty">${a("object_history_empty",t)}</p>`:p`
<div class="objects-table-wrap">
<table class="objects-table area-history-table">
<thead>
<tr>
<th>${a("date",t)}</th>
<th></th>
<th>${a("object",t)}</th>
<th>${a("task_name",t)}</th>
<th class="num">${a("cost",t)}</th>
<th class="num">${a("duration",t)}</th>
<th>${a("notes_label",t)}</th>
</tr>
</thead>
<tbody>
${m.map(e=>{let n=e.notes?B(e.notes,t):"";return p`
<tr class="objects-table-row area-history-row" @click=${()=>this._openTask(e.entryId,e.taskId)}>
<td title=${I(e.timestamp,t)}>${N(e.timestamp,t)}</td>
<td>
<span class="area-entry-type" style="color: ${tt[e.type]??"inherit"}">
<ha-icon .icon=${D[e.type]||"mdi:circle"}></ha-icon>${a(e.type,t)}
</span>
</td>
<td>
<button class="area-object-link" @click=${x=>{x.stopPropagation(),this._openObject(e.entryId)}}>
${e.objectName}
</button>
</td>
<td><button class="area-task-link">${e.taskName}${e.phaseName?` \xB7 ${e.phaseName}`:""}</button></td>
<td class="num">${e.cost!=null?v(e.cost,s,t):"\u2014"}</td>
<td class="num">${e.duration!=null?w(e.duration,t):"\u2014"}</td>
<td class="oc-notes" title=${n}>${n||"\u2014"}</td>
</tr>
`})}
</tbody>
</table>
</div>
${r.length>m.length?p`<ha-button appearance="plain" class="area-more" @click=${()=>{this._expanded=!0}}>
${a("show_all",t)} (${g(r.length,t)})
</ha-button>`:y}
`}
${this._histories.capped?p`<p class="area-cap-note">${a("object_history_cap_note",t)}</p>`:y}
</div>
`}};$([k({attribute:!1})],_.prototype,"hass",2),$([k()],_.prototype,"areaKey",2),$([k({attribute:!1})],_.prototype,"objects",2),$([k({type:Boolean})],_.prototype,"showArchived",2),$([k()],_.prototype,"currencySymbol",2),$([k({attribute:!1})],_.prototype,"userName",2),$([j()],_.prototype,"_from",2),$([j()],_.prototype,"_to",2),$([j()],_.prototype,"_entryFilter",2),$([j()],_.prototype,"_taskQuery",2),$([j()],_.prototype,"_expanded",2),$([j()],_.prototype,"_printing",2);customElements.get("maintenance-area-view")||customElements.define("maintenance-area-view",_);export{_ as MaintenanceAreaView};
@@ -0,0 +1,54 @@
/*! maintenance_supporter frontend 2.98.0 */
import{a as O}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-WH65ZHYZ.js";import{q as j,r as E,s as L,t as R,u as C}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-S7XZBRRU.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-4PKMHUVO.js";import{a as _,b as S,c as A}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKVMZFOV.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-NEWWPCTD.js";import{H as g,K as k,L as v,O as w,a as l,c as a,f as m,h as y,l as p,m as b,s as r,u as $,y as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var q=[{key:"name",label:"area",num:!1},{key:"objects",label:"objects",num:!0},{key:"tasks",label:"tasks",num:!0},{key:"overdue",label:"overdue",num:!0},{key:"due_soon",label:"due_soon",num:!0},{key:"cost_year",label:"area_cost_year",num:!0},{key:"cost_total",label:"total_cost",num:!0},{key:"last",label:"last_performed",num:!1}],i=class extends y{constructor(){super(...arguments);this.objects=[];this.showArchived=!1;this.currencySymbol="";this._query="";this._sort=E(S(_.areaSort));this._histories=new O(this);this._localeReady=!1;this._memo=null}createRenderRoot(){return this}get _lang(){return g(this.hass)}willUpdate(t){if(super.willUpdate(t),t.has("objects")||t.has("showArchived")||t.has("hass")&&t.get("hass")===void 0){let o=this.objects.filter(s=>this.showArchived||!s.object.archived).flatMap(s=>s.tasks.map(n=>({entryId:s.entry_id,task:n})));this._histories.sync(this.hass,o)}}updated(t){super.updated(t),!this._localeReady&&this.hass&&(this._localeReady=!0,$(this._lang).then(()=>this.requestUpdate()))}_rows(t){let o=this._lang,s=[this.objects,this.showArchived,this._histories.version,this.hass?.areas,o,t];if(this._memo&&this._memo.deps.every((c,h)=>c===s[h]))return this._memo.rows;let n=j(this.objects,{showArchived:this.showArchived,areas:this.hass?.areas,noAreaLabel:r("no_area",o),year:t,historyOf:(c,h)=>this._histories.historyOf(c,h)});return this._memo={deps:s,rows:n},n}_setSort(t){this._sort=L(this._sort,t),A(_.areaSort,`${this._sort.key}:${this._sort.dir}`)}_open(t){this.dispatchEvent(new CustomEvent("open-area",{detail:{areaKey:t},bubbles:!0,composed:!0}))}_toggleArchived(){this.dispatchEvent(new CustomEvent("archived-toggle",{bubbles:!0,composed:!0}))}_headerLabel(t,o,s){return t==="area_cost_year"?r("area_cost_year",s).replace("{year}",String(o)):r(t,s)}render(){if(!this.hass)return m;let t=this._lang,o=Number(f().slice(0,4)),s=this.currencySymbol,n=this._rows(o),c=R(C(n,this._query),this._sort),h=this.objects.filter(e=>e.object.archived).length,d=e=>k(e,t);return a`
<div class="filter-bar area-filter-bar">
<input
type="search"
class="search-input"
aria-label=${r("areas_filter",t)}
placeholder=${r("areas_filter",t)}
.value=${this._query}
@input=${e=>{this._query=e.target.value}}
/>
${this._histories.loading?a`<span class="area-loading">${r("loading",t)}</span>`:m}
${h>0?a`<ha-button class="archived-toggle ${this.showArchived?"active":""}" @click=${()=>this._toggleArchived()}>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this.showArchived?r("hide_archived",t):`${r("show_archived",t)} (${h})`}
</ha-button>`:m}
</div>
${n.length===0?a`<div class="empty-state">${r("no_objects",t)}</div>`:c.length===0?a`<div class="empty-state">${r("areas_filter_none",t)}</div>`:a`
<div class="objects-table-wrap">
<table class="objects-table areas-table">
<thead>
<tr>
${q.map(e=>{let u=this._sort.key===e.key;return a`<th class=${e.num?"num":""}
aria-sort=${u?this._sort.dir==="asc"?"ascending":"descending":"none"}>
<button class="area-sort ${u?"active":""}" title=${r("sort_label",t)} @click=${()=>this._setSort(e.key)}>
${this._headerLabel(e.label,o,t)}
${u?a`<ha-icon icon=${this._sort.dir==="asc"?"mdi:arrow-up":"mdi:arrow-down"}></ha-icon>`:m}
</button>
</th>`})}
</tr>
</thead>
<tbody>
${c.map(e=>a`
<tr class="objects-table-row" tabindex="0"
@click=${()=>this._open(e.key)}
@keydown=${u=>{u.key==="Enter"&&this._open(e.key)}}>
<td>
<ha-icon class="area-row-icon" .icon=${e.icon}></ha-icon>
<span class="objects-table-name">${e.name}</span>
</td>
<td class="num">${d(e.objects)}</td>
<td class="num">${d(e.tasks)}</td>
<td class="num">${e.overdue?a`<span class="area-count overdue">${d(e.overdue)}</span>`:d(0)}</td>
<td class="num">${e.dueSoon?a`<span class="area-count due-soon">${d(e.dueSoon)}</span>`:d(0)}</td>
<td class="num">${v(e.costYear,s,t)}</td>
<td class="num">${v(e.costTotal,s,t)}</td>
<td>${e.lastCompletion?w(e.lastCompletion,t):"\u2014"}</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`}};l([p({attribute:!1})],i.prototype,"hass",2),l([p({attribute:!1})],i.prototype,"objects",2),l([p({type:Boolean})],i.prototype,"showArchived",2),l([p()],i.prototype,"currencySymbol",2),l([b()],i.prototype,"_query",2),l([b()],i.prototype,"_sort",2);customElements.get("maintenance-areas-view")||customElements.define("maintenance-areas-view",i);export{i as MaintenanceAreasView};
@@ -0,0 +1,113 @@
/*! maintenance_supporter frontend 2.98.0 */
import"/maintenance_supporter_panelfiles/panel-chunks/chunk-ISGNEPYP.js";import{H as u,a as n,b as _,c as o,f as p,h as c,l as g,m as a,s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var d=["round_robin","least_completed","random"],v=["low","normal","high"],m=365,i=class extends c{constructor(){super(...arguments);this._open=!1;this._mode="assign";this._count=0;this._users=[];this._present=[];this._assignKind="one";this._person="";this._pool=new Set;this._rotation=d[0];this._addText="";this._remove=new Set;this._setWarning=!1;this._warning=7;this._setPriority=!1;this._priority="normal";this._setMute=!1;this._mute=!1;this._resolve=null}get _lang(){return u(this.hass)}open(t,e,r){return this._resolve?.(null),this._mode=t,this._count=e.length,this._users=[...r].sort((l,h)=>l.name.localeCompare(h.name)),this._present=[...new Set(e.flatMap(l=>l.labels||[]))].sort((l,h)=>l.localeCompare(h)),this._assignKind="one",this._person="",this._pool=new Set,this._rotation=d[0],this._addText="",this._remove=new Set,this._setWarning=!1,this._warning=7,this._setPriority=!1,this._priority="normal",this._setMute=!1,this._mute=!1,this._open=!0,new Promise(l=>{this._resolve=l})}_finish(t){this._open=!1;let e=this._resolve;this._resolve=null,e?.(t)}changes(){if(this._mode==="assign"){if(this._assignKind==="one")return{responsible_user_id:this._person||null,assignee_pool:null,rotation_strategy:null};let e=this._users.map(r=>r.id).filter(r=>this._pool.has(r));return e.length<2?null:{assignee_pool:e,rotation_strategy:this._rotation,responsible_user_id:null}}if(this._mode==="labels"){let e=[...new Set(this._addText.split(",").map(l=>l.trim()).filter(Boolean))],r=[...this._remove];return e.length||r.length?{labels_add:e,labels_remove:r}:null}let t={};return this._setWarning&&(t.warning_days=this._warning),this._setPriority&&(t.priority=this._priority),this._setMute&&(t.notify_enabled=!this._mute),Object.keys(t).length?t:null}_toggle(t,e){let r=new Set(t);return r.has(e)?r.delete(e):r.add(e),r}_renderAssign(t){return o`
<div class="hint">${s("bulk_assign_hint",t)}</div>
<label class="choice">
<input type="radio" name="kind" .checked=${this._assignKind==="one"} @change=${()=>this._assignKind="one"} />
${s("bulk_assign_one",t)}
</label>
${this._assignKind==="one"?o`<select class="person" .value=${this._person} @change=${e=>this._person=e.target.value}>
<option value="" ?selected=${!this._person}>${s("unassigned",t)}</option>
${this._users.map(e=>o`<option value=${e.id} ?selected=${this._person===e.id}>${e.name}</option>`)}
</select>`:p}
<label class="choice">
<input type="radio" name="kind" .checked=${this._assignKind==="rotate"} @change=${()=>this._assignKind="rotate"} />
${s("bulk_assign_rotate",t)}
</label>
${this._assignKind==="rotate"?o`<div class="pool">
${this._users.map(e=>o`<label class="row">
<input type="checkbox" .checked=${this._pool.has(e.id)} @change=${()=>this._pool=this._toggle(this._pool,e.id)} />
${e.name}
</label>`)}
</div>
<label class="field">
<span>${s("rotation_strategy",t)}</span>
<select class="rotation" .value=${this._rotation} @change=${e=>this._rotation=e.target.value}>
${d.map(e=>o`<option value=${e} ?selected=${this._rotation===e}>${s(`rotation_${e}`,t)}</option>`)}
</select>
</label>
${this._pool.size<2?o`<div class="hint">${s("bulk_rotation_min_two",t)}</div>`:p}`:p}
`}_renderLabels(t){return o`
<ms-textfield
class="labels-add"
label=${s("bulk_labels_add",t)}
.helper=${s("labels_help",t)}
.value=${this._addText}
@input=${e=>this._addText=e.target.value}
></ms-textfield>
${this._present.length?o`<div class="section-title">${s("bulk_labels_remove",t)}</div>
<div class="pool">
${this._present.map(e=>o`<label class="row">
<input type="checkbox" .checked=${this._remove.has(e)} @change=${()=>this._remove=this._toggle(this._remove,e)} />
${e}
</label>`)}
</div>`:p}
`}_renderEdit(t){return o`
<div class="hint">${s("bulk_edit_hint",t)}</div>
<div class="row edit-row">
<input type="checkbox" class="set-warning" .checked=${this._setWarning} @change=${e=>this._setWarning=e.target.checked} />
<span class="grow">${s("warning_days",t)}</span>
<input type="number" class="warning" min="0" max=${m} .value=${String(this._warning)} ?disabled=${!this._setWarning}
@change=${e=>{let r=parseInt(e.target.value,10);this._warning=Number.isInteger(r)?Math.min(Math.max(r,0),m):this._warning}} />
</div>
<div class="row edit-row">
<input type="checkbox" class="set-priority" .checked=${this._setPriority} @change=${e=>this._setPriority=e.target.checked} />
<span class="grow">${s("priority",t)}</span>
<select class="priority" .value=${this._priority} ?disabled=${!this._setPriority} @change=${e=>this._priority=e.target.value}>
${v.map(e=>o`<option value=${e} ?selected=${this._priority===e}>${s(`priority_${e}`,t)}</option>`)}
</select>
</div>
<div class="row edit-row">
<input type="checkbox" class="set-mute" .checked=${this._setMute} @change=${e=>this._setMute=e.target.checked} />
<span class="grow">${s("bulk_no_notifications",t)}</span>
<input type="checkbox" class="mute" .checked=${this._mute} ?disabled=${!this._setMute} @change=${e=>this._mute=e.target.checked} />
</div>
`}render(){if(!this._open)return o``;let t=this._lang,e=this._mode==="assign"?s("bulk_assign",t):this._mode==="labels"?s("labels",t):s("edit",t),r=this.changes()!==null;return o`
<ha-dialog open @closed=${()=>this._finish(null)}>
<div class="dialog-title">${e}</div>
<div class="content">
<div class="count">${s("bulk_n_selected",t).replace("{n}",String(this._count))}</div>
${this._mode==="assign"?this._renderAssign(t):this._mode==="labels"?this._renderLabels(t):this._renderEdit(t)}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${()=>this._finish(null)}>${s("cancel",t)}</ha-button>
<ha-button class="apply" .disabled=${!r} @click=${()=>this._finish(this.changes())}>${s("save",t)}</ha-button>
</div>
</ha-dialog>
`}};i.styles=_`
.dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; }
.content {
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 24px));
max-width: 520px;
max-height: 60vh;
overflow-y: auto;
}
@media (max-width: 600px) {
.content { min-width: 0; max-width: none; max-height: none; }
}
.count, .hint { color: var(--secondary-text-color); font-size: 13px; }
.section-title {
font-size: 14px;
font-weight: 500;
padding-bottom: 4px;
border-bottom: 1px solid var(--divider-color);
}
.choice, .row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; }
.pool { display: flex; flex-direction: column; gap: 4px; padding-left: 26px; }
.field { display: flex; align-items: center; gap: 8px; justify-content: space-between; font-size: 14px; }
.grow { flex: 1; min-width: 0; }
select, input[type="number"] {
font: inherit;
padding: 4px 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
max-width: 100%;
}
input[type="number"] { width: 72px; }
select.person { margin-left: 26px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 16px; }
`,n([g({attribute:!1})],i.prototype,"hass",2),n([a()],i.prototype,"_open",2),n([a()],i.prototype,"_mode",2),n([a()],i.prototype,"_count",2),n([a()],i.prototype,"_users",2),n([a()],i.prototype,"_present",2),n([a()],i.prototype,"_assignKind",2),n([a()],i.prototype,"_person",2),n([a()],i.prototype,"_pool",2),n([a()],i.prototype,"_rotation",2),n([a()],i.prototype,"_addText",2),n([a()],i.prototype,"_remove",2),n([a()],i.prototype,"_setWarning",2),n([a()],i.prototype,"_warning",2),n([a()],i.prototype,"_setPriority",2),n([a()],i.prototype,"_priority",2),n([a()],i.prototype,"_setMute",2),n([a()],i.prototype,"_mute",2);customElements.get("maintenance-bulk-edit-dialog")||customElements.define("maintenance-bulk-edit-dialog",i);export{i as MaintenanceBulkEditDialog};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
var E={\u00DF:"s","\u1E9E":"s",\u00E6:"a",\u0153:"o",\u00F8:"o",\u0111:"d",\u00F0:"d",\u00FE:"t",\u0142:"l",\u0131:"i"},d=10,R=7,g=4,h=2,S=5,x=3,_=/[a-z0-9]+/g;function u(r){let e="";for(let t of r.toLowerCase()){if(t.charCodeAt(0)<128&&t.length===1){e+=t;continue}let n=E[t];if(n){e+=n;continue}let o=t.normalize("NFKD")[0]||t;e+=o.charCodeAt(0)<128?o:t}return e}function m(r){return u(r).match(_)||[]}function C(r){return u(r).replace(/[^a-z0-9]+/g,"")}function b(r){return r.replace(/ue/g,"u").replace(/oe/g,"o").replace(/ae/g,"a").replace(/ss/g,"s")}function F(r){let e=[];for(let t of m(r)){let n=[t],o=b(t);o&&o!==t&&n.push(o),e.push(n)}return e}function p(r,e){if(r===e)return!0;let t=r.length,n=e.length;if(Math.abs(t-n)>1)return!1;if(t===n){let l=[];for(let i=0;i<t;i++)r[i]!==e[i]&&l.push(i);if(l.length===1)return!0;if(l.length===2&&l[1]===l[0]+1){let i=l[0];return r[i]===e[i+1]&&r[i+1]===e[i]}return!1}let[o,s]=t>n?[r,e]:[e,r],c=0,f=0,a=!1;for(;c<o.length&&f<s.length;)if(o[c]===s[f])c++,f++;else{if(a)return!1;a=!0,c++}return!0}function N(r,e){let t=0;for(let n of r)if(n){if(e===n)return d;if(e.startsWith(n)){t=Math.max(t,R);continue}if(n.length>=x&&e.includes(n)){t=Math.max(t,g);continue}n.length>=S&&t<h&&(p(n,e)||e.length>n.length&&p(n,e.slice(0,n.length)))&&(t=h)}return t}function I(r,e){let t=0;for(let n of m(e))if(t=Math.max(t,N(r,n)),t===d)return t;if(t<g){let n=C(e);for(let o of r)if(o.length>=x&&n.includes(o))return g}return t}function O(r,e){if(!r)return!1;let t=F(e);if(!t.length){let n=u(e.trim());return n.length>0&&u(r).includes(n)}return M(t,[{text:r,weight:1}])>0}function M(r,e){if(!r.length)return 0;let t=e.filter(o=>o.text),n=0;for(let o of r){let s=0;for(let c of t){let f=I(o,c.text)*c.weight;f>s&&(s=f)}if(s===0)return 0;n+=s}return n}export{F as a,O as b,M as c};
@@ -0,0 +1,37 @@
/*! maintenance_supporter frontend 2.98.0 */
import{$ as w,A as k,K as R,M as x,a as m,b as h,c as g,f as v,h as b,l as y,m as _,s as l}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";function E(i){return`${i.entry_id??""}\0${i.part_id}`}function S(i,t,e,o){let n=!!i.entry_id&&i.entry_id!==t,r=n?i.entry_id:t,s=e.find(p=>p.entry_id===r),a=(s?.parts||[]).find(p=>p.id===i.part_id)||null,c=n&&s?.object?.name||"",u=a?.name||l("shared_part_unknown",o);return{part:a,foreign:n,ownerName:c,label:c?`${u} (${c})`:u}}function N(i,t,e,o){let{part:n,label:r}=S(i,t,e,o),s=n&&n.stock!==null&&n.stock!==void 0?` (${x(n.stock,n.unit,o)})`:"",a=n?.storage_location?` \u2014 ${n.storage_location}`:"";return`${R(i.quantity,o)}\xD7 ${r}${s}${a}`}function V(i,t,e,o){let r=(e.find(a=>a.entry_id===t)?.parts||[]).map(a=>({...a})),s=new Set(r.map(a=>E({part_id:a.id})));for(let a of i?.consumes_parts||[]){if(!a.entry_id||a.entry_id===t)continue;let c=E(a);if(s.has(c))continue;s.add(c);let{part:u,ownerName:p}=S(a,t,e,o);r.push({id:a.part_id,name:u?.name||l("shared_part_unknown",o),unit:u?.unit,stock:u?.stock??null,storage_location:u?.storage_location,entry_id:a.entry_id,owner_name:p})}return r}var L=20;function T(){let i=new Uint8Array(4);return(globalThis.crypto??{getRandomValues:t=>t.map(()=>Math.floor(Math.random()*256))}).getRandomValues(i),Array.from(i,t=>t.toString(16).padStart(2,"0")).join("")}function f(i){let t=i?.reading_values;if(!Array.isArray(t))return[];let e=[],o=new Set;for(let n of t){if(!n||typeof n!="object")continue;let r=n;typeof r.id!="string"||!r.id||o.has(r.id)||typeof r.value!="number"||!Number.isFinite(r.value)||(o.add(r.id),e.push({id:r.id,name:typeof r.name=="string"?r.name:r.id,unit:typeof r.unit=="string"?r.unit:null,value:r.value}))}return e}function P(i){return i.filter(t=>t.type==="completed"&&(t.reading_value!=null||f(t).length>0)).sort((t,e)=>t.timestamp.localeCompare(e.timestamp))}function M(i){return P(i??[]).map(t=>({timestamp:t.timestamp,values:f(t)})).filter(t=>t.values.length>0)}function A(i,t,e){let o;for(let n of i){if(e!==void 0){let s=k(n.timestamp);if(!isNaN(s)&&s>=e)break}let r=n.values.find(s=>s.id===t);r&&(o=r)}return o}function C(i,t,e){let o=f(t).find(r=>r.id===e);if(!o)return null;let n=null;for(let r of P(i)){if(r.timestamp>=t.timestamp)break;let s=f(r).find(a=>a.id===e);s&&(n=s.value)}return n==null?null:o.value-n}function D(i){let t=new Set,e=new Set;for(let o of i){let n=(o.name||"").trim().toLowerCase();n&&(t.has(n)?e.add(o.id):t.add(n))}return e}function F(i){let t=[],e=new Set,o=new Set;for(let n of i){let r=(n.name||"").trim();if(!(!r||e.has(n.id)||o.has(r.toLowerCase()))&&(e.add(n.id),o.add(r.toLowerCase()),t.push({id:n.id,name:r,unit:(n.unit||"").trim()||null}),t.length>=L))break}return t}function j(i){let t=(i??"").trim();if(t==="")return null;let e=Number(t.replace(",","."));return!Number.isFinite(e)||e<0?null:Math.round(e)}var B=["notes","cost","duration","photo","user"],I={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"};var d=class extends b{constructor(){super(...arguments);this.value="";this.lang="en";this._credit=!1;this._amount=""}willUpdate(e){if(!e.has("value"))return;let o=(this.value??"").trim();if(o===this._signed())return;let n=parseFloat(o);if(o===""||Number.isNaN(n)){this._amount="",this._credit=!1;return}this._credit=n<0,this._amount=String(Math.abs(n))}_signed(){let e=this._amount.trim();return e===""?"":this._credit?`-${e}`:e}_emit(){this.value=this._signed(),this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:this.value},bubbles:!0,composed:!0}))}_setCredit(e){this._credit!==e&&(this._credit=e,this._emit())}_onInput(e){let o=e.target.value;o.startsWith("-")&&(this._credit=!0),this._amount=o.replace(/^-+/,""),this._emit()}render(){let e=this.lang;return g`
<div class="wrap">
<div class="sign" role="group">
<button type="button" class="kind cost ${this._credit?"":"on"}" aria-pressed=${String(!this._credit)}
@click=${()=>this._setCredit(!1)}>${l("cost",e)}</button>
<button type="button" class="kind credit ${this._credit?"on":""}" aria-pressed=${String(this._credit)}
@click=${()=>this._setCredit(!0)}>${l("cost_kind_credit",e)}</button>
</div>
<input type="number" step="0.01" min="0" inputmode="decimal" class="field-input amount"
.value=${this._amount} @input=${this._onInput} />
</div>
${this._credit?g`<div class="hint">${l("cost_credit_hint",e)}</div>`:v}
`}};d.styles=[w,h`
:host { display: block; }
.wrap { display: flex; gap: 8px; align-items: stretch; }
.sign {
display: inline-flex;
flex: none;
border: 1px solid var(--divider-color);
border-radius: 6px;
overflow: hidden;
}
.kind {
font: inherit;
font-size: 13px;
padding: 0 10px;
border: none;
background: transparent;
color: var(--secondary-text-color);
cursor: pointer;
}
.kind.on { background: var(--primary-color); color: var(--text-primary-color, #fff); }
.kind.credit.on { background: var(--success-color, #43a047); }
.amount { flex: 1; min-width: 0; }
.hint { margin-top: 4px; font-size: 12px; color: var(--secondary-text-color); }
`],m([y()],d.prototype,"value",2),m([y()],d.prototype,"lang",2),m([_()],d.prototype,"_credit",2),m([_()],d.prototype,"_amount",2);customElements.get("ms-cost-input")||customElements.define("ms-cost-input",d);export{E as a,N as b,V as c,L as d,T as e,f,M as g,A as h,C as i,D as j,F as k,j as l,B as m,I as n};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
import{s as a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var _=[{key:"name",labelKey:"name",required:!0},{key:"ref_no",labelKey:"ref_number"},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],u=_.map(e=>e.key),i=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function g(e){if(!Array.isArray(e))return[...i];let n=new Set,r=[];for(let t of e)typeof t=="string"&&u.includes(t)&&!n.has(t)&&(n.add(t),r.push(t));return r.length?(r.includes("name")||r.unshift("name"),r):[...i]}function f(e,n){return a(`home_dwelling_${e==="house"||e==="apartment"?e:"unknown"}`,n)}function m(e,n){if(!e)return"";try{return new Intl.DisplayNames([n],{type:"region"}).of(e)||e}catch{return e}}var l={floors:"home_reason_floors",basement_floor:"home_reason_basement",area_basement:"home_reason_basement",area_garden:"home_reason_garden",area_shed:"home_reason_shed",area_driveway:"home_reason_driveway",area_garage:"home_reason_garage",entity_garage:"home_reason_garage",area_attic:"home_reason_attic",entity_lawn_mower:"home_reason_lawn_mower",entity_gate:"home_reason_gate",area_balcony:"home_reason_balcony",small_home:"home_reason_small_home"},c={ups:"home_reason_ups"};function d(e,n){let r=[];for(let t of e){let o=l[t];if(!o)continue;let s=a(o,n);r.includes(s)||r.push(s)}return r.join(", ")}function b(e,n,r){if(e==="starter")return a("home_reason_starter",n);if(e==="country")return a("home_reason_country",n).replace("{country}",m(r,n));if(e.startsWith("feature_")){let t=e.slice(8),o=l[`area_${t}`]??c[t];return a("home_reason_feature",n).replace("{feature}",o?a(o,n):e)}return a(`home_trait_${e}`,n)}export{_ as a,i as b,g as c,f as d,m as e,d as f,b as g};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
import{b as s}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OUHDI2FL.js";async function a(o,n,t=300){return(await o.connection.sendMessagePromise({type:"auth/sign_path",path:n,expires:t})).path}async function r(o,n,t=300){return a(o,`/api/maintenance_supporter/document/${n}`,t)}async function p(o,n,t=""){let e=window.open("about:blank","_blank");try{let i=await r(o,n);e&&(e.location.href=new URL(i+t,window.location.origin).href)}catch(i){throw e&&e.close(),i}}async function l(o,n,t){s(await r(o,n,30),t)}function m(o,n=null){let t=URL.createObjectURL(new Blob([o],{type:"text/html"}));n&&!n.closed?n.location.href=t:window.open(t,"_blank"),setTimeout(()=>URL.revokeObjectURL(t),6e4)}function u(){return window.open("about:blank","_blank")}export{a,r as b,p as c,l as d,m as e,u as f};
@@ -0,0 +1,234 @@
/*! maintenance_supporter frontend 2.98.0 */
import{a as q}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-OUHDI2FL.js";import{H as k,a,b as f,c as n,f as u,h as x,l as h,m as c,s as e}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";function d(r){return r.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function _(r){return!r.startsWith("data:image/svg+xml,")&&!r.startsWith("data:image/png;base64,")?"":d(r)}function y(r){return r.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var l=class extends x{constructor(){super(...arguments);this.lang="";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._quickResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}get _dialogLang(){return this.lang||k(this.hass)}openForObject(t,i){this._entryId=t,this._taskId=null,this._objectName=i,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}openForTask(t,i,s,o){this._entryId=t,this._taskId=i,this._objectName=s,this._taskName=o,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null,this._open=!0,this._generate()}async _generate(){let t=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null,this._quickResult=null;try{let i={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(i.task_id=this._taskId);let s=[this.hass.connection.sendMessagePromise({...i,action:"view"})];this._taskId&&(s.push(this.hass.connection.sendMessagePromise({...i,action:"complete"})),await this._hasQuickCompleteDefaults()&&s.push(this.hass.connection.sendMessagePromise({...i,action:"quick_complete"})));let o=await Promise.all(s);if(t!==this._generateSeq)return;this._viewResult=o[0],o.length>1&&(this._completeResult=o[1]),o.length>2&&(this._quickResult=o[2])}catch(i){if(t!==this._generateSeq)return;let s=i?.code,o=i?.message;this._error=s==="no_url"||typeof o=="string"&&o.includes("No Home Assistant URL")?e("qr_error_no_url",this._dialogLang):e("qr_error",this._dialogLang)}finally{t===this._generateSeq&&(this._loading=!1)}}async _hasQuickCompleteDefaults(){try{let i=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object",entry_id:this._entryId})).tasks||[]).find(s=>s.id===this._taskId);return!!i?.quick_complete_defaults&&Object.keys(i.quick_complete_defaults).length>0}catch{return!1}}_setUrlMode(t){this._urlMode!==t&&(this._urlMode=t,this._generate())}_print(){if(!this._viewResult)return;let t=this._viewResult,i=t.label.task_name?`${t.label.object_name} \u2014 ${t.label.task_name}`:t.label.object_name,s=[t.label.manufacturer,t.label.model].filter(Boolean).join(" "),o=window.open("","_blank","width=600,height=500");if(!o)return;let p=this._dialogLang,m=d(i),g=d(s),v=!!this._completeResult,b=!!this._quickResult,w=d(e("qr_action_view",p)),$=d(e("qr_action_complete",p)),R=d(e("qr_action_quick_complete",p));o.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${m}</title>
<style>
/* Printable sheet \u2014 must not inherit the phone's dark theme. The QR images
carry their own white quiet zone and stay scannable either way, but the
labels below are explicit dark greys and would vanish on a WebView's dark
canvas. Same reasoning as helpers/report.ts. */
:root{color-scheme:light}
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
h2{margin:0 0 4px}
.sub{color:#666;font-size:14px;margin-bottom:16px}
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
.qr-col img{width:${b?"170px":v?"200px":"280px"}}
.qr-label{font-size:13px;font-weight:500;color:#333}
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
</style></head><body>
<h2>${m}</h2>
${g?`<div class="sub">${g}</div>`:""}
<div class="qr-row">
<div class="qr-col">
<img src="${_(this._viewResult.svg_data_uri)}" alt="QR Info" />
<div class="qr-label">${w}</div>
</div>
${v?`<div class="qr-col">
<img src="${_(this._completeResult.svg_data_uri)}" alt="QR Complete" />
<div class="qr-label">${$}</div>
</div>`:""}
${b?`<div class="qr-col">
<img src="${_(this._quickResult.svg_data_uri)}" alt="QR Quick-complete" />
<div class="qr-label">${R}</div>
</div>`:""}
</div>
<div class="url">${d(this._viewResult.url)}</div>
<script>setTimeout(()=>window.print(),300)<\/script>
</body></html>`),o.document.close()}_downloadSvg(t,i){let s=decodeURIComponent(t.svg_data_uri.replace("data:image/svg+xml,","")),o=this._taskName?`${this._objectName}-${this._taskName}`:this._objectName;q(s,`qr-${y(o)}-${i}.svg`,"image/svg+xml")}_close(){this._open=!1,this._viewResult=null,this._completeResult=null,this._quickResult=null,this._error="",this._loading=!1}render(){if(!this._open)return n``;let t=this._dialogLang,i=this._taskName?`${e("qr_code",t)}: ${this._objectName} \u2014 ${this._taskName}`:`${e("qr_code",t)}: ${this._objectName}`,s=!!this._viewResult;return n`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${i}</div>
<div class="content">
${this._loading?n`<div class="loading">${e("qr_generating",t)}</div>`:this._error?n`<div class="error">${this._error}</div>`:s?n`
<div class="qr-pair">
<div class="qr-item">
<img
class="qr-image ${this._completeResult?"small":""}"
src="${this._viewResult.svg_data_uri}"
alt="QR Info"
/>
<div class="qr-item-label">${e("qr_action_view",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._viewResult,"info")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
${this._completeResult?n`
<div class="qr-item">
<img
class="qr-image small"
src="${this._completeResult.svg_data_uri}"
alt="QR Complete"
/>
<div class="qr-item-label">${e("qr_action_complete",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._completeResult,"complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
`:u}
${this._quickResult?n`
<div class="qr-item quick">
<img
class="qr-image small"
src="${this._quickResult.svg_data_uri}"
alt="QR Quick-complete"
/>
<div class="qr-item-label">${e("qr_action_quick_complete",t)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._quickResult,"quick-complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${e("qr_download",t)}
</button>
</div>
`:u}
</div>
<div class="url-display">${this._viewResult.url}</div>
`:u}
<div class="action-row">
<label>${e("qr_url_mode",t)}</label>
<div class="action-toggle">
<button class="toggle-btn ${this._urlMode==="companion"?"active":""}"
@click=${()=>this._setUrlMode("companion")}>${e("qr_mode_companion",t)}</button>
<button class="toggle-btn ${this._urlMode==="local"?"active":""}"
@click=${()=>this._setUrlMode("local")}>${e("qr_mode_local",t)}</button>
<button class="toggle-btn ${this._urlMode==="server"?"active":""}"
@click=${()=>this._setUrlMode("server")}>${e("qr_mode_server",t)}</button>
</div>
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${e("cancel",t)}
</ha-button>
<ha-button
@click=${this._print}
.disabled=${!s}
>
${e("qr_print",t)}
</ha-button>
</div>
</ha-dialog>
`}};l.styles=f`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.qr-pair {
display: flex;
flex-wrap: wrap; /* three codes (quick-complete, #192) wrap on a phone */
gap: 20px;
justify-content: center;
width: 100%;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.qr-image {
width: 240px;
height: 240px;
image-rendering: pixelated;
}
.qr-image.small {
width: 180px;
height: 180px;
}
.qr-item-label {
max-width: 180px;
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
text-align: center;
}
.dl-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-size: 13px;
color: var(--primary-text-color);
padding: 6px 14px;
border-radius: 18px;
transition: background 0.2s, border-color 0.2s;
}
.dl-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
border-color: var(--primary-color);
}
.dl-btn ha-icon {
--mdc-icon-size: 18px;
}
.url-display {
font-size: 11px;
color: var(--secondary-text-color);
word-break: break-all;
text-align: center;
max-width: 400px;
}
.loading {
padding: 40px 0;
color: var(--secondary-text-color);
}
.error {
padding: 20px 0;
color: var(--error-color, #f44336);
}
.action-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.action-row label {
font-size: 13px;
color: var(--secondary-text-color);
}
.action-toggle {
display: flex;
gap: 4px;
background: var(--divider-color, #e0e0e0);
border-radius: 6px;
padding: 3px;
}
.toggle-btn {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--primary-text-color);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s;
line-height: 1.3;
}
.toggle-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-btn.active {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
`,a([h({attribute:!1})],l.prototype,"hass",2),a([h()],l.prototype,"lang",2),a([c()],l.prototype,"_open",2),a([c()],l.prototype,"_loading",2),a([c()],l.prototype,"_error",2),a([c()],l.prototype,"_viewResult",2),a([c()],l.prototype,"_completeResult",2),a([c()],l.prototype,"_quickResult",2),a([c()],l.prototype,"_urlMode",2);customElements.get("maintenance-qr-dialog")||customElements.define("maintenance-qr-dialog",l);export{l as a};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
function r(t){return String(t??"").replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n])}export{r as a};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
import{c as r,f as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var a=["#c62828","#ad1457","#6a1b9a","#4527a0","#283593","#1565c0","#00838f","#2e7d32","#558b2f","#ef6c00","#6d4c41","#546e7a"];function u(e){let s=(e||"").split(/\s+/).filter(Boolean);return s.length===0?"?":s.length===1?s[0][0].toUpperCase():(s[0][0]+s[s.length-1][0]).toUpperCase()}function c(e){let s=0;for(let n of e)s=s*31+n.charCodeAt(0)>>>0;return a[s%a.length]}function o(e){return e?{id:e.id,name:e.name,initials:e.initials||u(e.name),color:e.color||c(e.id)}:null}function h(e){return e?r`<span class="person-avatar" style="--person-color: ${e.color}" title=${e.name}>${e.initials}</span>`:i}function m(e,s=""){return e?r`<span class="person-chip ${s}" title=${e.name}>${h(e)}<span class="person-name">${e.name}</span></span>`:i}var l=class{constructor(s){this.usersCache=null;this.cacheTimestamp=0;this.CACHE_TTL_MS=6e4;this.hass=s}updateHass(s){this.hass=s}async getUsers(s=!1){let n=Date.now();if(!s&&this.usersCache&&n-this.cacheTimestamp<this.CACHE_TTL_MS)return this.usersCache;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/users/list"});return this.usersCache=t.users,this.cacheTimestamp=n,this.usersCache}catch(t){return console.error("Failed to fetch users:",t),this.usersCache||[]}}async assignUser(s,n,t){await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/assign_user",entry_id:s,task_id:n,user_id:t})}async getTasksByUser(s){return(await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/tasks/by_user",user_id:s})).tasks}getUserName(s){return!s||!this.usersCache?null:this.usersCache.find(t=>t.id===s)?.name||null}getPerson(s){return o(this.getUser(s))}getUser(s){return!s||!this.usersCache?null:this.usersCache.find(n=>n.id===s)||null}getCurrentUserId(){return this.hass.user?.id||null}isCurrentUser(s){return s?s===this.getCurrentUserId():!1}clearCache(){this.usersCache=null,this.cacheTimestamp=0}};export{a,o as b,h as c,m as d,l as e};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
import{O as n,s as o}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var l={"Completed from dashboard button":"hist_note_button","Completed from the To-do list":"hist_note_todo","Completed by voice":"hist_note_voice","Completed from the notification":"hist_note_notification","Completed via NFC tag":"hist_note_nfc","Completed from the shopping list":"hist_note_shopping_list","Completed from a mirrored to-do list":"hist_note_todo_mirror","Skipped from notification":"hist_note_skipped_notification","Skipped from dashboard button":"hist_note_skipped_button","Sensor trigger activated":"hist_note_sensor_triggered","Initial value set during task creation":"hist_note_initial"},m=[[/^Auto-completed: sensor recovered \((.+)\)$/,"hist_note_auto_recovered",t=>({value:t[1]})],[/^Reset to (\d{4}-\d{2}-\d{2})$/,"hist_note_reset",(t,e)=>({date:n(t[1],e)})],[/^Trigger entity replaced: (.+) → (.+)$/,"hist_note_trigger_replaced",t=>({old:t[1],new:t[2]})],[/^Sensor trigger removed \(entity was: (.+)\)\. Schedule converted to (\w+)\.$/,"hist_note_trigger_removed",(t,e)=>({entity:t[1],schedule:o(t[2],e)})],[/^Entity (.+) removed from compound trigger; (\d+) conditions remain\.$/,"hist_note_compound_removed",t=>({entity:t[1],count:t[2]})]];function h(t,e){if(!t)return"";let i=l[t.trim()];if(i)return o(i,e);for(let[s,d,_]of m){let r=t.trim().match(s);if(r){let c=_(r,e);return o(d,e).replace(/\{(\w+)\}/g,(a,g)=>c[g]??a)}}return t}export{h as a};
@@ -0,0 +1,79 @@
/*! maintenance_supporter frontend 2.98.0 */
import{a as r,b as l,c as i,f as o,h as n,l as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var e=class extends n{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(s){let a=s.target.value;this.value=a,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:a}}))}render(){return i`
<label class="field">
${this.label?i`<span class="label">${this.label}${this.required?i`<span class="req">*</span>`:o}</span>`:o}
${this.multiline?i`
<textarea
.value=${this.value??""}
rows=${this.rows}
?required=${this.required}
?disabled=${this.disabled}
placeholder=${this.placeholder}
@input=${this._onInput}
@change=${this._onInput}
></textarea>`:i`
<input
.value=${this.value??""}
.type=${this.type}
?required=${this.required}
?disabled=${this.disabled}
placeholder=${this.placeholder}
step=${this.step??o}
min=${this.min??o}
max=${this.max??o}
pattern=${this.pattern??o}
@input=${this._onInput}
@change=${this._onInput}
/>`}
${this.helper?i`<span class="helper">${this.helper}</span>`:o}
</label>
`}};e.styles=l`
:host { display: block; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
color: var(--secondary-text-color, #888);
font-weight: 500;
}
.req { color: var(--error-color, #f44336); margin-left: 2px; }
input {
padding: 8px 10px;
font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
border-radius: 6px;
font-family: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
input:focus {
border-color: var(--primary-color);
}
input:disabled { opacity: 0.5; cursor: not-allowed; }
textarea {
padding: 8px 10px;
font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
border-radius: 6px;
font-family: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
resize: vertical;
}
textarea:focus { border-color: var(--primary-color); }
textarea:disabled { opacity: 0.5; cursor: not-allowed; }
.helper {
font-size: 11px;
color: var(--secondary-text-color);
font-style: italic;
}
`,r([t()],e.prototype,"label",2),r([t()],e.prototype,"value",2),r([t()],e.prototype,"placeholder",2),r([t()],e.prototype,"type",2),r([t({type:Boolean})],e.prototype,"required",2),r([t({type:Boolean})],e.prototype,"disabled",2),r([t()],e.prototype,"step",2),r([t()],e.prototype,"min",2),r([t()],e.prototype,"max",2),r([t()],e.prototype,"pattern",2),r([t()],e.prototype,"helper",2),r([t({type:Boolean})],e.prototype,"multiline",2),r([t({type:Number})],e.prototype,"rows",2);customElements.get("ms-textfield")||customElements.define("ms-textfield",e);
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
var r={objectSections:"msp-object-sections",printOptions:"msp-print-options",batteryRosterOpen:"msp-bf-roster-open",docSort:"msp-doc-sort",cameraLabel:"msp-camera-label",overviewTab:"msp-overview-tab",collapsedSections:"msp-collapsed-sections",chartRange:"msp-chart-range",chartHideOutliers:"msp-chart-hide-outliers",taskSort:"maintenance_supporter_sort",objectSort:"maintenance_supporter_object_sort",groupBy:"maintenance_supporter_groupby",objectView:"maintenance_supporter_object_view",objectsCache:"msp-objects-cache",gettingStartedDismissed:"msp-gs-dismissed",batteryRosterSort:"ms_bf_roster_sort",areaSort:"msp-area-sort"};function s(t){try{return localStorage.getItem(t)}catch{return null}}function o(t,e){try{localStorage.setItem(t,e)}catch{}}export{r as a,s as b,o as c};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
import{b as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-NEWWPCTD.js";import{a as l}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-QFIANZWP.js";var a={operatorWriteEnabled:!1,operatorIds:[]};function m(e,t=a){return e?e.is_admin?!0:t.operatorWriteEnabled&&t.operatorIds.includes(e.id):!1}var _=["buttons_compact","buttons","icons"];function p(e){let[t,n]=l.default_warning_days,s=e?.general?.default_warning_days,r=e?.general?.row_action_style;return{features:{...o.features,...e?.features??{}},defaultWarningDays:typeof s=="number"&&Number.isFinite(s)&&s>=t&&s<=n?s:o.defaultWarningDays,rowActionStyle:_.includes(r??"")?r:o.rowActionStyle,access:{operatorWriteEnabled:e?.operator_write_enabled===!0,operatorIds:Array.isArray(e?.admin_panel_user_ids)?e.admin_panel_user_ids.filter(d=>typeof d=="string"):[]},budget:e?.budget??null,rowActionNoticePending:e?.general?.row_action_notice_pending===!0,refsInLists:e?.general?.ref_numbers_in_lists===!0,objectsTableColumns:Array.isArray(e?.objects_table_columns)?e.objects_table_columns:void 0,partsCostMode:i(e?.budget?.parts_cost_mode)}}var o={features:{adaptive:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1},defaultWarningDays:7,rowActionStyle:"buttons_compact",access:a,budget:null,rowActionNoticePending:!1,refsInLists:!1,objectsTableColumns:void 0,partsCostMode:"purchase"};function c(){let e=window;return e.__msSettingsCache??={promise:null}}function S(e){let t=c();if(t.promise)return t.promise;let n=e.connection.sendMessagePromise({type:"maintenance_supporter/settings"}).then(s=>p(s)).catch(()=>(t.promise===n&&(t.promise=null),o));return t.promise=n,n}function y(){c().promise=null}var u=class{constructor(){this._handle=null}schedule(t,n=4e3){this.clear(),this._handle=setTimeout(()=>{this._handle=null,t()},n)}clear(){this._handle!==null&&(clearTimeout(this._handle),this._handle=null)}get pending(){return this._handle!==null}};export{a,m as b,_ as c,p as d,S as e,y as f,u as g};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
function c(n){return n==="use"?"use":"purchase"}var s=n=>typeof n=="number"&&Number.isFinite(n)?n:null;function a(n){let t=s(n?.cost);if(t===null)return null;let o=s(n?.package_size);return o&&o>0?t/o:t}function p(n,t){let o=s(t)&&t>0?t:s(n.restock_quantity)||1,e=s(n.package_size);return Math.round(o*(e&&e>0?e:1)*100)/100}function l(n){let t=s(n.cost);if(n.cost_basis!=="use")return t;let o=s(n.parts_cost),e=n.purchase?null:t;return e===null&&o===null?null:(e??0)+(o??0)}function _(n){if(!n)return[];let t=[],o=n.photo_doc_id;typeof o=="string"&&t.push(o);let e=n.photo_doc_ids;Array.isArray(e)&&t.push(...e);let r=[];for(let u of t){if(typeof u!="string")continue;let i=u.trim();if(!(!i||r.includes(i))&&(r.push(i),r.length>=10))break}return r}export{_ as a,c as b,a as c,p as d,l as e};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
function c(o,t,e){let l=new Blob([o],{type:e}),d=URL.createObjectURL(l),n=document.createElement("a");n.href=d,n.download=t,n.target="_blank",n.rel="noopener",n.style.display="none",document.body.appendChild(n),n.dispatchEvent(new MouseEvent("click")),document.body.removeChild(n),setTimeout(()=>URL.revokeObjectURL(d),6e4)}function r(o,t){let e=document.createElement("a");e.href=o,e.download=t,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}export{c as a,r as b};
@@ -0,0 +1,217 @@
/*! maintenance_supporter frontend 2.98.0 */
import{a as _}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-MN2RZ73S.js";import{H as v,a as s,b as p,c as l,f as o,h,l as c,m as n,s as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";var r=class extends h{constructor(){super(...arguments);this.objects=[];this._open=!1;this._loading=!1;this._error="";this._name="";this._manufacturer="";this._model="";this._serialNumber="";this._areaId="";this._installationDate="";this._warrantyExpiry="";this._documentationUrl="";this._notes="";this._haDeviceId="";this._parentEntryId="";this._entryId=null;this._replacing=!1;this._oldDeviceId="";this._replaceDevice="keep"}get _lang(){return v(this.hass)}openCreate(){this._entryId=null,this._name="",this._manufacturer="",this._model="",this._serialNumber="",this._areaId="",this._installationDate="",this._warrantyExpiry="",this._documentationUrl="",this._notes="",this._haDeviceId="",this._parentEntryId="",this._replacing=!1,this._error="",this._open=!0}openReplace(e,a){this._entryId=e,this._replacing=!0,this._name=a.name||"",this._oldDeviceId=a.ha_device_id||"",this._haDeviceId="",this._replaceDevice="keep",this._error="",this._open=!0}openEdit(e,a){this._entryId=e,this._name=a.name||"",this._manufacturer=a.manufacturer||"",this._model=a.model||"",this._serialNumber=a.serial_number||"",this._areaId=a.area_id||"",this._installationDate=a.installation_date||"",this._warrantyExpiry=a.warranty_expiry||"",this._documentationUrl=a.documentation_url||"",this._notes=a.notes||"",this._haDeviceId=a.ha_device_id||"",this._parentEntryId=a.parent_entry_id||"",this._replacing=!1,this._error="",this._open=!0}async _replace(){if(this._loading||!this._entryId)return;let e={type:"maintenance_supporter/object/replace",entry_id:this._entryId,name:this._name.trim()||null};this._oldDeviceId?(this._replaceDevice==="other"&&(e.ha_device_id=this._haDeviceId),this._replaceDevice==="none"&&(e.ha_device_id=null)):this._haDeviceId&&(e.ha_device_id=this._haDeviceId);let a=await _(this,e,{busy:t=>{this._loading=t},fallbackKey:"save_error",onError:t=>{this._error=t}});a!==void 0&&(this._open=!1,this.dispatchEvent(new CustomEvent("object-replaced",{detail:{entry_id:a?.entry_id,device_swap:a?.device_swap}})))}_deviceName(e){let t=this.hass?.devices?.[e];return t?.name_by_user||t?.name||e}_deviceSelector(e){return l`<ha-form
.hass=${this.hass}
.data=${{device:this._haDeviceId||void 0}}
.schema=${[{name:"device",selector:{device:{}}}]}
.computeLabel=${()=>e}
@value-changed=${a=>this._haDeviceId=a.detail.value?.device||""}
></ha-form>`}_renderReplace(){let e=this._lang,a=(d,u)=>l`<label class="radio-row">
<input
type="radio"
name="replace-device"
.checked=${this._replaceDevice===d}
@change=${()=>{this._replaceDevice=d}}
/>
<span>${u}</span>
</label>`,t=this._replaceDevice==="other"&&!this._haDeviceId;return l`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${i("replace_object",e)}</div>
<div class="content">
${this._error?l`<div class="error">${this._error}</div>`:o}
<div class="hint">${i("replace_object_prompt",e)}</div>
<ms-textfield
label="${i("replace_name_label",e)}"
.value=${this._name}
@input=${d=>this._name=d.target.value}
></ms-textfield>
${this._oldDeviceId?l`<div class="radio-group" role="radiogroup" aria-label=${i("replace_device_heading",e)}>
<div class="textarea-label">${i("replace_device_heading",e)}</div>
${a("keep",i("replace_device_keep",e).replace("{device}",this._deviceName(this._oldDeviceId)))}
${this._replaceDevice==="keep"?l`<div class="hint">${i("replace_device_keep_hint",e)}</div>`:o}
${a("other",i("replace_device_other",e))}
${this._replaceDevice==="other"?l`${this._deviceSelector(i("replace_device_pick",e))}
<div class="hint">${i("replace_device_other_hint",e)}</div>`:o}
${a("none",i("replace_device_none",e))}
</div>`:this._deviceSelector(i("link_device_optional",e))}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${i("cancel",e)}
</ha-button>
<ha-button @click=${this._replace} .disabled=${this._loading||t}>
${this._loading?i("saving",e):i("replace_object",e)}
</ha-button>
</div>
</ha-dialog>
`}async _save(){if(this._loading||!this._name.trim())return;this._error="";let e={name:this._name,manufacturer:this._manufacturer||null,model:this._model||null,serial_number:this._serialNumber||null,area_id:this._areaId||null,installation_date:this._installationDate||null,warranty_expiry:this._warrantyExpiry||null,documentation_url:this._documentationUrl.trim()||null,notes:this._notes.trim()||null,ha_device_id:this._haDeviceId||null,parent_entry_id:this._parentEntryId||null},a=await _(this,this._entryId?{type:"maintenance_supporter/object/update",entry_id:this._entryId,...e}:{type:"maintenance_supporter/object/create",...e},{busy:t=>{this._loading=t},fallbackKey:"save_error",onError:t=>{this._error=t}});a!==void 0&&(this._open=!1,this.dispatchEvent(new CustomEvent("object-saved",{detail:{device_swap:a?.device_swap}})))}_parentChoices(){return(this.objects||[]).filter(e=>e.entry_id!==this._entryId)}_close(){this._open=!1}render(){if(!this._open)return l``;if(this._replacing)return this._renderReplace();let e=this._lang,a=this._entryId?i("edit_object",e):i("new_object",e);return l`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${a}</div>
<div class="content">
${this._error?l`<div class="error">${this._error}</div>`:o}
<ms-textfield
label="${i("name",e)}"
required
.value=${this._name}
@input=${t=>this._name=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("manufacturer_optional",e)}"
.value=${this._manufacturer}
@input=${t=>this._manufacturer=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("model_optional",e)}"
.value=${this._model}
@input=${t=>this._model=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("serial_number_optional",e)}"
.value=${this._serialNumber}
@input=${t=>this._serialNumber=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("documentation_url_optional",e)}"
type="url"
.value=${this._documentationUrl}
@input=${t=>this._documentationUrl=t.target.value}
></ms-textfield>
<ha-area-picker
.hass=${this.hass}
label="${i("area_id_optional",e)}"
.value=${this._areaId}
@value-changed=${t=>this._areaId=t.detail.value||""}
></ha-area-picker>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${e}
label="${i("installation_date_optional",e)}"
.value=${this._installationDate}
@value-changed=${t=>this._installationDate=t.detail.value}
></ms-date-field>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${e}
label="${i("warranty_expiry_optional",e)}"
.value=${this._warrantyExpiry}
@value-changed=${t=>this._warrantyExpiry=t.detail.value}
></ms-date-field>
<ha-form
.hass=${this.hass}
.data=${{device:this._haDeviceId||void 0}}
.schema=${[{name:"device",selector:{device:{}}}]}
.computeLabel=${()=>i("link_device_optional",e)}
@value-changed=${t=>this._haDeviceId=t.detail.value?.device||""}
></ha-form>
${this._parentChoices().length?l`<label class="textarea-field">
<span class="textarea-label">${i("parent_object_optional",e)}</span>
<select
class="parent-select"
.value=${this._parentEntryId}
@change=${t=>this._parentEntryId=t.target.value}
>
<option value="" ?selected=${!this._parentEntryId}>
${i("parent_none",e)}
</option>
${this._parentChoices().map(t=>l`<option
value=${t.entry_id}
?selected=${this._parentEntryId===t.entry_id}
>${t.object.name}</option>`)}
</select>
</label>`:o}
<label class="textarea-field">
<span class="textarea-label">${i("object_notes_optional",e)}</span>
<textarea
rows="3"
.value=${this._notes}
@input=${t=>this._notes=t.target.value}
></textarea>
<span class="md-hint">${i("notes_markdown_hint",e)}</span>
</label>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${i("cancel",this._lang)}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${this._loading||!this._name.trim()}
>
${this._loading?i("saving",this._lang):i("save",this._lang)}
</ha-button>
</div>
</ha-dialog>
`}};r.styles=p`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
ms-textfield {
display: block;
}
.textarea-field {
display: flex; flex-direction: column; gap: 4px;
}
.textarea-label {
font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500;
}
.textarea-field textarea {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
resize: vertical;
}
.textarea-field textarea:focus {
outline: none; border-color: var(--primary-color);
}
.md-hint {
font-size: 11px; color: var(--secondary-text-color); font-style: italic;
}
.parent-select {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.hint {
font-size: 13px;
color: var(--secondary-text-color);
line-height: 1.4;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.radio-row input {
margin: 0;
}
`,s([c({attribute:!1})],r.prototype,"hass",2),s([c({attribute:!1})],r.prototype,"objects",2),s([n()],r.prototype,"_open",2),s([n()],r.prototype,"_loading",2),s([n()],r.prototype,"_error",2),s([n()],r.prototype,"_name",2),s([n()],r.prototype,"_manufacturer",2),s([n()],r.prototype,"_model",2),s([n()],r.prototype,"_serialNumber",2),s([n()],r.prototype,"_areaId",2),s([n()],r.prototype,"_installationDate",2),s([n()],r.prototype,"_warrantyExpiry",2),s([n()],r.prototype,"_documentationUrl",2),s([n()],r.prototype,"_notes",2),s([n()],r.prototype,"_haDeviceId",2),s([n()],r.prototype,"_parentEntryId",2),s([n()],r.prototype,"_entryId",2),s([n()],r.prototype,"_replacing",2),s([n()],r.prototype,"_oldDeviceId",2),s([n()],r.prototype,"_replaceDevice",2);customElements.get("maintenance-object-dialog")||customElements.define("maintenance-object-dialog",r);export{r as a};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
var n={default_warning_days:[0,365],default_consumable_threshold:[1,90],battery_low_percent:[1,90],battery_recovered_percent:[20,100],archive_oneoff_days:[0,3650],delete_archived_oneoff_days:[0,3650],notify_due_soon_interval_hours:[0,720],notify_overdue_interval_hours:[0,720],notify_triggered_interval_hours:[0,720],max_notifications_per_day:[0,1e3],notification_bundle_threshold:[2,20],snooze_duration_hours:[1,168],warranty_reminder_days:[1,365],budget_alert_threshold:[10,100],currency_decimals:[0,3]};function o(e){let r=n[e];if(!r)throw new Error(`not an int-ranged setting: ${e}`);return r}var t=[0,14],_=[0,1440],u=[1,1e4],a=[0,365],d=15,s=[.1,.9],b=[0,365],A=[.01,1e4],E=[.01,1e5],i=[.1,5];export{n as a,o as b,t as c,_ as d,u as e,a as f,d as g,s as h,b as i,A as j,E as k,i as l};
@@ -0,0 +1,88 @@
/*! maintenance_supporter frontend 2.98.0 */
import{O as m,a,b as y,c as s,f as l,h as g,l as o,m as f,s as d}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-27MZ7YLE.js";function $(n){let t=m("2001-02-03",n).match(/\d+/g)||[],e=v=>t.findIndex(_=>Number(_)===v),h=e(2001),c=e(2),u=e(3);return h===0?"YMD":c>=0&&u>=0&&c<u?"MDY":"DMY"}function p(n,i,t){if(n<1e3||n>9999||i<1||i>12||t<1||t>31)return null;let e=new Date(n,i-1,t);return e.getFullYear()!==n||e.getMonth()!==i-1||e.getDate()!==t?null:`${n}-${String(i).padStart(2,"0")}-${String(t).padStart(2,"0")}`}function b(n,i){let t=n.trim(),e;if(e=t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))return p(+e[1],+e[2],+e[3]);if(e=t.match(/^(\d{4})$/))return p(+e[1],1,1);if(e=t.match(/^(\d{1,2})[./](\d{4})$/))return p(+e[2],+e[1],1);if(e=t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/))return p(+e[3],+e[2],+e[1]);if(e=t.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/)){let h=+e[1],c=+e[2],u=+e[3];return $(i)==="MDY"?p(u,h,c):p(u,c,h)}return null}function x(n,i){if(i)switch(n){case"date":return i.split("T")[0];case"time":return i.length===5?`${i}:00`:i;case"datetime":{let[t,e="00:00:00"]=i.split("T");return`${t} ${e.length===5?`${e}:00`:e}`}}}function k(n,i){if(typeof i!="string"||!i)return"";switch(n){case"date":return i.slice(0,10);case"time":return i.slice(0,5);case"datetime":{let[t,e="00:00:00"]=i.split(" ");return`${t}T${e.length===5?`${e}:00`:e}`}}}var r=class extends g{constructor(){super(...arguments);this.kind="date";this.label="";this.value="";this.clearable=!1;this.disabled=!1;this.required=!1;this.lang="en";this._typing=!1;this._typed="";this._typedInvalid=!1}_startTyping(){this._typed=this.value,this._typedInvalid=!1,this._typing=!0,this.updateComplete.then(()=>this.shadowRoot?.querySelector("input.typed")?.focus())}_commitTyped(){if(!this._typed.trim()){this._typing=!1;return}let t=b(this._typed,this.lang);if(!t){this._typedInvalid=!0;return}this._typing=!1,this._typedInvalid=!1,this._emit(t)}_onTypedKey(t){t.key==="Enter"?(t.preventDefault(),this._commitTyped()):t.key==="Escape"&&(t.preventDefault(),this._typing=!1,this._typedInvalid=!1)}_selector(){switch(this.kind){case"date":return{date:{}};case"time":return{time:{no_second:!0}};case"datetime":return{datetime:{}}}}_onSelectorChange(t){t.stopPropagation(),this._emit(k(this.kind,t.detail?.value))}_clear(){this._emit("")}_emit(t){t!==this.value&&(this.value=t,this.dispatchEvent(new CustomEvent("value-changed",{bubbles:!0,composed:!0,detail:{value:t}})))}render(){return s`
<div class="field">
${this.label?s`<span class="label">${this.label}${this.required?s`<span class="req">*</span>`:l}</span>`:l}
<div class="row">
${this._typing?s`<input class="typed" type="text" inputmode="numeric" autocomplete="off"
.value=${this._typed}
placeholder=${m("1978-03-15",this.lang)}
aria-invalid=${this._typedInvalid?"true":"false"}
@input=${t=>{this._typed=t.target.value,this._typedInvalid=!1}}
@keydown=${this._onTypedKey}
@blur=${this._commitTyped} />`:s`<ha-selector
.hass=${this.hass}
.selector=${this._selector()}
.value=${x(this.kind,this.value)}
.required=${this.required}
.disabled=${this.disabled}
@value-changed=${this._onSelectorChange}
></ha-selector>`}
${this.kind==="date"&&!this.disabled&&!this._typing?s`<button type="button" class="type-toggle" title=${d("date_type_toggle",this.lang)} aria-label=${d("date_type_toggle",this.lang)} @click=${this._startTyping}>
<ha-icon icon="mdi:keyboard-outline"></ha-icon>
</button>`:l}
${this.clearable&&this.value&&!this.disabled&&!this._typing?s`<button type="button" class="clear" title=${d("clear",this.lang)} aria-label=${d("clear",this.lang)} @click=${this._clear}>
<ha-icon icon="mdi:close"></ha-icon>
</button>`:l}
</div>
${this._typedInvalid?s`<span class="helper invalid">${d("date_type_invalid",this.lang)}</span>`:this.helper?s`<span class="helper">${this.helper}</span>`:l}
</div>
`}};r.styles=y`
:host { display: block; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
color: var(--secondary-text-color, #888);
font-weight: 500;
}
.req { color: var(--error-color, #f44336); margin-left: 2px; }
.row {
display: flex;
align-items: center;
gap: 4px;
}
ha-selector {
flex: 1;
min-width: 0;
}
.clear, .type-toggle {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 20px;
}
.clear:hover, .type-toggle:hover { background: rgba(var(--rgb-primary-text-color, 0, 0, 0), 0.08); }
.clear:focus-visible, .type-toggle:focus-visible { outline: 2px solid var(--primary-color); }
.helper {
font-size: 11px;
color: var(--secondary-text-color);
font-style: italic;
}
.helper.invalid { color: var(--error-color, #f44336); font-style: normal; }
input.typed {
flex: 1;
min-width: 0;
height: 40px;
box-sizing: border-box;
padding: 0 12px;
border: 1px solid var(--divider-color, #ccc);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font: inherit;
}
input.typed[aria-invalid="true"] { border-color: var(--error-color, #f44336); }
input.typed:focus { outline: 2px solid var(--primary-color); outline-offset: -1px; }
`,a([o({attribute:!1})],r.prototype,"hass",2),a([o()],r.prototype,"kind",2),a([o()],r.prototype,"label",2),a([o()],r.prototype,"value",2),a([o()],r.prototype,"helper",2),a([o({type:Boolean})],r.prototype,"clearable",2),a([o({type:Boolean})],r.prototype,"disabled",2),a([o({type:Boolean})],r.prototype,"required",2),a([o()],r.prototype,"lang",2),a([f()],r.prototype,"_typing",2),a([f()],r.prototype,"_typed",2),a([f()],r.prototype,"_typedInvalid",2);customElements.get("ms-date-field")||customElements.define("ms-date-field",r);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.98.0 */
async function i(o,n,e){let t=o.auth;if(t?.expired&&t.refreshAccessToken)try{await t.refreshAccessToken()}catch{}return fetch(n,{...e,headers:{...e.headers??{},Authorization:`Bearer ${t?.data?.access_token??""}`}})}var u=500*1024*1024;async function d(o,n,e,t="doc_too_large",s=[]){let r=await i(o,n,{method:"POST",body:e});if(r.status===413)throw new Error(t);if(!r.ok){let a=await r.json().catch(()=>null);throw typeof a?.code=="string"&&s.includes(a.code)?new Error(a.code):new Error("doc_upload_failed")}return await r.json()}async function c(o,n,e,t){let s=new FormData;s.append("entry_id",n);for(let a of t)s.append("tags",a);s.append("file",e,e.name);let r=await d(o,"/api/maintenance_supporter/document/upload",s);if(!r.id)throw new Error("doc_upload_failed");return{id:r.id,deduped:!!r.deduped,duplicate_in_object:r.duplicate_in_object??null}}async function l(o,n,e){return(await c(o,n,e,["photo"])).id}async function m(o,n,e){await Promise.all(e.map(t=>o.connection.sendMessagePromise({type:"maintenance_supporter/documents/discard_upload",entry_id:n,doc_id:t}).catch(()=>{})))}export{u as a,d as b,c,l as d,m as e};

Some files were not shown because too many files have changed in this diff Show More