/** v2.4.0 — Interactive Vacation Section Card. * * Replaces the read-only markdown card from Phase 5. Lets the user toggle * vacation mode + edit start/end/buffer inline, without leaving Lovelace. * * Used as a section-strategy card (HA 2026.5+ sections) AND as a stand-alone * card the user can drop onto any dashboard. * * Permissions: only admins see edit affordances; non-admins get a read-only * view with a deep-link to the panel (vacation config is admin-only at the * WS layer too). */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale, langOf, syncLocaleFromHass } from "../styles"; import { registerCustomCard } from "../helpers/register-card"; import { runWs } from "../helpers/ws-run"; import { VACATION_BUFFER_DAYS_RANGE } from "../helpers/setting-ranges"; import { confirmAction } from "../helpers/confirm"; import { sectionCardSharedStyles } from "./section-card-shared-styles"; import "./ms-date-field"; import type { HomeAssistant } from "../types"; interface VacationState { enabled?: boolean; is_active?: boolean; start?: string | null; end?: string | null; buffer_days?: number; window_end?: string | null; exempt_task_ids?: string[]; } interface CardConfig { type: string; title?: string; } export class MaintenanceVacationSectionCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config: CardConfig = { type: "" }; @state() private _state: VacationState | null = null; @state() private _busy = false; @state() private _error = ""; @state() private _localStart = ""; @state() private _localEnd = ""; /** Typed text — validated against VACATION_BUFFER_DAYS_RANGE on Save. */ @state() private _localBuffer = "3"; @state() private _dirty = false; private _loaded = false; setConfig(config: CardConfig): void { this._config = config; } getCardSize(): number { return 2; } private get _lang(): string { return langOf(this.hass); } private get _isAdmin(): boolean { return (this.hass?.user?.is_admin ?? true) as boolean; } updated(changedProps: Map): void { super.updated(changedProps); // #163 tripwire: the date field's typed-entry placeholder formats a date. syncLocaleFromHass(this, changedProps); if (changedProps.has("hass") && this.hass && !this._loaded) { this._loaded = true; void this._load(); void ensureLocale(this._lang).then(() => this.requestUpdate()); } } private async _load(): Promise { const r = await runWs(this, { type: "maintenance_supporter/vacation/state" }, { onError: (m) => { this._error = m; }, }); if (!r) return; this._state = r; this._localStart = r.start || ""; this._localEnd = r.end || ""; this._localBuffer = String(r.buffer_days ?? 3); this._dirty = false; } /** One vacation write — helpers/ws-run runWs with the busy flag and the * error line; resolves the new state (undefined when refused). */ private _act(payload: Record): Promise { this._error = ""; return runWs(this, payload, { busy: (b) => { this._busy = b; }, onError: (m) => { this._error = m; }, }); } private async _toggleEnabled(on: boolean): Promise { const r = await this._act({ type: "maintenance_supporter/vacation/update", enabled: on }); if (r) this._state = r; } private async _save(): Promise { if (!this._isAdmin) return; // Same range + message as the settings view (DRY audit 2026-09-26): an // out-of-range buffer is named up front instead of round-tripping into // a voluptuous error. const [min, max] = VACATION_BUFFER_DAYS_RANGE; const buffer = Number(this._localBuffer); if (this._localBuffer.trim() === "" || !Number.isInteger(buffer) || buffer < min || buffer > max) { this._error = t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)); return; } const r = await this._act({ type: "maintenance_supporter/vacation/update", start: this._localStart || null, end: this._localEnd || null, buffer_days: buffer, }); if (!r) return; this._state = r; this._dirty = false; } private async _endNow(): Promise { if (!this._isAdmin) return; // The confirm dialog, not the browser's native modal (DRY audit 2026-09-26). const confirmed = await confirmAction(this.hass, { title: t("vacation_end_now", this._lang), message: t("vacation_end_now_confirm", this._lang), confirmText: t("vacation_end_now", this._lang), }); if (!confirmed) return; const r = await this._act({ type: "maintenance_supporter/vacation/end_now" }); if (!r) return; this._state = r; this._localStart = r.start || ""; this._localEnd = r.end || ""; } private _onDeepLink(): void { const path = "/maintenance-supporter?ms_action=open_vacation"; history.pushState(null, "", path); window.dispatchEvent(new CustomEvent("location-changed")); } render() { const L = this._lang; const s = this._state; if (!s) { return html`
${t("loading", L)}
`; } const active = s.is_active === true; const enabled = s.enabled === true; const exemptCount = s.exempt_task_ids?.length ?? 0; const statusLabel = active ? t("vacation_status_active", L) : enabled ? t("vacation_status_scheduled", L) : t("vacation_status_inactive", L); const statusClass = active ? "active" : enabled ? "scheduled" : "inactive"; return html`
🏖️ ${this._config.title || t("vacation_mode", L)}
${statusLabel}
${this._error ? html`
${this._error}
` : nothing} ${this._isAdmin ? html`
this._toggleEnabled((e.target as HTMLInputElement).checked)} >
{ this._localStart = e.detail.value as string; this._dirty = true; }} >
{ this._localEnd = e.detail.value as string; this._dirty = true; }} >
{ this._localBuffer = (e.target as HTMLInputElement).value; this._dirty = true; }} />
${active ? html`` : nothing} ${exemptCount > 0 ? html`` : html``}
` : html`
${enabled && s.start && s.end ? html`
${s.start} → ${s.end}
` : nothing}
`}
`; } static styles = [sectionCardSharedStyles, css` .status-pill { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.5px; } .status-pill.active { background: rgba(76, 175, 80, 0.15); color: #4caf50; } .status-pill.scheduled { background: rgba(255, 152, 0, 0.15); color: #ff9800; } .status-pill.inactive { background: rgba(158, 158, 158, 0.15); color: var(--secondary-text-color); } .row.toggle-row { display: flex; align-items: center; justify-content: space-between; } .row.toggle-row label { font-size: 14px; color: var(--primary-text-color); } .dates-row { display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px; } .date-field.buffer label { white-space: nowrap; } .date-field { display: flex; flex-direction: column; gap: 4px; } .date-field label { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.3px; } .date-field input { padding: 6px 8px; font-size: 13px; background: var(--secondary-background-color, #2c2c2c); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: 6px; font-family: inherit; } .date-field input:disabled { opacity: 0.5; cursor: not-allowed; } .actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .readonly { display: flex; flex-direction: column; gap: 8px; } `]; } if (!customElements.get("maintenance-vacation-section-card")) { customElements.define( "maintenance-vacation-section-card", MaintenanceVacationSectionCard, ); } registerCustomCard({ type: "maintenance-vacation-section-card", name: "Maintenance Supporter — Vacation", description: "Switch vacation mode on or off and set its dates right on the dashboard.", preview: false, });