187 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
2026.8.3
|
||||
2026.9.0
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.8.3", "start_ts": 1788369992.7159514}
|
||||
{"pid": 70, "version": 1, "ha_version": "2026.9.0", "start_ts": 1788457712.680675}
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"id": "53837b51a371459b95ffc0989ce615fb",
|
||||
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749400",
|
||||
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749410",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -196,7 +196,7 @@
|
||||
},
|
||||
{
|
||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374110",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374111",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -315,7 +315,7 @@
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "3ea9e48c0f2140d7b50b8c69d7d0f331",
|
||||
"id": "bdf8a9bb0de54fc99bf6c66ab68ea6ed",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ from .const import (
|
||||
SIGNAL_NEW_OBJECT_ENTRY,
|
||||
SIGNAL_OBJECT_ENTRY_REMOVED,
|
||||
STORES_CACHE_KEY,
|
||||
TriggerType,
|
||||
slugify_object_name,
|
||||
task_unique_id,
|
||||
)
|
||||
from .const import (
|
||||
DOCUMENT_STORE_KEY as _DS_KEY,
|
||||
@@ -393,6 +396,30 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
# Mirrors renderers/progress.ts: the configured limit and the unit the panel
|
||||
# shows next to the live reading, per trigger type. Runtime accumulates HOURS
|
||||
# regardless of the watched entity (a switch has no unit_of_measurement),
|
||||
# state_change counts transitions (no unit), threshold/counter carry the
|
||||
# source entity's unit.
|
||||
def _trigger_target_and_unit(hass: HomeAssistant, tc: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
ttype = tc.get("type")
|
||||
if ttype == TriggerType.RUNTIME:
|
||||
return tc.get("trigger_runtime_hours"), "h"
|
||||
if ttype == TriggerType.STATE_CHANGE:
|
||||
return tc.get("trigger_target_changes"), None
|
||||
if ttype == TriggerType.COMPOUND:
|
||||
return None, None
|
||||
unit: str | None = None
|
||||
if tc.get("entity_id"):
|
||||
st = hass.states.get(tc["entity_id"])
|
||||
if st:
|
||||
unit = st.attributes.get("unit_of_measurement")
|
||||
if ttype == TriggerType.COUNTER:
|
||||
return tc.get("trigger_target_value"), unit
|
||||
above, below = tc.get("trigger_above"), tc.get("trigger_below")
|
||||
return (above if above is not None else below), unit
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Maintenance Supporter integration."""
|
||||
return await _async_setup_shared(hass)
|
||||
@@ -701,6 +728,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
wanted_entry = call.data.get("entry_id")
|
||||
wanted_status = call.data.get("status")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
ent_reg = er.async_get(hass)
|
||||
for ce in hass.config_entries.async_entries(DOMAIN):
|
||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
@@ -711,6 +739,9 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
if coordinator is None or not coordinator.data:
|
||||
continue
|
||||
object_name = aggregate_object_name(ce)
|
||||
# #151 follow-up: the task sensor's REGISTERED entity_id (users
|
||||
# rename them), so a row can be fed straight into complete/skip.
|
||||
object_slug = slugify_object_name((ce.data.get(CONF_OBJECT) or {}).get("name", "unknown"))
|
||||
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
|
||||
status = str(task.get("_status", ""))
|
||||
if status == "archived":
|
||||
@@ -727,15 +758,14 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
except ValueError:
|
||||
days_since = None
|
||||
tc = task.get("trigger_config") or {}
|
||||
trigger_unit: str | None = None
|
||||
if tc.get("entity_id"):
|
||||
st = hass.states.get(tc["entity_id"])
|
||||
if st:
|
||||
trigger_unit = st.attributes.get("unit_of_measurement")
|
||||
trigger_target, trigger_unit = _trigger_target_and_unit(hass, tc)
|
||||
tasks.append(
|
||||
{
|
||||
"entry_id": ce.entry_id,
|
||||
"task_id": task_id,
|
||||
"entity_id": ent_reg.async_get_entity_id(
|
||||
"sensor", DOMAIN, task_unique_id(object_slug, task_id)
|
||||
),
|
||||
"object_name": object_name,
|
||||
"name": task.get("name"),
|
||||
"status": status,
|
||||
@@ -745,7 +775,12 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
"priority": task.get("priority", "normal"),
|
||||
"last_performed": lp,
|
||||
"days_since_last_completed": days_since,
|
||||
"trigger_type": tc.get("type"),
|
||||
"trigger_current_value": task.get("_trigger_current_value"),
|
||||
# Delta-mode counters: progress since the last service
|
||||
# (what the panel bar shows); None for every other trigger.
|
||||
"trigger_current_delta": task.get("_trigger_current_delta"),
|
||||
"trigger_target": trigger_target,
|
||||
"trigger_unit": trigger_unit,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ from .const import (
|
||||
)
|
||||
from .helpers.global_options import get_default_warning_days
|
||||
from .helpers.task_fields import INTERVAL_DAYS_RANGE, WARNING_DAYS_RANGE
|
||||
from .helpers.trigger_fallback import threshold_limits_overlap
|
||||
|
||||
# Domains allowed for trigger entity selection.
|
||||
# Includes all domains from entity_attributes.DOMAIN_ATTRIBUTE_MAP plus
|
||||
@@ -498,10 +499,16 @@ class TriggerConfigMixin:
|
||||
equals = user_input.get(CONF_TRIGGER_EQUALS)
|
||||
not_equals = user_input.get(CONF_TRIGGER_NOT_EQUALS)
|
||||
|
||||
tc = self._current_task["trigger_config"]
|
||||
if above is None and below is None and equals is None and not_equals is None:
|
||||
errors["base"] = "invalid_threshold"
|
||||
elif threshold_limits_overlap(
|
||||
# Blank fields keep the stored value — judge the merged pair (#156).
|
||||
above if above is not None else tc.get(CONF_TRIGGER_ABOVE),
|
||||
below if below is not None else tc.get(CONF_TRIGGER_BELOW),
|
||||
):
|
||||
errors["base"] = "overlapping_threshold"
|
||||
else:
|
||||
tc = self._current_task["trigger_config"]
|
||||
if above is not None:
|
||||
tc[CONF_TRIGGER_ABOVE] = above
|
||||
if below is not None:
|
||||
@@ -967,6 +974,7 @@ class TriggerConfigMixin:
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure a compound condition's type-specific settings."""
|
||||
cond = self._current_compound_condition
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
cancel = await self._mixin_check_go_back(user_input)
|
||||
@@ -976,6 +984,11 @@ class TriggerConfigMixin:
|
||||
if condition_type == TriggerType.THRESHOLD:
|
||||
above = user_input.get(CONF_TRIGGER_ABOVE)
|
||||
below = user_input.get(CONF_TRIGGER_BELOW)
|
||||
if threshold_limits_overlap(above, below):
|
||||
# Store nothing from the refused attempt: a field left
|
||||
# blank on the retry must not inherit its value (#156).
|
||||
errors["base"] = "overlapping_threshold"
|
||||
else:
|
||||
if above is not None:
|
||||
cond["trigger_above"] = above
|
||||
if below is not None:
|
||||
@@ -1010,6 +1023,7 @@ class TriggerConfigMixin:
|
||||
if len(entity_ids) > 1 and user_input.get(CONF_TRIGGER_ENTITY_LOGIC):
|
||||
cond["entity_logic"] = user_input[CONF_TRIGGER_ENTITY_LOGIC]
|
||||
|
||||
if not errors:
|
||||
self._compound_conditions.append(cond)
|
||||
self._current_compound_condition = {}
|
||||
return await on_complete()
|
||||
@@ -1082,6 +1096,7 @@ class TriggerConfigMixin:
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=vol.Schema(self._mixin_add_go_back(schema_fields)),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _trigger_compound_review(
|
||||
|
||||
@@ -114,6 +114,23 @@ export interface CreateMockHassResult {
|
||||
subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a value in an `<ms-date-field>` the way HA's selector would report it
|
||||
* (#163): the wrapper renders `<ha-selector>` (undefined under web-test-runner,
|
||||
* so it is an inert unknown element) and converts the selector's own value
|
||||
* format — "YYYY-MM-DD", "HH:MM:SS", "YYYY-MM-DD HH:MM:SS" — into the value
|
||||
* contract the consumers store. Dispatching on the inner selector exercises
|
||||
* that conversion end-to-end.
|
||||
*/
|
||||
export function pickDateField(field: Element | null, haValue: string | undefined): void {
|
||||
if (!field) throw new Error("pickDateField: no <ms-date-field>");
|
||||
const sel = field.shadowRoot?.querySelector("ha-selector");
|
||||
if (!sel) throw new Error("pickDateField: <ms-date-field> rendered no <ha-selector>");
|
||||
sel.dispatchEvent(new CustomEvent("value-changed", {
|
||||
bubbles: true, composed: true, detail: { value: haValue },
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CreateMockHassOptions {
|
||||
/** Override the default settings response (deep-merge not done — pass full shape). */
|
||||
settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE;
|
||||
|
||||
+27
-10
@@ -10,7 +10,7 @@
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/complete-dialog.js";
|
||||
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
import { createMockHass, pickDateField } from "./_test-utils.js";
|
||||
|
||||
type MountOpts = {
|
||||
checklist?: string[];
|
||||
@@ -104,12 +104,28 @@ describe("complete-dialog", () => {
|
||||
expect("completed_at" in msg).to.be.false;
|
||||
});
|
||||
|
||||
/** The optional backdate starts as a "Set date & time" button (#163): the
|
||||
* HA datetime selector has no empty state, so the field only appears once
|
||||
* the user asks for it, seeded with the current minute. */
|
||||
async function openBackdate(el: MaintenanceCompleteDialog) {
|
||||
const pick = el.shadowRoot!.querySelector<HTMLButtonElement>(".backdate-pick")!;
|
||||
expect(pick, "backdate seed button rendered").to.exist;
|
||||
expect(el.shadowRoot!.querySelector("ms-date-field"), "no field before the seed click").to.be.null;
|
||||
pick.click();
|
||||
await el.updateComplete;
|
||||
const field = el.shadowRoot!.querySelector("ms-date-field")!;
|
||||
expect(field, "backdate field rendered after the seed click").to.exist;
|
||||
await (field as unknown as { updateComplete: Promise<unknown> }).updateComplete;
|
||||
return field;
|
||||
}
|
||||
|
||||
it("sends completed_at with seconds re-added when a backdate is picked (#133)", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const dt = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="datetime-local"]')!;
|
||||
expect(dt, "backdate field rendered").to.exist;
|
||||
dt.value = "2026-01-10T14:30";
|
||||
dt.dispatchEvent(new Event("change"));
|
||||
const field = await openBackdate(el);
|
||||
// Seeded with the current minute, in the contract format.
|
||||
expect((field as unknown as { value: string }).value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00$/);
|
||||
// HA's datetime selector speaks "YYYY-MM-DD HH:MM:SS".
|
||||
pickDateField(field, "2026-01-10 14:30:00");
|
||||
await el.updateComplete;
|
||||
|
||||
clickComplete(el);
|
||||
@@ -124,10 +140,9 @@ describe("complete-dialog", () => {
|
||||
const { el, sent } = await mount();
|
||||
const future = new Date(Date.now() + 48 * 3600 * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())}T12:00`;
|
||||
const dt = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="datetime-local"]')!;
|
||||
dt.value = v;
|
||||
dt.dispatchEvent(new Event("change"));
|
||||
const v = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())} 12:00:00`;
|
||||
const field = await openBackdate(el);
|
||||
pickDateField(field, v);
|
||||
await el.updateComplete;
|
||||
|
||||
clickComplete(el);
|
||||
@@ -245,7 +260,9 @@ describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => {
|
||||
c.click();
|
||||
await el.updateComplete;
|
||||
const cost = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][1];
|
||||
expect(cost.value).to.equal("17.00");
|
||||
// Machine value for the <input type="number"> — never profile-formatted
|
||||
// ("17,00" would be rejected by the input under a decimal_comma profile).
|
||||
expect(cost.value).to.equal("17");
|
||||
expect(chip(el), "chip hides once cost is set").to.equal(null);
|
||||
});
|
||||
|
||||
|
||||
+59
-19
@@ -3,31 +3,32 @@
|
||||
* English UI always showed mm/dd/yyyy even with dd/mm/yyyy configured). */
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { formatDate, formatDateTime, setDateTimePrefs } from "../styles";
|
||||
import { formatDate, formatDateTime, formatDateShort, formatTimeOfDay, formatWeekday, formatMonth, weekdayName, monthName, setProfilePrefs } from "../styles";
|
||||
import { fmtDateTick, fmtDateTime } from "../renderers/chart-utils";
|
||||
|
||||
const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined }, null);
|
||||
const reset = () => setProfilePrefs({ date_format: undefined, time_format: undefined, number_format: undefined }, null);
|
||||
|
||||
describe("formatDate with HA profile date_format (#97)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("DMY renders dd/mm/yyyy regardless of language", () => {
|
||||
setDateTimePrefs({ date_format: "DMY" });
|
||||
setProfilePrefs({ date_format: "DMY" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10/08/2026");
|
||||
});
|
||||
|
||||
it("MDY renders mm/dd/yyyy regardless of language", () => {
|
||||
setDateTimePrefs({ date_format: "MDY" });
|
||||
setProfilePrefs({ date_format: "MDY" });
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("08/10/2026");
|
||||
});
|
||||
|
||||
it("YMD renders yyyy-mm-dd", () => {
|
||||
setDateTimePrefs({ date_format: "YMD" });
|
||||
setProfilePrefs({ date_format: "YMD" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
|
||||
});
|
||||
|
||||
it('"language" / unset keeps the language-derived format (backwards compatible)', () => {
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); // en → en-US
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); // de → de-DE
|
||||
reset();
|
||||
@@ -35,7 +36,7 @@ describe("formatDate with HA profile date_format (#97)", () => {
|
||||
});
|
||||
|
||||
it("pl/cs/sv derive their own locale, not en-US (live-check find)", () => {
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
// These three were missing from langToLocale and silently rendered
|
||||
// US-ordered dates. Day must come before month for all of them.
|
||||
expect(formatDate("2026-08-10", "pl")).to.equal("10.08.2026");
|
||||
@@ -45,7 +46,7 @@ describe("formatDate with HA profile date_format (#97)", () => {
|
||||
});
|
||||
|
||||
it("null/invalid input unchanged by prefs", () => {
|
||||
setDateTimePrefs({ date_format: "DMY" });
|
||||
setProfilePrefs({ date_format: "DMY" });
|
||||
expect(formatDate(null, "en")).to.equal("—");
|
||||
});
|
||||
});
|
||||
@@ -54,12 +55,12 @@ describe("formatDateTime with HA profile time_format (#97)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("24 forces 24h clock even for en", () => {
|
||||
setDateTimePrefs({ date_format: "DMY", time_format: "24" });
|
||||
setProfilePrefs({ date_format: "DMY", time_format: "24" });
|
||||
expect(formatDateTime("2026-08-10T14:30:00", "en")).to.equal("10/08/2026 14:30");
|
||||
});
|
||||
|
||||
it("12 forces a 12h clock even for de", () => {
|
||||
setDateTimePrefs({ date_format: "DMY", time_format: "12" });
|
||||
setProfilePrefs({ date_format: "DMY", time_format: "12" });
|
||||
const out = formatDateTime("2026-08-10T14:30:00", "de");
|
||||
expect(out.startsWith("10/08/2026 ")).to.be.true;
|
||||
expect(out).to.match(/2:30/);
|
||||
@@ -71,34 +72,73 @@ describe("server country regionalizes the language default (#140)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it('"en" + AU renders dd/mm/yyyy without any profile setting', () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "AU");
|
||||
setProfilePrefs({ date_format: "language" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
});
|
||||
|
||||
it('"en" + US keeps mm/dd/yyyy; "de" + AT stays dd.mm.yyyy', () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "US");
|
||||
setProfilePrefs({ date_format: "language" }, "US");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "language" }, "AT");
|
||||
setProfilePrefs({ date_format: "language" }, "AT");
|
||||
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026");
|
||||
});
|
||||
|
||||
it("an explicit profile format always beats the country", () => {
|
||||
setDateTimePrefs({ date_format: "MDY" }, "AU");
|
||||
setProfilePrefs({ date_format: "MDY" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "YMD" }, "AU");
|
||||
setProfilePrefs({ date_format: "YMD" }, "AU");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
|
||||
});
|
||||
|
||||
it("garbage/unset country falls back to the language mapping", () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "!!");
|
||||
setProfilePrefs({ date_format: "language" }, "!!");
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
setDateTimePrefs({ date_format: "language" }, null);
|
||||
setProfilePrefs({ date_format: "language" }, null);
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026");
|
||||
});
|
||||
|
||||
it("country survives a later prefs update that omits it (undefined ≠ clear)", () => {
|
||||
setDateTimePrefs({ date_format: "language" }, "AU");
|
||||
setDateTimePrefs({ date_format: "language" });
|
||||
setProfilePrefs({ date_format: "language" }, "AU");
|
||||
setProfilePrefs({ date_format: "language" });
|
||||
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("name-style helpers + chart labels route through styles.ts (#163 DRY)", () => {
|
||||
afterEach(reset);
|
||||
|
||||
it("formatTimeOfDay follows the profile time format", () => {
|
||||
const d = new Date(2026, 7, 10, 14, 30);
|
||||
setProfilePrefs({ time_format: "24" });
|
||||
expect(formatTimeOfDay(d, "en")).to.equal("14:30");
|
||||
setProfilePrefs({ time_format: "12" });
|
||||
expect(formatTimeOfDay(d, "de")).to.match(/2:30/);
|
||||
expect(formatTimeOfDay(d, "de").toLowerCase()).to.match(/pm|nachm/);
|
||||
});
|
||||
|
||||
it("formatDateShort: day+month in the UI language, optional 2-digit year", () => {
|
||||
const d = new Date(2026, 6, 3);
|
||||
expect(formatDateShort(d, "en")).to.equal("Jul 3");
|
||||
expect(formatDateShort(d, "de")).to.equal("3. Juli");
|
||||
expect(formatDateShort(d, "en", true)).to.match(/^Jul 3, ?26$/);
|
||||
});
|
||||
|
||||
it("weekday / month names come from the UI language, indexed Mon=0 / Jan=0", () => {
|
||||
expect(weekdayName(0, "en", "short")).to.equal("Mon");
|
||||
expect(weekdayName(6, "de", "long")).to.equal("Sonntag");
|
||||
expect(monthName(0, "en", "short")).to.equal("Jan");
|
||||
expect(monthName(11, "de", "long")).to.equal("Dezember");
|
||||
const sun = new Date(2026, 6, 5); // 2026-07-05 is a Sunday
|
||||
expect(formatWeekday(sun, "en", "short")).to.equal("Sun");
|
||||
expect(formatMonth(sun, "de", "long")).to.equal("Juli");
|
||||
});
|
||||
|
||||
it("chart crosshair label honours the profile time format (was browser/language-only)", () => {
|
||||
const ts = new Date(2026, 6, 3, 14, 30).getTime();
|
||||
setProfilePrefs({ time_format: "24" });
|
||||
expect(fmtDateTime(ts, "en")).to.equal("Jul 3, 14:30");
|
||||
setProfilePrefs({ time_format: "12" });
|
||||
expect(fmtDateTime(ts, "de")).to.match(/^3\. Juli, .*2:30/);
|
||||
expect(fmtDateTick(ts, "en", false)).to.equal("Jul 3");
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -89,10 +89,10 @@ describe("settings-view vacation section", () => {
|
||||
vacationStart: "2099-06-10",
|
||||
vacationEnd: "2099-06-20",
|
||||
});
|
||||
const dateInputs = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=date]");
|
||||
expect(dateInputs.length, "two date inputs").to.equal(2);
|
||||
expect(dateInputs[0].value).to.equal("2099-06-10");
|
||||
expect(dateInputs[1].value).to.equal("2099-06-20");
|
||||
const dateFields = vacationSection(el)!.querySelectorAll<HTMLElement & { value: string }>(".vac-grid ms-date-field");
|
||||
expect(dateFields.length, "two date fields").to.equal(2);
|
||||
expect(dateFields[0].value).to.equal("2099-06-10");
|
||||
expect(dateFields[1].value).to.equal("2099-06-20");
|
||||
});
|
||||
|
||||
it("shows the active badge when vacation.is_active is true", async () => {
|
||||
|
||||
+11
@@ -60,6 +60,17 @@ describe("storage-section-card", () => {
|
||||
expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist;
|
||||
});
|
||||
|
||||
it("survives a malformed summary (no by_object) without throwing", async () => {
|
||||
// A generic WS stub answering `{}` used to reach Object.entries(undefined)
|
||||
// in render(); the card must hide itself instead of erroring.
|
||||
const bare = await mount({});
|
||||
expect(bare.shadowRoot!.querySelector("ha-card"), "hidden for {}").to.not.exist;
|
||||
const partial = await mount({ ...SUMMARY, by_object: undefined });
|
||||
await expand(partial);
|
||||
expect(partial.shadowRoot!.querySelector("ha-card"), "card renders").to.exist;
|
||||
expect(partial.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(0);
|
||||
});
|
||||
|
||||
it("navigates to an object when its row is clicked", async () => {
|
||||
const el = await mount();
|
||||
await expand(el);
|
||||
|
||||
+7
-1
@@ -150,11 +150,17 @@ describe("task-detail renderer", () => {
|
||||
promptSkip: () => { skipped++; },
|
||||
}));
|
||||
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
|
||||
(buttons[0] as HTMLElement).click(); // Complete (filled)
|
||||
(buttons[0] as HTMLElement).click(); // Complete (accent/success)
|
||||
(buttons[1] as HTMLElement).click(); // Skip
|
||||
expect(completed).to.not.be.null;
|
||||
expect(completed!.name).to.equal("Filter Wechsel");
|
||||
expect(skipped).to.equal(1);
|
||||
// Same pairing as the task rows and the quick-actions dialog (#150
|
||||
// follow-up): filled green Complete, outlined orange Skip.
|
||||
expect(buttons[0].getAttribute("appearance")).to.equal("accent");
|
||||
expect(buttons[0].getAttribute("variant")).to.equal("success");
|
||||
expect(buttons[1].getAttribute("appearance")).to.equal("outlined");
|
||||
expect(buttons[1].getAttribute("variant")).to.equal("warning");
|
||||
});
|
||||
|
||||
it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => {
|
||||
|
||||
+38
@@ -75,6 +75,44 @@ describe("task-dialog live trigger hint", () => {
|
||||
expect(hint(el)).to.equal(null);
|
||||
});
|
||||
|
||||
it("#156: below > above shows the overlap warning and blocks save (the limits are OR-ed)", async () => {
|
||||
const { hass, sent } = createMockHass({
|
||||
states: { "sensor.low_count": { state: "1", attributes: { unit_of_measurement: "batteries" } } },
|
||||
handlers: { "maintenance_supporter/task/create": () => ({ task_id: "new1" }) },
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
el.openCreate("e1", []);
|
||||
await el.updateComplete;
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
await el.updateComplete;
|
||||
(el as any)._name = "Replace batteries";
|
||||
(el as any)._triggerEntityId = "sensor.low_count";
|
||||
(el as any)._triggerEntityIds = ["sensor.low_count"];
|
||||
(el as any)._triggerType = "threshold";
|
||||
(el as any)._triggerAbove = "0";
|
||||
(el as any)._triggerBelow = "5";
|
||||
await el.updateComplete;
|
||||
const warn = el.shadowRoot!.querySelector(".trigger-live-hint.warn");
|
||||
expect(warn, "overlap warning rendered").to.not.equal(null);
|
||||
expect(warn!.textContent).to.include("can never recover");
|
||||
// The regular hint still lists both limits above the warning.
|
||||
expect(hint(el)).to.include("0 batteries");
|
||||
|
||||
await (el as any)._save();
|
||||
expect(sent.some((m) => m.type === "maintenance_supporter/task/create"), "save blocked").to.be.false;
|
||||
expect(el.shadowRoot!.querySelector(".error")?.textContent).to.include("can never recover");
|
||||
|
||||
// A real band (below < above) or equal limits carry no warning.
|
||||
(el as any)._triggerBelow = "0";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null);
|
||||
(el as any)._triggerAbove = "5";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".trigger-live-hint.warn")).to.equal(null);
|
||||
});
|
||||
|
||||
it("editing a delta task uses the since-last-completion wording (baseline is not the current reading)", async () => {
|
||||
const { hass } = createMockHass({
|
||||
states: { "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } } },
|
||||
|
||||
+3
-3
@@ -37,18 +37,18 @@ describe("renderTriggerProgress — delta-mode counter (issue #102)", () => {
|
||||
|
||||
it("uses the exposed delta when present", async () => {
|
||||
const label = await labelOf(row({ trigger_current_delta: 100 }));
|
||||
expect(label).to.contain("100.0 / 15000");
|
||||
expect(label).to.contain("100.0 / 15,000");
|
||||
});
|
||||
|
||||
it("computes the delta from the baseline when only the baseline is exposed", async () => {
|
||||
const label = await labelOf(row({ trigger_baseline_value: 27000 }));
|
||||
expect(label).to.contain("0.0 / 15000");
|
||||
expect(label).to.contain("0.0 / 15,000");
|
||||
});
|
||||
|
||||
it("keeps the raw value for absolute-mode counters", async () => {
|
||||
const label = await labelOf(
|
||||
row({ trigger_config: { type: "counter", trigger_target_value: 30000 } as never }),
|
||||
);
|
||||
expect(label).to.contain("27000.0 / 30000");
|
||||
expect(label).to.contain("27,000.0 / 30,000");
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -12,7 +12,7 @@
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/vacation-section-card.js";
|
||||
import type { MaintenanceVacationSectionCard } from "../components/vacation-section-card";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
import { createMockHass, pickDateField } from "./_test-utils.js";
|
||||
|
||||
const BASE_STATE = {
|
||||
enabled: false,
|
||||
@@ -51,7 +51,7 @@ describe("vacation-section-card", () => {
|
||||
const root = el.shadowRoot!;
|
||||
expect(root.querySelector("ha-card"), "card rendered").to.exist;
|
||||
expect(root.querySelector("ha-switch"), "no enable switch").to.be.null;
|
||||
expect(root.querySelector('input[type="date"]'), "no date inputs").to.be.null;
|
||||
expect(root.querySelector("ms-date-field"), "no date fields").to.be.null;
|
||||
expect(root.querySelector(".actions"), "no action buttons").to.be.null;
|
||||
});
|
||||
|
||||
@@ -71,11 +71,11 @@ describe("vacation-section-card", () => {
|
||||
it("editing dates + Save dispatches start/end/buffer_days", async () => {
|
||||
const { el, sent } = await mount({ isAdmin: true });
|
||||
const root = el.shadowRoot!;
|
||||
const [start, end] = [...root.querySelectorAll<HTMLInputElement>('input[type="date"]')];
|
||||
start.value = "2026-09-01";
|
||||
start.dispatchEvent(new Event("input"));
|
||||
end.value = "2026-09-10";
|
||||
end.dispatchEvent(new Event("input"));
|
||||
const [start, end] = [...root.querySelectorAll("ms-date-field")];
|
||||
expect(end, "two date fields").to.exist;
|
||||
await Promise.all([start, end].map((f) => (f as unknown as { updateComplete: Promise<unknown> }).updateComplete));
|
||||
pickDateField(start, "2026-09-01");
|
||||
pickDateField(end, "2026-09-10");
|
||||
const buffer = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||
buffer.value = "3";
|
||||
buffer.dispatchEvent(new Event("input"));
|
||||
|
||||
+18
-7
@@ -6,9 +6,10 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { px } from "../renderers/chart-utils";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface BatteryRow {
|
||||
@@ -100,6 +101,11 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
// #163: the forecast dates go through formatDate(), which reads the HA
|
||||
// profile date format from a window singleton — feed it here too, so a
|
||||
// stand-alone battery-fleet card (no panel / task card on the page to do
|
||||
// it) still renders DD/MM vs MM/DD the way the profile says.
|
||||
syncLocaleFromHass(this, changed);
|
||||
if (changed.has("hass") && this.hass && !this._localeReady) {
|
||||
this._localeReady = true;
|
||||
ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
@@ -257,9 +263,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
const tMax = Math.max(tLast, projEnd ?? tLast);
|
||||
const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P));
|
||||
const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P);
|
||||
const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const line = h.points.map(([t, v]) => `${px(x(t))},${px(y(v))}`).join(" ");
|
||||
const vLast = h.points[h.points.length - 1][1];
|
||||
const yTh = y(h.threshold).toFixed(1);
|
||||
const yTh = px(y(h.threshold));
|
||||
return html`<svg
|
||||
class="bf-spark"
|
||||
viewBox="0 0 ${W} ${H}"
|
||||
@@ -272,9 +278,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
${projEnd !== null
|
||||
? html`<line
|
||||
class="bf-spark-proj"
|
||||
x1=${x(tLast).toFixed(1)}
|
||||
y1=${y(vLast).toFixed(1)}
|
||||
x2=${x(projEnd).toFixed(1)}
|
||||
x1=${px(x(tLast))}
|
||||
y1=${px(y(vLast))}
|
||||
x2=${px(x(projEnd))}
|
||||
y2=${yTh}
|
||||
></line>`
|
||||
: nothing}
|
||||
@@ -314,8 +320,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
return this._fmtDate(Date.now() + daysUntil * 864e5);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
private _fmtDate(epochMs: number): string {
|
||||
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
|
||||
const d = new Date(epochMs);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang);
|
||||
}
|
||||
|
||||
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
|
||||
|
||||
+5
-4
@@ -8,7 +8,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL, langOf } from "../styles";
|
||||
import { t, syncLocaleFromHass, DEFAULT_CURRENCY_SYMBOL, langOf, formatNumber, formatCost } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
@@ -50,10 +50,11 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
// Standalone card: nothing else feeds the profile number format in (#163).
|
||||
syncLocaleFromHass(this, changedProps);
|
||||
if (changedProps.has("hass") && this.hass && !this._loaded) {
|
||||
this._loaded = true;
|
||||
void this._load();
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
<div class="track spent-only">
|
||||
<div class="track-label-row">
|
||||
<label>${track.label}</label>
|
||||
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
|
||||
<span class="track-numbers ok">${formatCost(track.spent, sym, L, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -159,7 +160,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
<div class="track-label-row">
|
||||
<label>${track.label}</label>
|
||||
<span class="track-numbers ${warn}">
|
||||
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
|
||||
${formatNumber(track.spent, L, 0)} / ${formatCost(track.budget, sym, L, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, TaskPartLink } from "../types";
|
||||
import { t, nativeFieldStyles } from "../styles";
|
||||
import { t, nativeFieldStyles, formatCost } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
|
||||
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-date-field";
|
||||
|
||||
export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -65,7 +66,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@state() private _photoUploading = false;
|
||||
@state() private _readingValue = "";
|
||||
@state() private _restockQty = "";
|
||||
/** #133: optional backdated completion moment (datetime-local value; "" = now). */
|
||||
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = 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. */
|
||||
@@ -289,11 +290,11 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
if (this._cost.trim() !== "") return nothing;
|
||||
const suggestion = this._partsCostSuggestion();
|
||||
if (suggestion == null || suggestion <= 0) return nothing;
|
||||
const amount = `${suggestion.toFixed(2)}${this.currencySymbol ? ` ${this.currencySymbol}` : ""}`;
|
||||
const amount = formatCost(suggestion, this.currencySymbol, L);
|
||||
return html`<button
|
||||
type="button"
|
||||
class="cost-suggestion"
|
||||
@click=${() => (this._cost = suggestion.toFixed(2))}
|
||||
@click=${() => (this._cost = String(Math.round(suggestion * 100) / 100))}
|
||||
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
|
||||
}
|
||||
|
||||
@@ -301,6 +302,13 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
/** Seed the backdate field with the current minute (local, seconds zeroed). */
|
||||
private _pickCompletedAt(): void {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this.lang || this.hass?.language || "en";
|
||||
@@ -409,13 +417,21 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<div class="field">
|
||||
<span class="field-label">${t("completed_at_optional", L)}</span>
|
||||
<input type="datetime-local" class="field-input"
|
||||
max=${new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16)}
|
||||
${this._completedAt
|
||||
? html`<ms-date-field
|
||||
kind="datetime"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._completedAt}
|
||||
@change=${(e: Event) => (this._completedAt = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
@value-changed=${(e: CustomEvent) => (this._completedAt = e.detail.value as string)}
|
||||
></ms-date-field>`
|
||||
: html`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
|
||||
<ha-icon icon="mdi:calendar-clock"></ha-icon>${t("completed_at_pick", L)}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
|
||||
${this._photoPreview
|
||||
@@ -564,6 +580,26 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
width: fit-content;
|
||||
}
|
||||
.photo-pick:hover { border-color: var(--primary-color); }
|
||||
/* #163: the backdate moment starts EMPTY (= now); the button seeds the
|
||||
HA date+time picker with the current minute instead of the picker's
|
||||
own 00:00 default, so a backdated completion never lands at midnight
|
||||
by accident. */
|
||||
.backdate-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--divider-color);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
width: fit-content;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.backdate-pick:hover { border-color: var(--primary-color); }
|
||||
.photo-pick input[type="file"] { display: none; }
|
||||
.photo-preview {
|
||||
position: relative;
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
const isFile = doc.kind === "file";
|
||||
const cat = this._category_of(doc);
|
||||
const meta = isFile
|
||||
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size)}`
|
||||
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size, L)}`
|
||||
: t("doc_link_badge", L);
|
||||
const thumb = this._thumbs[doc.id];
|
||||
return html`
|
||||
|
||||
+15
-10
@@ -12,6 +12,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf } from "../styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-date-field";
|
||||
|
||||
export interface HistoryEntryDraft {
|
||||
entry_id: string;
|
||||
@@ -228,16 +229,20 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
<ha-icon icon="mdi:tag-outline"></ha-icon>
|
||||
<span>${t(d.type, L) || d.type}</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>${t("history_edit_timestamp", L) || "Timestamp"}</span>
|
||||
<input type="datetime-local"
|
||||
.value=${d.timestamp.length >= 16 ? d.timestamp.slice(0, 16) : d.timestamp}
|
||||
@change=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
// Re-add seconds if input drops them
|
||||
this._set("timestamp", v.length === 16 ? `${v}:00` : v);
|
||||
}} />
|
||||
</label>
|
||||
<ms-date-field
|
||||
kind="datetime"
|
||||
required
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.label=${t("history_edit_timestamp", L) || "Timestamp"}
|
||||
.value=${d.timestamp.slice(0, 19)}
|
||||
@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.
|
||||
const v = e.detail.value as string;
|
||||
if (v) this._set("timestamp", v);
|
||||
}}
|
||||
></ms-date-field>
|
||||
<label>
|
||||
<span>${t("notes_label", L)}</span>
|
||||
<textarea
|
||||
|
||||
@@ -7,6 +7,7 @@ import { t, langOf } from "../styles";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-textfield";
|
||||
import "./ms-date-field";
|
||||
|
||||
export class MaintenanceObjectDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -168,18 +169,24 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._areaId = (e.detail.value as string) || "")}
|
||||
></ha-area-picker>
|
||||
<ms-textfield
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("installation_date_optional", L)}"
|
||||
type="date"
|
||||
.value=${this._installationDate}
|
||||
@input=${(e: Event) => (this._installationDate = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
@value-changed=${(e: CustomEvent) => (this._installationDate = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("warranty_expiry_optional", L)}"
|
||||
type="date"
|
||||
.value=${this._warrantyExpiry}
|
||||
@input=${(e: Event) => (this._warrantyExpiry = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@value-changed=${(e: CustomEvent) => (this._warrantyExpiry = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{ device: this._haDeviceId || undefined }}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { t, ensureLocale, langOf, formatCost } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
@@ -360,7 +360,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
${this._inventoryValue() !== null
|
||||
? html`<span class="inventory-value" title=${t("parts_inventory_value", L)}
|
||||
>${t("parts_inventory_value", L)}:
|
||||
${this._inventoryValue()!.toFixed(2)} ${this.currencySymbol}</span>`
|
||||
${formatCost(this._inventoryValue()!, this.currencySymbol, L)}</span>`
|
||||
: nothing}
|
||||
</h3>
|
||||
${this.canWrite && !this._editing
|
||||
|
||||
@@ -11,6 +11,7 @@ import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
import "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
* Empty `services` means no Companion device is linked, so their reminders
|
||||
@@ -816,13 +817,25 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${n.quiet_hours_enabled ? html`
|
||||
<div class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_quiet_start", L)}</span>
|
||||
<input type="time" .value=${n.quiet_hours_start}
|
||||
@change=${(e: Event) => this._updateSetting("quiet_hours_start", (e.target as HTMLInputElement).value)} />
|
||||
<ms-date-field
|
||||
kind="time"
|
||||
required
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${n.quiet_hours_start}
|
||||
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_start", v); }}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
<div class="setting-row sub-row">
|
||||
<span class="setting-desc">${t("settings_quiet_end", L)}</span>
|
||||
<input type="time" .value=${n.quiet_hours_end}
|
||||
@change=${(e: Event) => this._updateSetting("quiet_hours_end", (e.target as HTMLInputElement).value)} />
|
||||
<ms-date-field
|
||||
kind="time"
|
||||
required
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${n.quiet_hours_end}
|
||||
@value-changed=${(e: CustomEvent) => { const v = e.detail.value as string; if (v) this._updateSetting("quiet_hours_end", v); }}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
@@ -1000,16 +1013,28 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</label>
|
||||
|
||||
<div class="vac-grid">
|
||||
<label class="vac-field">
|
||||
<div class="vac-field">
|
||||
<span class="filter-label">${t("vacation_start", L)}</span>
|
||||
<input type="date" .value=${this._vacStart}
|
||||
@change=${(e: Event) => this._setVacationDate("start", (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="vac-field">
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._vacStart}
|
||||
@value-changed=${(e: CustomEvent) => this._setVacationDate("start", e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
<div class="vac-field">
|
||||
<span class="filter-label">${t("vacation_end", L)}</span>
|
||||
<input type="date" .value=${this._vacEnd}
|
||||
@change=${(e: Event) => this._setVacationDate("end", (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._vacEnd}
|
||||
@value-changed=${(e: CustomEvent) => this._setVacationDate("end", e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
<label class="vac-field">
|
||||
<span class="filter-label">${t("vacation_buffer", L)}</span>
|
||||
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
|
||||
@@ -1843,9 +1868,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 18px; height: 18px; flex-shrink: 0;
|
||||
}
|
||||
.setting-row ms-date-field {
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
}
|
||||
.setting-row input[type="number"],
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="time"] {
|
||||
.setting-row input[type="text"] {
|
||||
width: 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
|
||||
+7
-7
@@ -166,10 +166,10 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
render() {
|
||||
if (!this._loaded || !this._summary) return nothing;
|
||||
const s = this._summary;
|
||||
if (s.document_count === 0) return nothing; // self-hide when unused
|
||||
if (!s.document_count) return nothing; // self-hide when unused (or malformed)
|
||||
const L = this._lang;
|
||||
|
||||
const rows = Object.entries(s.by_object)
|
||||
const rows = Object.entries(s.by_object ?? {})
|
||||
.filter(([, v]) => v.files > 0 || v.links > 0)
|
||||
.map(([id, v]) => ({ id, name: this._nameFor(id), entry: this._entryFor(id), ...v }))
|
||||
.sort((a, b) => b.bytes - a.bytes);
|
||||
@@ -188,8 +188,8 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
<span class="emoji">🗄️</span>
|
||||
<span class="title-text">${t("doc_storage_title", L)}</span>
|
||||
<span class="header-summary">
|
||||
${formatBytes(s.total_bytes)}
|
||||
${s.dedup_savings_bytes > 0 ? html`<span class="saved">−${formatBytes(s.dedup_savings_bytes)}</span>` : nothing}
|
||||
${formatBytes(s.total_bytes, L)}
|
||||
${s.dedup_savings_bytes > 0 ? html`<span class="saved">−${formatBytes(s.dedup_savings_bytes, L)}</span>` : nothing}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -207,7 +207,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
<div class="body">
|
||||
<div class="totals">
|
||||
<div class="stat">
|
||||
<div class="stat-value">${formatBytes(s.total_bytes)}</div>
|
||||
<div class="stat-value">${formatBytes(s.total_bytes, L)}</div>
|
||||
<div class="stat-label">
|
||||
<ha-icon icon="mdi:file-document-outline"></ha-icon> ${s.file_count}
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon> ${s.link_count}
|
||||
@@ -215,7 +215,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
</div>
|
||||
${s.dedup_savings_bytes > 0
|
||||
? html`<div class="stat">
|
||||
<div class="stat-value saved">−${formatBytes(s.dedup_savings_bytes)}</div>
|
||||
<div class="stat-value saved">−${formatBytes(s.dedup_savings_bytes, L)}</div>
|
||||
<div class="stat-label">${t("doc_storage_saved", L)}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
@@ -275,7 +275,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
${r.files > 0 ? html`<ha-icon icon="mdi:file-document-outline"></ha-icon>${r.files}` : nothing}
|
||||
${r.links > 0 ? html`<ha-icon icon="mdi:link-variant"></ha-icon>${r.links}` : nothing}
|
||||
</span>
|
||||
<span class="obj-size">${formatBytes(r.bytes)}</span>
|
||||
<span class="obj-size">${formatBytes(r.bytes, L)}</span>
|
||||
${eid ? html`<ha-icon class="obj-go" icon="mdi:chevron-right"></ha-icon>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||
import { formatDate, t, weekdayName, langOf } from "../styles";
|
||||
import { formatDate, t, weekdayName, monthName, langOf } from "../styles";
|
||||
import { UserService } from "../user-service";
|
||||
import { partLinkKey } from "../helpers/shared-parts";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-textfield";
|
||||
import "./ms-date-field";
|
||||
|
||||
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
|
||||
const PRIORITY_KEYS = ["low", "normal", "high"];
|
||||
@@ -129,8 +130,7 @@ function weekdayNames(lang?: string): string[] {
|
||||
|
||||
/** Short localized month names, indexed 0=Jan … 11=Dec. */
|
||||
function monthNames(lang?: string): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" });
|
||||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1)));
|
||||
return Array.from({ length: 12 }, (_, i) => monthName(i, lang, "short"));
|
||||
}
|
||||
|
||||
export class MaintenanceTaskDialog extends LitElement {
|
||||
@@ -1190,6 +1190,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this._triggerType === "threshold" && this._thresholdLimitsOverlap()) {
|
||||
this._error = t("trigger_hint_overlap", this._lang);
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
@@ -2204,12 +2208,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>` : nothing}
|
||||
${this._endsMode === "until" ? html`
|
||||
<ms-textfield
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("series_end_until_label", L)}"
|
||||
type="date"
|
||||
.value=${this._endsUntil}
|
||||
@input=${(e: Event) => (this._endsUntil = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>` : nothing}
|
||||
@value-changed=${(e: CustomEvent) => (this._endsUntil = e.detail.value as string)}
|
||||
></ms-date-field>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2299,12 +2305,23 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
* sensor's current reading (not from zero) and restarts after each
|
||||
* completion. Renders nothing when there's no entity/state to read.
|
||||
*/
|
||||
/** #156: above/below are OR-ed server-side — `below > above` makes every
|
||||
* reading a hit, so the trigger never clears (and never auto-completes). */
|
||||
private _thresholdLimitsOverlap(): boolean {
|
||||
const above = parseFloat(this._triggerAbove);
|
||||
const below = parseFloat(this._triggerBelow);
|
||||
return !isNaN(above) && !isNaN(below) && below > above;
|
||||
}
|
||||
|
||||
private _renderTriggerLiveHint() {
|
||||
if (this._triggerType === "compound") return nothing;
|
||||
const overlap = this._triggerType === "threshold" && this._thresholdLimitsOverlap()
|
||||
? html`<div class="trigger-live-hint warn">${t("trigger_hint_overlap", this._lang)}</div>`
|
||||
: nothing;
|
||||
const entityId = this._triggerEntityId || this._triggerEntityIds[0];
|
||||
if (!entityId || !this.hass?.states) return nothing;
|
||||
if (!entityId || !this.hass?.states) return overlap;
|
||||
const st = this.hass.states[entityId];
|
||||
if (!st) return nothing;
|
||||
if (!st) return overlap;
|
||||
const L = this._lang;
|
||||
|
||||
const unitAttr = st.attributes?.unit_of_measurement;
|
||||
@@ -2362,8 +2379,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
);
|
||||
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
|
||||
}
|
||||
if (!parts.length) return nothing;
|
||||
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>`;
|
||||
if (!parts.length) return overlap;
|
||||
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>${overlap}`;
|
||||
}
|
||||
|
||||
private _renderTriggerTypeFields() {
|
||||
@@ -2643,25 +2660,30 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
</select>
|
||||
</div>
|
||||
${this.scheduleTimeEnabled ? html`
|
||||
<ms-textfield
|
||||
<ms-date-field
|
||||
kind="time"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("schedule_time_optional", L)}"
|
||||
type="time"
|
||||
.value=${this._scheduleTime}
|
||||
helper="${t("schedule_time_help", L)}"
|
||||
@input=${(e: Event) => (this._scheduleTime = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
` : nothing}
|
||||
`
|
||||
: nothing}
|
||||
${this._renderCalendarFields()}
|
||||
${this._scheduleType === "one_time"
|
||||
? html`
|
||||
<ms-textfield
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("due_date", L)}"
|
||||
type="date"
|
||||
.value=${this._dueDate}
|
||||
@input=${(e: Event) => (this._dueDate = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@value-changed=${(e: CustomEvent) => (this._dueDate = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
`
|
||||
: nothing}
|
||||
${this._renderRecurrenceExtras()}
|
||||
@@ -2707,12 +2729,15 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
<ms-textfield
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("last_performed_optional", L)}"
|
||||
type="date"
|
||||
.value=${this._lastPerformed}
|
||||
@input=${(e: Event) => (this._lastPerformed = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@value-changed=${(e: CustomEvent) => (this._lastPerformed = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
<div class="select-row">
|
||||
<label>${t("responsible_user", L)}</label>
|
||||
<select
|
||||
@@ -3131,6 +3156,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
padding: 6px 10px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.trigger-live-hint.warn {
|
||||
color: var(--primary-text-color);
|
||||
border-left-color: var(--warning-color, #ff9800);
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
.field-help a,
|
||||
.link-button {
|
||||
background: none;
|
||||
|
||||
@@ -228,7 +228,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
const isPdf = this._isPdf(doc);
|
||||
const page = this._pageFor(doc);
|
||||
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
|
||||
const meta = isFile ? formatBytes(doc.size) : t("doc_link_badge", L);
|
||||
const meta = isFile ? formatBytes(doc.size, L) : t("doc_link_badge", L);
|
||||
return html`
|
||||
<div class="tdoc-row">
|
||||
<ha-icon class="tdoc-icon" icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}></ha-icon>
|
||||
|
||||
+12
-6
@@ -14,7 +14,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, formatNumber, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
|
||||
@@ -22,6 +22,7 @@ import { phaseLabel } from "../helpers/phases";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
import "./ms-date-field";
|
||||
import {
|
||||
renderSeasonalCardCompact,
|
||||
renderSeasonalCardExpanded,
|
||||
@@ -466,7 +467,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
|
||||
<span class="stat-value">${totalCost.toFixed(2)}</span>
|
||||
<span class="stat-value">${formatNumber(totalCost, L, 2)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
|
||||
@@ -501,7 +502,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
: nothing}
|
||||
${entry.cost != null || entry.duration != null
|
||||
? html`<div class="history-meta">
|
||||
${entry.cost != null ? html`<span>💰 ${entry.cost.toFixed(2)}</span>` : nothing}
|
||||
${entry.cost != null ? html`<span>💰 ${formatNumber(entry.cost, L, 2)}</span>` : nothing}
|
||||
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
@@ -582,8 +583,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
? html`
|
||||
<div class="inline-form">
|
||||
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
|
||||
<input type="date" .value=${this._resetDate}
|
||||
@input=${(e: Event) => { this._resetDate = (e.target as HTMLInputElement).value; }} />
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._resetDate}
|
||||
@value-changed=${(e: CustomEvent) => { this._resetDate = e.detail.value as string; }}
|
||||
></ms-date-field>
|
||||
<div class="inline-actions">
|
||||
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
|
||||
${t("cancel", L) || "Cancel"}
|
||||
@@ -602,7 +608,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
</ha-button>
|
||||
${task.allow_skip !== false
|
||||
? html`
|
||||
<ha-button appearance="outlined" variant="neutral" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
|
||||
<ha-button appearance="outlined" variant="warning" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
|
||||
${t("skip", L) || "Skip"}
|
||||
</ha-button>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { LitElement, html, css, svg, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear } from "../renderers/chart-utils";
|
||||
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear, px } from "../renderers/chart-utils";
|
||||
|
||||
export interface ChartPoint {
|
||||
ts: number;
|
||||
@@ -170,18 +170,18 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
const toX = (ts: number) => PAD_L + ((ts - tsMin) / tsSpan) * plotW;
|
||||
const toY = (v: number) => PAD_T + (1 - (v - niceMin) / (niceMax - niceMin || 1)) * plotH;
|
||||
|
||||
const linePts = pts.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
|
||||
const linePts = pts.map((p) => `${px(toX(p.ts))},${px(toY(p.val))}`).join(" ");
|
||||
const areaPath =
|
||||
`M${toX(pts[0].ts).toFixed(1)},${plotB} ` +
|
||||
pts.map((p) => `L${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ") +
|
||||
` L${toX(pts[pts.length - 1].ts).toFixed(1)},${plotB} Z`;
|
||||
`M${px(toX(pts[0].ts))},${plotB} ` +
|
||||
pts.map((p) => `L${px(toX(p.ts))},${px(toY(p.val))}`).join(" ") +
|
||||
` L${px(toX(pts[pts.length - 1].ts))},${plotB} Z`;
|
||||
|
||||
// Optional min/max band behind the line.
|
||||
let bandPath = "";
|
||||
const band = pts.filter((p) => p.min != null && p.max != null);
|
||||
if (band.length >= 2) {
|
||||
const up = band.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.max!).toFixed(1)}`);
|
||||
const dn = [...band].reverse().map((p) => `${toX(p.ts).toFixed(1)},${toY(p.min!).toFixed(1)}`);
|
||||
const up = band.map((p) => `${px(toX(p.ts))},${px(toY(p.max!))}`);
|
||||
const dn = [...band].reverse().map((p) => `${px(toX(p.ts))},${px(toY(p.min!))}`);
|
||||
bandPath = `M${up[0]} ` + up.slice(1).map((x) => `L${x}`).join(" ") + ` L${dn.join(" L")} Z`;
|
||||
}
|
||||
|
||||
@@ -190,11 +190,11 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
const zones: { y: number; h: number; lineY: number; label: string; labelY: number }[] = [];
|
||||
if (this.thresholdBelow != null) {
|
||||
const zy = toY(this.thresholdBelow);
|
||||
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow)}`, labelY: Math.min(plotB - 4, zy + 13) });
|
||||
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow, L)}`, labelY: Math.min(plotB - 4, zy + 13) });
|
||||
}
|
||||
if (this.thresholdAbove != null) {
|
||||
const zy = toY(this.thresholdAbove);
|
||||
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
|
||||
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove, L)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
|
||||
}
|
||||
|
||||
const lastP = pts[pts.length - 1];
|
||||
@@ -218,7 +218,7 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
<defs>
|
||||
<clipPath id="plot"><rect x="${PAD_L}" y="${PAD_T}" width="${plotW}" height="${plotH}" /></clipPath>
|
||||
${zones.length
|
||||
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}" />`)}</clipPath>`
|
||||
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${px(z.y)}" width="${plotW}" height="${px(z.h)}" />`)}</clipPath>`
|
||||
: nothing}
|
||||
<!-- Diagonal hatch so the danger zone reads without relying on the
|
||||
red tint alone (dark-theme contrast + colour-blind support). -->
|
||||
@@ -232,13 +232,13 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
const y = toY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
|
||||
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}"
|
||||
stroke="var(--divider-color)" stroke-width="1" opacity="0.6" />
|
||||
<text x="${PAD_L - 7}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" class="tick-label">${fmtNum(v)}</text>`;
|
||||
<text x="${PAD_L - 7}" y="${px(y + 3.5)}" text-anchor="end" class="tick-label">${fmtNum(v, L)}</text>`;
|
||||
})}
|
||||
|
||||
${zones.map(
|
||||
(z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}"
|
||||
(z) => svg`<rect x="${PAD_L}" y="${px(z.y)}" width="${plotW}" height="${px(z.h)}"
|
||||
fill="url(#dangerHatch)" />`,
|
||||
)}
|
||||
|
||||
@@ -253,27 +253,27 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
|
||||
${zones.map(
|
||||
(z) => svg`
|
||||
<line x1="${PAD_L}" y1="${z.lineY.toFixed(1)}" x2="${W - PAD_R}" y2="${z.lineY.toFixed(1)}"
|
||||
<line x1="${PAD_L}" y1="${px(z.lineY)}" x2="${W - PAD_R}" y2="${px(z.lineY)}"
|
||||
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
|
||||
<text x="${W - PAD_R - 4}" y="${z.labelY.toFixed(1)}" text-anchor="end" class="zone-label">${z.label}</text>`,
|
||||
<text x="${W - PAD_R - 4}" y="${px(z.labelY)}" text-anchor="end" class="zone-label">${z.label}</text>`,
|
||||
)}
|
||||
|
||||
${this.targetValue != null
|
||||
? svg`<line x1="${PAD_L}" y1="${toY(this.targetValue).toFixed(1)}" x2="${W - PAD_R}" y2="${toY(this.targetValue).toFixed(1)}"
|
||||
? svg`<line x1="${PAD_L}" y1="${px(toY(this.targetValue))}" x2="${W - PAD_R}" y2="${px(toY(this.targetValue))}"
|
||||
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
|
||||
<text x="${W - PAD_R - 4}" y="${(toY(this.targetValue) - 5).toFixed(1)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue)} ${this.unit}</text>`
|
||||
<text x="${W - PAD_R - 4}" y="${px(toY(this.targetValue) - 5)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue, L)} ${this.unit}</text>`
|
||||
: nothing}
|
||||
|
||||
${this.projection && this.projection.length === 2
|
||||
? svg`<line x1="${toX(this.projection[0].ts).toFixed(1)}" y1="${toY(this.projection[0].val).toFixed(1)}"
|
||||
x2="${Math.min(toX(this.projection[1].ts), W - PAD_R).toFixed(1)}" y2="${toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))).toFixed(1)}"
|
||||
? svg`<line x1="${px(toX(this.projection[0].ts))}" y1="${px(toY(this.projection[0].val))}"
|
||||
x2="${px(Math.min(toX(this.projection[1].ts), W - PAD_R))}" y2="${px(toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))))}"
|
||||
stroke="var(--warning-color, #ff9800)" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8" />`
|
||||
: nothing}
|
||||
|
||||
${xTicks.map((ts, i) => {
|
||||
const x = toX(ts);
|
||||
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
|
||||
return svg`<text x="${x.toFixed(1)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
|
||||
return svg`<text x="${px(x)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
|
||||
})}
|
||||
|
||||
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
|
||||
@@ -287,19 +287,19 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
? "var(--warning-color, #ff9800)"
|
||||
: "var(--info-color, #2196f3)";
|
||||
return svg`
|
||||
<line x1="${x.toFixed(1)}" y1="${PAD_T}" x2="${x.toFixed(1)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
|
||||
<rect x="${(x - 1.5).toFixed(1)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
|
||||
<line x1="${px(x)}" y1="${PAD_T}" x2="${px(x)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
|
||||
<rect x="${px(x - 1.5)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
|
||||
<title>${fmtDateTime(e.ts, L)}</title>
|
||||
</rect>`;
|
||||
})}
|
||||
|
||||
${hover
|
||||
? svg`
|
||||
<line x1="${hover.x.toFixed(1)}" y1="${PAD_T}" x2="${hover.x.toFixed(1)}" y2="${plotB}"
|
||||
<line x1="${px(hover.x)}" y1="${PAD_T}" x2="${px(hover.x)}" y2="${plotB}"
|
||||
stroke="var(--secondary-text-color)" stroke-width="1" stroke-dasharray="3,3" opacity="0.7" />
|
||||
<circle cx="${hover.x.toFixed(1)}" cy="${hover.y.toFixed(1)}" r="4.5" fill="var(--primary-color)"
|
||||
<circle cx="${px(hover.x)}" cy="${px(hover.y)}" r="4.5" fill="var(--primary-color)"
|
||||
stroke="var(--card-background-color, #fff)" stroke-width="2" />`
|
||||
: svg`<circle cx="${toX(lastP.ts).toFixed(1)}" cy="${toY(lastP.val).toFixed(1)}" r="4" fill="var(--primary-color)"
|
||||
: svg`<circle cx="${px(toX(lastP.ts))}" cy="${px(toY(lastP.val))}" r="4" fill="var(--primary-color)"
|
||||
stroke="var(--card-background-color, #fff)" stroke-width="1.5" />`}
|
||||
</svg>
|
||||
${hover
|
||||
@@ -311,7 +311,7 @@ export class MaintenanceTriggerChart extends LitElement {
|
||||
<div class="hover-val">
|
||||
${fmtVal(hover.p.val, this.unit, L)}
|
||||
${hover.p.min != null && hover.p.max != null
|
||||
? html`<span class="hover-range">(${fmtNum(hover.p.min)}–${fmtNum(hover.p.max)})</span>`
|
||||
? html`<span class="hover-range">(${fmtNum(hover.p.min, L)}–${fmtNum(hover.p.max, L)})</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
+23
-10
@@ -17,6 +17,7 @@ import { t, ensureLocale, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import "./ms-date-field";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface VacationState {
|
||||
@@ -192,21 +193,33 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
<div class="dates-row">
|
||||
<div class="date-field">
|
||||
<label>${t("vacation_start", L) || "Start"}</label>
|
||||
<input type="date" .value=${this._localStart}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localStart = (e.target as HTMLInputElement).value;
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._localStart}
|
||||
.disabled=${this._busy}
|
||||
@value-changed=${(e: CustomEvent) => {
|
||||
this._localStart = e.detail.value as string;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
}}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
<div class="date-field">
|
||||
<label>${t("vacation_end", L) || "End"}</label>
|
||||
<input type="date" .value=${this._localEnd}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localEnd = (e.target as HTMLInputElement).value;
|
||||
<ms-date-field
|
||||
kind="date"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._localEnd}
|
||||
.disabled=${this._busy}
|
||||
@value-changed=${(e: CustomEvent) => {
|
||||
this._localEnd = e.detail.value as string;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
}}
|
||||
></ms-date-field>
|
||||
</div>
|
||||
<div class="date-field buffer">
|
||||
<label>${t("vacation_buffer", L) || "Buffer days"}</label>
|
||||
|
||||
@@ -44,7 +44,7 @@ import type { MaintenanceQrDialog } from "./components/qr-dialog";
|
||||
import type { MaintenanceTaskQuickActionsDialog } from "./components/task-quick-actions-dialog";
|
||||
import type { MaintenanceObjectQuickActionsDialog } from "./components/object-quick-actions-dialog";
|
||||
import type { HomeAssistant, MaintenanceObject } from "./types";
|
||||
import { ensureLocale, isLocaleLoaded, langOf } from "./styles";
|
||||
import { ensureLocale, isLocaleLoaded, langOf, setProfilePrefs } from "./styles";
|
||||
import { fillAndOpenCompleteDialog, type CompleteDialogArgs } from "./helpers/complete-dialog-args";
|
||||
|
||||
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
|
||||
@@ -98,6 +98,11 @@ function syncHass(el: HTMLElement & { hass?: HomeAssistant }): boolean {
|
||||
(el as unknown as { requestUpdate?: () => void }).requestUpdate?.();
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { formatNumber } from "../styles";
|
||||
|
||||
/** Human-readable byte size (B / KB / MB) for document storage figures. */
|
||||
export function formatBytes(bytes: number | undefined): string {
|
||||
export function formatBytes(bytes: number | undefined, lang?: string): string {
|
||||
const b = bytes ?? 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (b < 1024 * 1024) return `${formatNumber(b / 1024, lang, 1)} KB`;
|
||||
return `${formatNumber(b / (1024 * 1024), lang, 1)} MB`;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function buildObjectReportHtml(
|
||||
tasks: MaintenanceTask[],
|
||||
labels: ReportLabels,
|
||||
fmtDate: (iso: string | null | undefined) => string,
|
||||
currencySymbol: string,
|
||||
fmtCost: (amount: number) => string,
|
||||
nowIso: string,
|
||||
): string {
|
||||
const meta = ([
|
||||
@@ -66,7 +66,7 @@ export function buildObjectReportHtml(
|
||||
<td>${esc(t.last_performed ? fmtDate(t.last_performed) : labels.none)}</td>
|
||||
<td>${esc(t.next_due ? fmtDate(t.next_due) : labels.none)}</td>
|
||||
<td class="num">${t.times_performed ?? 0}</td>
|
||||
<td class="num">${(t.total_cost ?? 0).toFixed(2)} ${esc(currencySymbol)}</td>
|
||||
<td class="num">${esc(fmtCost(t.total_cost ?? 0))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
@@ -112,7 +112,7 @@ export function buildObjectReportHtml(
|
||||
<th class="num">${esc(labels.colTimes)}</th><th class="num">${esc(labels.colCost)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows || `<tr><td colspan="8">${esc(labels.none)}</td></tr>`}</tbody>
|
||||
<tfoot><tr><td colspan="7">${esc(labels.totalCost)}</td><td class="num">${totalCost.toFixed(2)} ${esc(currencySymbol)}</td></tr></tfoot>
|
||||
<tfoot><tr><td colspan="7">${esc(labels.totalCost)}</td><td class="num">${esc(fmtCost(totalCost))}</td></tr></tfoot>
|
||||
</table>
|
||||
${obj.notes ? `<div class="notes"><strong>${esc(labels.notes)}:</strong>\n${esc(obj.notes)}</div>` : ""}
|
||||
</body></html>`;
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: neplatná hodnota",
|
||||
"feat_schedule_time": "Plánování podle denní doby",
|
||||
"feat_schedule_time_desc": "Úkoly se stanou po termínu v určenou denní dobu místo o půlnoci.",
|
||||
"schedule_time_optional": "Termín v čase (volitelné, HH:MM)",
|
||||
"schedule_time_optional": "Termín v čase (volitelné)",
|
||||
"schedule_time_help": "Prázdné = půlnoc (výchozí). Časové pásmo HA.",
|
||||
"at_time": "v",
|
||||
"notes_optional": "Poznámky (volitelné)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Náklady (volitelné)",
|
||||
"duration_minutes": "Doba trvání v minutách (volitelné)",
|
||||
"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.",
|
||||
"days": "dní",
|
||||
"day": "den",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Neplatná hodnota",
|
||||
"seasonal_override_range": "Faktor musí být mezi 0.1 a 5.0",
|
||||
"clear_all": "Vymazat vše",
|
||||
"clear": "Vymazat",
|
||||
"seasonal_chart_title": "Sezónní faktory",
|
||||
"seasonal_learned": "Naučené",
|
||||
"seasonal_manual": "Manuální",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Senzor nyní ukazuje {value}.",
|
||||
"trigger_hint_above": "Úkol se spustí, jakmile hodnota překročí {target}.",
|
||||
"trigger_hint_below": "Spustí se, jakmile hodnota klesne pod {target}.",
|
||||
"trigger_hint_overlap": "Tyto meze se překrývají: každé měření spustí úkol a ten se nikdy nemůže zotavit. Jedno z polí nechte prázdné.",
|
||||
"trigger_hint_counter_delta": "Počítá od aktuální hodnoty ({value}): splatné při {due} (+{target}); počítadlo se po každém dokončení restartuje.",
|
||||
"trigger_hint_counter_delta_edit": "Počítá spotřebu od posledního dokončení: splatné po +{target}; počítadlo se po každém dokončení restartuje.",
|
||||
"trigger_hint_counter_abs": "Úkol bude splatný, jakmile senzor dosáhne {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ugyldig værdi",
|
||||
"feat_schedule_time": "Planlægning efter tidspunkt på dagen",
|
||||
"feat_schedule_time_desc": "Opgaver bliver forfaldne på et bestemt tidspunkt på dagen i stedet for midnat.",
|
||||
"schedule_time_optional": "Forfalder kl. (valgfrit, TT:MM)",
|
||||
"schedule_time_optional": "Forfalder kl. (valgfrit)",
|
||||
"schedule_time_help": "Tom = midnat (standard). HA-tidszone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Noter (valgfrit)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Omkostning (valgfrit)",
|
||||
"duration_minutes": "Varighed i minutter (valgfrit)",
|
||||
"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.",
|
||||
"days": "dage",
|
||||
"day": "dag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ugyldig værdi",
|
||||
"seasonal_override_range": "Faktor skal være mellem 0.1 og 5.0",
|
||||
"clear_all": "Ryd alle",
|
||||
"clear": "Ryd",
|
||||
"seasonal_chart_title": "Sæsonfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensoren viser {value} lige nu.",
|
||||
"trigger_hint_above": "Opgaven udløses, når værdien stiger over {target}.",
|
||||
"trigger_hint_below": "Den udløses, når værdien falder under {target}.",
|
||||
"trigger_hint_overlap": "Disse grænser overlapper: hver måling udløser opgaven, og den kan aldrig komme sig. Lad et af felterne stå tomt.",
|
||||
"trigger_hint_counter_delta": "Tæller fra den aktuelle aflæsning ({value}): forfalder ved {due} (+{target}); tælleren starter forfra efter hver fuldførelse.",
|
||||
"trigger_hint_counter_delta_edit": "Tæller forbruget siden sidste fuldførelse: forfalder efter +{target}; tælleren starter forfra efter hver fuldførelse.",
|
||||
"trigger_hint_counter_abs": "Opgaven forfalder, når sensoren når {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ungültiger Wert",
|
||||
"feat_schedule_time": "Uhrzeit-Scheduling",
|
||||
"feat_schedule_time_desc": "Tasks werden zu einer festen Uhrzeit fällig statt um Mitternacht.",
|
||||
"schedule_time_optional": "Fällig um (optional, HH:MM)",
|
||||
"schedule_time_optional": "Fällig um (optional)",
|
||||
"schedule_time_help": "Leer = Mitternacht (Default). HA-Zeitzone.",
|
||||
"at_time": "um",
|
||||
"notes_optional": "Notizen (optional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kosten (optional)",
|
||||
"duration_minutes": "Dauer in Minuten (optional)",
|
||||
"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.",
|
||||
"days": "Tage",
|
||||
"day": "Tag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ungültiger Wert",
|
||||
"seasonal_override_range": "Faktor muss zwischen 0.1 und 5.0 liegen",
|
||||
"clear_all": "Alle zurücksetzen",
|
||||
"clear": "Leeren",
|
||||
"seasonal_chart_title": "Saisonale Faktoren",
|
||||
"seasonal_learned": "Gelernt",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Der Sensor zeigt aktuell {value}.",
|
||||
"trigger_hint_above": "Die Aufgabe wird ausgelöst, sobald der Wert über {target} steigt.",
|
||||
"trigger_hint_below": "Sie wird ausgelöst, sobald der Wert unter {target} fällt.",
|
||||
"trigger_hint_overlap": "Diese Grenzen überschneiden sich: Jeder Messwert löst die Aufgabe aus, und sie kann sich nie erholen. Lass eines der Felder leer.",
|
||||
"trigger_hint_counter_delta": "Zählt ab dem aktuellen Stand ({value}): fällig bei {due} (+{target}); der Zähler startet nach jedem Abschluss neu.",
|
||||
"trigger_hint_counter_delta_edit": "Zählt den Verbrauch seit dem letzten Abschluss: fällig nach +{target}; der Zähler startet nach jedem Abschluss neu.",
|
||||
"trigger_hint_counter_abs": "Die Aufgabe wird fällig, sobald der Sensor {target} erreicht.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: invalid value",
|
||||
"feat_schedule_time": "Time-of-day scheduling",
|
||||
"feat_schedule_time_desc": "Tasks become overdue at a specific time of day instead of midnight.",
|
||||
"schedule_time_optional": "Due at time (optional, HH:MM)",
|
||||
"schedule_time_optional": "Due at time (optional)",
|
||||
"schedule_time_help": "Empty = midnight (default). HA timezone.",
|
||||
"at_time": "at",
|
||||
"notes_optional": "Notes (optional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Cost (optional)",
|
||||
"duration_minutes": "Duration in minutes (optional)",
|
||||
"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.",
|
||||
"days": "days",
|
||||
"day": "day",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Invalid value",
|
||||
"seasonal_override_range": "Factor must be between 0.1 and 5.0",
|
||||
"clear_all": "Clear all",
|
||||
"clear": "Clear",
|
||||
"seasonal_chart_title": "Seasonal Factors",
|
||||
"seasonal_learned": "Learned",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "The sensor reads {value} right now.",
|
||||
"trigger_hint_above": "The task triggers once it rises above {target}.",
|
||||
"trigger_hint_below": "It triggers once it falls below {target}.",
|
||||
"trigger_hint_overlap": "These limits overlap: every reading triggers the task and it can never recover. Leave one of them empty.",
|
||||
"trigger_hint_counter_delta": "Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",
|
||||
"trigger_hint_counter_delta_edit": "Counts usage since the last completion: due after +{target}; the count restarts after each completion.",
|
||||
"trigger_hint_counter_abs": "The task becomes due once the sensor reaches {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Programación por hora",
|
||||
"feat_schedule_time_desc": "Las tareas vencen a una hora específica en lugar de medianoche.",
|
||||
"schedule_time_optional": "Vence a las (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence a las (opcional)",
|
||||
"schedule_time_help": "Vacío = medianoche (predeterminado). Zona horaria HA.",
|
||||
"at_time": "a las",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Coste (opcional)",
|
||||
"duration_minutes": "Duración en minutos (opcional)",
|
||||
"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.",
|
||||
"days": "días",
|
||||
"day": "día",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valor no válido",
|
||||
"seasonal_override_range": "El factor debe estar entre 0.1 y 5.0",
|
||||
"clear_all": "Borrar todo",
|
||||
"clear": "Borrar",
|
||||
"seasonal_chart_title": "Factores estacionales",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "El sensor marca {value} ahora mismo.",
|
||||
"trigger_hint_above": "La tarea se activa cuando supere {target}.",
|
||||
"trigger_hint_below": "Se activa cuando baje de {target}.",
|
||||
"trigger_hint_overlap": "Estos límites se solapan: cada lectura activa la tarea y nunca puede recuperarse. Deja uno de los dos vacío.",
|
||||
"trigger_hint_counter_delta": "Cuenta desde la lectura actual ({value}): vence en {due} (+{target}); el contador se reinicia tras cada finalización.",
|
||||
"trigger_hint_counter_delta_edit": "Cuenta el uso desde la última finalización: vence tras +{target}; el contador se reinicia tras cada finalización.",
|
||||
"trigger_hint_counter_abs": "La tarea vence cuando el sensor alcance {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: virheellinen arvo",
|
||||
"feat_schedule_time": "Kellonajan ajoitus",
|
||||
"feat_schedule_time_desc": "Tehtävät erääntyvät tiettyyn kellonaikaan keskiyön sijaan.",
|
||||
"schedule_time_optional": "Erääntyy klo (valinnainen, HH:MM)",
|
||||
"schedule_time_optional": "Erääntyy klo (valinnainen)",
|
||||
"schedule_time_help": "Tyhjä = keskiyö (oletus). HA:n aikavyöhyke.",
|
||||
"at_time": "klo",
|
||||
"notes_optional": "Muistiinpanot (valinnainen)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kustannus (valinnainen)",
|
||||
"duration_minutes": "Kesto minuutteina (valinnainen)",
|
||||
"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.",
|
||||
"days": "päivää",
|
||||
"day": "päivä",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Virheellinen arvo",
|
||||
"seasonal_override_range": "Kertoimen on oltava välillä 0,1–5,0",
|
||||
"clear_all": "Tyhjennä kaikki",
|
||||
"clear": "Tyhjennä",
|
||||
"seasonal_chart_title": "Kausikertoimet",
|
||||
"seasonal_learned": "Opittu",
|
||||
"seasonal_manual": "Manuaalinen",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Anturi näyttää nyt {value}.",
|
||||
"trigger_hint_above": "Tehtävä laukeaa, kun arvo ylittää {target}.",
|
||||
"trigger_hint_below": "Se laukeaa, kun arvo alittaa {target}.",
|
||||
"trigger_hint_overlap": "Nämä rajat menevät päällekkäin: jokainen lukema laukaisee tehtävän eikä se voi koskaan palautua. Jätä toinen kentistä tyhjäksi.",
|
||||
"trigger_hint_counter_delta": "Laskee nykyisestä lukemasta ({value}): erääntyy kohdassa {due} (+{target}); laskuri alkaa alusta jokaisen suorituksen jälkeen.",
|
||||
"trigger_hint_counter_delta_edit": "Laskee käytön viime suorituksesta: erääntyy +{target} jälkeen; laskuri alkaa alusta jokaisen suorituksen jälkeen.",
|
||||
"trigger_hint_counter_abs": "Tehtävä erääntyy, kun anturi saavuttaa {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field} : valeur invalide",
|
||||
"feat_schedule_time": "Planification à l'heure",
|
||||
"feat_schedule_time_desc": "Les tâches arrivent à échéance à une heure précise plutôt qu'à minuit.",
|
||||
"schedule_time_optional": "Échéance à l'heure (optionnel, HH:MM)",
|
||||
"schedule_time_optional": "Échéance à l'heure (optionnel)",
|
||||
"schedule_time_help": "Vide = minuit (défaut). Fuseau horaire HA.",
|
||||
"at_time": "à",
|
||||
"notes_optional": "Notes (optionnel)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Coût (optionnel)",
|
||||
"duration_minutes": "Durée en minutes (optionnel)",
|
||||
"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.",
|
||||
"days": "jours",
|
||||
"day": "jour",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valeur invalide",
|
||||
"seasonal_override_range": "Le facteur doit être entre 0.1 et 5.0",
|
||||
"clear_all": "Tout effacer",
|
||||
"clear": "Effacer",
|
||||
"seasonal_chart_title": "Facteurs saisonniers",
|
||||
"seasonal_learned": "Appris",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Le capteur indique {value} en ce moment.",
|
||||
"trigger_hint_above": "La tâche se déclenche dès que la valeur dépasse {target}.",
|
||||
"trigger_hint_below": "Elle se déclenche dès que la valeur passe sous {target}.",
|
||||
"trigger_hint_overlap": "Ces limites se chevauchent : chaque relevé déclenche la tâche et elle ne peut jamais se rétablir. Laissez l'une des deux vide.",
|
||||
"trigger_hint_counter_delta": "Compte à partir de la valeur actuelle ({value}) : échéance à {due} (+{target}) ; le compteur repart après chaque exécution.",
|
||||
"trigger_hint_counter_delta_edit": "Compte l'usage depuis la dernière exécution : échéance après +{target} ; le compteur repart après chaque exécution.",
|
||||
"trigger_hint_counter_abs": "La tâche devient due dès que le capteur atteint {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: अमान्य मान",
|
||||
"feat_schedule_time": "दिन का समय निर्धारण",
|
||||
"feat_schedule_time_desc": "कार्य मध्यरात्रि के बजाय दिन के एक विशिष्ट समय पर अतिदेय हो जाते हैं।",
|
||||
"schedule_time_optional": "इस समय देय (वैकल्पिक, HH:MM)",
|
||||
"schedule_time_optional": "इस समय देय (वैकल्पिक)",
|
||||
"schedule_time_help": "खाली = मध्यरात्रि (डिफ़ॉल्ट)। HA समय क्षेत्र।",
|
||||
"at_time": "बजे",
|
||||
"notes_optional": "टिप्पणियाँ (वैकल्पिक)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "लागत (वैकल्पिक)",
|
||||
"duration_minutes": "मिनटों में अवधि (वैकल्पिक)",
|
||||
"completed_at_optional": "पूर्ण होने का समय (वैकल्पिक, खाली = अभी)",
|
||||
"completed_at_pick": "तारीख और समय चुनें",
|
||||
"completed_at_future_error": "पूर्णता की तारीख भविष्य में नहीं हो सकती।",
|
||||
"days": "दिन",
|
||||
"day": "दिन",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "अमान्य मान",
|
||||
"seasonal_override_range": "कारक 0.1 और 5.0 के बीच होना चाहिए",
|
||||
"clear_all": "सभी साफ़ करें",
|
||||
"clear": "साफ़ करें",
|
||||
"seasonal_chart_title": "मौसमी कारक",
|
||||
"seasonal_learned": "सीखा गया",
|
||||
"seasonal_manual": "मैनुअल",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "सेंसर अभी {value} दिखा रहा है।",
|
||||
"trigger_hint_above": "मान {target} से ऊपर जाते ही कार्य ट्रिगर होगा।",
|
||||
"trigger_hint_below": "मान {target} से नीचे आते ही यह ट्रिगर होगा।",
|
||||
"trigger_hint_overlap": "ये सीमाएँ एक-दूसरे को ओवरलैप करती हैं: हर रीडिंग कार्य को ट्रिगर करेगी और वह कभी सामान्य नहीं हो पाएगा। दोनों में से एक खाली छोड़ें।",
|
||||
"trigger_hint_counter_delta": "वर्तमान रीडिंग ({value}) से गिनती: {due} पर देय (+{target}); हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
|
||||
"trigger_hint_counter_delta_edit": "पिछली पूर्णता से उपयोग गिनता है: +{target} के बाद देय; हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
|
||||
"trigger_hint_counter_abs": "सेंसर {target} तक पहुँचते ही कार्य देय होगा।",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: érvénytelen érték",
|
||||
"feat_schedule_time": "Napszakhoz kötött ütemezés",
|
||||
"feat_schedule_time_desc": "A feladatok nem éjfélkor, hanem egy megadott napszakban válnak lejárttá.",
|
||||
"schedule_time_optional": "Esedékesség időpontja (opcionális, ÓÓ:PP)",
|
||||
"schedule_time_optional": "Esedékesség időpontja (opcionális)",
|
||||
"schedule_time_help": "Üres = éjfél (alapértelmezés). HA időzóna.",
|
||||
"at_time": "ekkor:",
|
||||
"notes_optional": "Megjegyzések (opcionális)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Költség (opcionális)",
|
||||
"duration_minutes": "Időtartam percben (opcionális)",
|
||||
"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.",
|
||||
"days": "nap",
|
||||
"day": "nap",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Érvénytelen érték",
|
||||
"seasonal_override_range": "A tényezőnek 0,1 és 5,0 között kell lennie",
|
||||
"clear_all": "Összes törlése",
|
||||
"clear": "Törlés",
|
||||
"seasonal_chart_title": "Szezonális tényezők",
|
||||
"seasonal_learned": "Tanult",
|
||||
"seasonal_manual": "Kézi",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Az érzékelő jelenlegi értéke: {value}.",
|
||||
"trigger_hint_above": "A feladat akkor aktiválódik, amikor az érték {target} fölé emelkedik.",
|
||||
"trigger_hint_below": "Akkor aktiválódik, amikor az érték {target} alá csökken.",
|
||||
"trigger_hint_overlap": "Ezek a határok átfedik egymást: minden mérés kiváltja a feladatot, és az soha nem állhat helyre. Hagyja üresen az egyik mezőt.",
|
||||
"trigger_hint_counter_delta": "A jelenlegi értéktől ({value}) számol: esedékes {due} értéknél (+{target}), és a számlálás minden elvégzés után újraindul.",
|
||||
"trigger_hint_counter_delta_edit": "A legutóbbi elvégzés óta számolja a használatot: esedékes +{target} után; a számlálás minden elvégzés után újraindul.",
|
||||
"trigger_hint_counter_abs": "A feladat akkor válik esedékessé, amikor az érzékelő eléri a(z) {target} értéket.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valore non valido",
|
||||
"feat_schedule_time": "Pianificazione oraria",
|
||||
"feat_schedule_time_desc": "Le attività scadono a un'ora specifica anziché a mezzanotte.",
|
||||
"schedule_time_optional": "Scadenza all'ora (opzionale, HH:MM)",
|
||||
"schedule_time_optional": "Scadenza all'ora (opzionale)",
|
||||
"schedule_time_help": "Vuoto = mezzanotte (default). Fuso orario HA.",
|
||||
"at_time": "alle",
|
||||
"notes_optional": "Note (opzionale)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Costo (opzionale)",
|
||||
"duration_minutes": "Durata in minuti (opzionale)",
|
||||
"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.",
|
||||
"days": "giorni",
|
||||
"day": "giorno",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valore non valido",
|
||||
"seasonal_override_range": "Il fattore deve essere tra 0.1 e 5.0",
|
||||
"clear_all": "Cancella tutto",
|
||||
"clear": "Cancella",
|
||||
"seasonal_chart_title": "Fattori stagionali",
|
||||
"seasonal_learned": "Appreso",
|
||||
"seasonal_manual": "Manuale",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Il sensore indica {value} in questo momento.",
|
||||
"trigger_hint_above": "L'attività scatta quando supera {target}.",
|
||||
"trigger_hint_below": "Scatta quando scende sotto {target}.",
|
||||
"trigger_hint_overlap": "Questi limiti si sovrappongono: ogni lettura attiva l'attività e non può mai rientrare. Lascia vuoto uno dei due campi.",
|
||||
"trigger_hint_counter_delta": "Conta dalla lettura attuale ({value}): scade a {due} (+{target}); il conteggio riparte dopo ogni completamento.",
|
||||
"trigger_hint_counter_delta_edit": "Conta l'uso dall'ultimo completamento: scade dopo +{target}; il conteggio riparte dopo ogni completamento.",
|
||||
"trigger_hint_counter_abs": "L'attività scade quando il sensore raggiunge {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 無効な値です",
|
||||
"feat_schedule_time": "時刻指定スケジュール",
|
||||
"feat_schedule_time_desc": "タスクが深夜0時ではなく指定した時刻に期限超過となります。",
|
||||
"schedule_time_optional": "期限の時刻(任意、HH:MM)",
|
||||
"schedule_time_optional": "期限の時刻(任意)",
|
||||
"schedule_time_help": "空欄=深夜0時(既定)。HAのタイムゾーン。",
|
||||
"at_time": "時刻",
|
||||
"notes_optional": "メモ(任意)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "費用(任意)",
|
||||
"duration_minutes": "所要時間(分、任意)",
|
||||
"completed_at_optional": "完了日時(任意・空欄 = 現在)",
|
||||
"completed_at_pick": "日時を設定",
|
||||
"completed_at_future_error": "完了日時に未来は指定できません。",
|
||||
"days": "日",
|
||||
"day": "日",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "無効な値です",
|
||||
"seasonal_override_range": "係数は0.1〜5.0の範囲で指定してください",
|
||||
"clear_all": "すべてクリア",
|
||||
"clear": "クリア",
|
||||
"seasonal_chart_title": "季節係数",
|
||||
"seasonal_learned": "学習値",
|
||||
"seasonal_manual": "手動",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "センサーは現在 {value} を示しています。",
|
||||
"trigger_hint_above": "値が {target} を上回るとタスクが発動します。",
|
||||
"trigger_hint_below": "値が {target} を下回ると発動します。",
|
||||
"trigger_hint_overlap": "これらの制限は重なっています。すべての読み取り値でタスクがトリガーされ、復帰できなくなります。どちらか一方を空欄にしてください。",
|
||||
"trigger_hint_counter_delta": "現在の読み値({value})から数えます: {due}(+{target})で期限になり、完了のたびにカウントはやり直しになります。",
|
||||
"trigger_hint_counter_delta_edit": "前回の完了からの使用量を数えます: +{target} で期限になり、完了のたびにカウントはやり直しになります。",
|
||||
"trigger_hint_counter_abs": "センサーが {target} に達するとタスクが期限になります。",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 유효하지 않은 값입니다",
|
||||
"feat_schedule_time": "시각 지정 일정",
|
||||
"feat_schedule_time_desc": "작업이 자정이 아닌 지정한 시각에 기한 초과로 전환됩니다.",
|
||||
"schedule_time_optional": "기한 시각 (선택, HH:MM)",
|
||||
"schedule_time_optional": "기한 시각 (선택)",
|
||||
"schedule_time_help": "비워두면 자정(기본값)입니다. HA 시간대 기준.",
|
||||
"at_time": "시각",
|
||||
"notes_optional": "메모 (선택)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "비용 (선택)",
|
||||
"duration_minutes": "소요 시간(분, 선택)",
|
||||
"completed_at_optional": "완료 시각 (선택, 비우면 지금)",
|
||||
"completed_at_pick": "날짜 및 시간 설정",
|
||||
"completed_at_future_error": "완료 날짜는 미래일 수 없습니다.",
|
||||
"days": "일",
|
||||
"day": "일",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "유효하지 않은 값",
|
||||
"seasonal_override_range": "계수는 0.1에서 5.0 사이여야 합니다",
|
||||
"clear_all": "모두 지우기",
|
||||
"clear": "지우기",
|
||||
"seasonal_chart_title": "계절 계수",
|
||||
"seasonal_learned": "학습됨",
|
||||
"seasonal_manual": "수동",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "센서의 현재 값은 {value}입니다.",
|
||||
"trigger_hint_above": "{target}을(를) 초과하면 작업이 트리거됩니다.",
|
||||
"trigger_hint_below": "{target} 미만으로 떨어지면 트리거됩니다.",
|
||||
"trigger_hint_overlap": "이 한계값들이 겹칩니다. 모든 측정값이 작업을 트리거하여 복구되지 않습니다. 둘 중 하나를 비워 두세요.",
|
||||
"trigger_hint_counter_delta": "현재 값({value})부터 계수합니다: {due} 도달 시 기한 (+{target}), 완료할 때마다 계수가 다시 시작됩니다.",
|
||||
"trigger_hint_counter_delta_edit": "마지막 완료 이후 사용량을 계수합니다: +{target} 후 기한이 되며, 완료할 때마다 계수가 다시 시작됩니다.",
|
||||
"trigger_hint_counter_abs": "센서가 {target}에 도달하면 작업 기한이 됩니다.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ugyldig verdi",
|
||||
"feat_schedule_time": "Planlegging med klokkeslett",
|
||||
"feat_schedule_time_desc": "Oppgaver forfaller på et bestemt klokkeslett i stedet for ved midnatt.",
|
||||
"schedule_time_optional": "Forfaller kl. (valgfritt, TT:MM)",
|
||||
"schedule_time_optional": "Forfaller kl. (valgfritt)",
|
||||
"schedule_time_help": "Tom = midnatt (standard). HA-tidssone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Notater (valgfritt)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kostnad (valgfritt)",
|
||||
"duration_minutes": "Varighet i minutter (valgfritt)",
|
||||
"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.",
|
||||
"days": "dager",
|
||||
"day": "dag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ugyldig verdi",
|
||||
"seasonal_override_range": "Faktor må være mellom 0,1 og 5,0",
|
||||
"clear_all": "Fjern alle",
|
||||
"clear": "Tøm",
|
||||
"seasonal_chart_title": "Sesongfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensoren viser {value} akkurat nå.",
|
||||
"trigger_hint_above": "Oppgaven utløses når verdien stiger over {target}.",
|
||||
"trigger_hint_below": "Den utløses når verdien faller under {target}.",
|
||||
"trigger_hint_overlap": "Disse grensene overlapper: hver måling utløser oppgaven, og den kan aldri gjenopprettes. La ett av feltene stå tomt.",
|
||||
"trigger_hint_counter_delta": "Teller fra gjeldende avlesning ({value}): forfaller ved {due} (+{target}); telleren starter på nytt etter hver fullføring.",
|
||||
"trigger_hint_counter_delta_edit": "Teller forbruket siden forrige fullføring: forfaller etter +{target}; telleren starter på nytt etter hver fullføring.",
|
||||
"trigger_hint_counter_abs": "Oppgaven forfaller når sensoren når {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: ongeldige waarde",
|
||||
"feat_schedule_time": "Tijd-van-dag-planning",
|
||||
"feat_schedule_time_desc": "Taken vervallen op een specifieke tijd in plaats van middernacht.",
|
||||
"schedule_time_optional": "Vervaldagstijd (optioneel, HH:MM)",
|
||||
"schedule_time_optional": "Vervaldagstijd (optioneel)",
|
||||
"schedule_time_help": "Leeg = middernacht (standaard). HA-tijdzone.",
|
||||
"at_time": "om",
|
||||
"notes_optional": "Notities (optioneel)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Kosten (optioneel)",
|
||||
"duration_minutes": "Duur in minuten (optioneel)",
|
||||
"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.",
|
||||
"days": "dagen",
|
||||
"day": "dag",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Ongeldige waarde",
|
||||
"seasonal_override_range": "Factor moet tussen 0.1 en 5.0 liggen",
|
||||
"clear_all": "Alles wissen",
|
||||
"clear": "Wissen",
|
||||
"seasonal_chart_title": "Seizoensfactoren",
|
||||
"seasonal_learned": "Geleerd",
|
||||
"seasonal_manual": "Handmatig",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "De sensor staat nu op {value}.",
|
||||
"trigger_hint_above": "De taak wordt geactiveerd zodra de waarde boven {target} komt.",
|
||||
"trigger_hint_below": "Hij wordt geactiveerd zodra de waarde onder {target} zakt.",
|
||||
"trigger_hint_overlap": "Deze grenzen overlappen: elke meting activeert de taak en deze kan nooit herstellen. Laat een van beide leeg.",
|
||||
"trigger_hint_counter_delta": "Telt vanaf de huidige stand ({value}): vervalt bij {due} (+{target}); de teller herstart na elke afronding.",
|
||||
"trigger_hint_counter_delta_edit": "Telt het gebruik sinds de laatste afronding: vervalt na +{target}; de teller herstart na elke afronding.",
|
||||
"trigger_hint_counter_abs": "De taak vervalt zodra de sensor {target} bereikt.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: nieprawidłowa wartość",
|
||||
"feat_schedule_time": "Harmonogram według pory dnia",
|
||||
"feat_schedule_time_desc": "Zadania stają się zaległe o określonej porze dnia zamiast o północy.",
|
||||
"schedule_time_optional": "Termin o godzinie (opcjonalne, HH:MM)",
|
||||
"schedule_time_optional": "Termin o godzinie (opcjonalne)",
|
||||
"schedule_time_help": "Puste = północ (domyślnie). Strefa czasowa HA.",
|
||||
"at_time": "o",
|
||||
"notes_optional": "Notatki (opcjonalne)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Koszt (opcjonalne)",
|
||||
"duration_minutes": "Czas trwania w minutach (opcjonalne)",
|
||||
"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.",
|
||||
"days": "dni",
|
||||
"day": "dzień",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Nieprawidłowa wartość",
|
||||
"seasonal_override_range": "Czynnik musi być między 0.1 a 5.0",
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"clear": "Wyczyść",
|
||||
"seasonal_chart_title": "Czynniki sezonowe",
|
||||
"seasonal_learned": "Wyuczone",
|
||||
"seasonal_manual": "Ręczne",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Czujnik wskazuje teraz {value}.",
|
||||
"trigger_hint_above": "Zadanie uruchomi się, gdy wartość przekroczy {target}.",
|
||||
"trigger_hint_below": "Uruchomi się, gdy wartość spadnie poniżej {target}.",
|
||||
"trigger_hint_overlap": "Te granice nakładają się: każdy odczyt wyzwala zadanie i nigdy nie może ono wrócić do normy. Pozostaw jedno z pól puste.",
|
||||
"trigger_hint_counter_delta": "Liczy od bieżącego odczytu ({value}): termin przy {due} (+{target}); licznik startuje od nowa po każdym ukończeniu.",
|
||||
"trigger_hint_counter_delta_edit": "Liczy zużycie od ostatniego ukończenia: termin po +{target}; licznik startuje od nowa po każdym ukończeniu.",
|
||||
"trigger_hint_counter_abs": "Zadanie staje się wymagalne, gdy czujnik osiągnie {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Agendamento por horário",
|
||||
"feat_schedule_time_desc": "As tarefas ficam atrasadas em um horário específico do dia, em vez de à meia-noite.",
|
||||
"schedule_time_optional": "Vence às (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence às (opcional)",
|
||||
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário do HA.",
|
||||
"at_time": "às",
|
||||
"notes_optional": "Observações (opcional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Custo (opcional)",
|
||||
"duration_minutes": "Duração em minutos (opcional)",
|
||||
"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.",
|
||||
"days": "dias",
|
||||
"day": "dia",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Valor inválido",
|
||||
"seasonal_override_range": "O fator deve estar entre 0,1 e 5,0",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear": "Limpar",
|
||||
"seasonal_chart_title": "Fatores sazonais",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "O sensor lê {value} neste momento.",
|
||||
"trigger_hint_above": "A tarefa é acionada quando o valor sobe acima de {target}.",
|
||||
"trigger_hint_below": "É acionada quando o valor cai abaixo de {target}.",
|
||||
"trigger_hint_overlap": "Esses limites se sobrepõem: toda leitura aciona a tarefa e ela nunca consegue se recuperar. Deixe um dos campos vazio.",
|
||||
"trigger_hint_counter_delta": "Conta a partir da leitura atual ({value}): vence em {due} (+{target}), e a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_delta_edit": "Conta o uso desde a última conclusão: vence após +{target}; a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_abs": "A tarefa vence quando o sensor atinge {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Agendamento por hora",
|
||||
"feat_schedule_time_desc": "Tarefas vencem em um horário específico em vez de meia-noite.",
|
||||
"schedule_time_optional": "Vence às (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence às (opcional)",
|
||||
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário HA.",
|
||||
"at_time": "às",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Custo (opcional)",
|
||||
"duration_minutes": "Duração em minutos (opcional)",
|
||||
"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.",
|
||||
"days": "dias",
|
||||
"day": "dia",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valor inválido",
|
||||
"seasonal_override_range": "O fator deve estar entre 0.1 e 5.0",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear": "Limpar",
|
||||
"seasonal_chart_title": "Fatores sazonais",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "O sensor marca {value} neste momento.",
|
||||
"trigger_hint_above": "A tarefa dispara quando ultrapassar {target}.",
|
||||
"trigger_hint_below": "Dispara quando descer abaixo de {target}.",
|
||||
"trigger_hint_overlap": "Estes limites sobrepõem-se: cada leitura aciona a tarefa e esta nunca consegue recuperar. Deixe um dos campos vazio.",
|
||||
"trigger_hint_counter_delta": "Conta a partir da leitura atual ({value}): vence em {due} (+{target}); a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_delta_edit": "Conta o uso desde a última conclusão: vence após +{target}; a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_abs": "A tarefa vence quando o sensor atingir {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: неверное значение",
|
||||
"feat_schedule_time": "Планирование по времени дня",
|
||||
"feat_schedule_time_desc": "Задачи становятся просроченными в определённое время дня, а не в полночь.",
|
||||
"schedule_time_optional": "Срок (необязательно, HH:MM)",
|
||||
"schedule_time_optional": "Срок (необязательно)",
|
||||
"schedule_time_help": "Пусто = полночь (по умолчанию). Часовой пояс HA.",
|
||||
"at_time": "в",
|
||||
"notes_optional": "Примечания (опционально)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Стоимость (опционально)",
|
||||
"duration_minutes": "Длительность в минутах (опционально)",
|
||||
"completed_at_optional": "Выполнено (необязательно, пусто = сейчас)",
|
||||
"completed_at_pick": "Указать дату и время",
|
||||
"completed_at_future_error": "Дата выполнения не может быть в будущем.",
|
||||
"days": "дней",
|
||||
"day": "день",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Недопустимое значение",
|
||||
"seasonal_override_range": "Коэффициент должен быть от 0.1 до 5.0",
|
||||
"clear_all": "Очистить все",
|
||||
"clear": "Очистить",
|
||||
"seasonal_chart_title": "Сезонные факторы",
|
||||
"seasonal_learned": "Изученные",
|
||||
"seasonal_manual": "Ручные",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Сейчас датчик показывает {value}.",
|
||||
"trigger_hint_above": "Задача сработает, когда значение поднимется выше {target}.",
|
||||
"trigger_hint_below": "Она сработает, когда значение опустится ниже {target}.",
|
||||
"trigger_hint_overlap": "Эти пределы перекрываются: каждое показание запускает задачу, и она никогда не сможет восстановиться. Оставьте одно из полей пустым.",
|
||||
"trigger_hint_counter_delta": "Отсчёт от текущего показания ({value}): срок при {due} (+{target}); счётчик начинается заново после каждого выполнения.",
|
||||
"trigger_hint_counter_delta_edit": "Считает расход с последнего выполнения: срок после +{target}; счётчик начинается заново после каждого выполнения.",
|
||||
"trigger_hint_counter_abs": "Задача наступит, когда датчик достигнет {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: ogiltigt värde",
|
||||
"feat_schedule_time": "Schemaläggning per tid på dygnet",
|
||||
"feat_schedule_time_desc": "Uppgifter blir försenade vid en specifik tid på dygnet istället för midnatt.",
|
||||
"schedule_time_optional": "Förfaller kl. (valfritt, HH:MM)",
|
||||
"schedule_time_optional": "Förfaller kl. (valfritt)",
|
||||
"schedule_time_help": "Tomt = midnatt (standard). HA-tidszon.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Anteckningar (valfritt)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Kostnad (valfritt)",
|
||||
"duration_minutes": "Varaktighet i minuter (valfritt)",
|
||||
"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.",
|
||||
"days": "dagar",
|
||||
"day": "dag",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Ogiltigt värde",
|
||||
"seasonal_override_range": "Faktor måste vara mellan 0.1 och 5.0",
|
||||
"clear_all": "Rensa alla",
|
||||
"clear": "Rensa",
|
||||
"seasonal_chart_title": "Säsongsfaktorer",
|
||||
"seasonal_learned": "Lärt",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensorn visar {value} just nu.",
|
||||
"trigger_hint_above": "Uppgiften utlöses när värdet stiger över {target}.",
|
||||
"trigger_hint_below": "Den utlöses när värdet faller under {target}.",
|
||||
"trigger_hint_overlap": "Dessa gränser överlappar: varje mätvärde utlöser uppgiften och den kan aldrig återhämta sig. Lämna ett av fälten tomt.",
|
||||
"trigger_hint_counter_delta": "Räknar från den aktuella avläsningen ({value}): förfaller vid {due} (+{target}); räknaren börjar om efter varje slutförande.",
|
||||
"trigger_hint_counter_delta_edit": "Räknar förbrukningen sedan senaste slutförandet: förfaller efter +{target}; räknaren börjar om efter varje slutförande.",
|
||||
"trigger_hint_counter_abs": "Uppgiften förfaller när sensorn når {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: geçersiz değer",
|
||||
"feat_schedule_time": "Gün içi saat zamanlaması",
|
||||
"feat_schedule_time_desc": "Görevler gece yarısı yerine günün belirli bir saatinde gecikmiş olur.",
|
||||
"schedule_time_optional": "Vade saati (isteğe bağlı, SS:DD)",
|
||||
"schedule_time_optional": "Vade saati (isteğe bağlı)",
|
||||
"schedule_time_help": "Boş = gece yarısı (varsayılan). HA saat dilimi.",
|
||||
"at_time": "saat",
|
||||
"notes_optional": "Notlar (isteğe bağlı)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Maliyet (isteğe bağlı)",
|
||||
"duration_minutes": "Dakika cinsinden süre (isteğe bağlı)",
|
||||
"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.",
|
||||
"days": "gün",
|
||||
"day": "gün",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Geçersiz değer",
|
||||
"seasonal_override_range": "Faktör 0.1 ile 5.0 arasında olmalıdır",
|
||||
"clear_all": "Tümünü temizle",
|
||||
"clear": "Temizle",
|
||||
"seasonal_chart_title": "Mevsimsel Faktörler",
|
||||
"seasonal_learned": "Öğrenilmiş",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensör şu anda {value} değerini okuyor.",
|
||||
"trigger_hint_above": "Görev, değer {target} üzerine çıktığında tetiklenir.",
|
||||
"trigger_hint_below": "Değer {target} altına düştüğünde tetiklenir.",
|
||||
"trigger_hint_overlap": "Bu sınırlar çakışıyor: her okuma görevi tetikler ve görev asla normale dönemez. Alanlardan birini boş bırakın.",
|
||||
"trigger_hint_counter_delta": "Mevcut okumadan ({value}) itibaren sayar: {due} değerinde vadesi gelir (+{target}) ve sayım her tamamlamadan sonra yeniden başlar.",
|
||||
"trigger_hint_counter_delta_edit": "Son tamamlamadan bu yana kullanımı sayar: +{target} sonrasında vadesi gelir; sayım her tamamlamadan sonra yeniden başlar.",
|
||||
"trigger_hint_counter_abs": "Sensör {target} değerine ulaştığında görevin vadesi gelir.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: невірне значення",
|
||||
"feat_schedule_time": "Планування за часом доби",
|
||||
"feat_schedule_time_desc": "Задачі стають простроченими у певний час доби, а не опівночі.",
|
||||
"schedule_time_optional": "Час прострочення (необов'язково, HH:MM)",
|
||||
"schedule_time_optional": "Час прострочення (необов'язково)",
|
||||
"schedule_time_help": "Порожньо = опівночі (за замовчуванням). Часовий пояс HA.",
|
||||
"at_time": "о",
|
||||
"notes_optional": "Примітки (необов'язково)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Вартість (необов'язково)",
|
||||
"duration_minutes": "Тривалість у хвилинах (необов'язково)",
|
||||
"completed_at_optional": "Виконано (необов'язково, порожньо = зараз)",
|
||||
"completed_at_pick": "Вказати дату й час",
|
||||
"completed_at_future_error": "Дата виконання не може бути в майбутньому.",
|
||||
"days": "днів",
|
||||
"day": "день",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Недійсне значення",
|
||||
"seasonal_override_range": "Коефіцієнт має бути між 0.1 та 5.0",
|
||||
"clear_all": "Очистити все",
|
||||
"clear": "Очистити",
|
||||
"seasonal_chart_title": "Сезонні коефіцієнти",
|
||||
"seasonal_learned": "Навчена",
|
||||
"seasonal_manual": "Ручна",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Зараз датчик показує {value}.",
|
||||
"trigger_hint_above": "Завдання спрацює, коли значення підніметься вище {target}.",
|
||||
"trigger_hint_below": "Воно спрацює, коли значення опуститься нижче {target}.",
|
||||
"trigger_hint_overlap": "Ці межі перекриваються: кожне показання запускає завдання, і воно ніколи не зможе відновитися. Залиште одне з полів порожнім.",
|
||||
"trigger_hint_counter_delta": "Відлік від поточного показника ({value}): термін при {due} (+{target}); лічильник починається заново після кожного виконання.",
|
||||
"trigger_hint_counter_delta_edit": "Рахує використання з останнього виконання: термін після +{target}; лічильник починається заново після кожного виконання.",
|
||||
"trigger_hint_counter_abs": "Завдання настане, коли датчик досягне {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 无效数值",
|
||||
"feat_schedule_time": "具体时间调度",
|
||||
"feat_schedule_time_desc": "任务将在特定时间点变为逾期,而非默认的午夜。",
|
||||
"schedule_time_optional": "到期时间 (可选, HH:MM)",
|
||||
"schedule_time_optional": "到期时间 (可选)",
|
||||
"schedule_time_help": "留空 = 午夜 (默认)。基于 HA 时区。",
|
||||
"at_time": "于",
|
||||
"notes_optional": "备注 (可选)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "成本 (可选)",
|
||||
"duration_minutes": "耗时 (分钟, 可选)",
|
||||
"completed_at_optional": "完成时间(可选,留空 = 现在)",
|
||||
"completed_at_pick": "设置日期和时间",
|
||||
"completed_at_future_error": "完成日期不能是未来时间。",
|
||||
"days": "天",
|
||||
"day": "天",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "无效数值",
|
||||
"seasonal_override_range": "系数必须介于 0.1 到 5.0 之间",
|
||||
"clear_all": "全部清除",
|
||||
"clear": "清除",
|
||||
"seasonal_chart_title": "季节性因素",
|
||||
"seasonal_learned": "自动学习",
|
||||
"seasonal_manual": "手动设置",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "传感器当前读数为 {value}。",
|
||||
"trigger_hint_above": "当数值升到 {target} 以上时任务触发。",
|
||||
"trigger_hint_below": "当数值降到 {target} 以下时任务触发。",
|
||||
"trigger_hint_overlap": "这两个界限相互重叠:每个读数都会触发任务,任务将永远无法恢复。请将其中一项留空。",
|
||||
"trigger_hint_counter_delta": "从当前读数({value})开始计数:到 {due}(+{target})时到期;每次完成后重新计数。",
|
||||
"trigger_hint_counter_delta_edit": "从上次完成开始计算用量:+{target} 后到期;每次完成后重新计数。",
|
||||
"trigger_hint_counter_abs": "当传感器达到 {target} 时任务到期。",
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type CalendarEvent,
|
||||
} from "./helpers/calendar-bucket";
|
||||
import { calendarStyles } from "./calendar-styles";
|
||||
import { syncLocaleFromHass, sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatWeekday, formatMonth, langOf, formatCost } from "./styles";
|
||||
import { registerCustomCard } from "./helpers/register-card";
|
||||
import { openHistoryEditDialog, openTaskQuickActions } from "./dialog-mount";
|
||||
import type {
|
||||
@@ -371,7 +371,7 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
${recurEvery}
|
||||
</div>
|
||||
${ev.avg_cost != null && ev.avg_cost > 0
|
||||
? html`<span class="cal-event-cost">${ev.avg_cost.toFixed(0)} ${currencySymbol}</span>`
|
||||
? html`<span class="cal-event-cost">${formatCost(ev.avg_cost, currencySymbol, L, 0)}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
@@ -381,8 +381,8 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
const [y, m, d] = bucket.date.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
const isToday = bucket.date === todayIso;
|
||||
const weekday = date.toLocaleDateString(L, { weekday: "short" });
|
||||
const monthLabel = date.toLocaleDateString(L, { month: "long" });
|
||||
const weekday = formatWeekday(date, L, "short");
|
||||
const monthLabel = formatMonth(date, L, "long");
|
||||
return html`
|
||||
<div class="cal-day-row">
|
||||
<div class="cal-day-pill ${isToday ? "cal-today" : ""}">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays, langOf } from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, langOf } from "./styles";
|
||||
import { openSignedDocument } from "./helpers/document-url";
|
||||
import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { registerCustomCard } from "./helpers/register-card";
|
||||
|
||||
+20
@@ -779,6 +779,12 @@ class MaintenanceStrategyEditor extends HTMLElement {
|
||||
const SECTION_STRATEGY_TYPE = "maintenance-supporter-section";
|
||||
const SECTION_STRATEGY_TAG = `ll-strategy-section-${SECTION_STRATEGY_TYPE}`;
|
||||
|
||||
// Method syntax on purpose: parameters check bivariantly, so the concretely
|
||||
// typed generate() of each section class satisfies this without a cast.
|
||||
type SectionStrategyClass = {
|
||||
generate(config: never, hass: never): Promise<SectionConfig>;
|
||||
};
|
||||
|
||||
interface MaintenanceSectionStrategyConfig {
|
||||
type: "custom:maintenance-supporter-section" | "maintenance-supporter-section";
|
||||
area_id?: string;
|
||||
@@ -994,6 +1000,20 @@ if (!customElements.get(VACATION_TAG)) customElements.define(VACATION_TAG, Maint
|
||||
if (!customElements.get(BUDGET_TAG)) customElements.define(BUDGET_TAG, MaintenanceBudgetSectionStrategy);
|
||||
if (!customElements.get(GROUPS_TAG)) customElements.define(GROUPS_TAG, MaintenanceGroupsSectionStrategy);
|
||||
|
||||
// The shim (maintenance-strategy-shim.ts) defines all four section tags
|
||||
// synchronously at boot and delegates generate() here, keyed by strategy type.
|
||||
// Until v2.73.0 the tags above were the ONLY registration, i.e. they existed
|
||||
// only after the first dashboard-strategy generate() had pulled this bundle
|
||||
// in — a section strategy dropped into a foreign dashboard on a fresh page
|
||||
// (the documented use) hit HA's 5 s whenDefined timeout and rendered empty.
|
||||
// The defines above stay as the fallback for a bundle loaded without the shim.
|
||||
export const SECTION_STRATEGIES: Record<string, SectionStrategyClass> = {
|
||||
[SECTION_STRATEGY_TYPE]: MaintenanceSectionStrategy,
|
||||
"maintenance-supporter-vacation": MaintenanceVacationSectionStrategy,
|
||||
"maintenance-supporter-budget": MaintenanceBudgetSectionStrategy,
|
||||
"maintenance-supporter-groups": MaintenanceGroupsSectionStrategy,
|
||||
};
|
||||
|
||||
// ── fire-dom-event handler ──────────────────────────────────────────────────
|
||||
//
|
||||
// HA's standard ``tap_action: { action: "fire-dom-event", ll_custom: {...} }``
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs, langOf } from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatNumber, formatCost } from "./styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
@@ -83,6 +83,7 @@ import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail
|
||||
// would never register.
|
||||
import "./components/task-detail-view";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
|
||||
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
|
||||
|
||||
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
|
||||
|
||||
@@ -113,6 +114,16 @@ interface PartsOverviewRow {
|
||||
type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group";
|
||||
type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count";
|
||||
type GroupByMode = "none" | "area" | "group" | "user" | "object";
|
||||
type OverviewTab = "today" | "dashboard" | "calendar" | "settings";
|
||||
|
||||
// Value lists for everything that arrives as a free string — localStorage,
|
||||
// a saved view, a deep-link query — and must be validated before it lands in
|
||||
// state (an unknown mode would be persisted and silently break sorting).
|
||||
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
|
||||
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
|
||||
const OVERVIEW_TABS: readonly OverviewTab[] = ["today", "dashboard", "calendar", "settings"];
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar). */
|
||||
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
|
||||
|
||||
// Chart dimension constants for mini sparklines (overview)
|
||||
|
||||
@@ -209,7 +220,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _dismissedSuggestions = new Set<string>();
|
||||
|
||||
// Dashboard redesign state
|
||||
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
|
||||
@state() private _overviewTab: OverviewTab = (() => {
|
||||
try {
|
||||
const v = lsGet(LS_KEYS.overviewTab);
|
||||
return v === "today" || v === "calendar" ? v : "dashboard";
|
||||
@@ -240,6 +251,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _virtTotalRows = 0;
|
||||
private _virtScrollAttached = false;
|
||||
private _virtRaf = 0;
|
||||
// Split view (2026-09-02): the docked detail pane is scroll-aware sticky
|
||||
// (helpers/sticky-pane) — it never scrolls on its own; a pane taller than
|
||||
// the viewport is pinned by its bottom edge while scrolling down and by
|
||||
// its top edge while scrolling up.
|
||||
private _stickyState: StickyState = INITIAL_STICKY;
|
||||
private _stickySelectPending = false;
|
||||
private _stickyRaf = 0;
|
||||
private _stickyAttached = false;
|
||||
private _stickyObserver: ResizeObserver | null = null;
|
||||
// Group-by sections collapsed by the user (keyed by section label). Cleared
|
||||
// when the group-by mode changes — labels mean something else then.
|
||||
@state() private _collapsedGroups = new Set<string>();
|
||||
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
|
||||
// remembered per section across visits.
|
||||
@state() private _collapsedSections: Set<string> = (() => {
|
||||
@@ -292,6 +315,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
|
||||
private _locationChangedHandler = () => this._onLocationChanged();
|
||||
|
||||
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
|
||||
* view are code-split out of the entry bundle and fetched in parallel
|
||||
@@ -333,6 +357,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (idle) idle(kick, { timeout: 3000 });
|
||||
else window.setTimeout(kick, 1500);
|
||||
window.addEventListener("popstate", this._popstateHandler);
|
||||
window.addEventListener("location-changed", this._locationChangedHandler);
|
||||
window.addEventListener("keydown", this._paletteKeydown);
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._tightObserver = new ResizeObserver((entries) => {
|
||||
@@ -355,7 +380,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// other storage access in this file is wrapped; wrap these too.
|
||||
try {
|
||||
const saved = lsGet(LS_KEYS.taskSort);
|
||||
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
|
||||
if (saved && (SORT_MODES as readonly string[]).includes(saved)) {
|
||||
this._sortMode = saved as SortMode;
|
||||
}
|
||||
const savedObj = lsGet(LS_KEYS.objectSort);
|
||||
@@ -363,7 +388,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._objectSortMode = savedObj as ObjectSortMode;
|
||||
}
|
||||
const savedGroup = lsGet(LS_KEYS.groupBy);
|
||||
if (savedGroup && ["none", "area", "group", "user", "object"].includes(savedGroup)) {
|
||||
if (savedGroup && (GROUP_BY_MODES as readonly string[]).includes(savedGroup)) {
|
||||
this._groupByMode = savedGroup as GroupByMode;
|
||||
}
|
||||
const savedView = lsGet(LS_KEYS.objectView);
|
||||
@@ -387,6 +412,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("popstate", this._popstateHandler);
|
||||
window.removeEventListener("location-changed", this._locationChangedHandler);
|
||||
window.removeEventListener("keydown", this._paletteKeydown);
|
||||
this._tightObserver?.disconnect();
|
||||
this._tightObserver = null;
|
||||
@@ -394,17 +420,28 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onVirtualScroll);
|
||||
this._virtScrollAttached = false;
|
||||
if (this._virtRaf) cancelAnimationFrame(this._virtRaf);
|
||||
this._detachStickyPane();
|
||||
if (this._unsub) {
|
||||
this._unsub();
|
||||
this._unsub = null;
|
||||
}
|
||||
this._dataLoaded = false;
|
||||
this._initialLoadDone = false;
|
||||
this._lastConnection = null;
|
||||
this._deepLinkHandled = false;
|
||||
this._statsService?.clearCache();
|
||||
this._statsService = null;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: Map<string, unknown>): void {
|
||||
super.willUpdate(changedProps);
|
||||
// Section labels are mode-specific ("Kitchen" vs "Family Car"): a
|
||||
// collapsed set from the previous mode would fold random new sections.
|
||||
if (changedProps.has("_groupByMode") && this._collapsedGroups.size > 0) {
|
||||
this._collapsedGroups = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
syncLocaleFromHass(this, changedProps);
|
||||
@@ -451,6 +488,90 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._virtScrollAttached = true;
|
||||
}
|
||||
this._updateVirtualWindow();
|
||||
this._syncStickyPane(content);
|
||||
}
|
||||
|
||||
/** Split view: (de)attach the scroll-aware docking of the detail pane and
|
||||
* apply the current state to the just-rendered DOM. */
|
||||
private _syncStickyPane(content: Element | null | undefined): void {
|
||||
const pane = this.shadowRoot?.querySelector<HTMLElement>(".split-pane");
|
||||
if (!pane || !content) {
|
||||
if (this._stickyAttached) this._detachStickyPane();
|
||||
return;
|
||||
}
|
||||
if (!this._stickyAttached) {
|
||||
content.addEventListener("scroll", this._onStickyScroll, { passive: true });
|
||||
window.addEventListener("resize", this._onStickyScroll);
|
||||
if (typeof ResizeObserver !== "undefined") this._stickyObserver = new ResizeObserver(this._onStickyScroll);
|
||||
this._stickyAttached = true;
|
||||
}
|
||||
// Observing an already-observed node is a no-op; a re-created pane
|
||||
// (split left and came back) gets picked up here.
|
||||
this._stickyObserver?.observe(pane);
|
||||
this._updateStickyPane();
|
||||
}
|
||||
|
||||
private _detachStickyPane(): void {
|
||||
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onStickyScroll);
|
||||
window.removeEventListener("resize", this._onStickyScroll);
|
||||
this._stickyObserver?.disconnect();
|
||||
this._stickyObserver = null;
|
||||
if (this._stickyRaf) cancelAnimationFrame(this._stickyRaf);
|
||||
this._stickyRaf = 0;
|
||||
this._stickyAttached = false;
|
||||
this._stickyState = INITIAL_STICKY;
|
||||
this._stickySelectPending = false;
|
||||
}
|
||||
|
||||
/** rAF-throttled scroll/resize/pane-resize handler for the docked pane. */
|
||||
private _onStickyScroll = (): void => {
|
||||
if (this._stickyRaf) return;
|
||||
this._stickyRaf = requestAnimationFrame(() => {
|
||||
this._stickyRaf = 0;
|
||||
this._updateStickyPane();
|
||||
});
|
||||
};
|
||||
|
||||
/** Measure scroller/pane/list and apply the next docking state as inline
|
||||
* `top` + `margin-top` on the (sticky) pane. */
|
||||
private _updateStickyPane(): void {
|
||||
const sr = this.shadowRoot;
|
||||
const content = sr?.querySelector<HTMLElement>(".content");
|
||||
const pane = sr?.querySelector<HTMLElement>(".split-pane");
|
||||
const layout = sr?.querySelector<HTMLElement>(".split-layout");
|
||||
const list = sr?.querySelector<HTMLElement>(".split-list");
|
||||
if (!content || !pane || !layout || !list) return;
|
||||
// Viewport y of the scroller's content origin — turns client rects
|
||||
// into scrollTop-space offsets.
|
||||
const origin = content.getBoundingClientRect().top - content.scrollTop;
|
||||
const m = {
|
||||
scrollTop: content.scrollTop,
|
||||
viewH: content.clientHeight,
|
||||
paneH: pane.offsetHeight,
|
||||
listH: list.offsetHeight,
|
||||
layoutTop: layout.getBoundingClientRect().top - origin,
|
||||
renderedTop: pane.getBoundingClientRect().top - origin,
|
||||
};
|
||||
// Fresh selection: the detail view fills in asynchronously (its own
|
||||
// update + WS loads), so keep re-parking on every pane resize until the
|
||||
// user scrolls — from then on the direction machine owns the state.
|
||||
if (this._stickySelectPending && m.scrollTop === this._stickyState.lastScrollTop) {
|
||||
this._stickyState = stickyStateOnSelect(m);
|
||||
} else {
|
||||
this._stickySelectPending = false;
|
||||
this._stickyState = nextStickyState(this._stickyState, m);
|
||||
}
|
||||
const next = this._stickyState;
|
||||
pane.style.top = `${stickyTop(next.mode, m)}px`;
|
||||
pane.style.marginTop = next.marginTop > 0 ? `${next.marginTop}px` : "";
|
||||
}
|
||||
|
||||
/** A new selection parks the pane where the user is looking, whatever the
|
||||
* pane was doing before (see stickyStateOnSelect). */
|
||||
private _resetStickyPane(): void {
|
||||
const content = this.shadowRoot?.querySelector<HTMLElement>(".content");
|
||||
this._stickyState = { ...INITIAL_STICKY, lastScrollTop: content?.scrollTop ?? 0 };
|
||||
this._stickySelectPending = true;
|
||||
}
|
||||
|
||||
/** rAF-throttled scroll/resize handler for the virtualized task table. */
|
||||
@@ -567,10 +688,30 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._fetchMiniStatsForOverview();
|
||||
|
||||
// Handle deep-link URL parameters (from QR code scan)
|
||||
this._initialLoadDone = true;
|
||||
this._handleDeepLink();
|
||||
}
|
||||
|
||||
private _deepLinkHandled = false;
|
||||
/** Objects + saved views have arrived at least once — deep links that
|
||||
* reference them are routable. */
|
||||
private _initialLoadDone = false;
|
||||
|
||||
/** In-app navigation to the panel while it is already mounted (#160): HA's
|
||||
* navigate() — a dashboard button's navigation_path, a notification tap
|
||||
* with the app open — fires `location-changed` and, since the panel path
|
||||
* is unchanged, neither remounts us nor hands over a new route object
|
||||
* (verified on HA 2026.7: the route stays identical for a query-only
|
||||
* navigation). The URL is the only signal, so re-read the deep-link
|
||||
* params from it. Navigations away from the panel are not ours. */
|
||||
private _onLocationChanged(): void {
|
||||
if (!this._initialLoadDone || !window.location.search) return;
|
||||
const base = `/${typeof this.panel?.url_path === "string" ? this.panel.url_path : "maintenance-supporter"}`;
|
||||
const path = window.location.pathname;
|
||||
if (path !== base && !path.startsWith(`${base}/`)) return;
|
||||
this._deepLinkHandled = false;
|
||||
this._handleDeepLink();
|
||||
}
|
||||
|
||||
private _handleDeepLink(): void {
|
||||
if (this._deepLinkHandled) return;
|
||||
@@ -616,6 +757,48 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// v2.74.0 (Discussion #160, @maisun): overview deep links — land on a tab
|
||||
// with the task list pre-filtered / pre-sorted, or on a saved view, so a
|
||||
// dashboard button or a notification tap can open e.g. the Today list:
|
||||
// ?tab=today|dashboard|calendar|settings
|
||||
// ?view=<saved view id or name> (implies the Dashboard tab)
|
||||
// ?sort=due_date|object|type|task_name|area|assigned_user|group
|
||||
// ?status=overdue|due_soon|triggered|ok (implies the Dashboard tab)
|
||||
// Explicit sort/status win over the saved view's; unknown values are
|
||||
// ignored. Like a tap, the tab (also the implied one) and the sort are
|
||||
// remembered for the next plain visit. Consumed once and stripped like
|
||||
// every other param.
|
||||
const tab = params.get("tab");
|
||||
const view = params.get("view");
|
||||
const sort = params.get("sort");
|
||||
const status = params.get("status");
|
||||
if (tab !== null || view !== null || sort !== null || status !== null) {
|
||||
cleanMsActionUrl();
|
||||
if (this._view !== "overview") this._showOverview();
|
||||
if ((OVERVIEW_TABS as readonly string[]).includes(tab ?? "")) {
|
||||
this._setOverviewTab(tab as OverviewTab);
|
||||
}
|
||||
if (view !== null) {
|
||||
const wanted = view.trim().toLowerCase();
|
||||
const match = this._savedViews.find((v) => v.id === view)
|
||||
?? this._savedViews.find((v) => v.name.trim().toLowerCase() === wanted);
|
||||
if (match) {
|
||||
// Tab first: _applyView flips to the dashboard without persisting.
|
||||
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
|
||||
this._applyView(match.id);
|
||||
}
|
||||
}
|
||||
if ((SORT_MODES as readonly string[]).includes(sort ?? "")) {
|
||||
this._sortMode = sort as SortMode;
|
||||
this._activeViewId = "";
|
||||
lsSet(LS_KEYS.taskSort, this._sortMode);
|
||||
}
|
||||
if (STATUS_FILTERS.includes(status ?? "")) {
|
||||
if (this._overviewTab !== "dashboard") this._setOverviewTab("dashboard");
|
||||
this._filterByStatus(status as string);
|
||||
}
|
||||
}
|
||||
|
||||
const entryId = params.get("entry_id");
|
||||
if (!entryId) return;
|
||||
this._deepLinkHandled = true;
|
||||
@@ -1026,10 +1209,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// Validate against the current enums before assigning — a view saved by an
|
||||
// older/renamed build could carry a mode no longer valid, which would then
|
||||
// be persisted to localStorage and silently break sorting/grouping.
|
||||
if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) {
|
||||
if ((SORT_MODES as readonly string[]).includes(f.sort_mode)) {
|
||||
this._sortMode = f.sort_mode as SortMode;
|
||||
}
|
||||
if (["none", "area", "group", "user", "object"].includes(f.group_by)) {
|
||||
if ((GROUP_BY_MODES as readonly string[]).includes(f.group_by)) {
|
||||
this._groupByMode = f.group_by as GroupByMode;
|
||||
}
|
||||
// Persist sort/group like the manual controls do, so they stick after reload.
|
||||
@@ -1098,6 +1281,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._selectedTaskId = taskId;
|
||||
this._activeTab = "overview";
|
||||
this._historyFilter = null;
|
||||
this._resetStickyPane();
|
||||
this._fetchFullHistory(entryId, taskId);
|
||||
const task = this._getTask(entryId, taskId);
|
||||
if (task?.trigger_config?.entity_id) {
|
||||
@@ -1567,7 +1751,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const html = buildObjectReportHtml(
|
||||
resp.object, resp.tasks, labels,
|
||||
(iso) => (iso ? formatDate(iso, L) : ""),
|
||||
this._currencySymbol,
|
||||
(amount) => formatCost(amount, this._currencySymbol, L),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
openHtmlInNewTab(html);
|
||||
@@ -2295,16 +2479,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// duplicating what the card now does.
|
||||
|
||||
/** Status badge with a shape icon so status isn't conveyed by colour alone
|
||||
* (colour-blind accessibility) — icon + text + colour together. */
|
||||
* (colour-blind accessibility) — icon + text + colour together. The label
|
||||
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
|
||||
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
|
||||
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
|
||||
const L = this._lang;
|
||||
const cls = archived ? "archived" : (isDone ? "done" : status);
|
||||
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
|
||||
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
|
||||
return html`<span class="status-badge ${cls}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon>${label}</span>`;
|
||||
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
|
||||
}
|
||||
|
||||
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
|
||||
private _setOverviewTab(tab: OverviewTab): void {
|
||||
this._overviewTab = tab;
|
||||
try { lsSet(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
|
||||
this._scrollContentToTop();
|
||||
@@ -2655,7 +2841,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="task-row virt-sizer" aria-hidden="true">
|
||||
${this._bulkMode ? html`<span></span>` : nothing}
|
||||
<span class="cell-badges">
|
||||
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon>${widest}</span>
|
||||
<span class="status-badge"><ha-icon icon="mdi:circle-medium"></ha-icon><span class="status-label">${widest}</span></span>
|
||||
${anyDisabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
|
||||
${anyNfc ? html`<span class="nfc-badge"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
|
||||
${anyPriority ? html`<span class="priority-badge"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
|
||||
@@ -2721,20 +2907,51 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: this._groupByMode === "group" ? "mdi:folder-outline"
|
||||
: this._groupByMode === "object" ? "mdi:cube-outline"
|
||||
: "mdi:account-outline";
|
||||
// ONE grid owns the column tracks for every section (each section card
|
||||
// and its row block are column subgrids), so the badge/object/name
|
||||
// columns line up across sections no matter which badges a section
|
||||
// carries — per-section tables started the task name up to 60px apart
|
||||
// ("OK"-only vs overdue+ok sections). A <details> can't sit in that
|
||||
// chain (Chrome's ::details-content box breaks subgrid), hence the
|
||||
// div-based disclosure with its own collapsed state.
|
||||
return html`
|
||||
${sorted.map(([key, taskRows]) => html`
|
||||
<details class="group-section" open>
|
||||
<summary class="group-section-header">
|
||||
<div class="task-table grouped${this._bulkMode ? " bulk" : ""}">
|
||||
${sorted.map(([key, taskRows]) => {
|
||||
const open = !this._collapsedGroups.has(key);
|
||||
return html`
|
||||
<div class="group-section" ?open=${open}>
|
||||
<div
|
||||
class="group-section-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded=${open ? "true" : "false"}
|
||||
@click=${() => this._toggleGroup(key)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this._toggleGroup(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon icon="${icon}"></ha-icon>
|
||||
<span>${key}</span>
|
||||
<span class="group-section-count">(${taskRows.length})</span>
|
||||
</summary>
|
||||
<div class="task-table${this._bulkMode ? " bulk" : ""}">
|
||||
${taskRows.map((row) => this._renderOverviewRow(row))}
|
||||
</div>
|
||||
</details>
|
||||
`)}
|
||||
${open
|
||||
? html`<div class="group-rows">${taskRows.map((row) => this._renderOverviewRow(row))}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleGroup(key: string): void {
|
||||
const next = new Set(this._collapsedGroups);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
this._collapsedGroups = next;
|
||||
}
|
||||
|
||||
// (#67) Localised warranty label, shared by the detail meta + table cell.
|
||||
@@ -3223,17 +3440,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const pct = Math.min(100, Math.max(0, (spent / budget) * 100));
|
||||
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
|
||||
return html`
|
||||
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} / ${budget.toFixed(2)} ${cs}">
|
||||
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
|
||||
<span class="budget-tile-max">/ ${budget.toFixed(0)} ${cs}</span>
|
||||
<div class="stat-item budget-tile" title="${label}: ${formatNumber(spent, L, 2)} / ${formatCost(budget, cs, L)}">
|
||||
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
|
||||
<span class="budget-tile-max">/ ${formatCost(budget, cs, L, 0)}</span>
|
||||
<div class="budget-tile-bar"><div style="width:${pct}%; background:${color}"></div></div>
|
||||
<span class="stat-label">${label}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} ${cs}">
|
||||
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
|
||||
<div class="stat-item budget-tile" title="${label}: ${formatCost(spent, cs, L)}">
|
||||
<span class="stat-value budget-tile-value">${formatCost(spent, cs, L)}</span>
|
||||
<span class="stat-label">${label}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -3278,8 +3495,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
${row.priority === "high" ? html`<span class="priority-badge priority-high" title="${t("priority_high", L)}"><ha-icon icon="mdi:chevron-double-up"></ha-icon></span>` : nothing}
|
||||
${row.priority === "low" ? html`<span class="priority-badge priority-low" title="${t("priority_low", L)}"><ha-icon icon="mdi:chevron-double-down"></ha-icon></span>` : nothing}
|
||||
</span>
|
||||
<span class="row-head">
|
||||
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${row.task_name}</span>
|
||||
</span>
|
||||
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
|
||||
${row.group_names.length > 0 ? html`
|
||||
<span class="sub-chip" title="${t("groups", L)}">
|
||||
@@ -3338,25 +3557,27 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** #145: row actions as HA buttons — labelled (accent/success + plain, the
|
||||
* task-detail pairing) or, for "buttons_compact" on narrow screens,
|
||||
/** #145: row actions as HA buttons — labelled (accent/success + outlined
|
||||
* warning, the task-detail pairing) or, for "buttons_compact" on narrow screens,
|
||||
* icon-only — or the classic icon pair when the household chose "icons". */
|
||||
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void, allowSkip = true): TemplateResult {
|
||||
const style = this._actionStyle();
|
||||
if (style === "buttons" || style === "buttons_compact") {
|
||||
const iconOnly = style === "buttons_compact" && (this.narrow || this.tight);
|
||||
if (iconOnly) {
|
||||
// Compact narrow form: the primary action stays a solid ha-button
|
||||
// (icon only), the secondary one becomes HA's own icon button.
|
||||
// Compact narrow form: two icon-only ha-buttons — solid green
|
||||
// Complete, outlined orange Skip (#150). Skip wears HA's warning
|
||||
// variant on every surface, so the colour reads the same whether
|
||||
// or not the word is there.
|
||||
return html`
|
||||
<span class="row-actions as-buttons compact">
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" aria-label="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</ha-button>
|
||||
${allowSkip ? html`
|
||||
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" aria-label="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</ha-icon-button>` : nothing}
|
||||
</ha-button>` : nothing}
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
@@ -3365,7 +3586,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
|
||||
</ha-button>
|
||||
${allowSkip ? html`
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-button size="small" appearance="outlined" variant="warning" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
|
||||
</ha-button>` : nothing}
|
||||
</span>`;
|
||||
@@ -3499,7 +3720,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_first_task", L)}</ha-button>
|
||||
</div>`
|
||||
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
|
||||
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
}).map((task) => html`
|
||||
|
||||
@@ -37,6 +37,21 @@ const STRATEGY_TYPE = "maintenance-supporter";
|
||||
const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`;
|
||||
const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor";
|
||||
|
||||
// The four section strategies (docs/EXAMPLES.md "Section strategy" + the three
|
||||
// status sections). Registered here since v2.73.0: HA resolves a section's
|
||||
// `custom:` strategy through the same 5 s whenDefined race as a dashboard's,
|
||||
// and the heavy bundle used to be their only registration — which the shim
|
||||
// loads lazily on the FIRST dashboard-strategy generate(). So a section
|
||||
// dropped into the home dashboard (the documented use) on a fresh page had no
|
||||
// element to wait for and rendered empty unless the user had opened the
|
||||
// Maintenance Supporter dashboard first in the same page session.
|
||||
const SECTION_TYPES = [
|
||||
"maintenance-supporter-section",
|
||||
"maintenance-supporter-vacation",
|
||||
"maintenance-supporter-budget",
|
||||
"maintenance-supporter-groups",
|
||||
] as const;
|
||||
|
||||
// Absolute URL of the full strategy bundle (served at STRATEGY_URL by the
|
||||
// integration). The shim's only dependency, loaded on demand.
|
||||
//
|
||||
@@ -63,10 +78,13 @@ type DashboardStrategyClass = {
|
||||
generate(config: unknown, hass: unknown): Promise<unknown>;
|
||||
};
|
||||
|
||||
async function realDashboard(): Promise<DashboardStrategyClass> {
|
||||
const mod = (await loadBundle()) as {
|
||||
type StrategyBundle = {
|
||||
MaintenanceDashboardStrategy?: DashboardStrategyClass;
|
||||
SECTION_STRATEGIES?: Record<string, DashboardStrategyClass>;
|
||||
};
|
||||
|
||||
async function realDashboard(): Promise<DashboardStrategyClass> {
|
||||
const mod = (await loadBundle()) as StrategyBundle;
|
||||
if (!mod.MaintenanceDashboardStrategy) {
|
||||
throw new Error(
|
||||
"[maintenance-supporter] strategy bundle loaded but did not export " +
|
||||
@@ -76,6 +94,28 @@ async function realDashboard(): Promise<DashboardStrategyClass> {
|
||||
return mod.MaintenanceDashboardStrategy;
|
||||
}
|
||||
|
||||
async function realSection(type: string): Promise<DashboardStrategyClass> {
|
||||
const mod = (await loadBundle()) as StrategyBundle;
|
||||
const real = mod.SECTION_STRATEGIES?.[type];
|
||||
if (!real) {
|
||||
throw new Error(
|
||||
`[maintenance-supporter] strategy bundle loaded but has no section strategy "${type}"`,
|
||||
);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
// One tiny delegating class per section type — same lazy pattern as the
|
||||
// dashboard shim below, minus the editor (the sections have none).
|
||||
function sectionShim(type: string): CustomElementConstructor {
|
||||
return class MaintenanceSectionStrategyShim extends HTMLElement {
|
||||
static async generate(config: unknown, hass: unknown): Promise<unknown> {
|
||||
const real = await realSection(type);
|
||||
return real.generate(config, hass);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MaintenanceDashboardStrategyShim extends HTMLElement {
|
||||
static getCreateSuggestions(_hass: unknown) {
|
||||
return { title: "Maintenance Supporter", icon: "mdi:wrench-clock" };
|
||||
@@ -104,6 +144,13 @@ function defineShim(): void {
|
||||
} catch {
|
||||
/* already defined in this registry — fine */
|
||||
}
|
||||
for (const type of SECTION_TYPES) {
|
||||
try {
|
||||
customElements.define(`ll-strategy-section-${type}`, sectionShim(type));
|
||||
} catch {
|
||||
/* already defined in this registry — fine */
|
||||
}
|
||||
}
|
||||
}
|
||||
defineShim();
|
||||
|
||||
@@ -120,12 +167,8 @@ const w = window as unknown as {
|
||||
}>;
|
||||
};
|
||||
w.customStrategies = w.customStrategies || [];
|
||||
if (
|
||||
!w.customStrategies.some(
|
||||
(s) => s.type === STRATEGY_TYPE && s.strategyType === "dashboard",
|
||||
)
|
||||
) {
|
||||
w.customStrategies.push({
|
||||
const PICKER_ENTRIES: NonNullable<typeof w.customStrategies> = [
|
||||
{
|
||||
type: STRATEGY_TYPE,
|
||||
strategyType: "dashboard",
|
||||
name: "Maintenance Supporter",
|
||||
@@ -133,7 +176,46 @@ if (
|
||||
"Auto-generated dashboard. Group views by area, status, floor, or due date — picked from the strategy editor or YAML.",
|
||||
documentationURL:
|
||||
"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy",
|
||||
});
|
||||
},
|
||||
// The section entries used to be pushed by the heavy bundle only, so the
|
||||
// "Add section" picker on a foreign dashboard listed none of them until the
|
||||
// Maintenance Supporter dashboard had been generated once (v2.73.0).
|
||||
{
|
||||
type: "maintenance-supporter-section",
|
||||
strategyType: "section",
|
||||
name: "Maintenance Supporter — Section",
|
||||
description:
|
||||
"Embed maintenance tasks (filterable by area, status, due date) as a section in any dashboard view.",
|
||||
documentationURL:
|
||||
"https://github.com/iluebbe/maintenance_supporter#section-strategy",
|
||||
},
|
||||
{
|
||||
type: "maintenance-supporter-vacation",
|
||||
strategyType: "section",
|
||||
name: "Maintenance Supporter — Vacation Status",
|
||||
description: "Compact vacation-mode status banner. Tap to open settings in the panel.",
|
||||
},
|
||||
{
|
||||
type: "maintenance-supporter-budget",
|
||||
strategyType: "section",
|
||||
name: "Maintenance Supporter — Budget Status",
|
||||
description: "Monthly + yearly maintenance budget overview. Tap for details.",
|
||||
},
|
||||
{
|
||||
type: "maintenance-supporter-groups",
|
||||
strategyType: "section",
|
||||
name: "Maintenance Supporter — Groups",
|
||||
description: "List of configured task groups with member counts.",
|
||||
},
|
||||
];
|
||||
for (const entry of PICKER_ENTRIES) {
|
||||
if (
|
||||
!w.customStrategies.some(
|
||||
(s) => s.type === entry.type && s.strategyType === entry.strategyType,
|
||||
)
|
||||
) {
|
||||
w.customStrategies.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Self-heal ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,8 +9,22 @@ export const panelStyles = css`
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
|
||||
/* The panel lays itself out: a bounded host, the header on top and
|
||||
.content as THE scroll container (virtualized table, sticky bulk bar
|
||||
and the docked split-view detail all hang off its scroll position).
|
||||
HA 2026.8 started wrapping custom panels in a height-less, safe-area
|
||||
padded block — which turned our 100% into "auto" and moved scrolling to
|
||||
the document. panel.py opts out of that wrapper (handle_safe_area), so
|
||||
the insets are ours to apply: same padding HA would have added, inside
|
||||
the bounded box. The vars are 0 on desktop / older cores. */
|
||||
.panel {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
var(--safe-area-inset-top, 0px)
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right, 0px))
|
||||
var(--safe-area-inset-bottom, 0px)
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -141,6 +155,19 @@ export const panelStyles = css`
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Saved-views "save" icon-button sits in the select row: size it to the
|
||||
select (36px) so its icon centres on the select, not 6px above it.
|
||||
HA ≥2025.11 sizes ha-icon-button via --ha-icon-button-size (the old
|
||||
--mdc-icon-button-size is a no-op there); older cores the other way
|
||||
round, so declare both. */
|
||||
.views-save-btn {
|
||||
--ha-icon-button-size: 36px;
|
||||
--mdc-icon-button-size: 36px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
margin: 0 -2px 0 -4px;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--divider-color);
|
||||
@@ -230,6 +257,42 @@ export const panelStyles = css`
|
||||
}
|
||||
.bulk-check { display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
|
||||
/* Group-by sections: the OUTER .task-table owns the column tracks (every
|
||||
breakpoint's template above applies to it unchanged); each section card
|
||||
and its row block are column subgrids, so the badge/object/name columns
|
||||
line up across sections — a section whose rows are all "OK" no longer
|
||||
starts its names 60px left of one that also carries "Overdue". */
|
||||
.task-table.grouped > .group-section {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
.task-table.grouped .group-section-header { grid-column: 1 / -1; }
|
||||
.task-table.grouped .group-rows {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Object page task list: it has no object-name cell (the page IS the
|
||||
object), so under the shared 7-track template every cell sat one track
|
||||
to the left — the ~190px action pair landed in the 150px due track and
|
||||
poked past the row's right edge (a horizontal scrollbar as soon as a
|
||||
classic vertical one took its 17px; wider still with German labels).
|
||||
Six tracks for six cells. Below 769px the narrow/tight layouts place
|
||||
the cells explicitly and keep their own four tracks. */
|
||||
@media (min-width: 769px) {
|
||||
.task-table.object-tasks {
|
||||
grid-template-columns: auto minmax(120px, 1fr) minmax(0, 220px) 100px 150px auto;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.task-table.object-tasks {
|
||||
grid-template-columns: auto fit-content(400px) minmax(0, 1fr) 100px 150px auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Master-detail split (>=1500px panel, 2026-09-01): list left, docked task
|
||||
detail right - the width finally carries content instead of slack. Only
|
||||
the dashboard tab uses it; below the threshold everything is unchanged. */
|
||||
@@ -248,12 +311,18 @@ export const panelStyles = css`
|
||||
.split-list .task-table.bulk { grid-template-columns: auto auto minmax(100px, 180px) minmax(120px, 1fr) 150px auto; }
|
||||
.split-list .cell.type { display: none; }
|
||||
.split-list .task-sub { display: none; }
|
||||
/* Grouped list: the section cards carry a 12px vertical margin that does
|
||||
not collapse inside the grid, so the first card sat 12px below the
|
||||
pane's top edge and the two cards read as misaligned. Level them. */
|
||||
.split-list .task-table.grouped > .group-section:first-child { margin-top: 0; }
|
||||
/* The pane never scrolls on its own: it is as tall as the detail and
|
||||
docks scroll-aware (helpers/sticky-pane sets top/margin-top inline) —
|
||||
top edge while scrolling up, bottom edge while scrolling down, so a
|
||||
long detail is read by scrolling the list, not a nested scrollbar. */
|
||||
.split-pane {
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px 16px 16px;
|
||||
@@ -412,6 +481,11 @@ export const panelStyles = css`
|
||||
}
|
||||
|
||||
.cell { font-size: 14px; }
|
||||
/* Task name + object name travel as one group: in the wide grids the
|
||||
wrapper dissolves (display: contents) so both stay direct grid items in
|
||||
their own tracks; the narrow/tight layouts turn it into the first row
|
||||
(name left, object right — #150). */
|
||||
.row-head { display: contents; }
|
||||
.cell.object-name { color: var(--primary-color); cursor: pointer; }
|
||||
.cell.task-name { font-weight: 500; }
|
||||
.cell.type { color: var(--secondary-text-color); }
|
||||
@@ -474,7 +548,7 @@ export const panelStyles = css`
|
||||
|
||||
/* DESIGN PROTOTYPE (#145 wish 1): labelled, colour-coded action buttons
|
||||
instead of bare icons. Complete = filled success pill, Skip = outlined
|
||||
neutral pill; both keep the icon as a leading glyph. */
|
||||
warning (orange) pill; both keep the icon as a leading glyph. */
|
||||
.row-actions.as-buttons { gap: 4px; }
|
||||
.row-actions.as-buttons ha-button {
|
||||
--ha-button-font-size: 13px;
|
||||
@@ -893,15 +967,24 @@ export const panelStyles = css`
|
||||
/* Stale-bundle handshake banner (roadmap guard 2) */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px 10px;
|
||||
padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, var(--card-background-color, #fff));
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
/* The text claims the row; on a phone the buttons wrap under it instead of
|
||||
squeezing it into a one-word-per-line column (#150 round, 2026-09-02). */
|
||||
.update-banner span {
|
||||
flex: 1;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.update-banner ha-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.update-banner ha-button + ha-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.popup-menu {
|
||||
@@ -1518,24 +1601,54 @@ export const panelStyles = css`
|
||||
X-positions across rows regardless of content (sparkline, bar, %).
|
||||
Earlier flex-wrap-based layouts let the row wrap unpredictably so
|
||||
"X days" sometimes sat near the middle, sometimes at the right edge.
|
||||
Grid template:
|
||||
[badges auto | task-name 1fr | due-cell 100px | actions auto]
|
||||
Task-name spans the full top row (own row above), chips span the
|
||||
full bottom row. */
|
||||
Two rows since #150 (was three: name / badge+object+due+actions /
|
||||
chips — a phone list scrolled forever):
|
||||
row 1 task name ................................ object name
|
||||
row 2 [status icon | chips | due 100px | actions]
|
||||
The status pill drops its label (icon + colour stay, title/aria keep
|
||||
the word), the object name moves up beside the task name, and the
|
||||
chips take the freed middle track. */
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
grid-template-rows: auto auto auto;
|
||||
row-gap: 4px;
|
||||
grid-template-rows: auto auto;
|
||||
row-gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:host([narrow]) .cell.type { display: none; }
|
||||
:host([narrow]) .row-head {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The object page's rows have no object name — there the task name is a
|
||||
direct grid item and the grid placement applies; inside .row-head the
|
||||
flex rule wins and the grid lines are inert. */
|
||||
:host([narrow]) .cell.task-name {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
:host([narrow]) .cell.object-name {
|
||||
order: 1;
|
||||
flex: 0 1 auto;
|
||||
max-width: 50%;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
}
|
||||
/* Bulk selection on a phone: the checkbox takes the badge column of the
|
||||
first row, the name group starts one track further right. */
|
||||
:host([narrow]) .task-table.bulk .bulk-check { grid-column: 1; grid-row: 1; align-self: start; }
|
||||
:host([narrow]) .task-table.bulk .row-head { grid-column: 2 / -1; }
|
||||
:host([narrow]) .cell-badges {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
@@ -1547,19 +1660,11 @@ export const panelStyles = css`
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
:host([narrow]) .cell.object-name {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
/* Cap long object names at 2 lines with ellipsis instead of growing
|
||||
unbounded vertically. The full name is still readable via the panel
|
||||
object-detail view (one tap on the object). */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
}
|
||||
/* Icon-only status pill (#150): a round 22 px disc; the colour + shape
|
||||
still carry the state, the label lives in title/aria-label. */
|
||||
:host([narrow]) .task-row .status-label { display: none; }
|
||||
:host([narrow]) .task-row .status-badge { min-width: 0; padding: 3px; border-radius: 50%; }
|
||||
:host([narrow]) .task-row .status-badge ha-icon { --mdc-icon-size: 16px; margin-left: 0; }
|
||||
:host([narrow]) .due-cell {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
@@ -1583,15 +1688,15 @@ export const panelStyles = css`
|
||||
}
|
||||
:host([narrow]) .row-actions.as-buttons ha-button,
|
||||
:host([tight]) .row-actions.as-buttons ha-button { --ha-button-font-size: 12px; }
|
||||
/* buttons_compact: the labelled buttons collapse to an icon-only ha-button
|
||||
plus ha-icon-button side by side — the colour carries the meaning, the
|
||||
row keeps the icon-era height. */
|
||||
/* buttons_compact: the labelled buttons collapse to two icon-only
|
||||
ha-buttons side by side (solid green Complete, outlined orange Skip) —
|
||||
the colour carries the meaning, the row keeps the icon-era height.
|
||||
--wa-form-control-padding-inline is the wa-button inset HA's ha-button
|
||||
forwards (16 px default -> a 58 px pill around a 20 px glyph). */
|
||||
:host([narrow]) .row-actions.as-buttons.compact,
|
||||
:host([tight]) .row-actions.as-buttons.compact { flex-direction: row; align-items: center; gap: 2px; }
|
||||
:host([tight]) .row-actions.as-buttons.compact { flex-direction: row; align-items: center; gap: 4px; }
|
||||
:host([narrow]) .row-actions.compact ha-button,
|
||||
:host([tight]) .row-actions.compact ha-button { --ha-button-height: 36px; min-width: 0; }
|
||||
:host([narrow]) .row-actions.compact ha-icon-button,
|
||||
:host([tight]) .row-actions.compact ha-icon-button { --mdc-icon-button-size: 40px; --mdc-icon-size: 22px; color: var(--secondary-text-color); }
|
||||
:host([tight]) .row-actions.compact ha-button { --ha-button-height: 36px; --wa-form-control-padding-inline: 10px; min-width: 0; }
|
||||
:host([narrow]) .row-actions.compact ha-icon,
|
||||
:host([tight]) .row-actions.compact ha-icon { --mdc-icon-size: 20px; }
|
||||
|
||||
@@ -1609,26 +1714,35 @@ export const panelStyles = css`
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
grid-template-rows: auto auto auto;
|
||||
row-gap: 4px;
|
||||
grid-template-rows: auto auto;
|
||||
row-gap: 6px;
|
||||
padding: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
:host([tight]:not([narrow])) .cell.type { display: none; }
|
||||
:host([tight]:not([narrow])) .cell.task-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .row-head { display: flex; grid-column: 1 / -1; grid-row: 1; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .cell.task-name { grid-column: 1 / -1; grid-row: 1; flex: 1 1 auto; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .cell.object-name { order: 1; flex: 0 1 auto; max-width: 50%; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
||||
:host([tight]:not([narrow])) .task-table.bulk .bulk-check { grid-column: 1; grid-row: 1; align-self: start; }
|
||||
:host([tight]:not([narrow])) .task-table.bulk .row-head { grid-column: 2 / -1; }
|
||||
:host([tight]:not([narrow])) .cell-badges { grid-column: 1; grid-row: 2; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
:host([tight]:not([narrow])) .cell.object-name { grid-column: 2; grid-row: 2; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; }
|
||||
:host([tight]:not([narrow])) .task-row .status-label { display: none; }
|
||||
:host([tight]:not([narrow])) .task-row .status-badge { min-width: 0; padding: 3px; border-radius: 50%; }
|
||||
:host([tight]:not([narrow])) .task-row .status-badge ha-icon { --mdc-icon-size: 16px; margin-left: 0; }
|
||||
:host([tight]:not([narrow])) .due-cell { grid-column: 3; grid-row: 2; align-items: flex-end; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .row-actions { grid-column: 4; grid-row: 2; }
|
||||
:host([tight]:not([narrow])) .task-sub { grid-column: 1 / -1; grid-row: 3; font-size: 11px; gap: 6px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
:host([tight]:not([narrow])) .task-sub { grid-column: 2; grid-row: 2; align-self: center; font-size: 11px; gap: 4px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
:host([tight]:not([narrow])) .task-sub-empty { display: none; }
|
||||
:host([tight]:not([narrow])) .mini-sparkline { display: none; }
|
||||
:host([tight]:not([narrow])) .trend-arrow { display: inline; }
|
||||
/* Chips sit in the middle track of row 2 — the slot the object name
|
||||
vacated — vertically centred on the 36 px action buttons. */
|
||||
:host([narrow]) .task-sub {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
align-self: center;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1647,7 +1761,6 @@ export const panelStyles = css`
|
||||
column-gap: 6px;
|
||||
}
|
||||
:host([narrow]) .task-row { padding: 12px 8px; }
|
||||
:host([narrow]) .row-actions.compact ha-icon-button { --mdc-icon-button-size: 32px; --mdc-icon-size: 20px; }
|
||||
}
|
||||
|
||||
:host([narrow]) .detail-header {
|
||||
@@ -1781,17 +1894,23 @@ export const panelStyles = css`
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
grid-template-rows: auto auto auto;
|
||||
row-gap: 4px;
|
||||
grid-template-rows: auto auto;
|
||||
row-gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
.cell.type { display: none; }
|
||||
.cell.task-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
|
||||
.row-head { display: flex; grid-column: 1 / -1; grid-row: 1; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
|
||||
.cell.task-name { grid-column: 1 / -1; grid-row: 1; flex: 1 1 auto; min-width: 0; }
|
||||
.cell.object-name { order: 1; flex: 0 1 auto; max-width: 50%; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
||||
.task-table.bulk .bulk-check { grid-column: 1; grid-row: 1; align-self: start; }
|
||||
.task-table.bulk .row-head { grid-column: 2 / -1; }
|
||||
.cell-badges { grid-column: 1; grid-row: 2; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.cell.object-name { grid-column: 2; grid-row: 2; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; }
|
||||
.task-row .status-label { display: none; }
|
||||
.task-row .status-badge { min-width: 0; padding: 3px; border-radius: 50%; }
|
||||
.task-row .status-badge ha-icon { --mdc-icon-size: 16px; margin-left: 0; }
|
||||
.due-cell { grid-column: 3; grid-row: 2; align-items: flex-end; min-width: 0; }
|
||||
.row-actions { grid-column: 4; grid-row: 2; }
|
||||
.task-sub { grid-column: 1 / -1; grid-row: 3; font-size: 11px; gap: 6px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.task-sub { grid-column: 2; grid-row: 2; align-self: center; font-size: 11px; gap: 4px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.task-sub-empty { display: none; }
|
||||
.mini-sparkline { display: none; }
|
||||
.trend-arrow { display: inline; }
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
/** Shared chart helpers: nice axis ticks and consistent number/date formats. */
|
||||
|
||||
import { formatDateShort, formatTimeOfDay, formatNumber } from "../styles";
|
||||
|
||||
/** SVG / CSS coordinate: one decimal, ALWAYS a dot — never a user-facing
|
||||
* number. The only place outside styles.ts allowed to call toFixed (tripwire
|
||||
* rule D); human-facing figures go through formatNumber. */
|
||||
export function px(n: number): string {
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
/** Round axis ticks covering [min, max] — the classic "nice numbers" loop.
|
||||
* Returns the tick values plus the (possibly widened) nice domain, so the
|
||||
* chart can plot against the same bounds the labels describe. */
|
||||
@@ -32,47 +41,37 @@ export function niceTicks(
|
||||
return { ticks, niceMin, niceMax };
|
||||
}
|
||||
|
||||
/** Compact, consistent number format: 1.2M / 88k / 730 / 7.5 / 0.42 */
|
||||
export function fmtNum(v: number): string {
|
||||
/** Compact, consistent number format: 1.2M / 88k / 730 / 7.5 / 0.42 —
|
||||
* decimal separator per the HA profile number format. */
|
||||
export function fmtNum(v: number, lang?: string): string {
|
||||
const a = Math.abs(v);
|
||||
if (a >= 1_000_000) return trimZero((v / 1_000_000).toFixed(a >= 10_000_000 ? 0 : 1)) + "M";
|
||||
if (a >= 10_000) return trimZero((v / 1000).toFixed(0)) + "k";
|
||||
if (a >= 1000) return trimZero((v / 1000).toFixed(1)) + "k";
|
||||
if (a >= 100) return v.toFixed(0);
|
||||
if (a >= 10) return trimZero(v.toFixed(1));
|
||||
if (a >= 1) return trimZero(v.toFixed(1));
|
||||
const max = (d: number) => ({ maximumFractionDigits: d });
|
||||
if (a >= 1_000_000) return formatNumber(v / 1_000_000, lang, max(a >= 10_000_000 ? 0 : 1)) + "M";
|
||||
if (a >= 10_000) return formatNumber(v / 1000, lang, max(0)) + "k";
|
||||
if (a >= 1000) return formatNumber(v / 1000, lang, max(1)) + "k";
|
||||
if (a >= 100) return formatNumber(v, lang, max(0));
|
||||
if (a >= 1) return formatNumber(v, lang, max(1));
|
||||
if (a === 0) return "0";
|
||||
return trimZero(v.toFixed(2));
|
||||
return formatNumber(v, lang, max(2));
|
||||
}
|
||||
|
||||
function trimZero(s: string): string {
|
||||
return s.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
||||
}
|
||||
|
||||
/** Full-precision value + unit for tooltips (locale-aware grouping). */
|
||||
/** Full-precision value + unit for tooltips (profile grouping/decimal). */
|
||||
export function fmtVal(v: number, unit: string, lang: string): string {
|
||||
const s = v.toLocaleString(lang, { maximumFractionDigits: Math.abs(v) >= 100 ? 0 : 1 });
|
||||
const s = formatNumber(v, lang, { maximumFractionDigits: Math.abs(v) >= 100 ? 0 : 1 });
|
||||
return unit ? `${s} ${unit}` : s;
|
||||
}
|
||||
|
||||
/** Short date tick: "3. Juli" / "Jul 3", with year appended when the plotted
|
||||
* range spans more than ~10 months (disambiguates "Jun … Feb" sequences). */
|
||||
export function fmtDateTick(ts: number, lang: string, withYear: boolean): string {
|
||||
const d = new Date(ts);
|
||||
const opts: Intl.DateTimeFormatOptions = withYear
|
||||
? { month: "short", day: "numeric", year: "2-digit" }
|
||||
: { month: "short", day: "numeric" };
|
||||
return d.toLocaleDateString(lang, opts);
|
||||
return formatDateShort(new Date(ts), lang, withYear);
|
||||
}
|
||||
|
||||
/** Date+time for crosshair/tooltip labels. */
|
||||
/** Date+time for crosshair/tooltip labels — the clock half follows the HA
|
||||
* profile time format (#163). */
|
||||
export function fmtDateTime(ts: number, lang: string): string {
|
||||
return new Date(ts).toLocaleDateString(lang, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const d = new Date(ts);
|
||||
return `${formatDateShort(d, lang)}, ${formatTimeOfDay(d, lang)}`;
|
||||
}
|
||||
|
||||
/** Whether date ticks need the year: the range crosses a calendar-year
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear } from "./chart-utils";
|
||||
import { t, formatCost } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtDateTick, timeTicks, needsYear, px } from "./chart-utils";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
const COST_CHART_H = 200;
|
||||
@@ -20,6 +20,7 @@ export function renderCostDurationCard(
|
||||
lang: string,
|
||||
toggle: "cost" | "duration" | "both",
|
||||
setToggle: (val: "cost" | "duration" | "both") => void,
|
||||
currencySymbol: string,
|
||||
) {
|
||||
const completedEntries = task.history.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null));
|
||||
if (completedEntries.length < 2) return nothing;
|
||||
@@ -50,12 +51,12 @@ export function renderCostDurationCard(
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${renderHistoryChart(task, lang, toggle)}
|
||||
${renderHistoryChart(task, lang, toggle, currencySymbol)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both") {
|
||||
function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost" | "duration" | "both", currencySymbol: string) {
|
||||
const entries = task.history
|
||||
.filter((h) => h.type === "completed" && (h.cost != null || h.duration != null))
|
||||
.map((h) => ({ ts: new Date(h.timestamp).getTime(), cost: h.cost ?? 0, duration: h.duration ?? 0 }))
|
||||
@@ -109,27 +110,27 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
|
||||
const y = costY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}" stroke="var(--divider-color)" stroke-width="1" opacity="0.55" />
|
||||
<text x="${PAD_L - 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" fill="var(--primary-color)" font-size="10.5">${fmtNum(v)}€</text>`;
|
||||
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}" stroke="var(--divider-color)" stroke-width="1" opacity="0.55" />
|
||||
<text x="${PAD_L - 6}" y="${px(y + 3.5)}" text-anchor="end" fill="var(--primary-color)" font-size="10.5">${fmtNum(v, lang)}${currencySymbol}</text>`;
|
||||
}) : nothing}
|
||||
${showDuration ? durAxis.ticks.map((v) => {
|
||||
const y = durY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`<text x="${W - PAD_R + 6}" y="${(y + 3.5).toFixed(1)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v)}m</text>`;
|
||||
return svg`<text x="${W - PAD_R + 6}" y="${px(y + 3.5)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v, lang)}m</text>`;
|
||||
}) : nothing}
|
||||
|
||||
${showCost ? entries.filter((e) => e.cost > 0).map((e) => svg`
|
||||
<rect x="${(toX(e.ts) - barW / 2).toFixed(1)}" y="${costY(e.cost).toFixed(1)}" width="${barW.toFixed(1)}" height="${(plotB - costY(e.cost)).toFixed(1)}"
|
||||
<rect x="${px(toX(e.ts) - barW / 2)}" y="${px(costY(e.cost))}" width="${px(barW)}" height="${px(plotB - costY(e.cost))}"
|
||||
fill="var(--primary-color)" opacity="0.6" rx="2">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.cost.toLocaleString(lang)}€${e.duration ? ` · ${e.duration}m` : ""}</title>
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${formatCost(e.cost, currencySymbol, lang)}${e.duration ? ` · ${e.duration}m` : ""}</title>
|
||||
</rect>
|
||||
`) : nothing}
|
||||
${showDuration ? svg`
|
||||
<polyline points="${entries.map((e) => `${toX(e.ts).toFixed(1)},${durY(e.duration).toFixed(1)}`).join(" ")}"
|
||||
<polyline points="${entries.map((e) => `${px(toX(e.ts))},${px(durY(e.duration))}`).join(" ")}"
|
||||
fill="none" stroke="var(--accent-color, #ff9800)" stroke-width="2" stroke-linejoin="round" />
|
||||
${entries.map((e) => svg`
|
||||
<circle cx="${toX(e.ts).toFixed(1)}" cy="${durY(e.duration).toFixed(1)}" r="3.5" fill="var(--accent-color, #ff9800)">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.duration}m${e.cost ? ` · ${e.cost.toLocaleString(lang)}€` : ""}</title>
|
||||
<circle cx="${px(toX(e.ts))}" cy="${px(durY(e.duration))}" r="3.5" fill="var(--accent-color, #ff9800)">
|
||||
<title>${fmtDateTick(e.ts, lang, true)}: ${e.duration}m${e.cost ? ` · ${formatCost(e.cost, currencySymbol, lang)}` : ""}</title>
|
||||
</circle>
|
||||
`)}
|
||||
` : nothing}
|
||||
@@ -137,7 +138,7 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
|
||||
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
|
||||
${xTicks.map((ts, i) => {
|
||||
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
|
||||
return svg`<text x="${toX(ts).toFixed(1)}" y="${H - 6}" text-anchor="${anchor}" fill="var(--secondary-text-color)" font-size="10">${fmtDateTick(ts, lang, withYear)}</text>`;
|
||||
return svg`<text x="${px(toX(ts))}" y="${H - 6}" text-anchor="${anchor}" fill="var(--secondary-text-color)" font-size="10">${fmtDateTick(ts, lang, withYear)}</text>`;
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDateTime, STATUS_ICONS } from "../styles";
|
||||
import { t, formatDateTime, formatNumber, formatCost, STATUS_ICONS } from "../styles";
|
||||
import type { MaintenanceTask, HistoryEntry, HomeAssistant } from "../types";
|
||||
import "../components/history-photo";
|
||||
|
||||
@@ -114,13 +114,13 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryContext) {
|
||||
? html`<maintenance-history-photo .hass=${ctx.hass} .docId=${entry.photo_doc_id}></maintenance-history-photo>`
|
||||
: nothing}
|
||||
<div class="history-details">
|
||||
${entry.cost != null ? html`<span>${t("cost", L)}: ${entry.cost.toFixed(2)} ${ctx.currencySymbol}</span>` : nothing}
|
||||
${entry.cost != null ? html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>` : nothing}
|
||||
${entry.duration != null ? html`<span>${t("duration", L)}: ${entry.duration} min</span>` : nothing}
|
||||
${entry.trigger_value != null ? html`<span>${t("trigger_val", L)}: ${entry.trigger_value}</span>` : nothing}
|
||||
${entry.reading_value != null
|
||||
? html`<span>${t("reading_label", L)}: ${entry.reading_value}${ctx.readingUnit ? ` ${ctx.readingUnit}` : ""}${(() => {
|
||||
const d = ctx.readingDelta?.(entry);
|
||||
return d == null ? "" : ` (${d >= 0 ? "+" : ""}${Number(d.toFixed(3))})`;
|
||||
return d == null ? "" : ` (${d >= 0 ? "+" : ""}${formatNumber(d, L, { maximumFractionDigits: 3 })})`;
|
||||
})()}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Sensor prediction section renderer. */
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDate, fireMoreInfo } from "../styles";
|
||||
import { t, formatDate, fireMoreInfo, formatNumber } from "../styles";
|
||||
import type { MaintenanceTask, AdvancedFeatures } from "../types";
|
||||
|
||||
export function renderPredictionSection(task: MaintenanceTask, lang: string, features: AdvancedFeatures) {
|
||||
@@ -34,7 +34,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
|
||||
<ha-svg-icon path="${trendIcon}"></ha-svg-icon>
|
||||
<span class="prediction-label">${t("degradation_trend", lang)}</span>
|
||||
<span class="prediction-value ${task.degradation_trend}">${t("trend_" + task.degradation_trend, lang)}</span>
|
||||
${task.degradation_rate != null ? html`<span class="prediction-rate">${task.degradation_rate > 0 ? "+" : ""}${Math.abs(task.degradation_rate) >= 10 ? Math.round(task.degradation_rate).toLocaleString() : task.degradation_rate.toFixed(1)} ${task.trigger_entity_info?.unit_of_measurement || ""}/${t("day_short", lang)}</span>` : nothing}
|
||||
${task.degradation_rate != null ? html`<span class="prediction-rate">${task.degradation_rate > 0 ? "+" : ""}${formatNumber(task.degradation_rate, lang, Math.abs(task.degradation_rate) >= 10 ? 0 : 1)} ${task.trigger_entity_info?.unit_of_measurement || ""}/${t("day_short", lang)}</span>` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
${hasThreshold ? html`
|
||||
@@ -51,7 +51,7 @@ export function renderPredictionSection(task: MaintenanceTask, lang: string, fea
|
||||
<div class="prediction-item">
|
||||
<ha-svg-icon path="M15,13V5A3,3 0 0,0 12,2A3,3 0 0,0 9,5V13A5,5 0 0,0 7,17A5,5 0 0,0 12,22A5,5 0 0,0 17,17A5,5 0 0,0 15,13M12,4A1,1 0 0,1 13,5V8H11V5A1,1 0 0,1 12,4Z"></ha-svg-icon>
|
||||
<span class="prediction-label">${t("environmental_adjustment", lang)}</span>
|
||||
<span class="prediction-value">${task.environmental_factor!.toFixed(2)}x</span>
|
||||
<span class="prediction-value">${formatNumber(task.environmental_factor!, lang, 2)}x</span>
|
||||
${task.environmental_entity ? html`<span class="prediction-entity entity-link" @click=${(ev: Event) => fireMoreInfo(ev, task.environmental_entity!)}>${task.environmental_entity}</span>` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { t, formatDate, formatDueDays } from "../styles";
|
||||
import { t, formatDate, formatDueDays, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import { daysProgress } from "../helpers/interval";
|
||||
import type { MaintenanceTask, TaskRow, StatisticsPoint } from "../types";
|
||||
|
||||
@@ -92,7 +93,7 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
const low = below ?? 0;
|
||||
const range = above - low || 1;
|
||||
pct = Math.min(100, Math.max(0, ((val - low) / range) * 100));
|
||||
label = `${val.toFixed(1)} / ${above} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(above, opts?.lang)} ${unit}`;
|
||||
} else if (below != null) {
|
||||
// Progress toward lower limit (inverted: lower is worse)
|
||||
// Use entity max, or 2x the threshold as a stable "safe" reference.
|
||||
@@ -101,12 +102,12 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
const high = entityMax ?? ((below * 2) || 100);
|
||||
const range = high - below || 1;
|
||||
pct = Math.min(100, Math.max(0, ((high - val) / range) * 100));
|
||||
label = `${val.toFixed(1)} / ${below} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(below, opts?.lang)} ${unit}`;
|
||||
} else if (tc.trigger_equals != null || tc.trigger_not_equals != null) {
|
||||
// Discrete = / ≠ levels have no meaningful gradient — binary bar,
|
||||
// same treatment as compound.
|
||||
const target = tc.trigger_equals != null ? `= ${tc.trigger_equals}` : `≠ ${tc.trigger_not_equals}`;
|
||||
label = `${val.toFixed(1)} (${target}${unit ? ` ${unit}` : ""})`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} (${target}${unit ? ` ${unit}` : ""})`;
|
||||
pct = row.trigger_active ? 100 : 0;
|
||||
} else {
|
||||
return nothing;
|
||||
@@ -129,19 +130,19 @@ export function renderTriggerProgress(row: TaskRow | MaintenanceTask, opts?: { t
|
||||
}
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${val.toFixed(1)} / ${target} ${unit}`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)} / ${formatNumber(target, opts?.lang)} ${unit}`;
|
||||
} else if (triggerType === "state_change") {
|
||||
const target = tc.trigger_target_changes || 1;
|
||||
const val = row.trigger_current_value ?? null;
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${Math.round(val)} / ${target}`;
|
||||
label = `${formatNumber(val, opts?.lang, 0)} / ${formatNumber(target, opts?.lang, 0)}`;
|
||||
} else if (triggerType === "runtime") {
|
||||
const target = tc.trigger_runtime_hours || 100;
|
||||
const val = row.trigger_current_value ?? null;
|
||||
if (val == null) return nothing;
|
||||
pct = Math.min(100, Math.max(0, (val / target) * 100));
|
||||
label = `${val.toFixed(1)}h / ${target}h`;
|
||||
label = `${formatNumber(val, opts?.lang, 1)}h / ${formatNumber(target, opts?.lang)}h`;
|
||||
} else if (triggerType === "compound") {
|
||||
const logic = tc.compound_logic || (tc as any).operator || "AND";
|
||||
const condCount = tc.conditions?.length || 0;
|
||||
@@ -218,7 +219,7 @@ export function renderMiniSparkline(
|
||||
renderPoints = renderPoints.filter((_, i) => i % step === 0 || i === renderPoints.length - 1);
|
||||
}
|
||||
|
||||
const pts = renderPoints.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
|
||||
const pts = renderPoints.map((p) => `${px(toX(p.ts))},${px(toY(p.val))}`).join(" ");
|
||||
// Match the detail chart's semantics: an actively-triggered sensor tints red.
|
||||
const stroke = row.trigger_active
|
||||
? "var(--error-color, #f44336)"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Seasonal factor chart renderers. */
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { t, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import type { MaintenanceTask, AdvancedFeatures } from "../types";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
@@ -38,7 +39,7 @@ export function renderSeasonalCardCompact(task: MaintenanceTask, lang: string, f
|
||||
return html`
|
||||
<div class="seasonal-bar ${colorClass} ${isCurrentMonth ? 'current' : ''}"
|
||||
style="height: ${height}px"
|
||||
title="${months[i]}: ${factor.toFixed(2)}x">
|
||||
title="${months[i]}: ${formatNumber(factor, lang, 2)}x">
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@@ -82,7 +83,7 @@ function renderSeasonalChart(task: MaintenanceTask, lang: string) {
|
||||
${reason ? html`<span class="source-tag">${reason === "learned" ? t("seasonal_learned", lang) : t("seasonal_manual", lang)}</span>` : nothing}
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${t("chart_seasonal", lang)}">
|
||||
<line x1="0" y1="${baselineY.toFixed(1)}" x2="${W}" y2="${baselineY.toFixed(1)}"
|
||||
<line x1="0" y1="${px(baselineY)}" x2="${W}" y2="${px(baselineY)}"
|
||||
stroke="var(--divider-color)" stroke-width="1" stroke-dasharray="4,3" />
|
||||
${factors.map((f, i) => {
|
||||
const barH = (f / maxFactor) * chartH;
|
||||
@@ -95,8 +96,8 @@ function renderSeasonalChart(task: MaintenanceTask, lang: string) {
|
||||
? "var(--warning-color, #ff9800)"
|
||||
: "var(--secondary-text-color)";
|
||||
return svg`
|
||||
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||
width="${barInner.toFixed(1)}" height="${barH.toFixed(1)}"
|
||||
<rect x="${px(x)}" y="${px(y)}"
|
||||
width="${px(barInner)}" height="${px(barH)}"
|
||||
fill="${color}" opacity="${isCurrent ? 1 : 0.5}" rx="2" />
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
|
||||
import { t, formatDate, formatDateTime, formatRecurrence, formatNumber, formatCost } from "../styles";
|
||||
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
|
||||
import { renderTriggerSection, type SparklineContext } from "./sparkline";
|
||||
import { renderPredictionSection } from "./prediction";
|
||||
@@ -130,9 +130,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
}
|
||||
</div>
|
||||
<div class="task-header-actions">
|
||||
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
|
||||
<ha-button appearance="accent" variant="success" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
|
||||
${task.allow_skip !== false
|
||||
? html`<ha-button appearance="outlined" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>`
|
||||
? html`<ha-button appearance="outlined" variant="warning" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>`
|
||||
: nothing}
|
||||
<div class="more-menu-wrapper">
|
||||
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
|
||||
@@ -351,11 +351,11 @@ function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_cost", L)}</div>
|
||||
<div class="kpi-value">${avgCost.toFixed(0)} ${ctx.currencySymbol}</div>
|
||||
<div class="kpi-value">${formatCost(avgCost, ctx.currencySymbol, L, 0)}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">${t("avg_duration", L)}</div>
|
||||
<div class="kpi-value">${task.average_duration ? task.average_duration.toFixed(0) : "—"} min</div>
|
||||
<div class="kpi-value">${task.average_duration ? formatNumber(task.average_duration, L, 0) : "—"} min</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -422,7 +422,7 @@ function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
<span class="activity-icon">${getIcon(entry.type)}</span>
|
||||
<span class="activity-date">${formatDateTime(entry.timestamp, L)}</span>
|
||||
<span class="activity-note">${entry.notes || "—"}</span>
|
||||
${entry.cost ? html`<span class="activity-badge">${entry.cost.toFixed(0)}${ctx.currencySymbol}</span>` : nothing}
|
||||
${entry.cost ? html`<span class="activity-badge">${formatCost(entry.cost, ctx.currencySymbol, L, 0)}</span>` : nothing}
|
||||
${entry.duration ? html`<span class="activity-badge">${entry.duration}min</span>` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
@@ -471,7 +471,7 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="right-column">
|
||||
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v))}
|
||||
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v), ctx.currencySymbol)}
|
||||
</div>
|
||||
</div>
|
||||
${hasWeibullData
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Weibull reliability analysis renderers. */
|
||||
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { t } from "../styles";
|
||||
import { t, formatNumber } from "../styles";
|
||||
import { px } from "./chart-utils";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
export function renderWeibullSection(task: MaintenanceTask, lang: string) {
|
||||
@@ -51,7 +52,7 @@ function renderBetaBadge(beta: number, lang: string) {
|
||||
return html`
|
||||
<span class="beta-badge ${cls}">
|
||||
<ha-svg-icon path="${icon}"></ha-svg-icon>
|
||||
${t(key, lang)} (\u03B2=${beta.toFixed(2)})
|
||||
${t(key, lang)} (\u03B2=${formatNumber(beta, lang, 2)})
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -74,15 +75,15 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
points.push([x, y]);
|
||||
}
|
||||
|
||||
const polyline = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
|
||||
const polyline = points.map(([x, y]) => `${px(x)},${px(y)}`).join(" ");
|
||||
const areaPath = `M${PAD_L},${PAD_T + chartH} ` +
|
||||
points.map(([x, y]) => `L${x.toFixed(1)},${y.toFixed(1)}`).join(" ") +
|
||||
` L${points[N][0].toFixed(1)},${PAD_T + chartH} Z`;
|
||||
points.map(([x, y]) => `L${px(x)},${px(y)}`).join(" ") +
|
||||
` L${px(points[N][0])},${PAD_T + chartH} Z`;
|
||||
|
||||
const curX = PAD_L + (currentInterval / maxT) * chartW;
|
||||
const curCdf = 1.0 - Math.exp(-Math.pow(currentInterval / eta, beta));
|
||||
const curY = PAD_T + chartH - curCdf * chartH;
|
||||
const reliability = ((1.0 - curCdf) * 100).toFixed(0);
|
||||
const reliability = formatNumber((1.0 - curCdf) * 100, lang, 0);
|
||||
|
||||
const recX = PAD_L + (recommended / maxT) * chartW;
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
|
||||
@@ -93,10 +94,10 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
${yTicks.map(tick => {
|
||||
const y = PAD_T + chartH - tick * chartH;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
|
||||
<line x1="${PAD_L}" y1="${px(y)}" x2="${W - PAD_R}" y2="${px(y)}"
|
||||
stroke="var(--divider-color)" stroke-width="0.5" stroke-dasharray="${tick === 0.5 ? '4,3' : nothing}" />
|
||||
<text x="${PAD_L - 4}" y="${(y + 3).toFixed(1)}" fill="var(--secondary-text-color)"
|
||||
font-size="8" text-anchor="end">${(tick * 100).toFixed(0)}%</text>
|
||||
<text x="${PAD_L - 4}" y="${px(y + 3)}" fill="var(--secondary-text-color)"
|
||||
font-size="8" text-anchor="end">${formatNumber(tick * 100, lang, 0)}%</text>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -109,16 +110,16 @@ function renderWeibullChart(beta: number, eta: number, currentInterval: number,
|
||||
stroke="var(--primary-color, #03a9f4)" stroke-width="2" />
|
||||
|
||||
${currentInterval > 0 ? svg`
|
||||
<line x1="${curX.toFixed(1)}" y1="${PAD_T}" x2="${curX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
|
||||
<line x1="${px(curX)}" y1="${PAD_T}" x2="${px(curX)}" y2="${px(PAD_T + chartH)}"
|
||||
stroke="var(--primary-color, #03a9f4)" stroke-width="1.5" stroke-dasharray="4,3" />
|
||||
<circle cx="${curX.toFixed(1)}" cy="${curY.toFixed(1)}" r="3"
|
||||
<circle cx="${px(curX)}" cy="${px(curY)}" r="3"
|
||||
fill="var(--primary-color, #03a9f4)" />
|
||||
<text x="${(curX + 4).toFixed(1)}" y="${(curY - 6).toFixed(1)}" fill="var(--primary-color, #03a9f4)"
|
||||
<text x="${px(curX + 4)}" y="${px(curY - 6)}" fill="var(--primary-color, #03a9f4)"
|
||||
font-size="9" font-weight="600">R=${reliability}%</text>
|
||||
` : nothing}
|
||||
|
||||
${recommended > 0 && recommended !== currentInterval ? svg`
|
||||
<line x1="${recX.toFixed(1)}" y1="${PAD_T}" x2="${recX.toFixed(1)}" y2="${(PAD_T + chartH).toFixed(1)}"
|
||||
<line x1="${px(recX)}" y1="${PAD_T}" x2="${px(recX)}" y2="${px(PAD_T + chartH)}"
|
||||
stroke="var(--success-color, #4caf50)" stroke-width="1.5" stroke-dasharray="4,3" />
|
||||
` : nothing}
|
||||
|
||||
@@ -146,7 +147,7 @@ function renderWeibullInfo(analysis: NonNullable<MaintenanceTask["interval_analy
|
||||
${analysis.weibull_r_squared != null ? html`
|
||||
<div class="weibull-info-item">
|
||||
<span>${t("weibull_r_squared", lang)}</span>
|
||||
<span class="weibull-info-value">${analysis.weibull_r_squared!.toFixed(3)}</span>
|
||||
<span class="weibull-info-value">${formatNumber(analysis.weibull_r_squared!, lang, 3)}</span>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -174,9 +175,9 @@ function renderConfidenceInterval(analysis: NonNullable<MaintenanceTask["interva
|
||||
${t("confidence_interval", lang)}: ${rec} ${t("days", lang)} (${low}\u2013${high})
|
||||
</div>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="left:${fillLeft.toFixed(1)}%;width:${fillWidth.toFixed(1)}%"></div>
|
||||
${curPos >= 0 ? html`<div class="confidence-marker current" style="left:${curPos.toFixed(1)}%"></div>` : nothing}
|
||||
<div class="confidence-marker recommended" style="left:${recPos.toFixed(1)}%"></div>
|
||||
<div class="confidence-fill" style="left:${px(fillLeft)}%;width:${px(fillWidth)}%"></div>
|
||||
${curPos >= 0 ? html`<div class="confidence-marker current" style="left:${px(curPos)}%"></div>` : nothing}
|
||||
<div class="confidence-marker recommended" style="left:${px(recPos)}%"></div>
|
||||
</div>
|
||||
<div class="confidence-labels">
|
||||
<span class="confidence-text low">${t("confidence_conservative", lang)} (${low}${t("days", lang).charAt(0)})</span>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function syncLocaleFromHass(
|
||||
changedProps: Map<string, unknown>,
|
||||
): void {
|
||||
if (changedProps.has("hass")) {
|
||||
setDateTimePrefs(host.hass?.locale as Parameters<typeof setDateTimePrefs>[0], host.hass?.config?.country);
|
||||
setProfilePrefs(host.hass?.locale as Parameters<typeof setProfilePrefs>[0], host.hass?.config?.country);
|
||||
}
|
||||
const lang = host.hass?.language;
|
||||
if (lang && !isLocaleLoaded(lang)) {
|
||||
@@ -188,38 +188,88 @@ function langToLocale(lang?: string): string {
|
||||
return map[l] ?? "en-US";
|
||||
}
|
||||
|
||||
/** HA per-user date/time format prefs (issue #97).
|
||||
/** HA per-user format prefs (issue #97, #163).
|
||||
*
|
||||
* hass.locale.date_format ("language"|"system"|"DMY"|"MDY"|"YMD") and
|
||||
* time_format ("language"|"system"|"12"|"24") are frontend PROFILE settings —
|
||||
* deriving the format from the UI language alone showed en users mm/dd/yyyy
|
||||
* even with dd/mm/yyyy configured. Stored on a window singleton because each
|
||||
* bundle (panel / cards / dialog-mount) gets its own module scope; every
|
||||
* surface calls setDateTimePrefs() on hass updates and whichever runs first
|
||||
* wins for all of them.
|
||||
* hass.locale.date_format ("language"|"system"|"DMY"|"MDY"|"YMD"),
|
||||
* time_format ("language"|"system"|"12"|"24") and number_format ("language"|
|
||||
* "system"|"comma_decimal"|"decimal_comma"|"space_comma"|"none") are frontend
|
||||
* PROFILE settings — deriving the format from the UI language alone showed
|
||||
* en users mm/dd/yyyy even with dd/mm/yyyy configured. Stored on a window
|
||||
* singleton because each bundle (panel / cards / dialog-mount) gets its own
|
||||
* module scope; every surface calls setProfilePrefs() on hass updates and
|
||||
* whichever runs first wins for all of them. (The window key keeps its
|
||||
* original name so a stale cached bundle still shares the object.)
|
||||
*/
|
||||
interface DateTimePrefs {
|
||||
interface ProfilePrefs {
|
||||
date?: string;
|
||||
time?: string;
|
||||
number?: string;
|
||||
country?: string;
|
||||
}
|
||||
const _w = window as unknown as { __msDateTimePrefs?: DateTimePrefs };
|
||||
const DT_PREFS: DateTimePrefs = (_w.__msDateTimePrefs ??= {});
|
||||
const _w = window as unknown as { __msDateTimePrefs?: ProfilePrefs };
|
||||
const DT_PREFS: ProfilePrefs = (_w.__msDateTimePrefs ??= {});
|
||||
|
||||
/** Feed HA's per-user date/time format into formatDate/formatDateTime.
|
||||
/** Feed HA's per-user date/time/number formats into the formatters below.
|
||||
* `country` is the SERVER's configured country (#140): with the profile
|
||||
* format left on "language" it regionalizes the language default —
|
||||
* date format left on "language" it regionalizes the language default —
|
||||
* "en" + AU renders DD/MM/YYYY instead of the hard en-US mapping. */
|
||||
export function setDateTimePrefs(
|
||||
locale?: { date_format?: string; time_format?: string },
|
||||
export function setProfilePrefs(
|
||||
locale?: { date_format?: string; time_format?: string; number_format?: string },
|
||||
country?: string | null,
|
||||
): void {
|
||||
if (!locale) return;
|
||||
DT_PREFS.date = locale.date_format;
|
||||
DT_PREFS.time = locale.time_format;
|
||||
DT_PREFS.number = locale.number_format;
|
||||
if (country !== undefined) DT_PREFS.country = country || undefined;
|
||||
}
|
||||
|
||||
/** Locale for the profile number format — mirrors HA's own
|
||||
* numberFormatToLocale so our figures match the entity cards next to them:
|
||||
* the three explicit styles pin a representative locale, "system" is the
|
||||
* browser, and the "language" default is the UI language WITHOUT the
|
||||
* server-country regionalization dates get (#140) — HA formats "de" as
|
||||
* 1.234,56 whatever the country. */
|
||||
function numberLocale(lang?: string): string | string[] | undefined {
|
||||
switch (DT_PREFS.number) {
|
||||
case "comma_decimal": return ["en-US", "en"];
|
||||
case "decimal_comma": return ["de", "es", "it"];
|
||||
case "space_comma": return ["fr", "sv", "cs"];
|
||||
case "system": return undefined;
|
||||
default: return langToLocale(lang);
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a number for display honouring the HA profile number format.
|
||||
* `digits` pins the fraction digits (a number = exactly that many, like
|
||||
* toFixed) or passes raw Intl options; the default is at most 2, no trailing
|
||||
* zeros. "none" mirrors HA: plain digits, no grouping, trailing zeros
|
||||
* dropped. Every human-facing number in the UI goes through here — SVG
|
||||
* coordinates use chart-utils px() instead, which must stay locale-free. */
|
||||
export function formatNumber(n: number, lang?: string, digits?: number | Intl.NumberFormatOptions): string {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
const opts: Intl.NumberFormatOptions =
|
||||
typeof digits === "number"
|
||||
? { minimumFractionDigits: digits, maximumFractionDigits: digits }
|
||||
: { maximumFractionDigits: 2, ...digits };
|
||||
if (DT_PREFS.number === "none") {
|
||||
const f = Math.pow(10, opts.maximumFractionDigits ?? 2);
|
||||
return String(Math.round(n * f) / f);
|
||||
}
|
||||
try {
|
||||
return new Intl.NumberFormat(numberLocale(lang), opts).format(n);
|
||||
} catch {
|
||||
return new Intl.NumberFormat(undefined, opts).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + currency symbol ("12.50 €"; the symbol is optional) — every cost
|
||||
* figure in the UI and the printables goes through here. */
|
||||
export function formatCost(amount: number, symbol?: string, lang?: string, digits = 2): string {
|
||||
const num = formatNumber(amount, lang, digits);
|
||||
return symbol ? `${num} ${symbol}` : num;
|
||||
}
|
||||
|
||||
/** BCP-47 locale for the "language" default: the UI language regionalized
|
||||
* by the server country when Intl knows the combination (#140). Explicit
|
||||
* profile formats (DMY/MDY/YMD/system) never reach this path. */
|
||||
@@ -253,7 +303,10 @@ function _formatDateObj(d: Date, lang?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function _formatTimeObj(d: Date, lang?: string): string {
|
||||
/** 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). */
|
||||
export function formatTimeOfDay(d: Date, lang?: string): string {
|
||||
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 });
|
||||
@@ -279,7 +332,7 @@ export function formatDateTime(iso: string | null | undefined, lang?: string): s
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return _formatDateObj(d, lang) + " " + _formatTimeObj(d, lang);
|
||||
return _formatDateObj(d, lang) + " " + formatTimeOfDay(d, lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
@@ -306,9 +359,37 @@ export function formatInterval(
|
||||
}
|
||||
|
||||
/** Localized weekday name (0=Mon … 6=Sun) via Intl — 2024-01-01 is a Monday.
|
||||
* Single source for the dialog selectors AND formatRecurrence (DRY). */
|
||||
* Single source for the dialog selectors AND formatRecurrence (DRY).
|
||||
*
|
||||
* Name-only helpers (weekday / month / "Jul 3") follow the UI language like
|
||||
* HA's own formatDateShort does — the profile date_format only orders
|
||||
* NUMERIC dates. They still live here, not in the consumers: styles.ts is
|
||||
* the only module allowed to call Intl for dates and numbers (see
|
||||
* __tests__/profile-format-single-source.test.ts, #163). */
|
||||
export function weekdayName(i: number, lang?: string, style: "long" | "short" = "long"): string {
|
||||
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(langToLocale(lang), { weekday: style, timeZone: "UTC" });
|
||||
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(resolveLocale(lang), { weekday: style, timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** Localized month name (0=Jan … 11=Dec). */
|
||||
export function monthName(i: number, lang?: string, style: "long" | "short" = "long"): string {
|
||||
return new Date(Date.UTC(2024, i, 1)).toLocaleDateString(resolveLocale(lang), { month: style, timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** Weekday / month name of a local Date (calendar day rows). */
|
||||
export function formatWeekday(d: Date, lang?: string, style: "long" | "short" = "long"): string {
|
||||
return weekdayName((d.getDay() + 6) % 7, lang, style);
|
||||
}
|
||||
export function formatMonth(d: Date, lang?: string, style: "long" | "short" = "long"): string {
|
||||
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. */
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Recurrence shape carried on the WS payload (see types.TaskSchedule). */
|
||||
|
||||
@@ -9,14 +9,46 @@
|
||||
* npm run test:watch — watch mode
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { esbuildPlugin } from "@web/dev-server-esbuild";
|
||||
import { playwrightLauncher } from "@web/test-runner-playwright";
|
||||
import { defaultReporter, summaryReporter } from "@web/test-runner";
|
||||
|
||||
/** Source-level tripwires (e.g. profile-format-single-source.test.ts) need the
|
||||
* RAW TypeScript of every production module, but a browser test can neither
|
||||
* list directories nor bypass the esbuild transform. This serves
|
||||
* `/__source-manifest` = { "<relative path>": "<source>" } for every
|
||||
* .ts file outside __tests__/ and node_modules/. */
|
||||
const SOURCE_ROOT = fileURLToPath(new URL(".", import.meta.url));
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", "dist-ds", "screenshots", "locales"]);
|
||||
function collectSources(dir, out) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (!SKIP_DIRS.has(name)) collectSources(full, out);
|
||||
} else if (name.endsWith(".ts") && !name.endsWith(".d.ts")) {
|
||||
out[relative(SOURCE_ROOT, full).split("\\").join("/")] = readFileSync(full, "utf-8");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const sourceManifestPlugin = {
|
||||
name: "source-manifest",
|
||||
serve(context) {
|
||||
if (context.path === "/__source-manifest") {
|
||||
return { body: JSON.stringify(collectSources(SOURCE_ROOT, {})), type: "json" };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
files: "__tests__/**/*.test.ts",
|
||||
nodeResolve: true,
|
||||
plugins: [
|
||||
sourceManifestPlugin,
|
||||
esbuildPlugin({
|
||||
ts: true,
|
||||
json: true,
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: neplatná hodnota",
|
||||
"feat_schedule_time": "Plánování podle denní doby",
|
||||
"feat_schedule_time_desc": "Úkoly se stanou po termínu v určenou denní dobu místo o půlnoci.",
|
||||
"schedule_time_optional": "Termín v čase (volitelné, HH:MM)",
|
||||
"schedule_time_optional": "Termín v čase (volitelné)",
|
||||
"schedule_time_help": "Prázdné = půlnoc (výchozí). Časové pásmo HA.",
|
||||
"at_time": "v",
|
||||
"notes_optional": "Poznámky (volitelné)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Náklady (volitelné)",
|
||||
"duration_minutes": "Doba trvání v minutách (volitelné)",
|
||||
"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.",
|
||||
"days": "dní",
|
||||
"day": "den",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Neplatná hodnota",
|
||||
"seasonal_override_range": "Faktor musí být mezi 0.1 a 5.0",
|
||||
"clear_all": "Vymazat vše",
|
||||
"clear": "Vymazat",
|
||||
"seasonal_chart_title": "Sezónní faktory",
|
||||
"seasonal_learned": "Naučené",
|
||||
"seasonal_manual": "Manuální",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Senzor nyní ukazuje {value}.",
|
||||
"trigger_hint_above": "Úkol se spustí, jakmile hodnota překročí {target}.",
|
||||
"trigger_hint_below": "Spustí se, jakmile hodnota klesne pod {target}.",
|
||||
"trigger_hint_overlap": "Tyto meze se překrývají: každé měření spustí úkol a ten se nikdy nemůže zotavit. Jedno z polí nechte prázdné.",
|
||||
"trigger_hint_counter_delta": "Počítá od aktuální hodnoty ({value}): splatné při {due} (+{target}); počítadlo se po každém dokončení restartuje.",
|
||||
"trigger_hint_counter_delta_edit": "Počítá spotřebu od posledního dokončení: splatné po +{target}; počítadlo se po každém dokončení restartuje.",
|
||||
"trigger_hint_counter_abs": "Úkol bude splatný, jakmile senzor dosáhne {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ugyldig værdi",
|
||||
"feat_schedule_time": "Planlægning efter tidspunkt på dagen",
|
||||
"feat_schedule_time_desc": "Opgaver bliver forfaldne på et bestemt tidspunkt på dagen i stedet for midnat.",
|
||||
"schedule_time_optional": "Forfalder kl. (valgfrit, TT:MM)",
|
||||
"schedule_time_optional": "Forfalder kl. (valgfrit)",
|
||||
"schedule_time_help": "Tom = midnat (standard). HA-tidszone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Noter (valgfrit)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Omkostning (valgfrit)",
|
||||
"duration_minutes": "Varighed i minutter (valgfrit)",
|
||||
"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.",
|
||||
"days": "dage",
|
||||
"day": "dag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ugyldig værdi",
|
||||
"seasonal_override_range": "Faktor skal være mellem 0.1 og 5.0",
|
||||
"clear_all": "Ryd alle",
|
||||
"clear": "Ryd",
|
||||
"seasonal_chart_title": "Sæsonfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensoren viser {value} lige nu.",
|
||||
"trigger_hint_above": "Opgaven udløses, når værdien stiger over {target}.",
|
||||
"trigger_hint_below": "Den udløses, når værdien falder under {target}.",
|
||||
"trigger_hint_overlap": "Disse grænser overlapper: hver måling udløser opgaven, og den kan aldrig komme sig. Lad et af felterne stå tomt.",
|
||||
"trigger_hint_counter_delta": "Tæller fra den aktuelle aflæsning ({value}): forfalder ved {due} (+{target}); tælleren starter forfra efter hver fuldførelse.",
|
||||
"trigger_hint_counter_delta_edit": "Tæller forbruget siden sidste fuldførelse: forfalder efter +{target}; tælleren starter forfra efter hver fuldførelse.",
|
||||
"trigger_hint_counter_abs": "Opgaven forfalder, når sensoren når {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ungültiger Wert",
|
||||
"feat_schedule_time": "Uhrzeit-Scheduling",
|
||||
"feat_schedule_time_desc": "Tasks werden zu einer festen Uhrzeit fällig statt um Mitternacht.",
|
||||
"schedule_time_optional": "Fällig um (optional, HH:MM)",
|
||||
"schedule_time_optional": "Fällig um (optional)",
|
||||
"schedule_time_help": "Leer = Mitternacht (Default). HA-Zeitzone.",
|
||||
"at_time": "um",
|
||||
"notes_optional": "Notizen (optional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kosten (optional)",
|
||||
"duration_minutes": "Dauer in Minuten (optional)",
|
||||
"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.",
|
||||
"days": "Tage",
|
||||
"day": "Tag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ungültiger Wert",
|
||||
"seasonal_override_range": "Faktor muss zwischen 0.1 und 5.0 liegen",
|
||||
"clear_all": "Alle zurücksetzen",
|
||||
"clear": "Leeren",
|
||||
"seasonal_chart_title": "Saisonale Faktoren",
|
||||
"seasonal_learned": "Gelernt",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Der Sensor zeigt aktuell {value}.",
|
||||
"trigger_hint_above": "Die Aufgabe wird ausgelöst, sobald der Wert über {target} steigt.",
|
||||
"trigger_hint_below": "Sie wird ausgelöst, sobald der Wert unter {target} fällt.",
|
||||
"trigger_hint_overlap": "Diese Grenzen überschneiden sich: Jeder Messwert löst die Aufgabe aus, und sie kann sich nie erholen. Lass eines der Felder leer.",
|
||||
"trigger_hint_counter_delta": "Zählt ab dem aktuellen Stand ({value}): fällig bei {due} (+{target}); der Zähler startet nach jedem Abschluss neu.",
|
||||
"trigger_hint_counter_delta_edit": "Zählt den Verbrauch seit dem letzten Abschluss: fällig nach +{target}; der Zähler startet nach jedem Abschluss neu.",
|
||||
"trigger_hint_counter_abs": "Die Aufgabe wird fällig, sobald der Sensor {target} erreicht.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: invalid value",
|
||||
"feat_schedule_time": "Time-of-day scheduling",
|
||||
"feat_schedule_time_desc": "Tasks become overdue at a specific time of day instead of midnight.",
|
||||
"schedule_time_optional": "Due at time (optional, HH:MM)",
|
||||
"schedule_time_optional": "Due at time (optional)",
|
||||
"schedule_time_help": "Empty = midnight (default). HA timezone.",
|
||||
"at_time": "at",
|
||||
"notes_optional": "Notes (optional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Cost (optional)",
|
||||
"duration_minutes": "Duration in minutes (optional)",
|
||||
"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.",
|
||||
"days": "days",
|
||||
"day": "day",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Invalid value",
|
||||
"seasonal_override_range": "Factor must be between 0.1 and 5.0",
|
||||
"clear_all": "Clear all",
|
||||
"clear": "Clear",
|
||||
"seasonal_chart_title": "Seasonal Factors",
|
||||
"seasonal_learned": "Learned",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "The sensor reads {value} right now.",
|
||||
"trigger_hint_above": "The task triggers once it rises above {target}.",
|
||||
"trigger_hint_below": "It triggers once it falls below {target}.",
|
||||
"trigger_hint_overlap": "These limits overlap: every reading triggers the task and it can never recover. Leave one of them empty.",
|
||||
"trigger_hint_counter_delta": "Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",
|
||||
"trigger_hint_counter_delta_edit": "Counts usage since the last completion: due after +{target}; the count restarts after each completion.",
|
||||
"trigger_hint_counter_abs": "The task becomes due once the sensor reaches {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Programación por hora",
|
||||
"feat_schedule_time_desc": "Las tareas vencen a una hora específica en lugar de medianoche.",
|
||||
"schedule_time_optional": "Vence a las (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence a las (opcional)",
|
||||
"schedule_time_help": "Vacío = medianoche (predeterminado). Zona horaria HA.",
|
||||
"at_time": "a las",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Coste (opcional)",
|
||||
"duration_minutes": "Duración en minutos (opcional)",
|
||||
"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.",
|
||||
"days": "días",
|
||||
"day": "día",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valor no válido",
|
||||
"seasonal_override_range": "El factor debe estar entre 0.1 y 5.0",
|
||||
"clear_all": "Borrar todo",
|
||||
"clear": "Borrar",
|
||||
"seasonal_chart_title": "Factores estacionales",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "El sensor marca {value} ahora mismo.",
|
||||
"trigger_hint_above": "La tarea se activa cuando supere {target}.",
|
||||
"trigger_hint_below": "Se activa cuando baje de {target}.",
|
||||
"trigger_hint_overlap": "Estos límites se solapan: cada lectura activa la tarea y nunca puede recuperarse. Deja uno de los dos vacío.",
|
||||
"trigger_hint_counter_delta": "Cuenta desde la lectura actual ({value}): vence en {due} (+{target}); el contador se reinicia tras cada finalización.",
|
||||
"trigger_hint_counter_delta_edit": "Cuenta el uso desde la última finalización: vence tras +{target}; el contador se reinicia tras cada finalización.",
|
||||
"trigger_hint_counter_abs": "La tarea vence cuando el sensor alcance {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: virheellinen arvo",
|
||||
"feat_schedule_time": "Kellonajan ajoitus",
|
||||
"feat_schedule_time_desc": "Tehtävät erääntyvät tiettyyn kellonaikaan keskiyön sijaan.",
|
||||
"schedule_time_optional": "Erääntyy klo (valinnainen, HH:MM)",
|
||||
"schedule_time_optional": "Erääntyy klo (valinnainen)",
|
||||
"schedule_time_help": "Tyhjä = keskiyö (oletus). HA:n aikavyöhyke.",
|
||||
"at_time": "klo",
|
||||
"notes_optional": "Muistiinpanot (valinnainen)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kustannus (valinnainen)",
|
||||
"duration_minutes": "Kesto minuutteina (valinnainen)",
|
||||
"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.",
|
||||
"days": "päivää",
|
||||
"day": "päivä",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Virheellinen arvo",
|
||||
"seasonal_override_range": "Kertoimen on oltava välillä 0,1–5,0",
|
||||
"clear_all": "Tyhjennä kaikki",
|
||||
"clear": "Tyhjennä",
|
||||
"seasonal_chart_title": "Kausikertoimet",
|
||||
"seasonal_learned": "Opittu",
|
||||
"seasonal_manual": "Manuaalinen",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Anturi näyttää nyt {value}.",
|
||||
"trigger_hint_above": "Tehtävä laukeaa, kun arvo ylittää {target}.",
|
||||
"trigger_hint_below": "Se laukeaa, kun arvo alittaa {target}.",
|
||||
"trigger_hint_overlap": "Nämä rajat menevät päällekkäin: jokainen lukema laukaisee tehtävän eikä se voi koskaan palautua. Jätä toinen kentistä tyhjäksi.",
|
||||
"trigger_hint_counter_delta": "Laskee nykyisestä lukemasta ({value}): erääntyy kohdassa {due} (+{target}); laskuri alkaa alusta jokaisen suorituksen jälkeen.",
|
||||
"trigger_hint_counter_delta_edit": "Laskee käytön viime suorituksesta: erääntyy +{target} jälkeen; laskuri alkaa alusta jokaisen suorituksen jälkeen.",
|
||||
"trigger_hint_counter_abs": "Tehtävä erääntyy, kun anturi saavuttaa {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field} : valeur invalide",
|
||||
"feat_schedule_time": "Planification à l'heure",
|
||||
"feat_schedule_time_desc": "Les tâches arrivent à échéance à une heure précise plutôt qu'à minuit.",
|
||||
"schedule_time_optional": "Échéance à l'heure (optionnel, HH:MM)",
|
||||
"schedule_time_optional": "Échéance à l'heure (optionnel)",
|
||||
"schedule_time_help": "Vide = minuit (défaut). Fuseau horaire HA.",
|
||||
"at_time": "à",
|
||||
"notes_optional": "Notes (optionnel)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Coût (optionnel)",
|
||||
"duration_minutes": "Durée en minutes (optionnel)",
|
||||
"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.",
|
||||
"days": "jours",
|
||||
"day": "jour",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valeur invalide",
|
||||
"seasonal_override_range": "Le facteur doit être entre 0.1 et 5.0",
|
||||
"clear_all": "Tout effacer",
|
||||
"clear": "Effacer",
|
||||
"seasonal_chart_title": "Facteurs saisonniers",
|
||||
"seasonal_learned": "Appris",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Le capteur indique {value} en ce moment.",
|
||||
"trigger_hint_above": "La tâche se déclenche dès que la valeur dépasse {target}.",
|
||||
"trigger_hint_below": "Elle se déclenche dès que la valeur passe sous {target}.",
|
||||
"trigger_hint_overlap": "Ces limites se chevauchent : chaque relevé déclenche la tâche et elle ne peut jamais se rétablir. Laissez l'une des deux vide.",
|
||||
"trigger_hint_counter_delta": "Compte à partir de la valeur actuelle ({value}) : échéance à {due} (+{target}) ; le compteur repart après chaque exécution.",
|
||||
"trigger_hint_counter_delta_edit": "Compte l'usage depuis la dernière exécution : échéance après +{target} ; le compteur repart après chaque exécution.",
|
||||
"trigger_hint_counter_abs": "La tâche devient due dès que le capteur atteint {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: अमान्य मान",
|
||||
"feat_schedule_time": "दिन का समय निर्धारण",
|
||||
"feat_schedule_time_desc": "कार्य मध्यरात्रि के बजाय दिन के एक विशिष्ट समय पर अतिदेय हो जाते हैं।",
|
||||
"schedule_time_optional": "इस समय देय (वैकल्पिक, HH:MM)",
|
||||
"schedule_time_optional": "इस समय देय (वैकल्पिक)",
|
||||
"schedule_time_help": "खाली = मध्यरात्रि (डिफ़ॉल्ट)। HA समय क्षेत्र।",
|
||||
"at_time": "बजे",
|
||||
"notes_optional": "टिप्पणियाँ (वैकल्पिक)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "लागत (वैकल्पिक)",
|
||||
"duration_minutes": "मिनटों में अवधि (वैकल्पिक)",
|
||||
"completed_at_optional": "पूर्ण होने का समय (वैकल्पिक, खाली = अभी)",
|
||||
"completed_at_pick": "तारीख और समय चुनें",
|
||||
"completed_at_future_error": "पूर्णता की तारीख भविष्य में नहीं हो सकती।",
|
||||
"days": "दिन",
|
||||
"day": "दिन",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "अमान्य मान",
|
||||
"seasonal_override_range": "कारक 0.1 और 5.0 के बीच होना चाहिए",
|
||||
"clear_all": "सभी साफ़ करें",
|
||||
"clear": "साफ़ करें",
|
||||
"seasonal_chart_title": "मौसमी कारक",
|
||||
"seasonal_learned": "सीखा गया",
|
||||
"seasonal_manual": "मैनुअल",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "सेंसर अभी {value} दिखा रहा है।",
|
||||
"trigger_hint_above": "मान {target} से ऊपर जाते ही कार्य ट्रिगर होगा।",
|
||||
"trigger_hint_below": "मान {target} से नीचे आते ही यह ट्रिगर होगा।",
|
||||
"trigger_hint_overlap": "ये सीमाएँ एक-दूसरे को ओवरलैप करती हैं: हर रीडिंग कार्य को ट्रिगर करेगी और वह कभी सामान्य नहीं हो पाएगा। दोनों में से एक खाली छोड़ें।",
|
||||
"trigger_hint_counter_delta": "वर्तमान रीडिंग ({value}) से गिनती: {due} पर देय (+{target}); हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
|
||||
"trigger_hint_counter_delta_edit": "पिछली पूर्णता से उपयोग गिनता है: +{target} के बाद देय; हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
|
||||
"trigger_hint_counter_abs": "सेंसर {target} तक पहुँचते ही कार्य देय होगा।",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: érvénytelen érték",
|
||||
"feat_schedule_time": "Napszakhoz kötött ütemezés",
|
||||
"feat_schedule_time_desc": "A feladatok nem éjfélkor, hanem egy megadott napszakban válnak lejárttá.",
|
||||
"schedule_time_optional": "Esedékesség időpontja (opcionális, ÓÓ:PP)",
|
||||
"schedule_time_optional": "Esedékesség időpontja (opcionális)",
|
||||
"schedule_time_help": "Üres = éjfél (alapértelmezés). HA időzóna.",
|
||||
"at_time": "ekkor:",
|
||||
"notes_optional": "Megjegyzések (opcionális)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Költség (opcionális)",
|
||||
"duration_minutes": "Időtartam percben (opcionális)",
|
||||
"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.",
|
||||
"days": "nap",
|
||||
"day": "nap",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Érvénytelen érték",
|
||||
"seasonal_override_range": "A tényezőnek 0,1 és 5,0 között kell lennie",
|
||||
"clear_all": "Összes törlése",
|
||||
"clear": "Törlés",
|
||||
"seasonal_chart_title": "Szezonális tényezők",
|
||||
"seasonal_learned": "Tanult",
|
||||
"seasonal_manual": "Kézi",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Az érzékelő jelenlegi értéke: {value}.",
|
||||
"trigger_hint_above": "A feladat akkor aktiválódik, amikor az érték {target} fölé emelkedik.",
|
||||
"trigger_hint_below": "Akkor aktiválódik, amikor az érték {target} alá csökken.",
|
||||
"trigger_hint_overlap": "Ezek a határok átfedik egymást: minden mérés kiváltja a feladatot, és az soha nem állhat helyre. Hagyja üresen az egyik mezőt.",
|
||||
"trigger_hint_counter_delta": "A jelenlegi értéktől ({value}) számol: esedékes {due} értéknél (+{target}), és a számlálás minden elvégzés után újraindul.",
|
||||
"trigger_hint_counter_delta_edit": "A legutóbbi elvégzés óta számolja a használatot: esedékes +{target} után; a számlálás minden elvégzés után újraindul.",
|
||||
"trigger_hint_counter_abs": "A feladat akkor válik esedékessé, amikor az érzékelő eléri a(z) {target} értéket.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valore non valido",
|
||||
"feat_schedule_time": "Pianificazione oraria",
|
||||
"feat_schedule_time_desc": "Le attività scadono a un'ora specifica anziché a mezzanotte.",
|
||||
"schedule_time_optional": "Scadenza all'ora (opzionale, HH:MM)",
|
||||
"schedule_time_optional": "Scadenza all'ora (opzionale)",
|
||||
"schedule_time_help": "Vuoto = mezzanotte (default). Fuso orario HA.",
|
||||
"at_time": "alle",
|
||||
"notes_optional": "Note (opzionale)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Costo (opzionale)",
|
||||
"duration_minutes": "Durata in minuti (opzionale)",
|
||||
"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.",
|
||||
"days": "giorni",
|
||||
"day": "giorno",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valore non valido",
|
||||
"seasonal_override_range": "Il fattore deve essere tra 0.1 e 5.0",
|
||||
"clear_all": "Cancella tutto",
|
||||
"clear": "Cancella",
|
||||
"seasonal_chart_title": "Fattori stagionali",
|
||||
"seasonal_learned": "Appreso",
|
||||
"seasonal_manual": "Manuale",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Il sensore indica {value} in questo momento.",
|
||||
"trigger_hint_above": "L'attività scatta quando supera {target}.",
|
||||
"trigger_hint_below": "Scatta quando scende sotto {target}.",
|
||||
"trigger_hint_overlap": "Questi limiti si sovrappongono: ogni lettura attiva l'attività e non può mai rientrare. Lascia vuoto uno dei due campi.",
|
||||
"trigger_hint_counter_delta": "Conta dalla lettura attuale ({value}): scade a {due} (+{target}); il conteggio riparte dopo ogni completamento.",
|
||||
"trigger_hint_counter_delta_edit": "Conta l'uso dall'ultimo completamento: scade dopo +{target}; il conteggio riparte dopo ogni completamento.",
|
||||
"trigger_hint_counter_abs": "L'attività scade quando il sensore raggiunge {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 無効な値です",
|
||||
"feat_schedule_time": "時刻指定スケジュール",
|
||||
"feat_schedule_time_desc": "タスクが深夜0時ではなく指定した時刻に期限超過となります。",
|
||||
"schedule_time_optional": "期限の時刻(任意、HH:MM)",
|
||||
"schedule_time_optional": "期限の時刻(任意)",
|
||||
"schedule_time_help": "空欄=深夜0時(既定)。HAのタイムゾーン。",
|
||||
"at_time": "時刻",
|
||||
"notes_optional": "メモ(任意)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "費用(任意)",
|
||||
"duration_minutes": "所要時間(分、任意)",
|
||||
"completed_at_optional": "完了日時(任意・空欄 = 現在)",
|
||||
"completed_at_pick": "日時を設定",
|
||||
"completed_at_future_error": "完了日時に未来は指定できません。",
|
||||
"days": "日",
|
||||
"day": "日",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "無効な値です",
|
||||
"seasonal_override_range": "係数は0.1〜5.0の範囲で指定してください",
|
||||
"clear_all": "すべてクリア",
|
||||
"clear": "クリア",
|
||||
"seasonal_chart_title": "季節係数",
|
||||
"seasonal_learned": "学習値",
|
||||
"seasonal_manual": "手動",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "センサーは現在 {value} を示しています。",
|
||||
"trigger_hint_above": "値が {target} を上回るとタスクが発動します。",
|
||||
"trigger_hint_below": "値が {target} を下回ると発動します。",
|
||||
"trigger_hint_overlap": "これらの制限は重なっています。すべての読み取り値でタスクがトリガーされ、復帰できなくなります。どちらか一方を空欄にしてください。",
|
||||
"trigger_hint_counter_delta": "現在の読み値({value})から数えます: {due}(+{target})で期限になり、完了のたびにカウントはやり直しになります。",
|
||||
"trigger_hint_counter_delta_edit": "前回の完了からの使用量を数えます: +{target} で期限になり、完了のたびにカウントはやり直しになります。",
|
||||
"trigger_hint_counter_abs": "センサーが {target} に達するとタスクが期限になります。",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 유효하지 않은 값입니다",
|
||||
"feat_schedule_time": "시각 지정 일정",
|
||||
"feat_schedule_time_desc": "작업이 자정이 아닌 지정한 시각에 기한 초과로 전환됩니다.",
|
||||
"schedule_time_optional": "기한 시각 (선택, HH:MM)",
|
||||
"schedule_time_optional": "기한 시각 (선택)",
|
||||
"schedule_time_help": "비워두면 자정(기본값)입니다. HA 시간대 기준.",
|
||||
"at_time": "시각",
|
||||
"notes_optional": "메모 (선택)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "비용 (선택)",
|
||||
"duration_minutes": "소요 시간(분, 선택)",
|
||||
"completed_at_optional": "완료 시각 (선택, 비우면 지금)",
|
||||
"completed_at_pick": "날짜 및 시간 설정",
|
||||
"completed_at_future_error": "완료 날짜는 미래일 수 없습니다.",
|
||||
"days": "일",
|
||||
"day": "일",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "유효하지 않은 값",
|
||||
"seasonal_override_range": "계수는 0.1에서 5.0 사이여야 합니다",
|
||||
"clear_all": "모두 지우기",
|
||||
"clear": "지우기",
|
||||
"seasonal_chart_title": "계절 계수",
|
||||
"seasonal_learned": "학습됨",
|
||||
"seasonal_manual": "수동",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "센서의 현재 값은 {value}입니다.",
|
||||
"trigger_hint_above": "{target}을(를) 초과하면 작업이 트리거됩니다.",
|
||||
"trigger_hint_below": "{target} 미만으로 떨어지면 트리거됩니다.",
|
||||
"trigger_hint_overlap": "이 한계값들이 겹칩니다. 모든 측정값이 작업을 트리거하여 복구되지 않습니다. 둘 중 하나를 비워 두세요.",
|
||||
"trigger_hint_counter_delta": "현재 값({value})부터 계수합니다: {due} 도달 시 기한 (+{target}), 완료할 때마다 계수가 다시 시작됩니다.",
|
||||
"trigger_hint_counter_delta_edit": "마지막 완료 이후 사용량을 계수합니다: +{target} 후 기한이 되며, 완료할 때마다 계수가 다시 시작됩니다.",
|
||||
"trigger_hint_counter_abs": "센서가 {target}에 도달하면 작업 기한이 됩니다.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: ugyldig verdi",
|
||||
"feat_schedule_time": "Planlegging med klokkeslett",
|
||||
"feat_schedule_time_desc": "Oppgaver forfaller på et bestemt klokkeslett i stedet for ved midnatt.",
|
||||
"schedule_time_optional": "Forfaller kl. (valgfritt, TT:MM)",
|
||||
"schedule_time_optional": "Forfaller kl. (valgfritt)",
|
||||
"schedule_time_help": "Tom = midnatt (standard). HA-tidssone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Notater (valgfritt)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Kostnad (valgfritt)",
|
||||
"duration_minutes": "Varighet i minutter (valgfritt)",
|
||||
"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.",
|
||||
"days": "dager",
|
||||
"day": "dag",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Ugyldig verdi",
|
||||
"seasonal_override_range": "Faktor må være mellom 0,1 og 5,0",
|
||||
"clear_all": "Fjern alle",
|
||||
"clear": "Tøm",
|
||||
"seasonal_chart_title": "Sesongfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensoren viser {value} akkurat nå.",
|
||||
"trigger_hint_above": "Oppgaven utløses når verdien stiger over {target}.",
|
||||
"trigger_hint_below": "Den utløses når verdien faller under {target}.",
|
||||
"trigger_hint_overlap": "Disse grensene overlapper: hver måling utløser oppgaven, og den kan aldri gjenopprettes. La ett av feltene stå tomt.",
|
||||
"trigger_hint_counter_delta": "Teller fra gjeldende avlesning ({value}): forfaller ved {due} (+{target}); telleren starter på nytt etter hver fullføring.",
|
||||
"trigger_hint_counter_delta_edit": "Teller forbruket siden forrige fullføring: forfaller etter +{target}; telleren starter på nytt etter hver fullføring.",
|
||||
"trigger_hint_counter_abs": "Oppgaven forfaller når sensoren når {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: ongeldige waarde",
|
||||
"feat_schedule_time": "Tijd-van-dag-planning",
|
||||
"feat_schedule_time_desc": "Taken vervallen op een specifieke tijd in plaats van middernacht.",
|
||||
"schedule_time_optional": "Vervaldagstijd (optioneel, HH:MM)",
|
||||
"schedule_time_optional": "Vervaldagstijd (optioneel)",
|
||||
"schedule_time_help": "Leeg = middernacht (standaard). HA-tijdzone.",
|
||||
"at_time": "om",
|
||||
"notes_optional": "Notities (optioneel)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Kosten (optioneel)",
|
||||
"duration_minutes": "Duur in minuten (optioneel)",
|
||||
"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.",
|
||||
"days": "dagen",
|
||||
"day": "dag",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Ongeldige waarde",
|
||||
"seasonal_override_range": "Factor moet tussen 0.1 en 5.0 liggen",
|
||||
"clear_all": "Alles wissen",
|
||||
"clear": "Wissen",
|
||||
"seasonal_chart_title": "Seizoensfactoren",
|
||||
"seasonal_learned": "Geleerd",
|
||||
"seasonal_manual": "Handmatig",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "De sensor staat nu op {value}.",
|
||||
"trigger_hint_above": "De taak wordt geactiveerd zodra de waarde boven {target} komt.",
|
||||
"trigger_hint_below": "Hij wordt geactiveerd zodra de waarde onder {target} zakt.",
|
||||
"trigger_hint_overlap": "Deze grenzen overlappen: elke meting activeert de taak en deze kan nooit herstellen. Laat een van beide leeg.",
|
||||
"trigger_hint_counter_delta": "Telt vanaf de huidige stand ({value}): vervalt bij {due} (+{target}); de teller herstart na elke afronding.",
|
||||
"trigger_hint_counter_delta_edit": "Telt het gebruik sinds de laatste afronding: vervalt na +{target}; de teller herstart na elke afronding.",
|
||||
"trigger_hint_counter_abs": "De taak vervalt zodra de sensor {target} bereikt.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: nieprawidłowa wartość",
|
||||
"feat_schedule_time": "Harmonogram według pory dnia",
|
||||
"feat_schedule_time_desc": "Zadania stają się zaległe o określonej porze dnia zamiast o północy.",
|
||||
"schedule_time_optional": "Termin o godzinie (opcjonalne, HH:MM)",
|
||||
"schedule_time_optional": "Termin o godzinie (opcjonalne)",
|
||||
"schedule_time_help": "Puste = północ (domyślnie). Strefa czasowa HA.",
|
||||
"at_time": "o",
|
||||
"notes_optional": "Notatki (opcjonalne)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Koszt (opcjonalne)",
|
||||
"duration_minutes": "Czas trwania w minutach (opcjonalne)",
|
||||
"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.",
|
||||
"days": "dni",
|
||||
"day": "dzień",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Nieprawidłowa wartość",
|
||||
"seasonal_override_range": "Czynnik musi być między 0.1 a 5.0",
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"clear": "Wyczyść",
|
||||
"seasonal_chart_title": "Czynniki sezonowe",
|
||||
"seasonal_learned": "Wyuczone",
|
||||
"seasonal_manual": "Ręczne",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Czujnik wskazuje teraz {value}.",
|
||||
"trigger_hint_above": "Zadanie uruchomi się, gdy wartość przekroczy {target}.",
|
||||
"trigger_hint_below": "Uruchomi się, gdy wartość spadnie poniżej {target}.",
|
||||
"trigger_hint_overlap": "Te granice nakładają się: każdy odczyt wyzwala zadanie i nigdy nie może ono wrócić do normy. Pozostaw jedno z pól puste.",
|
||||
"trigger_hint_counter_delta": "Liczy od bieżącego odczytu ({value}): termin przy {due} (+{target}); licznik startuje od nowa po każdym ukończeniu.",
|
||||
"trigger_hint_counter_delta_edit": "Liczy zużycie od ostatniego ukończenia: termin po +{target}; licznik startuje od nowa po każdym ukończeniu.",
|
||||
"trigger_hint_counter_abs": "Zadanie staje się wymagalne, gdy czujnik osiągnie {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Agendamento por horário",
|
||||
"feat_schedule_time_desc": "As tarefas ficam atrasadas em um horário específico do dia, em vez de à meia-noite.",
|
||||
"schedule_time_optional": "Vence às (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence às (opcional)",
|
||||
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário do HA.",
|
||||
"at_time": "às",
|
||||
"notes_optional": "Observações (opcional)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Custo (opcional)",
|
||||
"duration_minutes": "Duração em minutos (opcional)",
|
||||
"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.",
|
||||
"days": "dias",
|
||||
"day": "dia",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Valor inválido",
|
||||
"seasonal_override_range": "O fator deve estar entre 0,1 e 5,0",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear": "Limpar",
|
||||
"seasonal_chart_title": "Fatores sazonais",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "O sensor lê {value} neste momento.",
|
||||
"trigger_hint_above": "A tarefa é acionada quando o valor sobe acima de {target}.",
|
||||
"trigger_hint_below": "É acionada quando o valor cai abaixo de {target}.",
|
||||
"trigger_hint_overlap": "Esses limites se sobrepõem: toda leitura aciona a tarefa e ela nunca consegue se recuperar. Deixe um dos campos vazio.",
|
||||
"trigger_hint_counter_delta": "Conta a partir da leitura atual ({value}): vence em {due} (+{target}), e a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_delta_edit": "Conta o uso desde a última conclusão: vence após +{target}; a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_abs": "A tarefa vence quando o sensor atinge {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Agendamento por hora",
|
||||
"feat_schedule_time_desc": "Tarefas vencem em um horário específico em vez de meia-noite.",
|
||||
"schedule_time_optional": "Vence às (opcional, HH:MM)",
|
||||
"schedule_time_optional": "Vence às (opcional)",
|
||||
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário HA.",
|
||||
"at_time": "às",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Custo (opcional)",
|
||||
"duration_minutes": "Duração em minutos (opcional)",
|
||||
"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.",
|
||||
"days": "dias",
|
||||
"day": "dia",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Valor inválido",
|
||||
"seasonal_override_range": "O fator deve estar entre 0.1 e 5.0",
|
||||
"clear_all": "Limpar tudo",
|
||||
"clear": "Limpar",
|
||||
"seasonal_chart_title": "Fatores sazonais",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "O sensor marca {value} neste momento.",
|
||||
"trigger_hint_above": "A tarefa dispara quando ultrapassar {target}.",
|
||||
"trigger_hint_below": "Dispara quando descer abaixo de {target}.",
|
||||
"trigger_hint_overlap": "Estes limites sobrepõem-se: cada leitura aciona a tarefa e esta nunca consegue recuperar. Deixe um dos campos vazio.",
|
||||
"trigger_hint_counter_delta": "Conta a partir da leitura atual ({value}): vence em {due} (+{target}); a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_delta_edit": "Conta o uso desde a última conclusão: vence após +{target}; a contagem recomeça após cada conclusão.",
|
||||
"trigger_hint_counter_abs": "A tarefa vence quando o sensor atingir {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: неверное значение",
|
||||
"feat_schedule_time": "Планирование по времени дня",
|
||||
"feat_schedule_time_desc": "Задачи становятся просроченными в определённое время дня, а не в полночь.",
|
||||
"schedule_time_optional": "Срок (необязательно, HH:MM)",
|
||||
"schedule_time_optional": "Срок (необязательно)",
|
||||
"schedule_time_help": "Пусто = полночь (по умолчанию). Часовой пояс HA.",
|
||||
"at_time": "в",
|
||||
"notes_optional": "Примечания (опционально)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Стоимость (опционально)",
|
||||
"duration_minutes": "Длительность в минутах (опционально)",
|
||||
"completed_at_optional": "Выполнено (необязательно, пусто = сейчас)",
|
||||
"completed_at_pick": "Указать дату и время",
|
||||
"completed_at_future_error": "Дата выполнения не может быть в будущем.",
|
||||
"days": "дней",
|
||||
"day": "день",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Недопустимое значение",
|
||||
"seasonal_override_range": "Коэффициент должен быть от 0.1 до 5.0",
|
||||
"clear_all": "Очистить все",
|
||||
"clear": "Очистить",
|
||||
"seasonal_chart_title": "Сезонные факторы",
|
||||
"seasonal_learned": "Изученные",
|
||||
"seasonal_manual": "Ручные",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Сейчас датчик показывает {value}.",
|
||||
"trigger_hint_above": "Задача сработает, когда значение поднимется выше {target}.",
|
||||
"trigger_hint_below": "Она сработает, когда значение опустится ниже {target}.",
|
||||
"trigger_hint_overlap": "Эти пределы перекрываются: каждое показание запускает задачу, и она никогда не сможет восстановиться. Оставьте одно из полей пустым.",
|
||||
"trigger_hint_counter_delta": "Отсчёт от текущего показания ({value}): срок при {due} (+{target}); счётчик начинается заново после каждого выполнения.",
|
||||
"trigger_hint_counter_delta_edit": "Считает расход с последнего выполнения: срок после +{target}; счётчик начинается заново после каждого выполнения.",
|
||||
"trigger_hint_counter_abs": "Задача наступит, когда датчик достигнет {target}.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: ogiltigt värde",
|
||||
"feat_schedule_time": "Schemaläggning per tid på dygnet",
|
||||
"feat_schedule_time_desc": "Uppgifter blir försenade vid en specifik tid på dygnet istället för midnatt.",
|
||||
"schedule_time_optional": "Förfaller kl. (valfritt, HH:MM)",
|
||||
"schedule_time_optional": "Förfaller kl. (valfritt)",
|
||||
"schedule_time_help": "Tomt = midnatt (standard). HA-tidszon.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Anteckningar (valfritt)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Kostnad (valfritt)",
|
||||
"duration_minutes": "Varaktighet i minuter (valfritt)",
|
||||
"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.",
|
||||
"days": "dagar",
|
||||
"day": "dag",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Ogiltigt värde",
|
||||
"seasonal_override_range": "Faktor måste vara mellan 0.1 och 5.0",
|
||||
"clear_all": "Rensa alla",
|
||||
"clear": "Rensa",
|
||||
"seasonal_chart_title": "Säsongsfaktorer",
|
||||
"seasonal_learned": "Lärt",
|
||||
"seasonal_manual": "Manuell",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensorn visar {value} just nu.",
|
||||
"trigger_hint_above": "Uppgiften utlöses när värdet stiger över {target}.",
|
||||
"trigger_hint_below": "Den utlöses när värdet faller under {target}.",
|
||||
"trigger_hint_overlap": "Dessa gränser överlappar: varje mätvärde utlöser uppgiften och den kan aldrig återhämta sig. Lämna ett av fälten tomt.",
|
||||
"trigger_hint_counter_delta": "Räknar från den aktuella avläsningen ({value}): förfaller vid {due} (+{target}); räknaren börjar om efter varje slutförande.",
|
||||
"trigger_hint_counter_delta_edit": "Räknar förbrukningen sedan senaste slutförandet: förfaller efter +{target}; räknaren börjar om efter varje slutförande.",
|
||||
"trigger_hint_counter_abs": "Uppgiften förfaller när sensorn når {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: geçersiz değer",
|
||||
"feat_schedule_time": "Gün içi saat zamanlaması",
|
||||
"feat_schedule_time_desc": "Görevler gece yarısı yerine günün belirli bir saatinde gecikmiş olur.",
|
||||
"schedule_time_optional": "Vade saati (isteğe bağlı, SS:DD)",
|
||||
"schedule_time_optional": "Vade saati (isteğe bağlı)",
|
||||
"schedule_time_help": "Boş = gece yarısı (varsayılan). HA saat dilimi.",
|
||||
"at_time": "saat",
|
||||
"notes_optional": "Notlar (isteğe bağlı)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "Maliyet (isteğe bağlı)",
|
||||
"duration_minutes": "Dakika cinsinden süre (isteğe bağlı)",
|
||||
"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.",
|
||||
"days": "gün",
|
||||
"day": "gün",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "Geçersiz değer",
|
||||
"seasonal_override_range": "Faktör 0.1 ile 5.0 arasında olmalıdır",
|
||||
"clear_all": "Tümünü temizle",
|
||||
"clear": "Temizle",
|
||||
"seasonal_chart_title": "Mevsimsel Faktörler",
|
||||
"seasonal_learned": "Öğrenilmiş",
|
||||
"seasonal_manual": "Manuel",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Sensör şu anda {value} değerini okuyor.",
|
||||
"trigger_hint_above": "Görev, değer {target} üzerine çıktığında tetiklenir.",
|
||||
"trigger_hint_below": "Değer {target} altına düştüğünde tetiklenir.",
|
||||
"trigger_hint_overlap": "Bu sınırlar çakışıyor: her okuma görevi tetikler ve görev asla normale dönemez. Alanlardan birini boş bırakın.",
|
||||
"trigger_hint_counter_delta": "Mevcut okumadan ({value}) itibaren sayar: {due} değerinde vadesi gelir (+{target}) ve sayım her tamamlamadan sonra yeniden başlar.",
|
||||
"trigger_hint_counter_delta_edit": "Son tamamlamadan bu yana kullanımı sayar: +{target} sonrasında vadesi gelir; sayım her tamamlamadan sonra yeniden başlar.",
|
||||
"trigger_hint_counter_abs": "Sensör {target} değerine ulaştığında görevin vadesi gelir.",
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"err_invalid_value": "{field}: невірне значення",
|
||||
"feat_schedule_time": "Планування за часом доби",
|
||||
"feat_schedule_time_desc": "Задачі стають простроченими у певний час доби, а не опівночі.",
|
||||
"schedule_time_optional": "Час прострочення (необов'язково, HH:MM)",
|
||||
"schedule_time_optional": "Час прострочення (необов'язково)",
|
||||
"schedule_time_help": "Порожньо = опівночі (за замовчуванням). Часовий пояс HA.",
|
||||
"at_time": "о",
|
||||
"notes_optional": "Примітки (необов'язково)",
|
||||
@@ -130,6 +130,7 @@
|
||||
"cost_optional": "Вартість (необов'язково)",
|
||||
"duration_minutes": "Тривалість у хвилинах (необов'язково)",
|
||||
"completed_at_optional": "Виконано (необов'язково, порожньо = зараз)",
|
||||
"completed_at_pick": "Вказати дату й час",
|
||||
"completed_at_future_error": "Дата виконання не може бути в майбутньому.",
|
||||
"days": "днів",
|
||||
"day": "день",
|
||||
@@ -287,6 +288,7 @@
|
||||
"seasonal_override_invalid": "Недійсне значення",
|
||||
"seasonal_override_range": "Коефіцієнт має бути між 0.1 та 5.0",
|
||||
"clear_all": "Очистити все",
|
||||
"clear": "Очистити",
|
||||
"seasonal_chart_title": "Сезонні коефіцієнти",
|
||||
"seasonal_learned": "Навчена",
|
||||
"seasonal_manual": "Ручна",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "Зараз датчик показує {value}.",
|
||||
"trigger_hint_above": "Завдання спрацює, коли значення підніметься вище {target}.",
|
||||
"trigger_hint_below": "Воно спрацює, коли значення опуститься нижче {target}.",
|
||||
"trigger_hint_overlap": "Ці межі перекриваються: кожне показання запускає завдання, і воно ніколи не зможе відновитися. Залиште одне з полів порожнім.",
|
||||
"trigger_hint_counter_delta": "Відлік від поточного показника ({value}): термін при {due} (+{target}); лічильник починається заново після кожного виконання.",
|
||||
"trigger_hint_counter_delta_edit": "Рахує використання з останнього виконання: термін після +{target}; лічильник починається заново після кожного виконання.",
|
||||
"trigger_hint_counter_abs": "Завдання настане, коли датчик досягне {target}.",
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"err_invalid_value": "{field}: 无效数值",
|
||||
"feat_schedule_time": "具体时间调度",
|
||||
"feat_schedule_time_desc": "任务将在特定时间点变为逾期,而非默认的午夜。",
|
||||
"schedule_time_optional": "到期时间 (可选, HH:MM)",
|
||||
"schedule_time_optional": "到期时间 (可选)",
|
||||
"schedule_time_help": "留空 = 午夜 (默认)。基于 HA 时区。",
|
||||
"at_time": "于",
|
||||
"notes_optional": "备注 (可选)",
|
||||
@@ -132,6 +132,7 @@
|
||||
"cost_optional": "成本 (可选)",
|
||||
"duration_minutes": "耗时 (分钟, 可选)",
|
||||
"completed_at_optional": "完成时间(可选,留空 = 现在)",
|
||||
"completed_at_pick": "设置日期和时间",
|
||||
"completed_at_future_error": "完成日期不能是未来时间。",
|
||||
"days": "天",
|
||||
"day": "天",
|
||||
@@ -289,6 +290,7 @@
|
||||
"seasonal_override_invalid": "无效数值",
|
||||
"seasonal_override_range": "系数必须介于 0.1 到 5.0 之间",
|
||||
"clear_all": "全部清除",
|
||||
"clear": "清除",
|
||||
"seasonal_chart_title": "季节性因素",
|
||||
"seasonal_learned": "自动学习",
|
||||
"seasonal_manual": "手动设置",
|
||||
@@ -802,6 +804,7 @@
|
||||
"trigger_hint_now": "传感器当前读数为 {value}。",
|
||||
"trigger_hint_above": "当数值升到 {target} 以上时任务触发。",
|
||||
"trigger_hint_below": "当数值降到 {target} 以下时任务触发。",
|
||||
"trigger_hint_overlap": "这两个界限相互重叠:每个读数都会触发任务,任务将永远无法恢复。请将其中一项留空。",
|
||||
"trigger_hint_counter_delta": "从当前读数({value})开始计数:到 {due}(+{target})时到期;每次完成后重新计数。",
|
||||
"trigger_hint_counter_delta_edit": "从上次完成开始计算用量:+{target} 后到期;每次完成后重新计数。",
|
||||
"trigger_hint_counter_abs": "当传感器达到 {target} 时任务到期。",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+250
@@ -0,0 +1,250 @@
|
||||
/*! maintenance_supporter frontend 2.74.0 */
|
||||
import{a as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-S2VAROHZ.js";import{a as p}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-NYOACMQR.js";import{a as o,b as _,c as i,f as l,h,l as g,m as n,q as t,s as u,u as v}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-Y2JXJRDF.js";var r=class extends h{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._users=[];this._responsible="";this._forMinutes="0";this._localeReady=!1;this._userService=null;this._toggle=s=>{let a=new Set(this._selected);a.has(s)?a.delete(s):a.add(s),this._selected=a};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(s=>s.entity_id))};this._adopt=async()=>{if(!(this._selected.size===0||this._adopting)){this._adopting=!0,this._error="";try{let s=this._sensors.filter(e=>this._selected.has(e.entity_id)).map(e=>({entity_id:e.entity_id,name:e.name,entry_id:e.suggested_entry_id??void 0,object_name:e.suggested_object_name,device_id:e.device_id??void 0,part_id:e.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),a=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/problem_sensors/adopt",selections:s});this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:a})),this._open=!1}catch(s){this._error=p(s,this._lang)}finally{this._adopting=!1}}}}get _lang(){return u(this.hass)}updated(s){s.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,v(this._lang).then(()=>this.requestUpdate()))}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._responsible="",this._forMinutes="0";try{this._userService?this._userService.updateHass(this.hass):this._userService=new f(this.hass);let[s,a]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/problem_sensors/discover"}),this._userService.getUsers().catch(()=>[])]);this._sensors=s.sensors||[],this._selected=new Set(this._sensors.map(e=>e.entity_id)),this._users=a}catch(s){this._error=p(s,this._lang)}finally{this._loading=!1}}_close(){this._open=!1}render(){if(!this._open)return i``;let s=this._lang,a=this._sensors.length>0&&this._selected.size===this._sensors.length;return i`
|
||||
<div class="overlay" @click=${this._close}>
|
||||
<div class="card" @click=${e=>e.stopPropagation()}>
|
||||
<div class="title">${t("adopt_problem_title",s)}</div>
|
||||
<div class="hint">${t("adopt_problem_hint",s)}</div>
|
||||
${this._error?i`<div class="error">${this._error}</div>`:l}
|
||||
|
||||
${this._loading?i`<div class="loading">…</div>`:this._sensors.length===0?i`<div class="empty">${t("adopt_problem_none",s)}</div>`:i`
|
||||
<label class="select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${a}
|
||||
@change=${this._toggleAll}
|
||||
/>
|
||||
<span>${t("selected",s)}: ${this._selected.size} / ${this._sensors.length}</span>
|
||||
</label>
|
||||
<div class="list">
|
||||
${this._sensors.map(e=>{let m=this._selected.has(e.entity_id),d=e.state==="on",c=[e.device_name,e.area_name].filter(Boolean).join(" \xB7 ");return i`
|
||||
<label class="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${m}
|
||||
@change=${()=>this._toggle(e.entity_id)}
|
||||
/>
|
||||
<div class="row-main">
|
||||
<div class="row-top">
|
||||
<span class="row-name">${e.name}</span>
|
||||
<span class="chip ${d?"chip-active":"chip-ok"}">
|
||||
${d?t("adopt_problem_active",s):t("adopt_problem_ok",s)}
|
||||
</span>
|
||||
</div>
|
||||
${c?i`<div class="row-sub">${c}</div>`:l}
|
||||
<div class="row-target">
|
||||
→ ${e.suggested_object_name}${e.suggested_entry_id?l:i` <span class="new-tag">${t("adopt_problem_new_object",s)}</span>`}
|
||||
</div>
|
||||
${e.suggested_part_name?i`<div class="row-part">
|
||||
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
|
||||
${t("adopt_problem_part",s).replace("{name}",e.suggested_part_name)}
|
||||
</div>`:l}
|
||||
</div>
|
||||
</label>
|
||||
`})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!this._loading&&this._sensors.length>0?i`
|
||||
<label class="responsible">
|
||||
<span>${t("for_at_least_minutes",s)}</span>
|
||||
<input
|
||||
class="for-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
.value=${this._forMinutes}
|
||||
@input=${e=>this._forMinutes=e.target.value}
|
||||
/>
|
||||
</label>
|
||||
<div class="for-hint">${t("adopt_for_minutes_hint",s)}</div>
|
||||
`:l}
|
||||
|
||||
${!this._loading&&this._sensors.length>0&&this._users.length>0?i`
|
||||
<label class="responsible">
|
||||
<span>${t("adopt_problem_responsible",s)}</span>
|
||||
<select
|
||||
.value=${this._responsible}
|
||||
@change=${e=>{this._responsible=e.target.value}}
|
||||
>
|
||||
<option value="" ?selected=${!this._responsible}>${t("no_user_assigned",s)}</option>
|
||||
${this._users.map(e=>i`<option value=${e.id} ?selected=${e.id===this._responsible}>${e.name}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
`:l}
|
||||
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel",s)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size===0||this._adopting}
|
||||
>
|
||||
${t("adopt_problem_adopt",s)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}};r.styles=_`
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: min(360px, calc(100vw - 24px));
|
||||
max-width: 560px;
|
||||
width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
.loading,
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-all input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row input {
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.row-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row-sub {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row-target {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row-part {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.row-part ha-icon {
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
.new-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-active {
|
||||
background: var(--error-color, #f44336);
|
||||
color: #fff;
|
||||
}
|
||||
.chip-ok {
|
||||
background: var(--divider-color);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.responsible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.for-input {
|
||||
width: 70px;
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.for-hint {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
.responsible select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
`,o([g({attribute:!1})],r.prototype,"hass",2),o([n()],r.prototype,"_open",2),o([n()],r.prototype,"_loading",2),o([n()],r.prototype,"_adopting",2),o([n()],r.prototype,"_error",2),o([n()],r.prototype,"_sensors",2),o([n()],r.prototype,"_selected",2),o([n()],r.prototype,"_users",2),o([n()],r.prototype,"_responsible",2),o([n()],r.prototype,"_forMinutes",2);customElements.get("maintenance-adopt-problem-sensors-dialog")||customElements.define("maintenance-adopt-problem-sensors-dialog",r);export{r as MaintenanceAdoptProblemSensorsDialog};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.74.0 */
|
||||
import{b as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-EOLJHFO4.js";async function l(e,n,t=300){return(await e.connection.sendMessagePromise({type:"auth/sign_path",path:n,expires:t})).path}async function r(e,n,t=300){return l(e,`/api/maintenance_supporter/document/${n}`,t)}async function y(e,n,t=""){let a=window.open("about:blank","_blank");try{let o=await r(e,n);a&&(a.location.href=new URL(o+t,window.location.origin).href)}catch(o){throw a&&a.close(),o}}async function p(e,n,t){i(await r(e,n,30),t)}function d(e){let n=URL.createObjectURL(new Blob([e],{type:"text/html"}));window.open(n,"_blank"),setTimeout(()=>URL.revokeObjectURL(n),6e4)}var c=[{key:"name",labelKey:"name",required:!0},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],u=c.map(e=>e.key),s=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function g(e){if(!Array.isArray(e))return[...s];let n=new Set,t=[];for(let a of e)typeof a=="string"&&u.includes(a)&&!n.has(a)&&(n.add(a),t.push(a));return t.length?(t.includes("name")||t.unshift("name"),t):[...s]}export{l as a,r as b,y as c,p as d,d as e,c as f,s as g,g as h};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.74.0 */
|
||||
function c(o,t,e){let l=new Blob([o],{type:e}),d=URL.createObjectURL(l),n=document.createElement("a");n.href=d,n.download=t,n.target="_blank",n.rel="noopener",n.style.display="none",document.body.appendChild(n),n.dispatchEvent(new MouseEvent("click")),document.body.removeChild(n),setTimeout(()=>URL.revokeObjectURL(d),6e4)}function r(o,t){let e=document.createElement("a");e.href=o,e.download=t,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}export{c as a,r as b};
|
||||
@@ -0,0 +1,341 @@
|
||||
/*! maintenance_supporter frontend 2.74.0 */
|
||||
import{a as _,e as y}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-YP2KCO26.js";import{a as k}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-NYOACMQR.js";import{K as m,a as s,b,c as o,f as d,h as v,l as n,m as l,q as r,x as g}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-Y2JXJRDF.js";var i=class extends v{constructor(){super(...arguments);this.entryId="";this.taskId="";this.taskName="";this.lang="en";this.checklist=[];this.adaptiveEnabled=!1;this.taskType="";this.readingUnit="";this.restockDefault=null;this.restockUnitCost=null;this.currencySymbol="";this.parts=[];this.consumesParts=[];this.consumesInfo=[];this.requiredFields=[];this.phaseLabel="";this.requireTagScan=!1;this.viaTagScan=!1;this._open=!1;this._notes="";this._cost="";this._duration="";this._loading=!1;this._error="";this._checklistState={};this._feedback="needed";this._photoDocId="";this._photoPreview="";this._photoUploading=!1;this._readingValue="";this._restockQty="";this._completedAt="";this._usedParts={};this.checklistPrefill={}}open(e={}){this._open||(this._open=!0,this.viaTagScan=!!e.viaTagScan,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState=Object.fromEntries(this.checklist.map((t,a)=>[String(a),!!this.checklistPrefill[t]]).filter(([,t])=>t)),this._feedback="needed",this._photoDocId="",this._photoPreview="",this._photoUploading=!1,this._readingValue="",this._restockQty=this.restockDefault!==null?String(this.restockDefault):"",this._completedAt="",this._usedParts=Object.fromEntries(this.consumesParts.map(t=>[_(t),{...t}])))}_toggleCheck(e){let t=String(e);this._checklistState={...this._checklistState,[t]:!this._checklistState[t]}}_setFeedback(e){this._feedback=e}async _onPhotoInput(e){let t=e.target,a=t.files?.[0];if(t.value="",!!a){this._photoUploading=!0,this._error="";try{let c=new FormData;c.append("entry_id",this.entryId),c.append("tags","photo"),c.append("file",a,a.name);let p=await fetch("/api/maintenance_supporter/document/upload",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:c});if(!p.ok){this._error=p.status===413?r("doc_too_large",this.lang):r("doc_upload_failed",this.lang);return}let u=await p.json();u.id&&(this._photoDocId=u.id,this._photoPreview=URL.createObjectURL(a))}catch{this._error=r("doc_upload_failed",this.lang)}finally{this._photoUploading=!1}}}_removePhoto(){this._photoPreview&&URL.revokeObjectURL(this._photoPreview),this._photoDocId="",this._photoPreview=""}async _complete(){this._loading=!0,this._error="";try{let e={type:"maintenance_supporter/task/complete",entry_id:this.entryId,task_id:this.taskId};if(this._notes&&(e.notes=this._notes),this._cost){let t=parseFloat(this._cost);!isNaN(t)&&t>=0&&(e.cost=t)}if(this._duration){let t=parseInt(this._duration,10);!isNaN(t)&&t>=0&&(e.duration=t)}if(this.checklist.length>0&&(e.checklist_state=this._checklistState),this.adaptiveEnabled&&(e.feedback=this._feedback),this._photoDocId&&(e.photo_doc_id=this._photoDocId),this.viaTagScan&&(e.via_tag_scan=!0),this._completedAt){if(new Date(this._completedAt).getTime()>Date.now()){this._error=r("completed_at_future_error",this.lang),this._loading=!1;return}e.completed_at=this._completedAt.length===16?`${this._completedAt}:00`:this._completedAt}if(this._readingValue!==""){let t=parseFloat(this._readingValue);isNaN(t)||(e.reading_value=t)}if(this.restockDefault!==null&&this._restockQty!==""){let t=parseFloat(this._restockQty);!isNaN(t)&&t>=1&&(e.restock_quantity=t)}this.parts.length>0&&(e.used_parts=Object.values(this._usedParts).filter(t=>Number.isFinite(t.quantity)&&t.quantity>0).map(t=>t.entry_id?{part_id:t.part_id,quantity:t.quantity,entry_id:t.entry_id}:{part_id:t.part_id,quantity:t.quantity})),await this.hass.connection.sendMessagePromise(e),this._open=!1,this.dispatchEvent(new CustomEvent("task-completed"))}catch(e){this._error=k(e,this.lang,r("save_error",this.lang))}finally{this._loading=!1}}get _missingRequired(){let e={notes:this._notes.trim()!=="",cost:this._cost.trim()!=="",duration:this._duration.trim()!=="",photo:this._photoDocId!=="",user:!!this.hass?.user};return this.requiredFields.filter(t=>!e[t])}_req(e){return this.requiredFields.includes(e)?o`<span class="req-mark" aria-hidden="true">*</span>`:d}_partsCostSuggestion(){if(this.restockDefault!==null){let a=parseFloat(this._restockQty);return this.restockUnitCost==null||!Number.isFinite(a)||a<=0?null:Math.round(this.restockUnitCost*a*100)/100}if(!this.parts.length)return null;let e=0,t=!1;for(let a of Object.values(this._usedParts)){let c=this.parts.find(p=>_({part_id:p.id,entry_id:p.entry_id})===_(a));c?.cost!=null&&(e+=c.cost*(a.quantity||1),t=!0)}return t?Math.round(e*100)/100:null}_renderCostSuggestion(e){if(this._cost.trim()!=="")return d;let t=this._partsCostSuggestion();if(t==null||t<=0)return d;let a=g(t,this.currencySymbol,e);return o`<button
|
||||
type="button"
|
||||
class="cost-suggestion"
|
||||
@click=${()=>this._cost=String(Math.round(t*100)/100)}
|
||||
>${r("cost_from_parts",e).replace("{amount}",a)}</button>`}_close(){this._open=!1}_pickCompletedAt(){let e=new Date,t=a=>String(a).padStart(2,"0");this._completedAt=`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}:00`}render(){if(!this._open)return o``;let e=this.lang||this.hass?.language||"en";return o`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${r("complete_title",e)}${this.taskName}</div>
|
||||
${this.phaseLabel?o`<div class="phase-line">${r("phase_current",e)}: ${this.phaseLabel}</div>`:d}
|
||||
${this.requireTagScan&&!this.viaTagScan?o`<div class="scan-required-note">${r("require_tag_scan_hint",e)}</div>`:d}
|
||||
<div class="content">
|
||||
${this._error?o`<div class="error">${this._error}</div>`:d}
|
||||
${this.checklist.length>0?o`
|
||||
<div class="checklist-section">
|
||||
<label class="checklist-label">${r("checklist",e)}</label>
|
||||
${this.checklist.map((t,a)=>o`
|
||||
<label class="checklist-item" @click=${()=>this._toggleCheck(a)}>
|
||||
<input type="checkbox" .checked=${!!this._checklistState[String(a)]} />
|
||||
<span>${t}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
`:d}
|
||||
${this.taskType==="reading"?o`
|
||||
<label class="field">
|
||||
<span class="field-label">${r("reading_value_label",e)}${this.readingUnit?` (${this.readingUnit})`:""}</span>
|
||||
<input type="number" step="any" class="field-input"
|
||||
.value=${this._readingValue}
|
||||
@input=${t=>this._readingValue=t.target.value} />
|
||||
</label>`:d}
|
||||
${this.parts.length?o`<div class="used-parts">
|
||||
<span class="field-label">${r("complete_parts_used",e)}</span>
|
||||
${this.parts.map(t=>{let a=_({part_id:t.id,entry_id:t.entry_id}),c=this._usedParts[a],p=c!==void 0,u=t.entry_id?{part_id:t.id,quantity:1,entry_id:t.entry_id}:{part_id:t.id,quantity:1};return o`<div class="used-part-row">
|
||||
<label class="used-part-check">
|
||||
<input type="checkbox" .checked=${p}
|
||||
@change=${f=>{let h={...this._usedParts};f.target.checked?h[a]=h[a]||u:delete h[a],this._usedParts=h}} />
|
||||
<span
|
||||
>${t.name}${t.owner_name?o`<span class="used-part-owner"> (${t.owner_name})</span>`:d}${t.stock!==null&&t.stock!==void 0?` (${t.stock}${t.unit?" "+t.unit:""})`:""}</span
|
||||
>
|
||||
</label>
|
||||
${p?o`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
.value=${String(c.quantity)}
|
||||
@input=${f=>{let h=parseFloat(f.target.value);this._usedParts={...this._usedParts,[a]:{...u,quantity:Number.isFinite(h)&&h>=.01?h:1}}}} />`:d}
|
||||
</div>`})}
|
||||
</div>`:this.consumesInfo.length?o`<div class="consumes-hint">
|
||||
${this.consumesInfo.map(t=>o`<div>${t}</div>`)}
|
||||
</div>`:d}
|
||||
${this.restockDefault!==null?o`
|
||||
<label class="field">
|
||||
<span class="field-label">${r("restock_quantity_label",e)}</span>
|
||||
<input type="number" step="0.01" min="0.01" class="field-input"
|
||||
.value=${this._restockQty}
|
||||
@input=${t=>this._restockQty=t.target.value} />
|
||||
</label>`:d}
|
||||
<!-- Native <input>s rather than <ha-textfield>: when this dialog
|
||||
is opened from a Lovelace card via dialog-mount, ha-textfield
|
||||
isn't yet registered (HA loads it lazily when its own panels
|
||||
need it) so the elements render with zero height and the user
|
||||
only sees the title + Cancel/Complete buttons — the original
|
||||
bug report. Native inputs always render. -->
|
||||
<label class="field">
|
||||
<span class="field-label">${r("notes_optional",e)}${this._req("notes")}</span>
|
||||
<input type="text" class="field-input"
|
||||
.value=${this._notes}
|
||||
@input=${t=>this._notes=t.target.value} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${r("cost_optional",e)}${this._req("cost")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._cost}
|
||||
@input=${t=>this._cost=t.target.value} />
|
||||
${this._renderCostSuggestion(e)}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${r("duration_minutes",e)}${this._req("duration")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${t=>this._duration=t.target.value} />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">${r("completed_at_optional",e)}</span>
|
||||
${this._completedAt?o`<ms-date-field
|
||||
kind="datetime"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${e}
|
||||
.value=${this._completedAt}
|
||||
@value-changed=${t=>this._completedAt=t.detail.value}
|
||||
></ms-date-field>`:o`<button type="button" class="backdate-pick" @click=${this._pickCompletedAt}>
|
||||
<ha-icon icon="mdi:calendar-clock"></ha-icon>${r("completed_at_pick",e)}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">${r("completion_photo_optional",e)}${this._req("photo")}</span>
|
||||
${this._photoPreview?o`
|
||||
<div class="photo-preview">
|
||||
<img src=${this._photoPreview} alt="" />
|
||||
<button type="button" class="photo-remove" @click=${this._removePhoto}
|
||||
title="${r("remove",e)}">✕</button>
|
||||
</div>`:o`
|
||||
<label class="photo-pick">
|
||||
<ha-icon icon="mdi:camera"></ha-icon>
|
||||
<span>${this._photoUploading?r("uploading",e):r("add_photo",e)}</span>
|
||||
<input type="file" accept="image/*" capture="environment"
|
||||
?disabled=${this._photoUploading}
|
||||
@change=${this._onPhotoInput} />
|
||||
</label>`}
|
||||
</div>
|
||||
${this.adaptiveEnabled?o`
|
||||
<div class="feedback-section">
|
||||
<label class="feedback-label">${r("was_maintenance_needed",e)}</label>
|
||||
<div class="feedback-buttons">
|
||||
<button
|
||||
class="feedback-btn ${this._feedback==="needed"?"selected":""}"
|
||||
@click=${()=>this._setFeedback("needed")}
|
||||
>${r("feedback_needed",e)}</button>
|
||||
<button
|
||||
class="feedback-btn ${this._feedback==="not_needed"?"selected":""}"
|
||||
@click=${()=>this._setFeedback("not_needed")}
|
||||
>${r("feedback_not_needed",e)}</button>
|
||||
<button
|
||||
class="feedback-btn ${this._feedback==="not_sure"?"selected":""}"
|
||||
@click=${()=>this._setFeedback("not_sure")}
|
||||
>${r("feedback_not_sure",e)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`:d}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${r("cancel",e)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._complete}
|
||||
.disabled=${this._loading||this._missingRequired.length>0}
|
||||
title=${this._missingRequired.length?this._missingRequired.map(t=>r("err_required",e).replace("{field}",r(y[t]??t,e))).join(" \xB7 "):""}
|
||||
>
|
||||
${this._loading?r("completing",e):r("complete",e)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`}};i.styles=[m,b`
|
||||
.req-mark {
|
||||
color: var(--error-color, #f44336);
|
||||
margin-left: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
|
||||
.cost-suggestion {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary-color);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.scan-required-note {
|
||||
margin: -4px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.phase-line {
|
||||
margin-top: -8px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 300px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.consumes-hint {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
/* #99: editable per-completion parts selection */
|
||||
.used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.used-part-row { display: flex; align-items: center; gap: 8px; }
|
||||
.used-part-check {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.used-part-check input { cursor: pointer; }
|
||||
/* #111: whose stock this row draws on. Muted but never omitted — an
|
||||
unlabelled foreign pool is indistinguishable from an own part. */
|
||||
.used-part-owner { color: var(--secondary-text-color); }
|
||||
.used-part-qty {
|
||||
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* .field/.field-label/.field-input come from nativeFieldStyles */
|
||||
.photo-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--divider-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
width: fit-content;
|
||||
}
|
||||
.photo-pick:hover { border-color: var(--primary-color); }
|
||||
/* #163: the backdate moment starts EMPTY (= now); the button seeds the
|
||||
HA date+time picker with the current minute instead of the picker's
|
||||
own 00:00 default, so a backdated completion never lands at midnight
|
||||
by accident. */
|
||||
.backdate-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--divider-color);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
width: fit-content;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.backdate-pick:hover { border-color: var(--primary-color); }
|
||||
.photo-pick input[type="file"] { display: none; }
|
||||
.photo-preview {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
}
|
||||
.photo-preview img {
|
||||
max-width: 160px;
|
||||
max-height: 160px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
.photo-remove {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--error-color, #db4437);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.checklist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.checklist-label {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checklist-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feedback-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.feedback-label {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.feedback-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.feedback-btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 8px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.feedback-btn:hover {
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
}
|
||||
.feedback-btn.selected {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
`],s([n({attribute:!1})],i.prototype,"hass",2),s([n()],i.prototype,"entryId",2),s([n()],i.prototype,"taskId",2),s([n()],i.prototype,"taskName",2),s([n()],i.prototype,"lang",2),s([n({type:Array})],i.prototype,"checklist",2),s([n({type:Boolean})],i.prototype,"adaptiveEnabled",2),s([n()],i.prototype,"taskType",2),s([n()],i.prototype,"readingUnit",2),s([n({attribute:!1})],i.prototype,"restockDefault",2),s([n({attribute:!1})],i.prototype,"restockUnitCost",2),s([n()],i.prototype,"currencySymbol",2),s([n({attribute:!1})],i.prototype,"parts",2),s([n({attribute:!1})],i.prototype,"consumesParts",2),s([n({type:Array})],i.prototype,"consumesInfo",2),s([n({type:Array})],i.prototype,"requiredFields",2),s([n()],i.prototype,"phaseLabel",2),s([n({type:Boolean})],i.prototype,"requireTagScan",2),s([n({type:Boolean})],i.prototype,"viaTagScan",2),s([l()],i.prototype,"_open",2),s([l()],i.prototype,"_notes",2),s([l()],i.prototype,"_cost",2),s([l()],i.prototype,"_duration",2),s([l()],i.prototype,"_loading",2),s([l()],i.prototype,"_error",2),s([l()],i.prototype,"_checklistState",2),s([l()],i.prototype,"_feedback",2),s([l()],i.prototype,"_photoDocId",2),s([l()],i.prototype,"_photoPreview",2),s([l()],i.prototype,"_photoUploading",2),s([l()],i.prototype,"_readingValue",2),s([l()],i.prototype,"_restockQty",2),s([l()],i.prototype,"_completedAt",2),s([l()],i.prototype,"_usedParts",2),s([n({attribute:!1})],i.prototype,"checklistPrefill",2);customElements.get("maintenance-complete-dialog")||customElements.define("maintenance-complete-dialog",i);export{i as a};
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user