1222 lines
42 KiB
TypeScript
1222 lines
42 KiB
TypeScript
/** Maintenance Supporter — custom dashboard strategy.
|
|
*
|
|
* Generates a complete Lovelace dashboard from the integration's WS feed.
|
|
* Honors a ``group_by`` config so YAML users can pick the layout:
|
|
*
|
|
* strategy:
|
|
* type: custom:maintenance-supporter
|
|
* group_by: area # default — one view per area + Unassigned
|
|
*
|
|
* strategy:
|
|
* type: custom:maintenance-supporter
|
|
* group_by: status # one view per status (Overdue / Triggered / Due Soon / OK)
|
|
*
|
|
* strategy:
|
|
* type: custom:maintenance-supporter
|
|
* group_by: floor # one view per floor with area sub-headings (#155 follow-up)
|
|
*
|
|
* strategy:
|
|
* type: custom:maintenance-supporter
|
|
* group_by: due_date # 5 buckets — Overdue / Today / This Week / This Month / Later
|
|
*
|
|
* Layout pattern follows HA's own ``maintenance-view-strategy``,
|
|
* ``areas-dashboard-strategy`` and ``home-overview-view-strategy``: a leading
|
|
* "Overview" view of actionable tasks, empty groups skipped, and
|
|
* STATE_NOT_RUNNING / recovery_mode handled with the same starting /
|
|
* recovery-mode card placeholders HA core uses.
|
|
*
|
|
* Requires Home Assistant 2026.5+ to appear in the "Add Dashboard" picker
|
|
* (that's when ``window.customStrategies`` got picked up by the frontend).
|
|
* On older HA versions the registration is a silent no-op.
|
|
*/
|
|
|
|
import { STATUS_ICONS } from "./status-constants";
|
|
|
|
interface MaintenanceObjectResp {
|
|
entry_id: string;
|
|
object: {
|
|
id: string;
|
|
name: string;
|
|
area_id: string | null;
|
|
};
|
|
tasks: Array<{ status?: string; days_until_due?: number | null }>;
|
|
}
|
|
|
|
interface AreaEntry {
|
|
area_id: string;
|
|
name: string;
|
|
icon?: string | null;
|
|
floor_id?: string | null;
|
|
}
|
|
|
|
interface FloorEntry {
|
|
floor_id: string;
|
|
name: string;
|
|
icon?: string | null;
|
|
level?: number | null;
|
|
}
|
|
|
|
interface HassLike {
|
|
language?: string;
|
|
config?: {
|
|
state?: string;
|
|
recovery_mode?: boolean;
|
|
};
|
|
connection: {
|
|
sendMessagePromise<T>(msg: Record<string, unknown>): Promise<T>;
|
|
};
|
|
areas?: Record<string, AreaEntry>;
|
|
floors?: Record<string, FloorEntry>;
|
|
}
|
|
|
|
type GroupBy = "area" | "status" | "floor" | "due_date" | "calendar";
|
|
|
|
interface MaintenanceDashboardStrategyConfig {
|
|
type: "custom:maintenance-supporter" | "maintenance-supporter";
|
|
group_by?: GroupBy;
|
|
}
|
|
|
|
interface DashboardConfig {
|
|
title?: string;
|
|
views: ViewConfig[];
|
|
}
|
|
|
|
interface ViewConfig {
|
|
title?: string;
|
|
path?: string;
|
|
icon?: string;
|
|
type?: string;
|
|
subview?: boolean;
|
|
cards?: CardConfig[];
|
|
sections?: SectionConfig[];
|
|
// HA 2026.5+ — banner card above the view (mobile + desktop), and a
|
|
// narrow column on the right (large screens only). See
|
|
// home-overview-view-strategy.ts for the canonical usage.
|
|
header?: { layout?: string; card: CardConfig };
|
|
sidebar?: {
|
|
sections: SectionConfig[];
|
|
visibility?: ViewColumnsCondition[];
|
|
content_label?: string;
|
|
sidebar_label?: string;
|
|
};
|
|
max_columns?: number;
|
|
}
|
|
|
|
interface ViewColumnsCondition {
|
|
condition: "view_columns";
|
|
min?: number;
|
|
max?: number;
|
|
}
|
|
|
|
interface SectionConfig {
|
|
type?: string;
|
|
cards: CardConfig[];
|
|
}
|
|
|
|
interface CardConfig {
|
|
type: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const STRATEGY_TYPE = "maintenance-supporter";
|
|
const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`;
|
|
const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor";
|
|
|
|
const STATE_NOT_RUNNING = "NOT_RUNNING";
|
|
|
|
const STATUS_VIEWS: Array<{
|
|
status: string;
|
|
title: string;
|
|
icon: string;
|
|
path: string;
|
|
}> = [
|
|
// Icons come from the shared STATUS_ICONS so the generated dashboard
|
|
// matches the panel (this table used to hardcode its own set — "Overdue"
|
|
// wore the panel's due-soon icon).
|
|
{ status: "overdue", title: "Overdue", icon: STATUS_ICONS.overdue, path: "overdue" },
|
|
{ status: "triggered", title: "Triggered", icon: STATUS_ICONS.triggered, path: "triggered" },
|
|
{ status: "due_soon", title: "Due Soon", icon: STATUS_ICONS.due_soon, path: "due-soon" },
|
|
{ status: "ok", title: "OK", icon: STATUS_ICONS.ok, path: "ok" },
|
|
];
|
|
|
|
const DUE_DATE_VIEWS: Array<{
|
|
title: string;
|
|
icon: string;
|
|
path: string;
|
|
filter: { filter_due_min_days?: number; filter_due_max_days?: number };
|
|
// Pre-check predicate so we can skip empty buckets without a card render.
|
|
matches: (days: number) => boolean;
|
|
}> = [
|
|
{
|
|
title: "Overdue",
|
|
icon: "mdi:alert-circle",
|
|
path: "overdue",
|
|
filter: { filter_due_max_days: -1 },
|
|
matches: (d) => d <= -1,
|
|
},
|
|
{
|
|
title: "Today",
|
|
icon: "mdi:calendar-today",
|
|
path: "today",
|
|
filter: { filter_due_min_days: 0, filter_due_max_days: 0 },
|
|
matches: (d) => d === 0,
|
|
},
|
|
{
|
|
title: "This Week",
|
|
icon: "mdi:calendar-week",
|
|
path: "this-week",
|
|
filter: { filter_due_min_days: 1, filter_due_max_days: 7 },
|
|
matches: (d) => d >= 1 && d <= 7,
|
|
},
|
|
{
|
|
title: "This Month",
|
|
icon: "mdi:calendar-month",
|
|
path: "this-month",
|
|
filter: { filter_due_min_days: 8, filter_due_max_days: 30 },
|
|
matches: (d) => d >= 8 && d <= 30,
|
|
},
|
|
{
|
|
title: "Later",
|
|
icon: "mdi:calendar-clock",
|
|
path: "later",
|
|
filter: { filter_due_min_days: 31 },
|
|
matches: (d) => d >= 31,
|
|
},
|
|
];
|
|
|
|
function makeCardSection(card: CardConfig): SectionConfig {
|
|
return { type: "grid", cards: [card] };
|
|
}
|
|
|
|
// Replicate HA core's responsive conditions (see panels/lovelace/strategies/
|
|
// helpers/view-columns-conditions.ts). They use the "view_columns" condition
|
|
// rather than raw media queries so they follow whatever breakpoint the user's
|
|
// view actually has.
|
|
const LARGE_SCREEN_CONDITION: ViewColumnsCondition = {
|
|
condition: "view_columns",
|
|
min: 2,
|
|
};
|
|
const SMALL_SCREEN_CONDITION: ViewColumnsCondition = {
|
|
condition: "view_columns",
|
|
max: 1,
|
|
};
|
|
|
|
// Headline counts come from the global summary sensors — the single source of
|
|
// 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).
|
|
// (#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 SummaryKey = "overdue" | "triggered" | "due_soon" | "ok";
|
|
type SummaryEntityIds = Partial<Record<SummaryKey, string | null>>;
|
|
type SummaryCounts = Partial<Record<SummaryKey, number>>;
|
|
|
|
// One chip's value: prefer the live summary sensor (reactive template) when its
|
|
// entity exists, but fall back to the literal count from the statistics WS when
|
|
// it doesn't. The summary sensors only exist while the global "Maintenance
|
|
// Supporter" entry is set up; if it was deleted (orphan object entries — #86,
|
|
// 2nd report) the templated `states()` reads "unknown". The count from the WS
|
|
// is always authoritative, so the chips stay correct either way.
|
|
function kpiValue(key: SummaryKey, ids: SummaryEntityIds, counts: SummaryCounts): string {
|
|
const id = ids[key];
|
|
if (id) return `{{ states('${id}') }}`;
|
|
const n = counts[key];
|
|
return n === undefined || n === null ? "—" : String(n);
|
|
}
|
|
|
|
export function kpiMarkdownCard(ids: SummaryEntityIds = {}, counts: SummaryCounts = {}): CardConfig {
|
|
return {
|
|
type: "markdown",
|
|
text_only: true,
|
|
content: [
|
|
`🔴 **${kpiValue("overdue", ids, counts)}** overdue`,
|
|
`⚡ **${kpiValue("triggered", ids, counts)}** triggered`,
|
|
`🟡 **${kpiValue("due_soon", ids, counts)}** due soon`,
|
|
`🟢 **${kpiValue("ok", ids, counts)}** ok`,
|
|
].join(" · "),
|
|
};
|
|
}
|
|
|
|
// Onboarding card shown when there are zero maintenance objects. Pattern
|
|
// follows HA core's home-overview-view-strategy empty-state branch.
|
|
//
|
|
// Two buttons:
|
|
// • "Open Maintenance panel" — plain navigate, the dependable path.
|
|
// • "Add object" — fire-dom-event with a custom ll-custom payload that
|
|
// our document-level handler (see registerLlCustomHandler below) catches
|
|
// and turns into a deep-link navigation. This demonstrates the fire-dom
|
|
// pattern HA core uses for in-place actions, without requiring us to
|
|
// rip our object-add dialog out of the panel into a custom element.
|
|
function emptyStateView(): ViewConfig {
|
|
return {
|
|
title: "Maintenance",
|
|
type: "panel",
|
|
cards: [
|
|
{
|
|
type: "empty-state",
|
|
icon: "mdi:wrench-clock",
|
|
icon_color: "primary",
|
|
content_only: true,
|
|
title: "No maintenance objects yet",
|
|
content:
|
|
"Open the Maintenance panel to add your first object — pool pump, HVAC filter, vehicle, anything that needs scheduled care.",
|
|
buttons: [
|
|
{
|
|
icon: "mdi:wrench",
|
|
text: "Open Maintenance panel",
|
|
appearance: "filled",
|
|
variant: "brand",
|
|
tap_action: {
|
|
action: "navigate",
|
|
navigation_path: "/maintenance-supporter",
|
|
},
|
|
},
|
|
{
|
|
icon: "mdi:plus",
|
|
text: "Add object",
|
|
appearance: "outlined",
|
|
variant: "brand",
|
|
tap_action: {
|
|
action: "fire-dom-event",
|
|
ll_custom: { type: "maintenance-supporter:add-object" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function overviewView(summaryIds: SummaryEntityIds = {}, counts: SummaryCounts = {}): ViewConfig {
|
|
const kpiCard = kpiMarkdownCard(summaryIds, counts);
|
|
return {
|
|
title: "Overview",
|
|
icon: "mdi:wrench-clock",
|
|
path: "overview",
|
|
type: "sections",
|
|
// Header runs above the main content — present on every screen size
|
|
// so the user always sees the headline counts. Layout "responsive"
|
|
// matches HA core's home strategy.
|
|
header: { layout: "responsive", card: kpiCard },
|
|
// Sidebar appears only on screens wide enough for ≥2 columns. Mirrors
|
|
// the same KPI card so power users with a wide dashboard never lose
|
|
// sight of it as they scroll long lists.
|
|
sidebar: {
|
|
sections: [
|
|
{
|
|
type: "grid",
|
|
cards: [
|
|
{
|
|
type: "heading",
|
|
heading: "Status",
|
|
heading_style: "title",
|
|
},
|
|
kpiCard,
|
|
],
|
|
},
|
|
],
|
|
visibility: [LARGE_SCREEN_CONDITION],
|
|
content_label: "Tasks",
|
|
sidebar_label: "Status",
|
|
},
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_status: ["overdue", "triggered", "due_soon"],
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
function viewsByArea(
|
|
objects: MaintenanceObjectResp[],
|
|
areas: Record<string, AreaEntry>,
|
|
): ViewConfig[] {
|
|
const byArea = new Map<string | null, MaintenanceObjectResp[]>();
|
|
for (const obj of objects) {
|
|
const aid = obj.object.area_id || null;
|
|
if (!byArea.has(aid)) byArea.set(aid, []);
|
|
byArea.get(aid)!.push(obj);
|
|
}
|
|
|
|
const areaIds = Array.from(byArea.keys()).filter(
|
|
(a): a is string => a !== null,
|
|
);
|
|
areaIds.sort((a, b) => {
|
|
const na = areas[a]?.name || a;
|
|
const nb = areas[b]?.name || b;
|
|
return na.localeCompare(nb);
|
|
});
|
|
|
|
const views: ViewConfig[] = [];
|
|
for (const areaId of areaIds) {
|
|
const objs = byArea.get(areaId)!;
|
|
if (objs.length === 0) continue;
|
|
const areaInfo = areas[areaId];
|
|
views.push({
|
|
title: areaInfo?.name || areaId,
|
|
icon: areaInfo?.icon || "mdi:floor-plan",
|
|
path: `area-${areaId}`,
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_objects: objs.map((o) => o.object.name),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
const unassigned = byArea.get(null);
|
|
if (unassigned && unassigned.length > 0) {
|
|
views.push({
|
|
title: "Unassigned",
|
|
icon: "mdi:help-circle-outline",
|
|
path: "unassigned",
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_objects: unassigned.map((o) => o.object.name),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
return views;
|
|
}
|
|
|
|
function viewsByStatus(objects: MaintenanceObjectResp[]): ViewConfig[] {
|
|
const present = new Set<string>();
|
|
for (const obj of objects) {
|
|
for (const task of obj.tasks || []) {
|
|
if (task.status) present.add(task.status);
|
|
}
|
|
}
|
|
|
|
const views: ViewConfig[] = [];
|
|
for (const v of STATUS_VIEWS) {
|
|
if (!present.has(v.status)) continue;
|
|
views.push({
|
|
title: v.title,
|
|
icon: v.icon,
|
|
path: v.path,
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_status: [v.status],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
return views;
|
|
}
|
|
|
|
function viewsByFloor(
|
|
objects: MaintenanceObjectResp[],
|
|
areas: Record<string, AreaEntry>,
|
|
floors: Record<string, FloorEntry>,
|
|
): ViewConfig[] {
|
|
// Build {floor_id → [object…]} via area.floor_id lookup; null bucket for
|
|
// objects whose area has no floor OR whose object has no area at all.
|
|
const byFloor = new Map<string | null, MaintenanceObjectResp[]>();
|
|
for (const obj of objects) {
|
|
const aid = obj.object.area_id;
|
|
const fid = aid ? areas[aid]?.floor_id || null : null;
|
|
if (!byFloor.has(fid)) byFloor.set(fid, []);
|
|
byFloor.get(fid)!.push(obj);
|
|
}
|
|
|
|
// Floor sort: by `level` then name (matches HA's own floor sorting).
|
|
const floorIds = Array.from(byFloor.keys()).filter(
|
|
(f): f is string => f !== null,
|
|
);
|
|
floorIds.sort((a, b) => {
|
|
const fa = floors[a];
|
|
const fb = floors[b];
|
|
const la = fa?.level ?? 0;
|
|
const lb = fb?.level ?? 0;
|
|
if (la !== lb) return la - lb;
|
|
return (fa?.name || a).localeCompare(fb?.name || b);
|
|
});
|
|
|
|
const views: ViewConfig[] = [];
|
|
for (const floorId of floorIds) {
|
|
const objs = byFloor.get(floorId)!;
|
|
if (objs.length === 0) continue;
|
|
const floor = floors[floorId];
|
|
views.push({
|
|
title: floor?.name || floorId,
|
|
icon: floor?.icon || "mdi:home-floor-1",
|
|
path: `floor-${floorId}`,
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_objects: objs.map((o) => o.object.name),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
const unassigned = byFloor.get(null);
|
|
if (unassigned && unassigned.length > 0) {
|
|
views.push({
|
|
title: "Other",
|
|
icon: "mdi:help-circle-outline",
|
|
path: "other",
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
filter_objects: unassigned.map((o) => o.object.name),
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
return views;
|
|
}
|
|
|
|
function viewsByCalendar(_objects: MaintenanceObjectResp[]): ViewConfig[] {
|
|
// One view per window. Each is a panel-typed view with a single
|
|
// ``maintenance-supporter-calendar`` card pinned to the matching
|
|
// window_days. Window-chips inside the card are hidden because the
|
|
// tab-bar already serves as the window selector.
|
|
const WINDOWS: Array<{
|
|
title: string;
|
|
icon: string;
|
|
path: string;
|
|
window_days: 7 | 14 | 30 | 365;
|
|
}> = [
|
|
{ title: "Week", icon: "mdi:calendar-week", path: "cal-7", window_days: 7 },
|
|
{ title: "Fortnight", icon: "mdi:calendar-week-begin", path: "cal-14", window_days: 14 },
|
|
{ title: "Month", icon: "mdi:calendar-month", path: "cal-30", window_days: 30 },
|
|
{ title: "Year", icon: "mdi:calendar-clock", path: "cal-365", window_days: 365 },
|
|
];
|
|
|
|
return WINDOWS.map((w) => ({
|
|
title: w.title,
|
|
icon: w.icon,
|
|
path: w.path,
|
|
type: "panel",
|
|
cards: [
|
|
{
|
|
type: "custom:maintenance-supporter-calendar-card",
|
|
window_days: w.window_days,
|
|
show_window_chips: false,
|
|
show_user_filter: true,
|
|
},
|
|
],
|
|
}));
|
|
}
|
|
|
|
function viewsByDueDate(objects: MaintenanceObjectResp[]): ViewConfig[] {
|
|
// Bucket-presence pre-check so we don't render an empty "Today" tab.
|
|
const presentBuckets = new Set<number>();
|
|
for (const obj of objects) {
|
|
for (const task of obj.tasks || []) {
|
|
const d = task.days_until_due;
|
|
if (d === null || d === undefined) continue;
|
|
DUE_DATE_VIEWS.forEach((v, i) => {
|
|
if (v.matches(d)) presentBuckets.add(i);
|
|
});
|
|
}
|
|
}
|
|
|
|
const views: ViewConfig[] = [];
|
|
DUE_DATE_VIEWS.forEach((v, i) => {
|
|
if (!presentBuckets.has(i)) return;
|
|
views.push({
|
|
title: v.title,
|
|
icon: v.icon,
|
|
path: v.path,
|
|
type: "sections",
|
|
sections: [
|
|
makeCardSection({
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
...v.filter,
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
return views;
|
|
}
|
|
|
|
export class MaintenanceDashboardStrategy extends HTMLElement {
|
|
static getCreateSuggestions(_hass: HassLike) {
|
|
return {
|
|
title: "Maintenance Supporter",
|
|
icon: "mdi:wrench-clock",
|
|
};
|
|
}
|
|
|
|
// Lazy-load editor so picker users who never hit "Edit Dashboard" don't
|
|
// pay for the LitElement bundle. HA's areas-dashboard-strategy uses the
|
|
// same pattern.
|
|
static async getConfigElement() {
|
|
// The editor element is registered in the same bundle below — just
|
|
// construct it. The dynamic import would work too, but with esbuild
|
|
// bundling everything into one file there's nothing extra to load.
|
|
return document.createElement(EDITOR_TAG);
|
|
}
|
|
|
|
static async generate(
|
|
config: MaintenanceDashboardStrategyConfig | undefined,
|
|
hass: HassLike,
|
|
): Promise<DashboardConfig> {
|
|
if (hass.config?.state === STATE_NOT_RUNNING) {
|
|
return {
|
|
views: [
|
|
{ type: "sections", sections: [{ cards: [{ type: "starting" }] }] },
|
|
],
|
|
};
|
|
}
|
|
if (hass.config?.recovery_mode) {
|
|
return {
|
|
views: [
|
|
{
|
|
type: "sections",
|
|
sections: [{ cards: [{ type: "recovery-mode" }] }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
let response: { objects: MaintenanceObjectResp[] };
|
|
try {
|
|
response = await hass.connection.sendMessagePromise<{
|
|
objects: MaintenanceObjectResp[];
|
|
}>({ type: "maintenance_supporter/objects" });
|
|
} catch {
|
|
return {
|
|
title: "Maintenance",
|
|
views: [
|
|
{
|
|
title: "Maintenance",
|
|
cards: [
|
|
{
|
|
type: "markdown",
|
|
content:
|
|
"**Maintenance Supporter** is not loaded. Install/enable the integration first.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const objects = response.objects || [];
|
|
|
|
// Onboarding short-circuit. With zero objects the area / status / floor /
|
|
// due_date branches all produce just the Overview view (which shows
|
|
// "no tasks") — that's a useless first impression. Show an actionable
|
|
// empty-state instead so the user knows where to click next.
|
|
if (objects.length === 0) {
|
|
return { title: "Maintenance", views: [emptyStateView()] };
|
|
}
|
|
|
|
const groupBy: GroupBy = config?.group_by ?? "area";
|
|
|
|
// Record-of-builders pattern from HA core's home-overview-view-strategy
|
|
// — each entry is a no-arg lambda producing the per-mode views. Extending
|
|
// with a new group_by mode means adding one entry instead of growing an
|
|
// if/else chain.
|
|
const viewBuilders: Record<GroupBy, () => ViewConfig[]> = {
|
|
area: () => viewsByArea(objects, hass.areas || {}),
|
|
status: () => viewsByStatus(objects),
|
|
floor: () => viewsByFloor(objects, hass.areas || {}, hass.floors || {}),
|
|
due_date: () => viewsByDueDate(objects),
|
|
calendar: () => viewsByCalendar(objects),
|
|
};
|
|
|
|
// Registry-resolved summary-sensor ids + live counts (#86); tolerate older
|
|
// backends. The counts are the fallback the chips render when the summary
|
|
// sensors don't exist (orphan object entries after the global entry was
|
|
// deleted), so they never read "unknown".
|
|
let summaryIds: SummaryEntityIds = {};
|
|
let counts: SummaryCounts = {};
|
|
try {
|
|
const stats = await hass.connection.sendMessagePromise<
|
|
{ summary_entity_ids?: SummaryEntityIds } & SummaryCounts
|
|
>({
|
|
type: "maintenance_supporter/statistics",
|
|
});
|
|
summaryIds = stats.summary_entity_ids || {};
|
|
counts = {
|
|
overdue: stats.overdue,
|
|
triggered: stats.triggered,
|
|
due_soon: stats.due_soon,
|
|
ok: stats.ok,
|
|
};
|
|
} catch { /* fall back to the documented default ids */ }
|
|
|
|
return {
|
|
title: "Maintenance",
|
|
views: [
|
|
overviewView(summaryIds, counts),
|
|
...(viewBuilders[groupBy] ?? viewBuilders.area)(),
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Editor ──────────────────────────────────────────────────────────────────
|
|
//
|
|
// Minimal LovelaceStrategyEditor: a single dropdown for ``group_by``. Pattern
|
|
// follows HA core's ``hui-areas-dashboard-strategy-editor`` — LitElement that
|
|
// exposes setConfig(), holds the current config in @state, and dispatches
|
|
// "config-changed" with the new config on user input.
|
|
//
|
|
// We keep the editor as a plain HTMLElement (no Lit dependency) because the
|
|
// strategy file otherwise stays Lit-free. It's enough HTML for one <select>.
|
|
|
|
const GROUP_BY_OPTIONS: Array<{ value: GroupBy; label: string }> = [
|
|
{ value: "area", label: "By area (default)" },
|
|
{ value: "status", label: "By status (Overdue / Triggered / Due Soon / OK)" },
|
|
{ value: "floor", label: "By floor (uses HA floors)" },
|
|
{ value: "due_date", label: "By due date (Overdue / Today / Week / Month / Later)" },
|
|
{ value: "calendar", label: "Rolling calendar (Week / Fortnight / Month / Year)" },
|
|
];
|
|
|
|
class MaintenanceStrategyEditor extends HTMLElement {
|
|
private _config: MaintenanceDashboardStrategyConfig = {
|
|
type: "custom:maintenance-supporter",
|
|
};
|
|
private _hass: HassLike | undefined;
|
|
|
|
set hass(hass: HassLike | undefined) {
|
|
this._hass = hass;
|
|
}
|
|
|
|
setConfig(config: MaintenanceDashboardStrategyConfig): void {
|
|
this._config = config;
|
|
this._render();
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
this._render();
|
|
}
|
|
|
|
private _render(): void {
|
|
const current = this._config.group_by ?? "area";
|
|
const options = GROUP_BY_OPTIONS.map(
|
|
(o) =>
|
|
`<option value="${o.value}"${o.value === current ? " selected" : ""}>${o.label}</option>`,
|
|
).join("");
|
|
this.innerHTML = `
|
|
<style>
|
|
:host, .editor { display: block; padding: 16px 0; }
|
|
label { display: block; font-weight: 500; margin-bottom: 8px; }
|
|
select {
|
|
width: 100%; padding: 8px; font-size: 14px;
|
|
background: var(--card-background-color, white);
|
|
color: var(--primary-text-color, black);
|
|
border: 1px solid var(--divider-color, #e0e0e0);
|
|
border-radius: 4px;
|
|
}
|
|
.help {
|
|
margin-top: 8px; font-size: 12px;
|
|
color: var(--secondary-text-color, #666);
|
|
}
|
|
</style>
|
|
<div class="editor">
|
|
<label for="group-by">Group views by</label>
|
|
<select id="group-by">${options}</select>
|
|
<div class="help">
|
|
The "Overview" view is always first. Empty groups are skipped.
|
|
</div>
|
|
</div>
|
|
`;
|
|
const select = this.querySelector("#group-by") as HTMLSelectElement | null;
|
|
select?.addEventListener("change", () => {
|
|
const newConfig: MaintenanceDashboardStrategyConfig = {
|
|
...this._config,
|
|
group_by: select.value as GroupBy,
|
|
};
|
|
this._config = newConfig;
|
|
this.dispatchEvent(
|
|
new CustomEvent("config-changed", {
|
|
detail: { config: newConfig },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Section Strategy ────────────────────────────────────────────────────────
|
|
//
|
|
// A standalone section users can drop into ANY view (HA's home dashboard, the
|
|
// areas dashboard's per-area view, a custom dashboard) to surface a slice of
|
|
// maintenance tasks in context. Mirrors HA core's ``common-controls`` section
|
|
// strategy pattern. YAML usage:
|
|
//
|
|
// sections:
|
|
// - strategy:
|
|
// type: custom:maintenance-supporter-section
|
|
// area_id: kitchen # optional — restrict to one area
|
|
// filter_status: # optional — restrict to certain statuses
|
|
// - overdue
|
|
// - triggered
|
|
// title: Kitchen upkeep # optional heading card
|
|
// max_items: 5 # optional row limit
|
|
//
|
|
// At least one filter is recommended; with no filters it shows everything,
|
|
// which is what the dashboard strategy already does.
|
|
|
|
const SECTION_STRATEGY_TYPE = "maintenance-supporter-section";
|
|
const SECTION_STRATEGY_TAG = `ll-strategy-section-${SECTION_STRATEGY_TYPE}`;
|
|
|
|
interface MaintenanceSectionStrategyConfig {
|
|
type: "custom:maintenance-supporter-section" | "maintenance-supporter-section";
|
|
area_id?: string;
|
|
filter_status?: string[];
|
|
filter_objects?: string[];
|
|
filter_due_min_days?: number;
|
|
filter_due_max_days?: number;
|
|
title?: string;
|
|
max_items?: number;
|
|
}
|
|
|
|
class MaintenanceSectionStrategy extends HTMLElement {
|
|
static async generate(
|
|
config: MaintenanceSectionStrategyConfig | undefined,
|
|
hass: HassLike,
|
|
): Promise<SectionConfig> {
|
|
const card: CardConfig = {
|
|
type: "custom:maintenance-supporter-card",
|
|
show_header: false,
|
|
};
|
|
|
|
// Pass through optional filters
|
|
if (config?.filter_status?.length) card.filter_status = config.filter_status;
|
|
if (config?.max_items) card.max_items = config.max_items;
|
|
if (config?.filter_due_min_days !== undefined) {
|
|
card.filter_due_min_days = config.filter_due_min_days;
|
|
}
|
|
if (config?.filter_due_max_days !== undefined) {
|
|
card.filter_due_max_days = config.filter_due_max_days;
|
|
}
|
|
|
|
// Resolve area_id → object names via WS (the card filters by name).
|
|
// Falls back gracefully if the WS call fails — empty filter means
|
|
// "show everything", which still renders something useful.
|
|
let names: string[] | undefined = config?.filter_objects;
|
|
if (config?.area_id && !names) {
|
|
try {
|
|
const r = await hass.connection.sendMessagePromise<{
|
|
objects: MaintenanceObjectResp[];
|
|
}>({ type: "maintenance_supporter/objects" });
|
|
names = (r.objects || [])
|
|
.filter((o) => o.object.area_id === config.area_id)
|
|
.map((o) => o.object.name);
|
|
} catch {
|
|
// ignore — card will show all
|
|
}
|
|
}
|
|
if (names && names.length > 0) card.filter_objects = names;
|
|
|
|
const cards: CardConfig[] = [];
|
|
if (config?.title) {
|
|
cards.push({
|
|
type: "heading",
|
|
heading: config.title,
|
|
heading_style: "title",
|
|
});
|
|
}
|
|
cards.push(card);
|
|
|
|
return { type: "grid", cards };
|
|
}
|
|
}
|
|
|
|
// v2.3.5 (issue #52 follow-up): wrap registration in try/catch + emit a
|
|
// banner-style console.log so that when ANY user hits a strategy timeout
|
|
// we can see in the browser console exactly which step failed instead of
|
|
// guessing. The non-error console.log also serves as a positive load
|
|
// signal — if the user doesn't see "[maintenance-supporter] strategy
|
|
// bundle loaded" at all, the script never ran (cached index.html / CSP /
|
|
// MIME-type problem). If they see the log but still get the timeout,
|
|
// something else (Lovelace resource conflict, HACS plugin clash) is
|
|
// holding the registry.
|
|
const STRATEGY_VERSION = "2.3.6";
|
|
const LOG_PREFIX = "[maintenance-supporter]";
|
|
|
|
function safeDefine(tag: string, ctor: CustomElementConstructor): "ok" | "skipped" | string {
|
|
try {
|
|
if (customElements.get(tag)) {
|
|
return "skipped";
|
|
}
|
|
customElements.define(tag, ctor);
|
|
return "ok";
|
|
} catch (err) {
|
|
return `ERROR: ${String(err)}`;
|
|
}
|
|
}
|
|
|
|
// NOTE: the dashboard tag (STRATEGY_TAG) is intentionally NOT defined here.
|
|
// It is owned by the tiny zero-import shim (maintenance-strategy-shim.ts),
|
|
// which is what HA loads via frontend_extra_module_url so registration wins
|
|
// HA's 5 s whenDefined race even under heavy plugin load. This heavy bundle is
|
|
// lazy-loaded by the shim on first generate()/getConfigElement(); the shim
|
|
// calls our exported MaintenanceDashboardStrategy.generate(). We still define
|
|
// the editor + section strategies here (not on HA's timeout-critical path).
|
|
const _defineResults = {
|
|
editor: safeDefine(EDITOR_TAG, MaintenanceStrategyEditor),
|
|
section: safeDefine(SECTION_STRATEGY_TAG, MaintenanceSectionStrategy),
|
|
};
|
|
|
|
console.log(
|
|
`${LOG_PREFIX} strategy bundle v${STRATEGY_VERSION} loaded — registrations:`,
|
|
_defineResults,
|
|
"— run maintenanceSupporterDiagnose() in console for full state",
|
|
);
|
|
|
|
// Global devtools helper — typing maintenanceSupporterDiagnose() in the
|
|
// browser console returns a snapshot of registration state + module URLs
|
|
// + Lovelace resources. Easier than asking users to paste a multi-line
|
|
// snippet they then can't run for whatever reason.
|
|
(window as unknown as { maintenanceSupporterDiagnose?: () => Promise<unknown> }).maintenanceSupporterDiagnose =
|
|
async () => {
|
|
const tag = STRATEGY_TAG;
|
|
let resources: Array<{ url?: string }> = [];
|
|
try {
|
|
const ha = document.querySelector("home-assistant") as
|
|
| (HTMLElement & { hass?: { connection: { sendMessagePromise<T>(m: Record<string, unknown>): Promise<T> } } })
|
|
| null;
|
|
if (ha?.hass) {
|
|
const r = await ha.hass.connection.sendMessagePromise<{ resources?: Array<{ url?: string }> }>(
|
|
{ type: "lovelace/resources" },
|
|
);
|
|
resources = r.resources || [];
|
|
}
|
|
} catch (e) { resources = [{ url: `<error: ${String(e)}>` }]; }
|
|
return {
|
|
version: STRATEGY_VERSION,
|
|
el_registered: !!customElements.get(tag),
|
|
el_class_excerpt: customElements.get(tag)?.toString().slice(0, 100) || null,
|
|
customStrategies_dashboard: (window as unknown as { customStrategies?: Array<{ type: string; strategyType: string }> })
|
|
.customStrategies?.filter((s) => s.strategyType === "dashboard").map((s) => s.type) || [],
|
|
scriptTagsInPage: Array.from(document.querySelectorAll("script[type=module]"))
|
|
.map((s) => (s as HTMLScriptElement).src)
|
|
.filter((s) => /maint|strateg/i.test(s)),
|
|
lovelaceResources_matching: resources.filter((r) => /maint|strateg/i.test(r.url || "")).map((r) => r.url),
|
|
registrations: _defineResults,
|
|
};
|
|
};
|
|
|
|
// ── Phase 5: Status Section-Strategies ──────────────────────────────────────
|
|
//
|
|
// Three small status sections so users with a Lovelace-only setup
|
|
// can monitor vacation / budget / groups state without leaving the dashboard.
|
|
// v2.4.0 update: each strategy now delegates to a live interactive custom
|
|
// card (vacation-section-card / budget-section-card / groups-section-card)
|
|
// that lets admins edit values inline.
|
|
//
|
|
// v2.3.4 (issue #52): the section-card modules are LAZY-loaded inside
|
|
// generate() — bundling them at module top blew the strategy file up to
|
|
// 509 KB, and HA's 5 s strategy-load timeout was firing on slow connections
|
|
// before customElements.define for ll-strategy-dashboard-* could run. Now
|
|
// the strategy bundle is tiny (~10 KB) and registers instantly; the heavy
|
|
// section-card code only loads when a section actually renders.
|
|
|
|
class MaintenanceVacationSectionStrategy extends HTMLElement {
|
|
static async generate(
|
|
_config: { type: string; title?: string },
|
|
_hass: HassLike,
|
|
): Promise<SectionConfig> {
|
|
await import("./components/vacation-section-card");
|
|
return {
|
|
type: "grid",
|
|
cards: [
|
|
{
|
|
type: "custom:maintenance-vacation-section-card",
|
|
title: _config?.title,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
class MaintenanceBudgetSectionStrategy extends HTMLElement {
|
|
static async generate(
|
|
_config: { type: string; title?: string },
|
|
_hass: HassLike,
|
|
): Promise<SectionConfig> {
|
|
await import("./components/budget-section-card");
|
|
return {
|
|
type: "grid",
|
|
cards: [
|
|
{
|
|
type: "custom:maintenance-budget-section-card",
|
|
title: _config?.title,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
class MaintenanceGroupsSectionStrategy extends HTMLElement {
|
|
static async generate(
|
|
_config: { type: string; title?: string },
|
|
_hass: HassLike,
|
|
): Promise<SectionConfig> {
|
|
await import("./components/groups-section-card");
|
|
return {
|
|
type: "grid",
|
|
cards: [
|
|
{
|
|
type: "custom:maintenance-groups-section-card",
|
|
title: _config?.title,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Register the three section strategies
|
|
const VACATION_TAG = "ll-strategy-section-maintenance-supporter-vacation";
|
|
const BUDGET_TAG = "ll-strategy-section-maintenance-supporter-budget";
|
|
const GROUPS_TAG = "ll-strategy-section-maintenance-supporter-groups";
|
|
if (!customElements.get(VACATION_TAG)) customElements.define(VACATION_TAG, MaintenanceVacationSectionStrategy);
|
|
if (!customElements.get(BUDGET_TAG)) customElements.define(BUDGET_TAG, MaintenanceBudgetSectionStrategy);
|
|
if (!customElements.get(GROUPS_TAG)) customElements.define(GROUPS_TAG, MaintenanceGroupsSectionStrategy);
|
|
|
|
// ── fire-dom-event handler ──────────────────────────────────────────────────
|
|
//
|
|
// HA's standard ``tap_action: { action: "fire-dom-event", ll_custom: {...} }``
|
|
// dispatches an ``ll-custom`` CustomEvent on the tapped element. We register
|
|
// one bubble-phase listener at document level that handles every payload
|
|
// whose ``type`` is namespaced with ``maintenance-supporter:`` — currently:
|
|
//
|
|
// maintenance-supporter:add-object → in-place open of MaintenanceObjectDialog
|
|
// maintenance-supporter:open-task → in-place open of MaintenanceTaskDialog
|
|
// (with entry_id + task_id in detail)
|
|
//
|
|
// The dialog components are imported via dialog-mount.ts and mounted lazily
|
|
// onto document.body the first time they are needed. If the in-place mount
|
|
// fails (e.g. <home-assistant> not yet ready), we fall back to deep-linking
|
|
// the panel via ?ms_action=… so the user still ends up where they want.
|
|
|
|
// v2.3.4 (issue #52): dialog-mount is heavy (pulls in 7 dialog components +
|
|
// their Lit runtime). We only need it when the user actually triggers a
|
|
// fire-dom-event from a strategy-generated card, so the import is dynamic
|
|
// and runs the first time a maintenance-supporter:* event fires.
|
|
type DialogMountModule = typeof import("./dialog-mount");
|
|
let _dialogMountPromise: Promise<DialogMountModule> | null = null;
|
|
function getDialogMount(): Promise<DialogMountModule> {
|
|
if (!_dialogMountPromise) {
|
|
// Don't cache a REJECTED import: after a version bump the browser may hold a
|
|
// stale strategy bundle whose hashed dialog-mount chunk 404s once; caching
|
|
// that rejection would dead-end every dialog action until a full reload.
|
|
// Clear the cache on failure so a later attempt (or fixed chunk) can retry.
|
|
_dialogMountPromise = import("./dialog-mount").catch((err) => {
|
|
_dialogMountPromise = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return _dialogMountPromise;
|
|
}
|
|
|
|
interface LlCustomEventDetail {
|
|
type?: string;
|
|
entry_id?: unknown;
|
|
task_id?: unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const LL_CUSTOM_HANDLER_FLAG = "_msSupporterLlCustomBound";
|
|
|
|
function deepLink(path: string): void {
|
|
history.pushState(null, "", path);
|
|
window.dispatchEvent(new CustomEvent("location-changed"));
|
|
}
|
|
|
|
function registerLlCustomHandler(): void {
|
|
const w = window as unknown as Record<string, unknown>;
|
|
if (w[LL_CUSTOM_HANDLER_FLAG]) return; // idempotent — strategy file may load twice
|
|
w[LL_CUSTOM_HANDLER_FLAG] = true;
|
|
|
|
document.addEventListener("ll-custom", (event: Event) => {
|
|
const raw = (event as CustomEvent<LlCustomEventDetail>).detail;
|
|
if (!raw || typeof raw !== "object") return;
|
|
// HA's `tap_action: { action: "fire-dom-event", ll_custom: {…} }` dispatches
|
|
// `ll-custom` with the ENTIRE action config as `detail`, nesting our payload
|
|
// under `.ll_custom` (verified live on HA 2026.6 — issue #69: "Add object"
|
|
// did nothing because we read `detail.type`, which was undefined). Direct
|
|
// dispatchers (the calendar card, the panel) put the payload at the top
|
|
// level. Accept both: prefer the nested `ll_custom` object when present.
|
|
const nested = (raw as { ll_custom?: unknown }).ll_custom;
|
|
const detail = (
|
|
nested && typeof nested === "object" ? nested : raw
|
|
) as LlCustomEventDetail;
|
|
if (!detail || typeof detail.type !== "string") return;
|
|
if (!detail.type.startsWith("maintenance-supporter:")) return;
|
|
const action = detail.type.slice("maintenance-supporter:".length);
|
|
|
|
if (action === "deep-link" && typeof detail.path === "string") {
|
|
deepLink(detail.path);
|
|
return;
|
|
}
|
|
|
|
// Everything else needs the heavy dialog-mount module — load it lazily.
|
|
void (async () => {
|
|
let dm: DialogMountModule;
|
|
try {
|
|
dm = await getDialogMount();
|
|
} catch {
|
|
// dialog-mount chunk unavailable (e.g. stale bundle after an update) —
|
|
// degrade to opening the panel instead of a silent, unhandled failure.
|
|
deepLink("/maintenance-supporter");
|
|
return;
|
|
}
|
|
|
|
if (action === "add-object") {
|
|
if (dm.openCreateObjectDialog()) return;
|
|
deepLink("/maintenance-supporter?ms_action=add_object");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
action === "open-task" &&
|
|
typeof detail.entry_id === "string" &&
|
|
typeof detail.task_id === "string"
|
|
) {
|
|
if (dm.openTaskQuickActions(detail.entry_id, detail.task_id)) return;
|
|
if (dm.openEditTaskDialog(detail.entry_id, detail.task_id)) return;
|
|
deepLink(
|
|
`/maintenance-supporter?entry_id=${encodeURIComponent(detail.entry_id)}` +
|
|
`&task_id=${encodeURIComponent(detail.task_id)}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (action === "open-object" && typeof detail.entry_id === "string") {
|
|
if (dm.openObjectQuickActions(detail.entry_id)) return;
|
|
deepLink(`/maintenance-supporter?entry_id=${encodeURIComponent(detail.entry_id)}`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
action === "edit-history" &&
|
|
typeof detail.entry_id === "string" &&
|
|
typeof detail.task_id === "string" &&
|
|
typeof detail.original_timestamp === "string"
|
|
) {
|
|
const haRoot = document.querySelector(
|
|
"home-assistant",
|
|
) as (HTMLElement & { hass?: { connection: { sendMessagePromise<T>(m: Record<string, unknown>): Promise<T> } } }) | null;
|
|
const hass = haRoot?.hass;
|
|
if (!hass) return;
|
|
try {
|
|
const r = await hass.connection.sendMessagePromise<{
|
|
tasks?: Array<{ id?: string; history?: Array<Record<string, unknown>> }>;
|
|
}>({
|
|
type: "maintenance_supporter/object",
|
|
entry_id: detail.entry_id,
|
|
});
|
|
const task = r.tasks?.find((t) => t.id === detail.task_id);
|
|
const histEntry = task?.history?.find(
|
|
(h) => h.timestamp === detail.original_timestamp,
|
|
);
|
|
if (!histEntry) return;
|
|
dm.openHistoryEditDialog({
|
|
entry_id: detail.entry_id as string,
|
|
task_id: detail.task_id as string,
|
|
original_timestamp: detail.original_timestamp as string,
|
|
type: (histEntry.type as string) || "completed",
|
|
timestamp: (histEntry.timestamp as string) || (detail.original_timestamp as string),
|
|
notes: (histEntry.notes as string) ?? null,
|
|
cost: (histEntry.cost as number | undefined) ?? null,
|
|
duration: (histEntry.duration as number | undefined) ?? null,
|
|
completed_by: (histEntry.completed_by as string) ?? null,
|
|
});
|
|
} catch {
|
|
deepLink("/maintenance-supporter");
|
|
}
|
|
}
|
|
})();
|
|
});
|
|
}
|
|
|
|
registerLlCustomHandler();
|
|
|
|
// Discovery — picked up by HA 2026.5+. Older HA ignores it silently.
|
|
const w = window as unknown as {
|
|
customStrategies?: Array<{
|
|
type: string;
|
|
strategyType: string;
|
|
name: string;
|
|
description?: string;
|
|
documentationURL?: string;
|
|
}>;
|
|
};
|
|
w.customStrategies = w.customStrategies || [];
|
|
|
|
function registerStrategy(entry: {
|
|
type: string;
|
|
strategyType: string;
|
|
name: string;
|
|
description?: string;
|
|
documentationURL?: string;
|
|
}): void {
|
|
const exists = w.customStrategies!.some(
|
|
(s) => s.type === entry.type && s.strategyType === entry.strategyType,
|
|
);
|
|
if (!exists) w.customStrategies!.push(entry);
|
|
}
|
|
|
|
registerStrategy({
|
|
type: STRATEGY_TYPE,
|
|
strategyType: "dashboard",
|
|
name: "Maintenance Supporter",
|
|
description:
|
|
"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",
|
|
});
|
|
|
|
registerStrategy({
|
|
type: SECTION_STRATEGY_TYPE,
|
|
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",
|
|
});
|
|
|
|
// Phase 5 status sections
|
|
registerStrategy({
|
|
type: "maintenance-supporter-vacation",
|
|
strategyType: "section",
|
|
name: "Maintenance Supporter — Vacation Status",
|
|
description: "Compact vacation-mode status banner. Tap to open settings in the panel.",
|
|
});
|
|
registerStrategy({
|
|
type: "maintenance-supporter-budget",
|
|
strategyType: "section",
|
|
name: "Maintenance Supporter — Budget Status",
|
|
description: "Monthly + yearly maintenance budget overview. Tap for details.",
|
|
});
|
|
registerStrategy({
|
|
type: "maintenance-supporter-groups",
|
|
strategyType: "section",
|
|
name: "Maintenance Supporter — Groups",
|
|
description: "List of configured task groups with member counts.",
|
|
});
|
|
|
|
export {};
|