672 lines
25 KiB
JavaScript
672 lines
25 KiB
JavaScript
/**
|
|
* TaskMate Routine Card (#676)
|
|
*
|
|
* A guided, one-task-at-a-time flow for morning and bedtime routines. The
|
|
* child card is a checklist — good for scanning, poor for a five-year-old
|
|
* being walked through getting ready. This shows a single task at a time with
|
|
* a big Done button, a progress bar, and a celebration at the end.
|
|
*
|
|
* Availability comes from the backend's own chore_availability matrix rather
|
|
* than a re-implementation of the child card's filter, so the weather gate,
|
|
* reactive deadlines, dependencies and rotation are all honoured for free.
|
|
*
|
|
* Version: 1.0.0
|
|
*/
|
|
|
|
const LitElement = customElements.get("hui-masonry-view")
|
|
? Object.getPrototypeOf(customElements.get("hui-masonry-view"))
|
|
: Object.getPrototypeOf(customElements.get("hui-view"));
|
|
|
|
const html = LitElement.prototype.html;
|
|
const css = LitElement.prototype.css;
|
|
|
|
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
|
|
|
const TIME_CATEGORIES = ["all", "morning", "afternoon", "evening", "night", "anytime"];
|
|
|
|
// The card has ONE layout, so it consumes the design tokens directly rather
|
|
// than carrying a second _renderDesigned() template like the multi-layout
|
|
// cards do. Every --tmd-* reference below is written with the card's original
|
|
// value as the fallback, so "classic" — which defines no tokens — renders
|
|
// exactly as it did before the design system was wired in.
|
|
const DEFAULT_ACCENT = "#7c3aed";
|
|
|
|
class TaskMateRoutineCard extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
hass: { type: Object },
|
|
config: { type: Object },
|
|
_index: { type: Number },
|
|
_finished: { type: Boolean },
|
|
_busy: { type: Boolean },
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._index = 0;
|
|
this._finished = false;
|
|
this._busy = false;
|
|
// Chores completed during THIS run, so the finish screen can total what
|
|
// was just earned rather than everything the child has done today.
|
|
this._runCompleted = new Map();
|
|
// Tasks the child chose to leave for later. Kept for this run only.
|
|
this._skipped = new Set();
|
|
}
|
|
|
|
setConfig(config) {
|
|
if (!config.entity) throw new Error("You need to define an entity");
|
|
if (!config.child_id) throw new Error("You need to define a child_id");
|
|
if (config.time_category && !TIME_CATEGORIES.includes(config.time_category)) {
|
|
throw new Error(`time_category must be one of: ${TIME_CATEGORIES.join(", ")}`);
|
|
}
|
|
this.config = config;
|
|
}
|
|
|
|
getCardSize() { return 8; }
|
|
|
|
static getStubConfig() {
|
|
return { entity: "sensor.taskmate_overview", child_id: "", time_category: "morning" };
|
|
}
|
|
|
|
shouldUpdate(changedProps) {
|
|
if (changedProps.has("hass")) {
|
|
return window.__taskmate_hasChanged
|
|
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
|
: true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_t(key, params) {
|
|
const fn = window.__taskmate_localize;
|
|
return fn ? fn(this.hass, key, params) : key;
|
|
}
|
|
|
|
_attrs() {
|
|
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|
|
|| this.hass?.states?.[this.config.entity]?.attributes || {};
|
|
}
|
|
|
|
/**
|
|
* Resolve the active design, stamp data-tm-design on the host (which is what
|
|
* makes the :host-scoped token block apply inside this shadow root), and
|
|
* settle the header colour.
|
|
*
|
|
* The accent is set as an inline property on the host rather than from a
|
|
* <style> in the template: a shadow tree's <style> is ordered BEFORE
|
|
* adoptedStyleSheets, so it loses to the :host default in static styles()
|
|
* at equal specificity. An element's own inline property beats both.
|
|
* An explicit header_color always wins; otherwise the rule in styles()
|
|
* takes the design's accent, falling back to the card's purple in classic.
|
|
* Headers stay full-colour in every style per the house rule.
|
|
*/
|
|
_applyDesign() {
|
|
const design = window.__taskmate_design
|
|
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
|
: "classic";
|
|
const configured = this.config.header_color;
|
|
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
|
|
this.style.setProperty("--routine-accent", _safeColor(configured, DEFAULT_ACCENT));
|
|
} else {
|
|
this.style.removeProperty("--routine-accent");
|
|
}
|
|
return design;
|
|
}
|
|
|
|
_child() {
|
|
const attrs = this._attrs();
|
|
return (attrs.children || []).find(c => String(c.id) === String(this.config.child_id));
|
|
}
|
|
|
|
/**
|
|
* The routine's task list: chores the backend says are available to this
|
|
* child, in the child's configured order, filtered to the card's period.
|
|
* Chores completed during this run stay in the list (so the progress bar
|
|
* doesn't jump around) but are marked done.
|
|
*/
|
|
_tasks() {
|
|
const attrs = this._attrs();
|
|
const child = this._child();
|
|
if (!child) return [];
|
|
|
|
const childId = String(child.id);
|
|
const availability = attrs.chore_availability || {};
|
|
const period = this.config.time_category || "all";
|
|
|
|
const completedToday = new Set(
|
|
(attrs.todays_completions || [])
|
|
.filter(c => String(c.child_id) === childId && !c.bonus_subtask_id)
|
|
.map(c => String(c.chore_id))
|
|
);
|
|
|
|
const order = (child.chore_order || []).map(String);
|
|
const list = (attrs.chores || []).filter(chore => {
|
|
const id = String(chore.id);
|
|
// Anything finished in this run stays visible as a completed step.
|
|
if (this._runCompleted.has(id)) return true;
|
|
if (!availability[id] || availability[id][childId] !== true) return false;
|
|
if (completedToday.has(id)) return false;
|
|
if (period !== "all" && chore.time_category !== period) return false;
|
|
return true;
|
|
});
|
|
|
|
list.sort((a, b) => {
|
|
const ai = order.indexOf(String(a.id));
|
|
const bi = order.indexOf(String(b.id));
|
|
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
if (ai !== -1) return -1;
|
|
if (bi !== -1) return 1;
|
|
return String(a.name).localeCompare(String(b.name));
|
|
});
|
|
return list;
|
|
}
|
|
|
|
_pointsFor(chore) {
|
|
return chore.effective_points ?? chore.points ?? 0;
|
|
}
|
|
|
|
async _complete(chore) {
|
|
if (this._busy) return;
|
|
const child = this._child();
|
|
if (!child) return;
|
|
this._busy = true;
|
|
try {
|
|
// Prefer the per-chore button entity so HA state-trigger automations
|
|
// fire, exactly as the child card does. Fall back to the service when
|
|
// the entity isn't registered yet (e.g. right after a restart).
|
|
const buttonEntityId = window.__taskmate_find_button
|
|
&& window.__taskmate_find_button(this.hass, child.id, "complete", chore.id);
|
|
if (buttonEntityId) {
|
|
await this.hass.callService("button", "press", { entity_id: buttonEntityId });
|
|
} else {
|
|
await this.hass.callService("taskmate", "complete_chore", {
|
|
chore_id: chore.id, child_id: child.id,
|
|
});
|
|
}
|
|
this._runCompleted.set(String(chore.id), {
|
|
points: this._pointsFor(chore),
|
|
pending: chore.requires_approval !== false,
|
|
});
|
|
// Skipped then done later is not "left for later" — otherwise the finish
|
|
// screen claims tasks are outstanding that the child actually did.
|
|
this._skipped.delete(String(chore.id));
|
|
this._advance();
|
|
} catch (err) {
|
|
// Surface rather than silently stalling the routine on a failed call.
|
|
this.dispatchEvent(new CustomEvent("hass-notification", {
|
|
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
|
|
}));
|
|
} finally {
|
|
this._busy = false;
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
_advance() {
|
|
const total = this._tasks().length;
|
|
if (this._index >= total - 1) {
|
|
this._finished = true;
|
|
} else {
|
|
this._index = this._index + 1;
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
_skip() {
|
|
const tasks = this._tasks();
|
|
const chore = tasks[this._index];
|
|
if (chore) this._skipped.add(String(chore.id));
|
|
this._advance();
|
|
}
|
|
|
|
_back() {
|
|
if (this._finished) { this._finished = false; return; }
|
|
if (this._index > 0) this._index = this._index - 1;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
_restart() {
|
|
this._index = 0;
|
|
this._finished = false;
|
|
this._runCompleted = new Map();
|
|
this._skipped = new Set();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
render() {
|
|
if (!this.hass || !this.config) return html``;
|
|
this._applyDesign();
|
|
const child = this._child();
|
|
if (!child) {
|
|
return html`<ha-card><div class="pad error">${this._t("routine.no_child")}</div></ha-card>`;
|
|
}
|
|
|
|
const attrs = this._attrs();
|
|
const pointsIcon = attrs.points_icon || "mdi:star";
|
|
const tasks = this._tasks();
|
|
const title = this.config.title || this._t("routine.title", { name: child.name });
|
|
|
|
if (!tasks.length) return this._renderEmpty(title);
|
|
if (this._finished) return this._renderFinished(title, tasks, pointsIcon, child);
|
|
|
|
// Guard against the list shrinking underneath us (a parent ticking a chore
|
|
// off elsewhere) — clamp rather than render undefined.
|
|
const index = Math.min(this._index, tasks.length - 1);
|
|
const chore = tasks[index];
|
|
const done = this._runCompleted.get(String(chore.id));
|
|
const pct = Math.round((index / tasks.length) * 100);
|
|
|
|
return html`
|
|
<ha-card>
|
|
<div class="head">
|
|
<span class="who">${title}</span>
|
|
</div>
|
|
|
|
<div class="progress">
|
|
<div class="rt-bar"><i style="width:${pct}%"></i></div>
|
|
<div class="count">${this._t("routine.task_of", { current: index + 1, total: tasks.length })}</div>
|
|
</div>
|
|
|
|
<div class="dots">
|
|
${tasks.map((t, i) => html`
|
|
<span class="dot ${this._runCompleted.has(String(t.id)) ? "done" : ""} ${i === index ? "now" : ""}"></span>
|
|
`)}
|
|
</div>
|
|
|
|
<div class="body">
|
|
<div class="icon"><ha-icon icon="${chore.icon || "mdi:checkbox-marked-circle-outline"}"></ha-icon></div>
|
|
<div class="task">${chore.name}</div>
|
|
${chore.description ? html`<div class="desc">${chore.description}</div>` : ""}
|
|
<div class="points">
|
|
<ha-icon icon="${pointsIcon}"></ha-icon> +${this._pointsFor(chore)}
|
|
</div>
|
|
${done?.pending ? html`<div class="pending">${this._t("routine.waiting_for_parent")}</div>` : ""}
|
|
${chore.require_photo ? html`<div class="note">${this._t("routine.photo_note")}</div>` : ""}
|
|
</div>
|
|
|
|
<div class="foot">
|
|
${done
|
|
? html`<button class="rt-btn rt-done is-done" disabled>
|
|
${done.pending ? this._t("routine.sent_for_checking") : this._t("routine.completed")}
|
|
</button>`
|
|
: html`<button class="rt-btn rt-done" ?disabled=${this._busy}
|
|
@click=${() => this._complete(chore)}>
|
|
${this._t("routine.done")}
|
|
</button>`}
|
|
<div class="rt-row">
|
|
<button class="rt-btn rt-back" ?disabled=${index === 0} @click=${this._back}>
|
|
${this._t("routine.back")}
|
|
</button>
|
|
<button class="rt-btn rt-skip" @click=${this._skip}>
|
|
${done ? this._t("routine.next") : this._t("routine.skip")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
_renderEmpty(title) {
|
|
return html`
|
|
<ha-card>
|
|
<div class="head"><span class="who">${title}</span></div>
|
|
<div class="empty">
|
|
<ha-icon icon="mdi:check-circle-outline"></ha-icon>
|
|
<h2>${this._t("routine.empty_title")}</h2>
|
|
<p>${this._t("routine.empty_body")}</p>
|
|
</div>
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
_renderFinished(title, tasks, pointsIcon, child) {
|
|
let earned = 0;
|
|
let pending = 0;
|
|
for (const entry of this._runCompleted.values()) {
|
|
if (entry.pending) pending += entry.points; else earned += entry.points;
|
|
}
|
|
const skipped = this._skipped.size;
|
|
|
|
return html`
|
|
<ha-card>
|
|
<div class="head"><span class="who">${title}</span></div>
|
|
<div class="progress">
|
|
<div class="rt-bar"><i class="full" style="width:100%"></i></div>
|
|
<div class="count">${this._t("routine.all_done_count", { total: tasks.length })}</div>
|
|
</div>
|
|
<div class="finished">
|
|
<div class="cheer">🎉</div>
|
|
<h2>${this._t("routine.finished_title")}</h2>
|
|
<p>${this._t("routine.finished_body", { name: child.name })}</p>
|
|
${earned > 0 ? html`
|
|
<div class="earned"><ha-icon icon="${pointsIcon}"></ha-icon> ${this._t("routine.earned", { points: earned })}</div>
|
|
` : ""}
|
|
${pending > 0 ? html`
|
|
<div class="earned pending-total">${this._t("routine.awaiting_points", { points: pending })}</div>
|
|
` : ""}
|
|
${skipped > 0 ? html`
|
|
<p class="skipped">${this._t("routine.skipped_note", { count: skipped })}</p>
|
|
` : ""}
|
|
</div>
|
|
<div class="foot">
|
|
<button class="rt-btn rt-done" @click=${this._restart}>${this._t("routine.start_again")}</button>
|
|
</div>
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
static get styles() {
|
|
// Layout classes carry an rt- prefix because the shared design kit defines
|
|
// its own .btn and .bar, and its :host([data-tm-design=...]) .btn rules
|
|
// outrank a bare .btn-done here — which repainted the green Done button
|
|
// with the console gradient and gave Skip equal visual weight.
|
|
const base = css`
|
|
:host { display: block; --routine-accent: var(--tmd-accent, #7c3aed); }
|
|
ha-card {
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 460px;
|
|
font-family: var(--tmd-font-body, inherit);
|
|
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
|
|
}
|
|
.head {
|
|
background: var(--routine-accent);
|
|
color: var(--tmd-hd-text, #fff);
|
|
padding: 16px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.who {
|
|
font-weight: 800; font-size: 17px; flex: 1;
|
|
font-family: var(--tmd-font-display, inherit);
|
|
}
|
|
|
|
.progress { padding: 14px 20px 0; }
|
|
.rt-bar {
|
|
height: 8px;
|
|
border-radius: 99px;
|
|
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
.rt-bar > i {
|
|
display: block;
|
|
height: 100%;
|
|
border-radius: 99px;
|
|
background: var(--routine-accent);
|
|
transition: width 0.25s ease;
|
|
}
|
|
.rt-bar > i.full { background: var(--tmd-good, var(--success-color, #16a34a)); }
|
|
.count {
|
|
font-size: 12.5px;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
margin-top: 7px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.dots {
|
|
display: flex;
|
|
gap: 6px;
|
|
justify-content: center;
|
|
padding: 10px 20px 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.dot {
|
|
width: 9px; height: 9px;
|
|
border-radius: 50%;
|
|
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
|
|
/* Transparent under classic, so the outline only appears in the
|
|
designed styles — it is what keeps the steps distinguishable in
|
|
accessible, where meaning must not rest on hue alone. */
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
box-sizing: border-box;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
.dot.done { background: var(--tmd-good, var(--success-color, #16a34a)); }
|
|
.dot.now { background: var(--routine-accent); transform: scale(1.35); }
|
|
|
|
.body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
padding: 20px 26px 8px;
|
|
gap: 14px;
|
|
}
|
|
.icon {
|
|
width: 128px; height: 128px;
|
|
border-radius: var(--tmd-radius, 34px);
|
|
background: color-mix(in srgb, var(--routine-accent), transparent 88%);
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--routine-accent);
|
|
}
|
|
.icon ha-icon { --mdc-icon-size: 64px; }
|
|
.task {
|
|
font-size: 27px; font-weight: 800; line-height: 1.2;
|
|
font-family: var(--tmd-font-display, inherit);
|
|
}
|
|
.desc {
|
|
font-size: 14.5px; line-height: 1.45;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.points {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: color-mix(in srgb, var(--tmd-warn, #f59e0b) 16%, transparent);
|
|
color: var(--tmd-gold, #92400e);
|
|
padding: 5px 13px; border-radius: 99px;
|
|
font-weight: 800; font-size: 14px;
|
|
}
|
|
.points ha-icon { --mdc-icon-size: 17px; }
|
|
.pending {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: color-mix(in srgb, var(--tmd-bad, #f97316) 12%, transparent);
|
|
color: var(--tmd-bad, #9a3412);
|
|
border: 1px solid color-mix(in srgb, var(--tmd-bad, #f97316) 35%, transparent);
|
|
padding: 5px 12px; border-radius: 99px;
|
|
font-weight: 700; font-size: 12.5px;
|
|
}
|
|
.note { font-size: 12.5px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
|
|
.foot { padding: 14px 20px 20px; display: flex; flex-direction: column; gap: 10px; }
|
|
.rt-btn {
|
|
border: 0; border-radius: var(--tmd-radius-sm, 16px);
|
|
font-family: var(--tmd-font-display, inherit); font-weight: 800;
|
|
width: 100%; cursor: pointer;
|
|
}
|
|
.rt-btn:disabled { cursor: default; }
|
|
.rt-done {
|
|
background: var(--tmd-good, var(--success-color, #16a34a));
|
|
color: #fff; padding: 19px; font-size: 19px;
|
|
}
|
|
/* The shared kit renders a "good" button as dark ink on the good colour
|
|
(.btn.good in taskmate-design.js). Matching it here matters beyond
|
|
consistency: on the accessible palette's #009E73, dark ink measures
|
|
4.2:1 against white's 3.4:1. */
|
|
:host([data-tm-design]:not([data-tm-design="classic"])) .rt-done { color: #06301f; }
|
|
.rt-done.is-done {
|
|
background: var(--tmd-dim, var(--disabled-text-color, #9ca3af));
|
|
}
|
|
:host([data-tm-design]:not([data-tm-design="classic"])) .rt-done.is-done { color: #fff; }
|
|
.rt-row { display: flex; gap: 10px; }
|
|
.rt-skip {
|
|
background: var(--tmd-surface-2, var(--divider-color, #e5e7eb));
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
padding: 12px; font-size: 14px; flex: 1;
|
|
}
|
|
.rt-back {
|
|
background: transparent;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
border: 1px solid var(--tmd-border, var(--divider-color, #e5e7eb));
|
|
padding: 12px; font-size: 14px; flex: 0 0 96px;
|
|
}
|
|
.rt-back:disabled { opacity: 0.4; }
|
|
|
|
.finished { text-align: center; padding: 34px 26px; flex: 1; }
|
|
.finished .cheer { font-size: 66px; line-height: 1; }
|
|
.finished h2 {
|
|
font-size: 25px; margin: 12px 0 6px;
|
|
font-family: var(--tmd-font-display, inherit);
|
|
}
|
|
.finished p {
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
font-size: 15px; margin: 0 0 6px;
|
|
}
|
|
.finished .skipped { font-size: 13px; margin-top: 12px; }
|
|
.earned {
|
|
display: inline-flex; gap: 6px; align-items: center; margin-top: 10px;
|
|
background: color-mix(in srgb, var(--tmd-good, #16a34a) 14%, transparent);
|
|
color: var(--tmd-good, #166534);
|
|
padding: 9px 18px; border-radius: 99px;
|
|
font-weight: 800; font-size: 16px;
|
|
}
|
|
.earned.pending-total {
|
|
background: color-mix(in srgb, var(--tmd-bad, #f97316) 12%, transparent);
|
|
color: var(--tmd-bad, #9a3412);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.empty {
|
|
text-align: center; padding: 64px 26px; flex: 1;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.empty ha-icon { --mdc-icon-size: 60px; opacity: 0.5; }
|
|
.empty h2 {
|
|
font-size: 20px; margin: 14px 0 6px;
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
font-family: var(--tmd-font-display, inherit);
|
|
}
|
|
.empty p { font-size: 14.5px; margin: 0; }
|
|
|
|
.pad { padding: 24px; }
|
|
.error { color: var(--tmd-bad, var(--error-color, #c62828)); }
|
|
|
|
@media (max-width: 420px) {
|
|
.icon { width: 104px; height: 104px; border-radius: 28px; }
|
|
.icon ha-icon { --mdc-icon-size: 52px; }
|
|
.task { font-size: 23px; }
|
|
}
|
|
`;
|
|
// The token block is :host-scoped, so it only takes effect when included
|
|
// in THIS card's styles — a document-level rule cannot cross into the
|
|
// shadow root.
|
|
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
|
? window.__taskmate_design.styles() : null;
|
|
return tokens ? [tokens, base] : base;
|
|
}
|
|
}
|
|
|
|
class TaskMateRoutineCardEditor extends LitElement {
|
|
static get properties() {
|
|
return { hass: { type: Object }, _config: { type: Object } };
|
|
}
|
|
|
|
setConfig(config) { this._config = { ...config }; }
|
|
|
|
_t(key, params) {
|
|
const fn = window.__taskmate_localize;
|
|
return fn ? fn(this.hass, key, params) : key;
|
|
}
|
|
|
|
render() {
|
|
if (!this._config) return html``;
|
|
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this._config.entity)) || {};
|
|
const children = attrs.children || [];
|
|
return html`
|
|
<div class="form">
|
|
<ha-textfield
|
|
label="Entity"
|
|
.value=${this._config.entity || "sensor.taskmate_overview"}
|
|
@input=${e => this._set("entity", e.target.value)}
|
|
></ha-textfield>
|
|
|
|
<ha-select
|
|
label="Child"
|
|
.value=${this._config.child_id || ""}
|
|
@selected=${e => this._set("child_id", e.target.value)}
|
|
@closed=${e => e.stopPropagation()}
|
|
>
|
|
${children.map(c => html`<mwc-list-item .value=${c.id}>${c.name}</mwc-list-item>`)}
|
|
</ha-select>
|
|
|
|
<ha-select
|
|
label="Time of day"
|
|
.value=${this._config.time_category || "all"}
|
|
@selected=${e => this._set("time_category", e.target.value)}
|
|
@closed=${e => e.stopPropagation()}
|
|
>
|
|
${TIME_CATEGORIES.map(t => html`<mwc-list-item .value=${t}>${t}</mwc-list-item>`)}
|
|
</ha-select>
|
|
|
|
<ha-textfield
|
|
label="Title (optional)"
|
|
.value=${this._config.title || ""}
|
|
@input=${e => this._set("title", e.target.value)}
|
|
></ha-textfield>
|
|
|
|
<ha-select
|
|
.label=${this._t("common.design.field_label")}
|
|
.value=${this._config.card_design || "global"}
|
|
@selected=${e => this._set("card_design", e.target.value)}
|
|
@closed=${e => e.stopPropagation()}
|
|
>
|
|
${(window.__taskmate_design
|
|
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
|
: [{ value: "global", label: "Use global default" }]
|
|
).map(o => html`<mwc-list-item .value=${o.value}>${o.label}</mwc-list-item>`)}
|
|
</ha-select>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_set(key, value) {
|
|
const newConfig = { ...this._config, [key]: value };
|
|
// "global" is the absence of a per-card override, not a value to store.
|
|
if (key === "card_design" && value === "global") delete newConfig[key];
|
|
else if (value === null || value === "" || value === undefined) delete newConfig[key];
|
|
this._config = newConfig;
|
|
this.dispatchEvent(new CustomEvent("config-changed", {
|
|
detail: { config: newConfig }, bubbles: true, composed: true,
|
|
}));
|
|
}
|
|
|
|
static get styles() {
|
|
return css`
|
|
.form { display: flex; flex-direction: column; gap: 14px; padding: 8px 0; }
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define("taskmate-routine-card", TaskMateRoutineCard);
|
|
customElements.define("taskmate-routine-card-editor", TaskMateRoutineCardEditor);
|
|
|
|
window.customCards = window.customCards || [];
|
|
window.customCards.push({
|
|
type: "taskmate-routine-card",
|
|
name: "TaskMate Routine",
|
|
description: "Guided one-task-at-a-time flow for morning and bedtime routines",
|
|
preview: true,
|
|
getEntitySuggestion: (hass, entityId) =>
|
|
window.__taskmate_suggest(hass, entityId, "taskmate-routine-card", "overview"),
|
|
});
|
|
|
|
// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM
|
|
const _tmVersion = new URLSearchParams(
|
|
Array.from(document.querySelectorAll('script[src*="/taskmate-routine-card.js"]'))
|
|
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
|
).get("v") || "?";
|
|
console.info(
|
|
"%c TASKMATE ROUTINE CARD %c v" + _tmVersion + " ",
|
|
"background:#7c3aed;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
|
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
|
);
|