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
@@ -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",