Updated Scheduler and Maintainance Apps

This commit is contained in:
2026-07-08 12:00:20 -04:00
parent fefc2c8b5c
commit 0f7585754d
227 changed files with 2877 additions and 958 deletions
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.7.1", "start_ts": 1783446559.6159952}
{"pid": 71, "version": 1, "ha_version": "2026.7.1", "start_ts": 1783526323.4156625}
+44 -9
View File
@@ -704,11 +704,11 @@
the cycle to finish: it requires a minimum of 4 minutes to pass AND the power
to drop back to the TV baseline.'
triggers:
- trigger: numeric_state
- trigger: state
entity_id: sensor.coffee_maker_power
above: 1000
id: coffee_started
conditions:
- condition: template
value_template: '{{ states(''sensor.coffee_maker_power'') | float(0) > 1000 }}'
- condition: state
entity_id: input_boolean.coffee_maker_running
state: 'off'
@@ -907,12 +907,12 @@
target:
entity_id: camera.backyard_shed
data:
filename: /media/blink.jpg
filename: /media/blink_shed.jpg
- action: camera.snapshot
target:
entity_id: camera.backyard_shed
data:
filename: /config/www/blink.jpg
filename: /config/www/blink_shed.jpg
- delay: 00:00:02
- action: ai_task.generate_data
continue_on_error: true
@@ -922,7 +922,7 @@
instructions: Describe what you see in this image in brief. Focus on any people,
objects, animals or activities. Text needs to be max 240 characters.
attachments:
- media_content_id: media-source://media_source/local/blink.jpg
- media_content_id: media-source://media_source/local/blink_shed.jpg
media_content_type: image/jpeg
response_variable: ai_profile
- action: notify.notify
@@ -939,7 +939,7 @@
else ''Motion detected, but the AI Task integration failed to return a response.''
}}
![Snapshot](/local/blink.jpg)'
![Snapshot](/local/blink_shed.jpg)'
data:
notification_id: backyard_shed_motion
mode: queued
@@ -1125,10 +1125,45 @@
event_type: state_changed
event_data:
entity_id: event.front_door_front_door_motion
enabled: false
- trigger: state
entity_id: event.living_room_living_room_camera_motion
enabled: false
- trigger: state
entity_id: event.front_door_front_door_motion
enabled: false
- trigger: event.received
target:
entity_id: event.entryway_entryway_camera_motion
options:
event_type:
- camera_motion
- camera_person
- camera_sound
enabled: false
- trigger: event.received
target:
device_id: 290f56e5a655e3af82cbf3cf9fd22406
options:
event_type:
- camera_motion
- camera_person
- camera_sound
- ring
- trigger: event.received
target:
device_id: 679d52e0d16cf48648d11dd024b961f8
options:
event_type:
- camera_motion
- camera_sound
- camera_person
enabled: false
conditions:
- condition: template
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
''person'', ''animal'', ''chime''] }}'
enabled: false
actions:
- action: blink.trigger_camera
target:
@@ -1160,13 +1195,13 @@
- action: notify.notify
continue_on_error: true
data:
title: Motion Detected - Backyard Shed
title: Motion Detected - Front Door
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
else ''Motion detected, but the AI Task integration failed to return a response.''
}}'
- action: notify.persistent_notification
data:
title: Motion Detected - Backyard Shed
title: Motion Detected - Front Door
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
else ''Motion detected, but the AI Task integration failed to return a response.''
}}
@@ -374,7 +374,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
# custom working weekdays instead of the plain Mon-Fri rule (#83 follow-up).
from .helpers.workday import async_setup_business_days
async_setup_business_days(hass)
await async_setup_business_days(hass)
# Create the notification manager (shared across all entries)
hass.data[DOMAIN][NOTIFICATION_MANAGER_KEY] = NotificationManager(hass)
@@ -10,7 +10,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from .const import CONF_TASK_INTERVAL_UNIT
from .const import (
CONF_TASK_ENDS_COUNT,
CONF_TASK_ENDS_UNTIL,
CONF_TASK_INTERVAL_UNIT,
CONF_TASK_SEASON_MONTHS,
)
from .helpers.dates import INTERVAL_UNITS
from .helpers.entity_analyzer import EntityAnalyzer
from .helpers.schedule import (
@@ -162,6 +167,74 @@ def apply_interval_unit(target: dict[str, Any], user_input: dict[str, Any]) -> N
target[CONF_TASK_INTERVAL_UNIT] = unit
# Hardcoded English month labels for the seasonal-window multi-select — same
# "keep the config-flow i18n surface small" choice as the weekday labels above.
_MONTH_LABELS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def season_ends_schema(current_schedule: dict[str, Any] | None) -> dict[Any, Any]:
"""Schema fragment for the seasonal window + finite-series end, prefilled
from the task's nested ``schedule``. Only meaningful for recurring kinds —
callers gate it on schedule_type accordingly."""
cur = current_schedule or {}
season_default = [str(m) for m in (cur.get("season_months") or [])]
ends_raw = cur.get("ends")
ends: dict[str, Any] = ends_raw if isinstance(ends_raw, dict) else {}
count = ends.get("count")
until = ends.get("until")
count_key = (
vol.Optional(CONF_TASK_ENDS_COUNT, default=int(count))
if isinstance(count, int) and count >= 1
else vol.Optional(CONF_TASK_ENDS_COUNT)
)
until_key = (
vol.Optional(CONF_TASK_ENDS_UNTIL, default=str(until))
if isinstance(until, str) and until
else vol.Optional(CONF_TASK_ENDS_UNTIL)
)
return {
vol.Optional(CONF_TASK_SEASON_MONTHS, default=season_default): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[selector.SelectOptionDict(value=str(i + 1), label=_MONTH_LABELS[i]) for i in range(12)],
multiple=True,
mode=selector.SelectSelectorMode.LIST,
)
),
count_key: selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=9999, step=1, mode=selector.NumberSelectorMode.BOX)
),
until_key: selector.DateSelector(),
}
def apply_season_ends(schedule: dict[str, Any], user_input: dict[str, Any]) -> None:
"""Inject the seasonal window + finite-series end from a flow step into a
(recurring) ``schedule`` dict in place — count wins over until; empties clear."""
season = sorted({int(m) for m in (user_input.get(CONF_TASK_SEASON_MONTHS) or []) if str(m).isdigit() and 1 <= int(m) <= 12})
if season:
schedule["season_months"] = season
else:
schedule.pop("season_months", None)
new_ends: dict[str, Any] = {}
count = user_input.get(CONF_TASK_ENDS_COUNT)
until = user_input.get(CONF_TASK_ENDS_UNTIL)
if isinstance(count, (int, float, str)) and str(count).strip() not in ("", "0"):
try:
n = int(float(count)) # NumberSelector may return a float
if n >= 1:
new_ends["count"] = n
except (ValueError, TypeError):
pass
elif until:
new_ends["until"] = str(until)
if new_ends:
schedule["ends"] = new_ends
else:
schedule.pop("ends", None)
async def async_get_threshold_suggestions(
hass: HomeAssistant,
trigger_entity_id: str | None,
@@ -10,10 +10,12 @@ from homeassistant.helpers import selector
from .config_flow_helpers import (
CALENDAR_KIND_VALUES,
apply_season_ends,
calendar_current,
calendar_schema,
interval_unit_selector,
schedule_from_calendar_input,
season_ends_schema,
)
from .const import (
CONF_ADVANCED_SCHEDULE_TIME,
@@ -169,6 +171,19 @@ class TaskCrudMixin:
for key in ("interval_days", "interval_unit", "interval_anchor", "due_date"):
updated_task.pop(key, None)
updated_task["schedule"] = schedule
# Seasonal window + finite-series end (recurring kinds). Calendar
# kinds carry them on their nested schedule; interval tasks get a
# minimal authoritative nested schedule only when extras are set
# (the backend carries them onto the flat-derived interval).
if edit_kind in CALENDAR_KIND_VALUES and isinstance(updated_task.get("schedule"), dict):
apply_season_ends(updated_task["schedule"], user_input)
elif edit_kind == ScheduleType.TIME_BASED:
extras: dict[str, Any] = {}
apply_season_ends(extras, user_input)
if extras:
updated_task["schedule"] = {"kind": "interval", **extras}
else:
updated_task.pop("schedule", None)
# schedule_time only present when global advanced flag is on; clear by submitting "".
if CONF_TASK_SCHEDULE_TIME in user_input:
sched = (user_input.get(CONF_TASK_SCHEDULE_TIME) or "").strip()
@@ -373,6 +388,13 @@ class TaskCrudMixin:
if sched["schedule_type"] in CALENDAR_KIND_VALUES
else dict[Any, Any]()
),
# Seasonal window + finite-series end — recurring kinds only.
**(
season_ends_schema(task.get("schedule"))
if sched["schedule_type"] == ScheduleType.TIME_BASED
or sched["schedule_type"] in CALENDAR_KIND_VALUES
else dict[Any, Any]()
),
vol.Optional(
CONF_TASK_WARNING_DAYS,
default=task.get("warning_days", get_default_warning_days(self.hass)),
@@ -270,6 +270,10 @@ CONF_TASK_LAST_PERFORMED = "last_performed"
CONF_TASK_SCHEDULE_TYPE = "schedule_type"
CONF_TASK_INTERVAL_UNIT = "interval_unit"
CONF_TASK_DUE_DATE = "due_date"
# Recurrence extras (config/options flow): seasonal window + finite-series end.
CONF_TASK_SEASON_MONTHS = "season_months"
CONF_TASK_ENDS_COUNT = "ends_count"
CONF_TASK_ENDS_UNTIL = "ends_until"
CONF_TASK_NOTES = "notes"
CONF_TASK_DOCUMENTATION_URL = "documentation_url"
CONF_TASK_ICON = "custom_icon"
@@ -870,6 +870,12 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
state["last_planned_due"] = lpd
elif "last_planned_due" in state:
del state["last_planned_due"]
# Per-occurrence postpone (set by async_postpone_task, cleared on complete).
do = td.get("due_override")
if do is not None:
state["due_override"] = do
elif "due_override" in state:
del state["due_override"]
self._store.set_history(task_id, td.get("history", []))
if task.adaptive_config:
self._store.set_adaptive_config(task_id, task.adaptive_config)
@@ -1120,6 +1126,19 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
),
)
async def async_postpone_task(self, task_id: str, until: date) -> None:
"""Postpone just the current occurrence to ``until`` (per-occurrence
defer). Sets a one-shot ``due_override`` that the next completion clears;
the cadence is untouched."""
merged = self._get_merged_tasks_data()
if task_id not in merged:
_LOGGER.error("Task %s not found in entry %s", task_id, self.entry.title)
return
task = MaintenanceTask.from_dict(merged[task_id])
task.due_override = until.isoformat()
await self._persist_and_signal_task_change(task_id, task)
_LOGGER.debug("Occurrence postponed to %s: %s on %s", until, task.name, self.maintenance_object.name)
async def skip_maintenance(
self,
task_id: str,
@@ -90,6 +90,9 @@ def _build_export_object(
# nth_weekday / day_of_month) that the flat fields above can't.
"schedule": Schedule.parse(tdata).to_dict(),
"last_planned_due": tdata.get("last_planned_due"),
# A pending per-occurrence postpone is user intent — round-trip it
# like last_planned_due so a backup/restore keeps the deferral.
"due_override": tdata.get("due_override"),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed"),
"notes": tdata.get("notes"),
@@ -100,6 +103,18 @@ def _build_export_object(
"entity_slug": tdata.get("entity_slug"),
"adaptive_config": tdata.get("adaptive_config"),
"checklist": tdata.get("checklist") or [],
"schedule_time": tdata.get("schedule_time"),
# v2.17+ / #83 task fields — persisted and user-facing, so a JSON
# backup must restore them (same field-completeness contract as #67
# for documentation_url/notes; import mirrors these keys).
"priority": tdata.get("priority", "normal"),
"labels": tdata.get("labels") or [],
"earliest_completion_days": tdata.get("earliest_completion_days"),
"on_complete_action": tdata.get("on_complete_action"),
"quick_complete_defaults": tdata.get("quick_complete_defaults"),
"assignee_pool": tdata.get("assignee_pool") or [],
"rotation_strategy": tdata.get("rotation_strategy"),
"reading_unit": tdata.get("reading_unit"),
"status": ct.get("_status", "ok"),
"days_until_due": ct.get("_days_until_due"),
"next_due": ct.get("_next_due"),
@@ -105,6 +105,7 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
promptPostpone: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
@@ -162,21 +163,32 @@ describe("task-detail renderer", () => {
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
});
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => {
const calls: string[] = [];
const host = mount(task(), ctx({
moreMenuOpen: true,
closeMoreMenu: () => calls.push("close"),
deleteTask: () => calls.push("delete"),
promptPostpone: () => calls.push("postpone"),
snoozeTask: () => calls.push("snooze"),
printWorksheet: () => calls.push("worksheet"),
}));
const items = [...host.querySelectorAll(".popup-menu-item")];
expect(items.length).to.equal(6);
(items[3] as HTMLElement).click(); // snooze
(items[4] as HTMLElement).click(); // work sheet (v2.21)
(items[5] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
expect(items.length).to.equal(7);
(items[3] as HTMLElement).click(); // postpone
(items[4] as HTMLElement).click(); // snooze
(items[5] as HTMLElement).click(); // work sheet (v2.21)
(items[6] as HTMLElement).click(); // delete (danger)
expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]);
});
it("shows a postponed badge when the task has a due_override, and none otherwise", () => {
const plain = mount(task(), ctx());
expect(plain.querySelector(".postponed-badge")).to.be.null;
const postponed = mount(task({ due_override: "2026-06-20" }), ctx());
const badge = postponed.querySelector(".postponed-badge");
expect(badge, "postponed badge").to.exist;
expect(badge!.textContent).to.match(/2026|20/);
});
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
@@ -0,0 +1,184 @@
/** Tests for <maintenance-task-detail-view> the task-detail sub-view as a
* web component (incremental step over the renderers/task-detail
* extraction). Pins the contract the component adds on top of the renderer:
* - registers and renders into LIGHT DOM (no own shadow root) so the
* panel's shadow-scoped styles keep matching
* - header / breadcrumb / actions come through the component boundary
* - callbacks in the passed TaskDetailContext still fire (Complete, tab
* switch) props in, panel callbacks out
* - property changes re-render (new task object new name in the DOM)
* - renders nothing until both `task` and `ctx` are set
*/
import { expect, fixture } from "@open-wc/testing";
import { html } from "lit";
import "../components/task-detail-view.js";
import type { MaintenanceTaskDetailView } from "../components/task-detail-view.js";
import type { TaskDetailContext } from "../renderers/task-detail.js";
import type { MaintenanceTask } from "../types";
import { createMockHass } from "./_test-utils.js";
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
return {
id: "t1",
name: "Filter Wechsel",
type: "cleaning",
enabled: true,
status: "due_soon",
schedule_type: "time_based",
interval_days: 30,
warning_days: 7,
days_until_due: 3,
next_due: "2026-07-10",
last_performed: "2026-06-10",
times_performed: 2,
total_cost: 50,
average_duration: 20,
history: [],
checklist: [],
is_done: false,
archived: false,
trigger_active: false,
...overrides,
} as unknown as MaintenanceTask;
}
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
const { hass } = createMockHass({
handler: () => ({ documents: [] }),
});
return {
lang: "en",
hass: hass as TaskDetailContext["hass"],
entryId: "entry1",
taskId: "t1",
objectName: "Pool Pump",
objectDocUrl: null,
isOperator: false,
actionLoading: false,
moreMenuOpen: false,
activeTab: "overview",
features: {
adaptive: false, predictions: false, seasonal: false,
environmental: false, budget: false, groups: false,
checklists: false, schedule_time: false, completion_actions: false,
},
currencySymbol: "€",
collapsedSections: new Set(),
costDurationToggle: "both",
suggestionDismissed: false,
sparkline: {
lang: "en",
detailStatsData: new Map(),
hasStatsService: false,
isCounterEntity: () => false,
rangeDays: 30,
setRangeDays: () => undefined,
hideOutliers: false,
setHideOutliers: () => undefined,
},
history: {
lang: "en",
hass: hass as TaskDetailContext["hass"],
filter: null,
search: "",
currencySymbol: "€",
setFilter: () => undefined,
setSearch: () => undefined,
openEdit: () => undefined,
},
getUserName: () => null,
setActiveTab: () => undefined,
toggleSection: () => undefined,
setCostDurationToggle: () => undefined,
showTaskView: () => undefined,
showObject: () => undefined,
toggleMoreMenu: () => undefined,
closeMoreMenu: () => undefined,
openEdit: () => undefined,
openComplete: () => undefined,
promptSkip: () => undefined,
toggleArchive: () => undefined,
openQr: () => undefined,
duplicateTask: () => undefined,
promptReset: () => undefined,
promptPostpone: () => undefined,
snoozeTask: () => undefined,
printWorksheet: () => undefined,
deleteTask: () => undefined,
applySuggestion: () => undefined,
reanalyze: () => undefined,
dismissSuggestion: () => undefined,
openSeasonalOverrides: () => undefined,
...overrides,
};
}
async function mount(t: MaintenanceTask, c: TaskDetailContext): Promise<MaintenanceTaskDetailView> {
const el = await fixture<MaintenanceTaskDetailView>(html`
<maintenance-task-detail-view .task=${t} .ctx=${c}></maintenance-task-detail-view>
`);
await el.updateComplete;
return el;
}
describe("maintenance-task-detail-view", () => {
it("is registered as a custom element", () => {
expect(customElements.get("maintenance-task-detail-view")).to.exist;
});
it("renders into light DOM so the panel's shadow-scoped styles keep applying", async () => {
const el = await mount(task(), ctx());
expect(el.shadowRoot, "no own shadow root").to.equal(null);
// The detail markup is queryable directly on the element (= light DOM).
expect(el.querySelector(".detail-section"), "detail section in light DOM").to.exist;
});
it("renders header, breadcrumb and status through the component boundary", async () => {
const el = await mount(task(), ctx());
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Filter Wechsel");
expect(el.querySelector(".object-name-breadcrumb")?.textContent).to.contain("Pool Pump");
expect(el.querySelector(".status-chip"), "status chip").to.exist;
expect(el.querySelector(".kpi-bar"), "KPI bar").to.exist;
});
it("routes the Complete action to the panel callback with the task", async () => {
let completed: MaintenanceTask | null = null;
const el = await mount(task(), ctx({ openComplete: (tk) => { completed = tk; } }));
const btn = [...el.querySelectorAll(".task-header-actions ha-button")]
.find((b) => b.textContent?.match(/complete/i)) as HTMLElement;
expect(btn, "complete button").to.exist;
btn.click();
expect(completed, "openComplete received the task").to.not.equal(null);
expect((completed as unknown as MaintenanceTask).id).to.equal("t1");
});
it("switches tabs via the panel-owned setActiveTab callback", async () => {
let tab = "";
const el = await mount(task(), ctx({ setActiveTab: (v) => { tab = v; } }));
const tabs = [...el.querySelectorAll(".tab-bar .tab")] as HTMLElement[];
expect(tabs.length).to.equal(2);
tabs[1].click();
expect(tab).to.equal("history");
});
it("re-renders when the task property changes", async () => {
const el = await mount(task(), ctx());
el.task = task({ name: "Pumpe entkalken" });
await el.updateComplete;
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Pumpe entkalken");
});
it("renders nothing until both task and ctx are set", async () => {
const el = await fixture<MaintenanceTaskDetailView>(html`
<maintenance-task-detail-view></maintenance-task-detail-view>
`);
await el.updateComplete;
expect(el.querySelector(".detail-section")).to.equal(null);
});
it("hides the more-menu for operators (read-only surface preserved)", async () => {
const el = await mount(task(), ctx({ isOperator: true }));
expect(el.querySelector(".more-menu-wrapper")).to.equal(null);
});
});
@@ -0,0 +1,106 @@
/**
* Lit tests for the seasonal-window + finite-series recurrence extras in
* <maintenance-task-dialog>. Pins hydration from the nested schedule and the
* outgoing `schedule` payload (backend: test_schedule.py / test_ws_io.py).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
"maintenance_supporter/task/update": () => ({}),
},
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.updateComplete;
return { el, sent };
}
describe("task-dialog recurrence extras (season / finite series)", () => {
it("hydrates season_months and a count end from the nested schedule", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
interval_days: 14, interval_unit: "days", warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5, 6], ends: { count: 6 } },
} as any);
await el.updateComplete;
expect((el as any)._seasonMonths).to.deep.equal([4, 5, 6]);
expect((el as any)._endsMode).to.equal("count");
expect((el as any)._endsCount).to.equal("6");
});
it("hydrates an until end", async () => {
const { el } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Cure", type: "custom", schedule_type: "time_based",
interval_days: 30, warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 30, unit: "days", ends: { until: "2027-01-01" } },
} as any);
await el.updateComplete;
expect((el as any)._endsMode).to.equal("until");
expect((el as any)._endsUntil).to.equal("2027-01-01");
});
it("create sends season_months + ends on an interval task's nested schedule", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Mow";
(el as any)._scheduleType = "time_based";
(el as any)._intervalDays = "14";
(el as any)._intervalUnit = "days";
(el as any)._seasonMonths = [7, 4, 5];
(el as any)._endsMode = "count";
(el as any)._endsCount = "6";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "interval", season_months: [4, 5, 7], ends: { count: 6 } });
expect(msg.interval_days).to.equal(14); // flat fields still carry the cadence
});
it("attaches the extras to a calendar-kind schedule too", async () => {
const { el, sent } = await mountDialog();
await el.openCreate("e");
(el as any)._name = "Gutter";
(el as any)._scheduleType = "day_of_month";
(el as any)._domDay = "15";
(el as any)._seasonMonths = [10, 11];
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15, season_months: [10, 11] });
});
it("clearing season/ends on edit sends an authoritative schedule without them", async () => {
const { el, sent } = await mountDialog();
await el.openEdit("e", {
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
interval_days: 14, warning_days: 7, enabled: true,
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5], ends: { count: 3 } },
} as any);
(el as any)._seasonMonths = [];
(el as any)._endsMode = "never";
await (el as any)._save();
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
expect(msg.schedule).to.deep.equal({ kind: "interval" }); // no season_months, no ends
});
it("shows the season chips + ends selector for recurring, not for one_time", async () => {
const { el } = await mountDialog();
await el.openCreate("e");
(el as any)._scheduleType = "time_based";
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(12);
expect(el.shadowRoot!.querySelector('select option[value="count"]')).to.exist;
(el as any)._scheduleType = "one_time";
await el.updateComplete;
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(0);
});
});
@@ -0,0 +1,37 @@
/** <maintenance-task-detail-view> the task-detail sub-view as a web
* component. The incremental step over the renderers/task-detail
* extraction: the panel now hands the component a task + the
* TaskDetailContext instead of calling the render function inline, giving
* the sub-view a real element boundary (own update lifecycle, testable in
* isolation, future home for detail-only state).
*
* Deliberately renders into LIGHT DOM (createRenderRoot returns `this`):
* the element lives inside the panel's shadow root, so every selector in
* panel-styles.ts (.task-header, .kpi-bar, .popup-menu, ) keeps matching
* and every dialog-opening callback still runs against the panel's shadow
* root. Moving the ~1000 lines of detail CSS and the dialog ownership in
* here would be the big-bang rewrite this component exists to avoid.
*/
import { LitElement, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { renderTaskDetail, type TaskDetailContext } from "../renderers/task-detail";
import type { MaintenanceTask } from "../types";
export class MaintenanceTaskDetailView extends LitElement {
@property({ attribute: false }) public task?: MaintenanceTask;
@property({ attribute: false }) public ctx?: TaskDetailContext;
protected createRenderRoot(): HTMLElement {
return this;
}
protected render(): unknown {
if (!this.task || !this.ctx) return nothing;
return html`${renderTaskDetail(this.task, this.ctx)}`;
}
}
if (!customElements.get("maintenance-task-detail-view")) {
customElements.define("maintenance-task-detail-view", MaintenanceTaskDetailView);
}
@@ -88,6 +88,12 @@ function weekdayNames(lang?: string): string[] {
return Array.from({ length: 7 }, (_, i) => weekdayName(i, lang, "short"));
}
/** 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)));
}
export class MaintenanceTaskDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
@@ -122,6 +128,12 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _domLastDay = false;
@state() private _domBusiness = false;
@state() private _calOffset = "0";
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
// window and a finite-series end condition.
@state() private _seasonMonths: number[] = [];
@state() private _endsMode: "never" | "count" | "until" = "never";
@state() private _endsCount = "";
@state() private _endsUntil = "";
@state() private _notes = "";
@state() private _documentationUrl = "";
@state() private _customIcon = "";
@@ -245,6 +257,16 @@ export class MaintenanceTaskDialog extends LitElement {
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
this._calOffset = sched?.offset ? String(sched.offset) : "0";
// Recurrence extras — read from the nested schedule wherever it lives.
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
const ends = sched?.ends;
if (ends && typeof ends.count === "number") {
this._endsMode = "count"; this._endsCount = String(ends.count); this._endsUntil = "";
} else if (ends && typeof ends.until === "string") {
this._endsMode = "until"; this._endsUntil = ends.until; this._endsCount = "";
} else {
this._endsMode = "never"; this._endsCount = ""; this._endsUntil = "";
}
this._warningDays = task.warning_days.toString();
this._earliestCompletionDays =
task.earliest_completion_days != null ? String(task.earliest_completion_days) : "";
@@ -345,6 +367,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._domLastDay = false;
this._domBusiness = false;
this._calOffset = "0";
this._seasonMonths = [];
this._endsMode = "never";
this._endsCount = "";
this._endsUntil = "";
this._notes = "";
this._documentationUrl = "";
this._customIcon = "";
@@ -700,7 +726,7 @@ export class MaintenanceTaskDialog extends LitElement {
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
// Calendar kinds are sent as the nested schedule; the backend prefers
// it over the flat fields and clears any stale interval/due_date.
data.schedule = this._buildSchedule();
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
data.interval_days = null;
if (this._taskId) data.due_date = null;
} else {
@@ -710,6 +736,14 @@ export class MaintenanceTaskDialog extends LitElement {
data.interval_days = parseInt(this._intervalDays, 10);
data.interval_unit = this._intervalUnit;
data.interval_anchor = this._intervalAnchor;
// Season / finite-series ride a minimal nested schedule alongside the
// flat interval fields; the backend carries them onto the interval it
// derives from the flat fields (they can't be expressed flatly).
// Always send an authoritative nested schedule for interval tasks so
// an edit that clears season/ends actually clears them.
if (this._scheduleType === "time_based") {
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
}
} else if (this._taskId) {
data.interval_days = null;
data.interval_anchor = "completion";
@@ -1133,6 +1167,72 @@ export class MaintenanceTaskDialog extends LitElement {
return withOffset(schedule);
}
/** The season/finite-series extras to attach to a recurring schedule. Empty
* values are omitted, so a clean schedule stays clean (and, since the sent
* schedule is authoritative, an omitted extra clears a previously-set one). */
private _recurrenceExtras(): { season_months?: number[]; ends?: { count?: number; until?: string } } {
const extras: { season_months?: number[]; ends?: { count?: number; until?: string } } = {};
if (this._seasonMonths.length) extras.season_months = [...this._seasonMonths].sort((a, b) => a - b);
if (this._endsMode === "count") {
const n = parseInt(this._endsCount, 10);
if (n >= 1) extras.ends = { count: n };
} else if (this._endsMode === "until" && this._endsUntil) {
extras.ends = { until: this._endsUntil };
}
return extras;
}
private _toggleSeasonMonth(m: number): void {
this._seasonMonths = this._seasonMonths.includes(m)
? this._seasonMonths.filter((x) => x !== m)
: [...this._seasonMonths, m];
}
/** Seasonal window + finite-series end shown for recurring (interval +
* calendar) kinds, where both apply. */
private _renderRecurrenceExtras() {
const L = this._lang;
const recurring = this._scheduleType === "time_based" || CALENDAR_KINDS.includes(this._scheduleType);
if (!recurring) return nothing;
const months = monthNames(L);
return html`
<label class="field-label">${t("season_window_label", L)}</label>
<div class="field-help">${t("season_window_hint", L)}</div>
<div class="weekday-chips season-chips">
${months.map((name, i) => html`
<button
type="button"
class="season-chip ${this._seasonMonths.includes(i + 1) ? "selected" : ""}"
@click=${() => this._toggleSeasonMonth(i + 1)}
>${name}</button>`)}
</div>
<label class="field-label">${t("series_end_label", L)}</label>
<div class="select-row">
<select .value=${this._endsMode}
@change=${(e: Event) => (this._endsMode = (e.target as HTMLSelectElement).value as "never" | "count" | "until")}>
<option value="never" ?selected=${this._endsMode === "never"}>${t("series_end_never", L)}</option>
<option value="count" ?selected=${this._endsMode === "count"}>${t("series_end_after_count", L)}</option>
<option value="until" ?selected=${this._endsMode === "until"}>${t("series_end_until", L)}</option>
</select>
</div>
${this._endsMode === "count" ? html`
<ms-textfield
label="${t("series_end_count_label", L)}"
type="number" min="1"
.value=${this._endsCount}
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
></ms-textfield>` : nothing}
${this._endsMode === "until" ? html`
<ms-textfield
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}
`;
}
/** Per-kind field groups for the calendar recurrence kinds. */
private _renderCalendarFields() {
const L = this._lang;
@@ -1415,6 +1515,7 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>
`
: nothing}
${this._renderRecurrenceExtras()}
<ms-textfield
label="${t("warning_days", L)}"
type="number"
@@ -1772,6 +1873,20 @@ export class MaintenanceTaskDialog extends LitElement {
color: var(--text-primary-color, #fff);
border-color: var(--primary-color, #03a9f4);
}
.season-chip {
padding: 6px 10px;
border: 1px solid var(--divider-color);
border-radius: 16px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
}
.season-chip.selected {
background: var(--primary-color, #03a9f4);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color, #03a9f4);
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Výňatek z manuálu",
"worksheet_pages": "strany",
"worksheet_printed": "Vytištěno",
"worksheet_never": "Nikdy"
"worksheet_never": "Nikdy",
"card_all_caught_up": "Vše hotovo — nic nevyžaduje pozornost",
"postpone": "Odložit",
"postpone_date_prompt": "Na jaké datum odložit tento termín?",
"postpone_date_label": "Nové datum",
"postponed": "Odloženo",
"postponed_to": "Odloženo na",
"season_window_label": "Sezónní okno (měsíce)",
"season_window_hint": "Termín jen ve vybraných měsících; data mimo sezónu se posunou na další aktivní měsíc. Žádné = celý rok.",
"series_end_label": "Končí",
"series_end_never": "Nikdy (opakuje se stále)",
"series_end_after_count": "Po počtu opakování",
"series_end_until": "K datu",
"series_end_count_label": "Počet opakování",
"series_end_until_label": "Datum konce"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Uddrag af manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Udskrevet",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Alt er klaret — intet kræver opmærksomhed",
"postpone": "Udskyd",
"postpone_date_prompt": "Udskyd denne forekomst til hvilken dato?",
"postpone_date_label": "Ny forfaldsdato",
"postponed": "Udskudt",
"postponed_to": "Udskudt til",
"season_window_label": "Sæsonvindue (måneder)",
"season_window_hint": "Kun forfalden i de valgte måneder; datoer uden for sæsonen rykkes til næste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldrig (gentages uendeligt)",
"series_end_after_count": "Efter et antal gange",
"series_end_until": "På en dato",
"series_end_count_label": "Antal gange",
"series_end_until_label": "Slutdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handbuch-Auszug",
"worksheet_pages": "Seiten",
"worksheet_printed": "Gedruckt",
"worksheet_never": "Nie"
"worksheet_never": "Nie",
"card_all_caught_up": "Alles erledigt — nichts braucht Aufmerksamkeit",
"postpone": "Verschieben",
"postpone_date_prompt": "Diesen Termin auf welches Datum verschieben?",
"postpone_date_label": "Neues Fälligkeitsdatum",
"postponed": "Verschoben",
"postponed_to": "Verschoben auf",
"season_window_label": "Saisonfenster (Monate)",
"season_window_hint": "Nur in den gewählten Monaten fällig; Termine außerhalb rollen in den nächsten aktiven Monat. Keine = ganzjährig.",
"series_end_label": "Endet",
"series_end_never": "Nie (wiederholt unbegrenzt)",
"series_end_after_count": "Nach Anzahl",
"series_end_until": "An einem Datum",
"series_end_count_label": "Anzahl",
"series_end_until_label": "Enddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Manual excerpt",
"worksheet_pages": "pages",
"worksheet_printed": "Printed",
"worksheet_never": "Never"
"worksheet_never": "Never",
"card_all_caught_up": "All caught up — nothing needs attention",
"postpone": "Postpone",
"postpone_date_prompt": "Postpone this occurrence to which date?",
"postpone_date_label": "New due date",
"postponed": "Postponed",
"postponed_to": "Postponed to",
"season_window_label": "Seasonal window (months)",
"season_window_hint": "Only due in the selected months; off-season dates roll to the next active month. None = all year.",
"series_end_label": "Ends",
"series_end_never": "Never (repeats indefinitely)",
"series_end_after_count": "After a number of times",
"series_end_until": "On a date",
"series_end_count_label": "Number of times",
"series_end_until_label": "End date"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extracto del manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impreso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Todo al día — nada requiere atención",
"postpone": "Posponer",
"postpone_date_prompt": "¿A qué fecha posponer esta vez?",
"postpone_date_label": "Nueva fecha de vencimiento",
"postponed": "Pospuesto",
"postponed_to": "Pospuesto hasta",
"season_window_label": "Ventana estacional (meses)",
"season_window_hint": "Solo vence en los meses seleccionados; las fechas fuera de temporada pasan al siguiente mes activo. Ninguno = todo el año.",
"series_end_label": "Termina",
"series_end_never": "Nunca (se repite indefinidamente)",
"series_end_after_count": "Tras un número de veces",
"series_end_until": "En una fecha",
"series_end_count_label": "Número de veces",
"series_end_until_label": "Fecha de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Ote käyttöohjeesta",
"worksheet_pages": "sivut",
"worksheet_printed": "Tulostettu",
"worksheet_never": "Ei koskaan"
"worksheet_never": "Ei koskaan",
"card_all_caught_up": "Kaikki kunnossa — mikään ei vaadi huomiota",
"postpone": "Lykkää",
"postpone_date_prompt": "Mihin päivään tämä kerta lykätään?",
"postpone_date_label": "Uusi eräpäivä",
"postponed": "Lykätty",
"postponed_to": "Lykätty päivään",
"season_window_label": "Kausi-ikkuna (kuukaudet)",
"season_window_hint": "Erääntyy vain valittuina kuukausina; kauden ulkopuoliset päivät siirtyvät seuraavaan aktiiviseen kuukauteen. Ei mitään = koko vuosi.",
"series_end_label": "Päättyy",
"series_end_never": "Ei koskaan (toistuu loputtomasti)",
"series_end_after_count": "Tietyn kertamäärän jälkeen",
"series_end_until": "Päivämääränä",
"series_end_count_label": "Kertojen määrä",
"series_end_until_label": "Päättymispäivä"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extrait du manuel",
"worksheet_pages": "pages",
"worksheet_printed": "Imprimé",
"worksheet_never": "Jamais"
"worksheet_never": "Jamais",
"card_all_caught_up": "Tout est à jour — rien ne requiert d'attention",
"postpone": "Reporter",
"postpone_date_prompt": "Reporter cette échéance à quelle date ?",
"postpone_date_label": "Nouvelle date d’échéance",
"postponed": "Reporté",
"postponed_to": "Reporté au",
"season_window_label": "Fenêtre saisonnière (mois)",
"season_window_hint": "Dû uniquement les mois sélectionnés ; les dates hors saison passent au mois actif suivant. Aucun = toute lannée.",
"series_end_label": "Se termine",
"series_end_never": "Jamais (répète indéfiniment)",
"series_end_after_count": "Après un nombre de fois",
"series_end_until": "À une date",
"series_end_count_label": "Nombre de fois",
"series_end_until_label": "Date de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "मैनुअल अंश",
"worksheet_pages": "पृष्ठ",
"worksheet_printed": "मुद्रित",
"worksheet_never": "कभी नहीं"
"worksheet_never": "कभी नहीं",
"card_all_caught_up": "सब पूरा — किसी चीज़ पर ध्यान देने की ज़रूरत नहीं",
"postpone": "टालें",
"postpone_date_prompt": "इस बार को किस तारीख तक टालें?",
"postpone_date_label": "नई नियत तिथि",
"postponed": "टाल दिया गया",
"postponed_to": "तक टाला गया",
"season_window_label": "मौसमी अवधि (महीने)",
"season_window_hint": "केवल चुने गए महीनों में देय; ऑफ-सीज़न तिथियाँ अगले सक्रिय महीने में चली जाती हैं। कोई नहीं = पूरे वर्ष।",
"series_end_label": "समाप्त होता है",
"series_end_never": "कभी नहीं (अनिश्चित रूप से दोहराता है)",
"series_end_after_count": "कुछ बार के बाद",
"series_end_until": "एक तिथि पर",
"series_end_count_label": "कितनी बार",
"series_end_until_label": "समाप्ति तिथि"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Estratto del manuale",
"worksheet_pages": "pagine",
"worksheet_printed": "Stampato",
"worksheet_never": "Mai"
"worksheet_never": "Mai",
"card_all_caught_up": "Tutto in ordine — niente richiede attenzione",
"postpone": "Posticipa",
"postpone_date_prompt": "A quale data posticipare questa scadenza?",
"postpone_date_label": "Nuova data di scadenza",
"postponed": "Posticipato",
"postponed_to": "Posticipato al",
"season_window_label": "Finestra stagionale (mesi)",
"season_window_hint": "Scade solo nei mesi selezionati; le date fuori stagione passano al mese attivo successivo. Nessuno = tutto lanno.",
"series_end_label": "Termina",
"series_end_never": "Mai (ripete allinfinito)",
"series_end_after_count": "Dopo un numero di volte",
"series_end_until": "In una data",
"series_end_count_label": "Numero di volte",
"series_end_until_label": "Data di fine"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "マニュアル抜粋",
"worksheet_pages": "ページ",
"worksheet_printed": "印刷日",
"worksheet_never": "未実施"
"worksheet_never": "未実施",
"card_all_caught_up": "すべて完了 — 対応が必要なものはありません",
"postpone": "延期",
"postpone_date_prompt": "この回をどの日付に延期しますか?",
"postpone_date_label": "新しい期日",
"postponed": "延期済み",
"postponed_to": "延期先",
"season_window_label": "季節ウィンドウ(月)",
"season_window_hint": "選択した月のみ期限;シーズン外の日付は次の有効な月へ繰り越し。なし=通年。",
"series_end_label": "終了",
"series_end_never": "なし(無期限に繰り返す)",
"series_end_after_count": "回数の後",
"series_end_until": "日付で",
"series_end_count_label": "回数",
"series_end_until_label": "終了日"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag fra manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Skrevet ut",
"worksheet_never": "Aldri"
"worksheet_never": "Aldri",
"card_all_caught_up": "Alt er i orden — ingenting trenger oppmerksomhet",
"postpone": "Utsett",
"postpone_date_prompt": "Utsett denne forekomsten til hvilken dato?",
"postpone_date_label": "Ny forfallsdato",
"postponed": "Utsatt",
"postponed_to": "Utsatt til",
"season_window_label": "Sesongvindu (måneder)",
"season_window_hint": "Forfaller kun i valgte måneder; datoer utenfor sesongen flyttes til neste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldri (gjentas uendelig)",
"series_end_after_count": "Etter et antall ganger",
"series_end_until": "På en dato",
"series_end_count_label": "Antall ganger",
"series_end_until_label": "Sluttdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handleidingfragment",
"worksheet_pages": "pagina's",
"worksheet_printed": "Afgedrukt",
"worksheet_never": "Nooit"
"worksheet_never": "Nooit",
"card_all_caught_up": "Alles bijgewerkt — niets vraagt aandacht",
"postpone": "Uitstellen",
"postpone_date_prompt": "Deze keer uitstellen tot welke datum?",
"postpone_date_label": "Nieuwe vervaldatum",
"postponed": "Uitgesteld",
"postponed_to": "Uitgesteld tot",
"season_window_label": "Seizoensvenster (maanden)",
"season_window_hint": "Alleen verschuldigd in de gekozen maanden; data buiten het seizoen schuiven naar de volgende actieve maand. Geen = hele jaar.",
"series_end_label": "Eindigt",
"series_end_never": "Nooit (herhaalt onbeperkt)",
"series_end_after_count": "Na een aantal keer",
"series_end_until": "Op een datum",
"series_end_count_label": "Aantal keer",
"series_end_until_label": "Einddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Fragment instrukcji",
"worksheet_pages": "strony",
"worksheet_printed": "Wydrukowano",
"worksheet_never": "Nigdy"
"worksheet_never": "Nigdy",
"card_all_caught_up": "Wszystko zrobione — nic nie wymaga uwagi",
"postpone": "Odłóż",
"postpone_date_prompt": "Przełożyć ten termin na jaką datę?",
"postpone_date_label": "Nowa data",
"postponed": "Odłożono",
"postponed_to": "Odłożono do",
"season_window_label": "Okno sezonowe (miesiące)",
"season_window_hint": "Termin tylko w wybranych miesiącach; daty poza sezonem przechodzą na następny aktywny miesiąc. Brak = cały rok.",
"series_end_label": "Kończy się",
"series_end_never": "Nigdy (powtarza bez końca)",
"series_end_after_count": "Po liczbie razy",
"series_end_until": "W dniu",
"series_end_count_label": "Liczba razy",
"series_end_until_label": "Data końcowa"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Excerto do manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impresso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Tudo em dia — nada requer atenção",
"postpone": "Adiar",
"postpone_date_prompt": "Adiar esta ocorrência para qual data?",
"postpone_date_label": "Nova data de vencimento",
"postponed": "Adiado",
"postponed_to": "Adiado para",
"season_window_label": "Janela sazonal (meses)",
"season_window_hint": "Só vence nos meses selecionados; datas fora de época passam para o próximo mês ativo. Nenhum = todo o ano.",
"series_end_label": "Termina",
"series_end_never": "Nunca (repete indefinidamente)",
"series_end_after_count": "Após um número de vezes",
"series_end_until": "Numa data",
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Выдержка из руководства",
"worksheet_pages": "страницы",
"worksheet_printed": "Напечатано",
"worksheet_never": "Никогда"
"worksheet_never": "Никогда",
"card_all_caught_up": "Всё выполнено — ничего не требует внимания",
"postpone": "Отложить",
"postpone_date_prompt": "На какую дату отложить этот раз?",
"postpone_date_label": "Новый срок",
"postponed": "Отложено",
"postponed_to": "Отложено до",
"season_window_label": "Сезонное окно (месяцы)",
"season_window_hint": "Срок только в выбранные месяцы; даты вне сезона переносятся на следующий активный месяц. Нет = весь год.",
"series_end_label": "Заканчивается",
"series_end_never": "Никогда (повторяется бесконечно)",
"series_end_after_count": "После числа раз",
"series_end_until": "В дату",
"series_end_count_label": "Число раз",
"series_end_until_label": "Дата окончания"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag ur manualen",
"worksheet_pages": "sidor",
"worksheet_printed": "Utskriven",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Allt klart — inget behöver åtgärdas",
"postpone": "Skjut upp",
"postpone_date_prompt": "Skjut upp den här gången till vilket datum?",
"postpone_date_label": "Nytt förfallodatum",
"postponed": "Uppskjuten",
"postponed_to": "Uppskjuten till",
"season_window_label": "Säsongsfönster (månader)",
"season_window_hint": "Förfaller endast valda månader; datum utanför säsongen flyttas till nästa aktiva månad. Inga = hela året.",
"series_end_label": "Slutar",
"series_end_never": "Aldrig (upprepas i oändlighet)",
"series_end_after_count": "Efter ett antal gånger",
"series_end_until": "På ett datum",
"series_end_count_label": "Antal gånger",
"series_end_until_label": "Slutdatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Витяг з посібника",
"worksheet_pages": "сторінки",
"worksheet_printed": "Надруковано",
"worksheet_never": "Ніколи"
"worksheet_never": "Ніколи",
"card_all_caught_up": "Усе виконано — ніщо не потребує уваги",
"postpone": "Відкласти",
"postpone_date_prompt": "На яку дату відкласти цей раз?",
"postpone_date_label": "Новий термін",
"postponed": "Відкладено",
"postponed_to": "Відкладено до",
"season_window_label": "Сезонне вікно (місяці)",
"season_window_hint": "Термін лише у вибрані місяці; дати поза сезоном переносяться на наступний активний місяць. Немає = весь рік.",
"series_end_label": "Завершується",
"series_end_never": "Ніколи (повторюється безкінечно)",
"series_end_after_count": "Після кількості разів",
"series_end_until": "У дату",
"series_end_count_label": "Кількість разів",
"series_end_until_label": "Дата завершення"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "手册摘录",
"worksheet_pages": "页",
"worksheet_printed": "打印于",
"worksheet_never": "从未"
"worksheet_never": "从未",
"card_all_caught_up": "全部完成——没有需要处理的事项",
"postpone": "推迟",
"postpone_date_prompt": "将此次推迟到哪一天?",
"postpone_date_label": "新的到期日",
"postponed": "已推迟",
"postponed_to": "推迟至",
"season_window_label": "季节窗口(月份)",
"season_window_hint": "仅在所选月份到期;非当季日期顺延至下一个活动月份。无 = 全年。",
"series_end_label": "结束",
"series_end_never": "从不(无限重复)",
"series_end_after_count": "达到次数后",
"series_end_until": "在某日期",
"series_end_count_label": "次数",
"series_end_until_label": "结束日期"
}
@@ -249,7 +249,13 @@ export class MaintenanceSupporterCard extends LitElement {
</div>
</div>
${tasks.length === 0
? this._objects.some((o) => o.tasks.length > 0)
? html`<div class="empty-card">
<!-- (#86) tasks exist but none match the filter (default:
actionable-only) "all caught up", NOT "no tasks yet". -->
<div class="all-caught-up"> ${t("card_all_caught_up", L)}</div>
</div>`
: html`<div class="empty-card">
<div>${t("card_no_tasks_title", L)}</div>
<a class="empty-link" href="/maintenance-supporter">${t("card_no_tasks_cta", L)}</a>
</div>`
@@ -262,7 +268,16 @@ export class MaintenanceSupporterCard extends LitElement {
title="${t("open_task", L) || "Open task"}">
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
<div class="task-info">
<div class="task-name">${task.name}</div>
<div class="task-name">
${task.name}
${(task as any).due_override
? html`<ha-icon
class="postponed-icon"
icon="mdi:calendar-clock"
title="${t("postponed", L) || "Postponed"}"
></ha-icon>`
: nothing}
</div>
${!compact ? html`<div class="task-meta">${object_name} · ${t(task.type, L)}</div>` : nothing}
</div>
<div class="task-due">
@@ -366,6 +381,7 @@ export class MaintenanceSupporterCard extends LitElement {
font-size: 13px;
}
.empty-link:hover { text-decoration: underline; }
.all-caught-up { color: var(--success-color, #4caf50); font-weight: 500; }
.task-list { padding: 0 16px 16px; }
.task-item {
@@ -387,6 +403,7 @@ export class MaintenanceSupporterCard extends LitElement {
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.postponed-icon { --mdc-icon-size: 14px; color: var(--secondary-text-color); vertical-align: text-bottom; margin-inline-start: 4px; }
.task-meta { font-size: 12px; color: var(--secondary-text-color); }
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; }
@@ -200,15 +200,22 @@ const SMALL_SCREEN_CONDITION: ViewColumnsCondition = {
// truth shared with the panel KPI chips and the statistics WS endpoint — so the
// numbers can't drift. Rendered live via a markdown template instead of being
// counted client-side at generation time (which froze them until reload).
function kpiMarkdownCard(): CardConfig {
// (#86) Entity ids come registry-resolved from the statistics WS: the
// documented sensor.maintenance_supporter_<key> ids are NOT guaranteed —
// a user rename, a _2 collision suffix, or a pre-pinning install that
// registered localized ids all made the hardcoded templates read "unknown".
type SummaryEntityIds = Partial<Record<"overdue" | "triggered" | "due_soon" | "ok", string | null>>;
function kpiMarkdownCard(ids: SummaryEntityIds = {}): CardConfig {
const eid = (key: keyof SummaryEntityIds) => ids[key] || `sensor.maintenance_supporter_${key}`;
return {
type: "markdown",
text_only: true,
content: [
"🔴 **{{ states('sensor.maintenance_supporter_overdue') }}** overdue",
"⚡ **{{ states('sensor.maintenance_supporter_triggered') }}** triggered",
"🟡 **{{ states('sensor.maintenance_supporter_due_soon') }}** due soon",
"🟢 **{{ states('sensor.maintenance_supporter_ok') }}** ok",
`🔴 **{{ states('${eid("overdue")}') }}** overdue`,
`⚡ **{{ states('${eid("triggered")}') }}** triggered`,
`🟡 **{{ states('${eid("due_soon")}') }}** due soon`,
`🟢 **{{ states('${eid("ok")}') }}** ok`,
].join(" · "),
};
}
@@ -263,8 +270,8 @@ function emptyStateView(): ViewConfig {
};
}
function overviewView(): ViewConfig {
const kpiCard = kpiMarkdownCard();
function overviewView(summaryIds: SummaryEntityIds = {}): ViewConfig {
const kpiCard = kpiMarkdownCard(summaryIds);
return {
title: "Overview",
icon: "mdi:wrench-clock",
@@ -614,10 +621,19 @@ export class MaintenanceDashboardStrategy extends HTMLElement {
calendar: () => viewsByCalendar(objects),
};
// Registry-resolved summary-sensor ids (#86); tolerate older backends.
let summaryIds: SummaryEntityIds = {};
try {
const stats = await hass.connection.sendMessagePromise<{ summary_entity_ids?: SummaryEntityIds }>({
type: "maintenance_supporter/statistics",
});
summaryIds = stats.summary_entity_ids || {};
} catch { /* fall back to the documented default ids */ }
return {
title: "Maintenance",
views: [
overviewView(),
overviewView(summaryIds),
...(viewBuilders[groupBy] ?? viewBuilders.area)(),
],
};
@@ -57,7 +57,11 @@ import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
import { type HistoryContext } from "./renderers/history";
import { renderTaskDetail, renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
// The task-detail sub-view as a web component (light-DOM, panel styles apply).
// Side-effect import: type-only imports get tree-shaken and the element
// would never register.
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
type View = "overview" | "object" | "task" | "all_objects";
@@ -1392,6 +1396,38 @@ export class MaintenanceSupporterPanel extends LitElement {
this._resetTask(entryId, taskId, result.value || undefined);
}
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
this._actionLoading = true;
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/postpone",
entry_id: entryId,
task_id: taskId,
until,
});
this._showToast(t("postponed", this._lang));
await this._loadData();
} catch {
this._showToast(t("action_error", this._lang));
} finally {
this._actionLoading = false;
}
}
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
if (!dlg) return;
const result = await dlg.prompt({
title: t("postpone", this._lang),
message: t("postpone_date_prompt", this._lang),
confirmText: t("postpone", this._lang),
inputLabel: t("postpone_date_label", this._lang),
inputType: "date",
});
if (!result.confirmed || !result.value) return;
this._postponeTask(entryId, taskId, result.value);
}
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
this._actionLoading = true;
try {
@@ -2855,6 +2891,7 @@ export class MaintenanceSupporterPanel extends LitElement {
openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName),
duplicateTask: () => this._duplicateTask(entryId, taskId),
promptReset: () => this._promptResetTask(entryId, taskId),
promptPostpone: () => this._promptPostponeTask(entryId, taskId),
snoozeTask: () => this._snoozeTask(entryId, taskId),
printWorksheet: () => this._printTaskWorksheet(entryId, taskId),
deleteTask: () => this._deleteTask(entryId, taskId),
@@ -2869,7 +2906,10 @@ export class MaintenanceSupporterPanel extends LitElement {
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
if (!task) return html`<p>Task not found.</p>`;
return renderTaskDetail(task, this._taskDetailCtx());
return html`<maintenance-task-detail-view
.task=${task}
.ctx=${this._taskDetailCtx()}
></maintenance-task-detail-view>`;
}
/** v2.2.0: open the in-place history-edit dialog for the given entry.
@@ -304,6 +304,10 @@ export const panelStyles = css`
--mdc-icon-size: 26px;
}
/* Custom elements default to display:inline; the task-detail component
renders light-DOM and must behave like the block it wraps. */
maintenance-task-detail-view { display: block; }
.detail-section { padding: 16px 0; }
.detail-header {
@@ -589,9 +593,29 @@ export const panelStyles = css`
border-radius: 12px;
padding: 2px;
}
/* Inside .cell-badges the parent's gap does the spacing the badges' own
margin-left (meant for inline use, e.g. the detail header) would double
it and push the priority chevron out of line with the other badges. */
.cell-badges .nfc-badge,
.cell-badges .priority-badge {
margin-left: 0;
}
.priority-badge ha-icon {
--mdc-icon-size: 16px;
}
.postponed-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
margin-left: 8px;
background: var(--secondary-background-color, #e8e8e8);
color: var(--secondary-text-color);
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.postponed-badge ha-icon { --mdc-icon-size: 13px; }
.priority-high {
color: var(--error-color, #db4437);
}
@@ -61,6 +61,7 @@ export interface TaskDetailContext {
openQr: (taskName: string) => void;
duplicateTask: () => void;
promptReset: () => void;
promptPostpone: () => void;
snoozeTask: () => void;
/** v2.21: open the printable one-pager for this task. */
printWorksheet: () => void;
@@ -104,6 +105,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
<span class="status-chip ${statusClass}">${statusText}</span>
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
</span>` : nothing}
${renderUserBadge(task, ctx.getUserName)}
${task.nfc_tag_id
? html`<span class="nfc-badge" title="${t("nfc_tag_id", L)}: ${task.nfc_tag_id}"><ha-icon icon="mdi:nfc-variant"></ha-icon> NFC</span>`
@@ -131,6 +135,7 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
<div class="popup-menu-divider"></div>
@@ -740,11 +740,14 @@ export const sharedStyles = css`
transition: width 0.3s;
}
/* Trigger progress bar (overview rows) */
/* Trigger progress bar (overview rows). width:100% the due-cell doesn't
stretch its children (align-items: flex-end), so without it the bar
shrinks to its label and reads shorter than the days-bar in other rows. */
.trigger-progress {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
min-width: 90px;
}
@@ -133,6 +133,10 @@ export interface TaskSchedule {
business?: boolean;
/** (#83) shift the computed occurrence by ±N days (clamped ±15). */
offset?: number;
/** Seasonal active window — months (1..12) the task may be due in. */
season_months?: number[];
/** Finite-series end condition: after N completions and/or past a date. */
ends?: { count?: number; until?: string };
}
export interface MaintenanceTask {
@@ -182,6 +186,8 @@ export interface MaintenanceTask {
archived_reason?: string | null;
days_until_due?: number | null;
next_due?: string | null;
/** Per-occurrence postpone: the ISO date the current cycle was deferred to. */
due_override?: string | null;
trigger_active: boolean;
trigger_current_value?: number | null;
trigger_current_delta?: number | null;
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Výňatek z manuálu",
"worksheet_pages": "strany",
"worksheet_printed": "Vytištěno",
"worksheet_never": "Nikdy"
"worksheet_never": "Nikdy",
"card_all_caught_up": "Vše hotovo — nic nevyžaduje pozornost",
"postpone": "Odložit",
"postpone_date_prompt": "Na jaké datum odložit tento termín?",
"postpone_date_label": "Nové datum",
"postponed": "Odloženo",
"postponed_to": "Odloženo na",
"season_window_label": "Sezónní okno (měsíce)",
"season_window_hint": "Termín jen ve vybraných měsících; data mimo sezónu se posunou na další aktivní měsíc. Žádné = celý rok.",
"series_end_label": "Končí",
"series_end_never": "Nikdy (opakuje se stále)",
"series_end_after_count": "Po počtu opakování",
"series_end_until": "K datu",
"series_end_count_label": "Počet opakování",
"series_end_until_label": "Datum konce"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Uddrag af manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Udskrevet",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Alt er klaret — intet kræver opmærksomhed",
"postpone": "Udskyd",
"postpone_date_prompt": "Udskyd denne forekomst til hvilken dato?",
"postpone_date_label": "Ny forfaldsdato",
"postponed": "Udskudt",
"postponed_to": "Udskudt til",
"season_window_label": "Sæsonvindue (måneder)",
"season_window_hint": "Kun forfalden i de valgte måneder; datoer uden for sæsonen rykkes til næste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldrig (gentages uendeligt)",
"series_end_after_count": "Efter et antal gange",
"series_end_until": "På en dato",
"series_end_count_label": "Antal gange",
"series_end_until_label": "Slutdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handbuch-Auszug",
"worksheet_pages": "Seiten",
"worksheet_printed": "Gedruckt",
"worksheet_never": "Nie"
"worksheet_never": "Nie",
"card_all_caught_up": "Alles erledigt — nichts braucht Aufmerksamkeit",
"postpone": "Verschieben",
"postpone_date_prompt": "Diesen Termin auf welches Datum verschieben?",
"postpone_date_label": "Neues Fälligkeitsdatum",
"postponed": "Verschoben",
"postponed_to": "Verschoben auf",
"season_window_label": "Saisonfenster (Monate)",
"season_window_hint": "Nur in den gewählten Monaten fällig; Termine außerhalb rollen in den nächsten aktiven Monat. Keine = ganzjährig.",
"series_end_label": "Endet",
"series_end_never": "Nie (wiederholt unbegrenzt)",
"series_end_after_count": "Nach Anzahl",
"series_end_until": "An einem Datum",
"series_end_count_label": "Anzahl",
"series_end_until_label": "Enddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Manual excerpt",
"worksheet_pages": "pages",
"worksheet_printed": "Printed",
"worksheet_never": "Never"
"worksheet_never": "Never",
"card_all_caught_up": "All caught up — nothing needs attention",
"postpone": "Postpone",
"postpone_date_prompt": "Postpone this occurrence to which date?",
"postpone_date_label": "New due date",
"postponed": "Postponed",
"postponed_to": "Postponed to",
"season_window_label": "Seasonal window (months)",
"season_window_hint": "Only due in the selected months; off-season dates roll to the next active month. None = all year.",
"series_end_label": "Ends",
"series_end_never": "Never (repeats indefinitely)",
"series_end_after_count": "After a number of times",
"series_end_until": "On a date",
"series_end_count_label": "Number of times",
"series_end_until_label": "End date"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extracto del manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impreso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Todo al día — nada requiere atención",
"postpone": "Posponer",
"postpone_date_prompt": "¿A qué fecha posponer esta vez?",
"postpone_date_label": "Nueva fecha de vencimiento",
"postponed": "Pospuesto",
"postponed_to": "Pospuesto hasta",
"season_window_label": "Ventana estacional (meses)",
"season_window_hint": "Solo vence en los meses seleccionados; las fechas fuera de temporada pasan al siguiente mes activo. Ninguno = todo el año.",
"series_end_label": "Termina",
"series_end_never": "Nunca (se repite indefinidamente)",
"series_end_after_count": "Tras un número de veces",
"series_end_until": "En una fecha",
"series_end_count_label": "Número de veces",
"series_end_until_label": "Fecha de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Ote käyttöohjeesta",
"worksheet_pages": "sivut",
"worksheet_printed": "Tulostettu",
"worksheet_never": "Ei koskaan"
"worksheet_never": "Ei koskaan",
"card_all_caught_up": "Kaikki kunnossa — mikään ei vaadi huomiota",
"postpone": "Lykkää",
"postpone_date_prompt": "Mihin päivään tämä kerta lykätään?",
"postpone_date_label": "Uusi eräpäivä",
"postponed": "Lykätty",
"postponed_to": "Lykätty päivään",
"season_window_label": "Kausi-ikkuna (kuukaudet)",
"season_window_hint": "Erääntyy vain valittuina kuukausina; kauden ulkopuoliset päivät siirtyvät seuraavaan aktiiviseen kuukauteen. Ei mitään = koko vuosi.",
"series_end_label": "Päättyy",
"series_end_never": "Ei koskaan (toistuu loputtomasti)",
"series_end_after_count": "Tietyn kertamäärän jälkeen",
"series_end_until": "Päivämääränä",
"series_end_count_label": "Kertojen määrä",
"series_end_until_label": "Päättymispäivä"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Extrait du manuel",
"worksheet_pages": "pages",
"worksheet_printed": "Imprimé",
"worksheet_never": "Jamais"
"worksheet_never": "Jamais",
"card_all_caught_up": "Tout est à jour — rien ne requiert d'attention",
"postpone": "Reporter",
"postpone_date_prompt": "Reporter cette échéance à quelle date ?",
"postpone_date_label": "Nouvelle date d’échéance",
"postponed": "Reporté",
"postponed_to": "Reporté au",
"season_window_label": "Fenêtre saisonnière (mois)",
"season_window_hint": "Dû uniquement les mois sélectionnés ; les dates hors saison passent au mois actif suivant. Aucun = toute lannée.",
"series_end_label": "Se termine",
"series_end_never": "Jamais (répète indéfiniment)",
"series_end_after_count": "Après un nombre de fois",
"series_end_until": "À une date",
"series_end_count_label": "Nombre de fois",
"series_end_until_label": "Date de fin"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "मैनुअल अंश",
"worksheet_pages": "पृष्ठ",
"worksheet_printed": "मुद्रित",
"worksheet_never": "कभी नहीं"
"worksheet_never": "कभी नहीं",
"card_all_caught_up": "सब पूरा — किसी चीज़ पर ध्यान देने की ज़रूरत नहीं",
"postpone": "टालें",
"postpone_date_prompt": "इस बार को किस तारीख तक टालें?",
"postpone_date_label": "नई नियत तिथि",
"postponed": "टाल दिया गया",
"postponed_to": "तक टाला गया",
"season_window_label": "मौसमी अवधि (महीने)",
"season_window_hint": "केवल चुने गए महीनों में देय; ऑफ-सीज़न तिथियाँ अगले सक्रिय महीने में चली जाती हैं। कोई नहीं = पूरे वर्ष।",
"series_end_label": "समाप्त होता है",
"series_end_never": "कभी नहीं (अनिश्चित रूप से दोहराता है)",
"series_end_after_count": "कुछ बार के बाद",
"series_end_until": "एक तिथि पर",
"series_end_count_label": "कितनी बार",
"series_end_until_label": "समाप्ति तिथि"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Estratto del manuale",
"worksheet_pages": "pagine",
"worksheet_printed": "Stampato",
"worksheet_never": "Mai"
"worksheet_never": "Mai",
"card_all_caught_up": "Tutto in ordine — niente richiede attenzione",
"postpone": "Posticipa",
"postpone_date_prompt": "A quale data posticipare questa scadenza?",
"postpone_date_label": "Nuova data di scadenza",
"postponed": "Posticipato",
"postponed_to": "Posticipato al",
"season_window_label": "Finestra stagionale (mesi)",
"season_window_hint": "Scade solo nei mesi selezionati; le date fuori stagione passano al mese attivo successivo. Nessuno = tutto lanno.",
"series_end_label": "Termina",
"series_end_never": "Mai (ripete allinfinito)",
"series_end_after_count": "Dopo un numero di volte",
"series_end_until": "In una data",
"series_end_count_label": "Numero di volte",
"series_end_until_label": "Data di fine"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "マニュアル抜粋",
"worksheet_pages": "ページ",
"worksheet_printed": "印刷日",
"worksheet_never": "未実施"
"worksheet_never": "未実施",
"card_all_caught_up": "すべて完了 — 対応が必要なものはありません",
"postpone": "延期",
"postpone_date_prompt": "この回をどの日付に延期しますか?",
"postpone_date_label": "新しい期日",
"postponed": "延期済み",
"postponed_to": "延期先",
"season_window_label": "季節ウィンドウ(月)",
"season_window_hint": "選択した月のみ期限;シーズン外の日付は次の有効な月へ繰り越し。なし=通年。",
"series_end_label": "終了",
"series_end_never": "なし(無期限に繰り返す)",
"series_end_after_count": "回数の後",
"series_end_until": "日付で",
"series_end_count_label": "回数",
"series_end_until_label": "終了日"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag fra manualen",
"worksheet_pages": "sider",
"worksheet_printed": "Skrevet ut",
"worksheet_never": "Aldri"
"worksheet_never": "Aldri",
"card_all_caught_up": "Alt er i orden — ingenting trenger oppmerksomhet",
"postpone": "Utsett",
"postpone_date_prompt": "Utsett denne forekomsten til hvilken dato?",
"postpone_date_label": "Ny forfallsdato",
"postponed": "Utsatt",
"postponed_to": "Utsatt til",
"season_window_label": "Sesongvindu (måneder)",
"season_window_hint": "Forfaller kun i valgte måneder; datoer utenfor sesongen flyttes til neste aktive måned. Ingen = hele året.",
"series_end_label": "Slutter",
"series_end_never": "Aldri (gjentas uendelig)",
"series_end_after_count": "Etter et antall ganger",
"series_end_until": "På en dato",
"series_end_count_label": "Antall ganger",
"series_end_until_label": "Sluttdato"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Handleidingfragment",
"worksheet_pages": "pagina's",
"worksheet_printed": "Afgedrukt",
"worksheet_never": "Nooit"
"worksheet_never": "Nooit",
"card_all_caught_up": "Alles bijgewerkt — niets vraagt aandacht",
"postpone": "Uitstellen",
"postpone_date_prompt": "Deze keer uitstellen tot welke datum?",
"postpone_date_label": "Nieuwe vervaldatum",
"postponed": "Uitgesteld",
"postponed_to": "Uitgesteld tot",
"season_window_label": "Seizoensvenster (maanden)",
"season_window_hint": "Alleen verschuldigd in de gekozen maanden; data buiten het seizoen schuiven naar de volgende actieve maand. Geen = hele jaar.",
"series_end_label": "Eindigt",
"series_end_never": "Nooit (herhaalt onbeperkt)",
"series_end_after_count": "Na een aantal keer",
"series_end_until": "Op een datum",
"series_end_count_label": "Aantal keer",
"series_end_until_label": "Einddatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Fragment instrukcji",
"worksheet_pages": "strony",
"worksheet_printed": "Wydrukowano",
"worksheet_never": "Nigdy"
"worksheet_never": "Nigdy",
"card_all_caught_up": "Wszystko zrobione — nic nie wymaga uwagi",
"postpone": "Odłóż",
"postpone_date_prompt": "Przełożyć ten termin na jaką datę?",
"postpone_date_label": "Nowa data",
"postponed": "Odłożono",
"postponed_to": "Odłożono do",
"season_window_label": "Okno sezonowe (miesiące)",
"season_window_hint": "Termin tylko w wybranych miesiącach; daty poza sezonem przechodzą na następny aktywny miesiąc. Brak = cały rok.",
"series_end_label": "Kończy się",
"series_end_never": "Nigdy (powtarza bez końca)",
"series_end_after_count": "Po liczbie razy",
"series_end_until": "W dniu",
"series_end_count_label": "Liczba razy",
"series_end_until_label": "Data końcowa"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Excerto do manual",
"worksheet_pages": "páginas",
"worksheet_printed": "Impresso",
"worksheet_never": "Nunca"
"worksheet_never": "Nunca",
"card_all_caught_up": "Tudo em dia — nada requer atenção",
"postpone": "Adiar",
"postpone_date_prompt": "Adiar esta ocorrência para qual data?",
"postpone_date_label": "Nova data de vencimento",
"postponed": "Adiado",
"postponed_to": "Adiado para",
"season_window_label": "Janela sazonal (meses)",
"season_window_hint": "Só vence nos meses selecionados; datas fora de época passam para o próximo mês ativo. Nenhum = todo o ano.",
"series_end_label": "Termina",
"series_end_never": "Nunca (repete indefinidamente)",
"series_end_after_count": "Após um número de vezes",
"series_end_until": "Numa data",
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Выдержка из руководства",
"worksheet_pages": "страницы",
"worksheet_printed": "Напечатано",
"worksheet_never": "Никогда"
"worksheet_never": "Никогда",
"card_all_caught_up": "Всё выполнено — ничего не требует внимания",
"postpone": "Отложить",
"postpone_date_prompt": "На какую дату отложить этот раз?",
"postpone_date_label": "Новый срок",
"postponed": "Отложено",
"postponed_to": "Отложено до",
"season_window_label": "Сезонное окно (месяцы)",
"season_window_hint": "Срок только в выбранные месяцы; даты вне сезона переносятся на следующий активный месяц. Нет = весь год.",
"series_end_label": "Заканчивается",
"series_end_never": "Никогда (повторяется бесконечно)",
"series_end_after_count": "После числа раз",
"series_end_until": "В дату",
"series_end_count_label": "Число раз",
"series_end_until_label": "Дата окончания"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Utdrag ur manualen",
"worksheet_pages": "sidor",
"worksheet_printed": "Utskriven",
"worksheet_never": "Aldrig"
"worksheet_never": "Aldrig",
"card_all_caught_up": "Allt klart — inget behöver åtgärdas",
"postpone": "Skjut upp",
"postpone_date_prompt": "Skjut upp den här gången till vilket datum?",
"postpone_date_label": "Nytt förfallodatum",
"postponed": "Uppskjuten",
"postponed_to": "Uppskjuten till",
"season_window_label": "Säsongsfönster (månader)",
"season_window_hint": "Förfaller endast valda månader; datum utanför säsongen flyttas till nästa aktiva månad. Inga = hela året.",
"series_end_label": "Slutar",
"series_end_never": "Aldrig (upprepas i oändlighet)",
"series_end_after_count": "Efter ett antal gånger",
"series_end_until": "På ett datum",
"series_end_count_label": "Antal gånger",
"series_end_until_label": "Slutdatum"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "Витяг з посібника",
"worksheet_pages": "сторінки",
"worksheet_printed": "Надруковано",
"worksheet_never": "Ніколи"
"worksheet_never": "Ніколи",
"card_all_caught_up": "Усе виконано — ніщо не потребує уваги",
"postpone": "Відкласти",
"postpone_date_prompt": "На яку дату відкласти цей раз?",
"postpone_date_label": "Новий термін",
"postponed": "Відкладено",
"postponed_to": "Відкладено до",
"season_window_label": "Сезонне вікно (місяці)",
"season_window_hint": "Термін лише у вибрані місяці; дати поза сезоном переносяться на наступний активний місяць. Немає = весь рік.",
"series_end_label": "Завершується",
"series_end_never": "Ніколи (повторюється безкінечно)",
"series_end_after_count": "Після кількості разів",
"series_end_until": "У дату",
"series_end_count_label": "Кількість разів",
"series_end_until_label": "Дата завершення"
}
@@ -692,5 +692,19 @@
"worksheet_manual_excerpt": "手册摘录",
"worksheet_pages": "页",
"worksheet_printed": "打印于",
"worksheet_never": "从未"
"worksheet_never": "从未",
"card_all_caught_up": "全部完成——没有需要处理的事项",
"postpone": "推迟",
"postpone_date_prompt": "将此次推迟到哪一天?",
"postpone_date_label": "新的到期日",
"postponed": "已推迟",
"postponed_to": "推迟至",
"season_window_label": "季节窗口(月份)",
"season_window_hint": "仅在所选月份到期;非当季日期顺延至下一个活动月份。无 = 全年。",
"series_end_label": "结束",
"series_end_never": "从不(无限重复)",
"series_end_after_count": "达到次数后",
"series_end_until": "在某日期",
"series_end_count_label": "次数",
"series_end_until_label": "结束日期"
}
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
@@ -1,4 +1,4 @@
import{a as f}from"./chunk-6EEEWEML.js";import{a as h,b as o,d as b,e as m,f as g,g as n,h as _,j as a,l as v,s as p}from"./chunk-2LLDFX26.js";import{a as i}from"./chunk-LO2NM3CE.js";var s=class extends m{constructor(){super(...arguments);this._config={type:""};this._status=null;this._busy=!1;this._error="";this._localMonthly="";this._localYearly="";this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),v(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"});this._status=t,this._localMonthly=t.monthly_budget?String(t.monthly_budget):"",this._localYearly=t.yearly_budget?String(t.yearly_budget):"",this._dirty=!1}catch(t){this._error=p(t,this._lang)}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=parseFloat(this._localMonthly),e=parseFloat(this._localYearly),r={};!isNaN(t)&&t>=0&&(r.budget_monthly=t),!isNaN(e)&&e>=0&&(r.budget_yearly=e),await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:r}),await this._load()}catch(t){this._error=p(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_budget"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,e=this._status;if(!e)return o`<ha-card><div class="loading">${a("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=e.currency_symbol||_,l=e.monthly_budget?Math.min(100,(e.monthly_spent||0)/e.monthly_budget*100):0,d=e.yearly_budget?Math.min(100,(e.yearly_spent||0)/e.yearly_budget*100):0,u=l>=100?"danger":l>=80?"warning":"ok",y=d>=100?"danger":d>=80?"warning":"ok";return o`
import{a as f}from"./chunk-SHU63LHK.js";import{a as h,b as o,d as b,e as m,f as g,g as n,h as _,j as a,l as v,s as p}from"./chunk-GKOWV2CF.js";import{a as i}from"./chunk-LO2NM3CE.js";var s=class extends m{constructor(){super(...arguments);this._config={type:""};this._status=null;this._busy=!1;this._error="";this._localMonthly="";this._localYearly="";this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),v(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"});this._status=t,this._localMonthly=t.monthly_budget?String(t.monthly_budget):"",this._localYearly=t.yearly_budget?String(t.yearly_budget):"",this._dirty=!1}catch(t){this._error=p(t,this._lang)}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=parseFloat(this._localMonthly),e=parseFloat(this._localYearly),r={};!isNaN(t)&&t>=0&&(r.budget_monthly=t),!isNaN(e)&&e>=0&&(r.budget_yearly=e),await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:r}),await this._load()}catch(t){this._error=p(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_budget"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,e=this._status;if(!e)return o`<ha-card><div class="loading">${a("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=e.currency_symbol||_,l=e.monthly_budget?Math.min(100,(e.monthly_spent||0)/e.monthly_budget*100):0,d=e.yearly_budget?Math.min(100,(e.yearly_spent||0)/e.yearly_budget*100):0,u=l>=100?"danger":l>=80?"warning":"ok",y=d>=100?"danger":d>=80?"warning":"ok";return o`
<ha-card>
<div class="card-content">
<div class="header">
@@ -1,4 +1,4 @@
import{a as r}from"./chunk-2LLDFX26.js";var a=r`
import{a as r}from"./chunk-GKOWV2CF.js";var a=r`
ha-card { overflow: hidden; }
.card-content {
padding: 16px;
@@ -1,4 +1,4 @@
import{a as m}from"./chunk-6EEEWEML.js";import{a as u,b as s,d as l,e as h,f as g,g as o,j as i,l as _,s as p}from"./chunk-2LLDFX26.js";import{a}from"./chunk-LO2NM3CE.js";var e=class extends h{constructor(){super(...arguments);this._config={type:""};this._groups={};this._loaded=!1;this._busy=!1;this._error="";this._newName="";this._editingId=null;this._editingName="";this._hasInitiallyLoaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._hasInitiallyLoaded&&(this._hasInitiallyLoaded=!0,this._load(),_(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"});this._groups=t.groups||{},this._loaded=!0}catch(t){this._error=p(t,this._lang)}}async _addGroup(){if(!this._isAdmin)return;let t=this._newName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/create",name:t}),this._newName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}_startEdit(t){this._editingId=t,this._editingName=this._groups[t]?.name||""}async _saveEdit(){if(!this._isAdmin||!this._editingId)return;let t=this._editingName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/update",group_id:this._editingId,name:t}),this._editingId=null,this._editingName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}async _deleteGroup(t,r){if(!this._isAdmin)return;let n=(i("group_delete_confirm",this._lang)||'Delete group "{name}"?').replace("{name}",r);if(window.confirm(n)){this._busy=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/delete",group_id:t}),await this._load()}catch(d){this._error=p(d,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_groups"),window.dispatchEvent(new CustomEvent("location-changed"))}_onKeyDown(t,r){t.key==="Enter"?(t.preventDefault(),r()):t.key==="Escape"&&(t.preventDefault(),this._editingId=null,this._editingName="")}render(){let t=this._lang;if(!this._loaded)return s`<ha-card><div class="loading">${i("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=Object.keys(this._groups);return s`
import{a as m}from"./chunk-SHU63LHK.js";import{a as u,b as s,d as l,e as h,f as g,g as o,j as i,l as _,s as p}from"./chunk-GKOWV2CF.js";import{a}from"./chunk-LO2NM3CE.js";var e=class extends h{constructor(){super(...arguments);this._config={type:""};this._groups={};this._loaded=!1;this._busy=!1;this._error="";this._newName="";this._editingId=null;this._editingName="";this._hasInitiallyLoaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._hasInitiallyLoaded&&(this._hasInitiallyLoaded=!0,this._load(),_(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"});this._groups=t.groups||{},this._loaded=!0}catch(t){this._error=p(t,this._lang)}}async _addGroup(){if(!this._isAdmin)return;let t=this._newName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/create",name:t}),this._newName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}_startEdit(t){this._editingId=t,this._editingName=this._groups[t]?.name||""}async _saveEdit(){if(!this._isAdmin||!this._editingId)return;let t=this._editingName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/update",group_id:this._editingId,name:t}),this._editingId=null,this._editingName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}async _deleteGroup(t,r){if(!this._isAdmin)return;let n=(i("group_delete_confirm",this._lang)||'Delete group "{name}"?').replace("{name}",r);if(window.confirm(n)){this._busy=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/delete",group_id:t}),await this._load()}catch(d){this._error=p(d,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_groups"),window.dispatchEvent(new CustomEvent("location-changed"))}_onKeyDown(t,r){t.key==="Enter"?(t.preventDefault(),r()):t.key==="Escape"&&(t.preventDefault(),this._editingId=null,this._editingName="")}render(){let t=this._lang;if(!this._loaded)return s`<ha-card><div class="loading">${i("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=Object.keys(this._groups);return s`
<ha-card>
<div class="card-content">
<div class="header">
@@ -1,4 +1,4 @@
import{a as m}from"./chunk-6EEEWEML.js";import{a as h,b as n,d as c,e as _,f as v,g as r,j as e,l as f,s as l}from"./chunk-2LLDFX26.js";import{a as i}from"./chunk-LO2NM3CE.js";var a=class extends _{constructor(){super(...arguments);this._config={type:""};this._state=null;this._busy=!1;this._error="";this._localStart="";this._localEnd="";this._localBuffer=7;this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),f(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/state"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||"",this._localBuffer=t.buffer_days??7,this._dirty=!1}catch(t){this._error=l(t,this._lang)}}async _toggleEnabled(t){this._busy=!0,this._error="";try{let s=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",enabled:t});this._state=s}catch(s){this._error=l(s,this._lang)}finally{this._busy=!1}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",start:this._localStart||null,end:this._localEnd||null,buffer_days:this._localBuffer});this._state=t,this._dirty=!1}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}async _endNow(){if(this._isAdmin&&window.confirm(e("vacation_end_now_confirm",this._lang)||"End vacation immediately?")){this._busy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/end_now"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||""}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_vacation"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,s=this._state;if(!s)return n`<ha-card><div class="loading">${e("loading",t)||"Loading\u2026"}</div></ha-card>`;let p=s.is_active===!0,d=s.enabled===!0,u=s.exempt_task_ids?.length??0,b=p?e("vacation_status_active",t)||"Active now":d?e("vacation_status_scheduled",t)||"Scheduled":e("vacation_status_inactive",t)||"Inactive",g=p?"active":d?"scheduled":"inactive";return n`
import{a as m}from"./chunk-SHU63LHK.js";import{a as h,b as n,d as c,e as _,f as v,g as r,j as e,l as f,s as l}from"./chunk-GKOWV2CF.js";import{a as i}from"./chunk-LO2NM3CE.js";var a=class extends _{constructor(){super(...arguments);this._config={type:""};this._state=null;this._busy=!1;this._error="";this._localStart="";this._localEnd="";this._localBuffer=7;this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),f(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/state"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||"",this._localBuffer=t.buffer_days??7,this._dirty=!1}catch(t){this._error=l(t,this._lang)}}async _toggleEnabled(t){this._busy=!0,this._error="";try{let s=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",enabled:t});this._state=s}catch(s){this._error=l(s,this._lang)}finally{this._busy=!1}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",start:this._localStart||null,end:this._localEnd||null,buffer_days:this._localBuffer});this._state=t,this._dirty=!1}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}async _endNow(){if(this._isAdmin&&window.confirm(e("vacation_end_now_confirm",this._lang)||"End vacation immediately?")){this._busy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/end_now"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||""}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_vacation"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,s=this._state;if(!s)return n`<ha-card><div class="loading">${e("loading",t)||"Loading\u2026"}</div></ha-card>`;let p=s.is_active===!0,d=s.enabled===!0,u=s.exempt_task_ids?.length??0,b=p?e("vacation_status_active",t)||"Active now":d?e("vacation_status_scheduled",t)||"Scheduled":e("vacation_status_inactive",t)||"Inactive",g=p?"active":d?"scheduled":"inactive";return n`
<ha-card>
<div class="card-content">
<div class="header">
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