Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/vacation-section-card.ts
T

339 lines
12 KiB
TypeScript

/** 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<string, unknown>): 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<void> {
const r = await runWs<VacationState>(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<string, unknown>): Promise<VacationState | null | undefined> {
this._error = "";
return runWs<VacationState>(this, payload, {
busy: (b) => { this._busy = b; },
onError: (m) => { this._error = m; },
});
}
private async _toggleEnabled(on: boolean): Promise<void> {
const r = await this._act({ type: "maintenance_supporter/vacation/update", enabled: on });
if (r) this._state = r;
}
private async _save(): Promise<void> {
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<void> {
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`<ha-card><div class="loading">${t("loading", L)}</div></ha-card>`;
}
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`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">🏖️</span>
<span>${this._config.title || t("vacation_mode", L)}</span>
</div>
<span class="status-pill ${statusClass}">${statusLabel}</span>
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._isAdmin
? html`
<div class="row toggle-row">
<label>${t("enable", L)}</label>
<ha-switch
.checked=${enabled}
.disabled=${this._busy}
@change=${(e: Event) =>
this._toggleEnabled((e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="dates-row">
<div class="date-field">
<label>${t("vacation_start", L)}</label>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._localStart}
.disabled=${this._busy}
@value-changed=${(e: CustomEvent) => {
this._localStart = e.detail.value as string;
this._dirty = true;
}}
></ms-date-field>
</div>
<div class="date-field">
<label>${t("vacation_end", L)}</label>
<ms-date-field
kind="date"
clearable
.hass=${this.hass}
.lang=${L}
.value=${this._localEnd}
.disabled=${this._busy}
@value-changed=${(e: CustomEvent) => {
this._localEnd = e.detail.value as string;
this._dirty = true;
}}
></ms-date-field>
</div>
<div class="date-field buffer">
<label>${t("vacation_buffer", L)}</label>
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]}
.value=${this._localBuffer}
?disabled=${this._busy}
@input=${(e: Event) => {
this._localBuffer = (e.target as HTMLInputElement).value;
this._dirty = true;
}} />
</div>
</div>
<div class="actions">
<button class="btn ${this._dirty ? "primary" : "muted"}"
@click=${this._save}
?disabled=${this._busy || !this._dirty}>
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
${this._dirty
? t("save", L)
: t("saved", L)}
</button>
${active
? html`<button class="btn"
@click=${this._endNow}
?disabled=${this._busy}>
${t("vacation_end_now", L)}
</button>`
: nothing}
${exemptCount > 0
? html`<button class="btn link"
@click=${this._onDeepLink}>
${exemptCount} ${t("vacation_exempt_count", L)}…
</button>`
: html`<button class="btn link"
@click=${this._onDeepLink}>
${t("vacation_advanced", L)}
</button>`}
</div>
`
: html`
<div class="readonly">
${enabled && s.start && s.end
? html`<div>${s.start} → ${s.end}</div>`
: nothing}
<button class="btn link" @click=${this._onDeepLink}>
${t("vacation_open_panel", L)}
</button>
</div>
`}
</div>
</ha-card>
`;
}
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: "Inline vacation mode toggle + dates",
preview: false,
});