155 files
This commit is contained in:
+3
-5
@@ -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", () => {
|
||||
|
||||
+9
-7
@@ -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}>
|
||||
|
||||
+15
-5
@@ -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));
|
||||
}
|
||||
|
||||
+6
-4
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user