Files
HomeAssistantVS/custom_components/taskmate/www/taskmate-incentive-card.js
T
2026-07-14 23:57:03 -04:00

1142 lines
45 KiB
JavaScript

/**
* TaskMate Incentive Card (shared base for Bonuses + Penalties) — QUAL-2.
* One parameterised component; the two cards differ only by name/colour/icon/
* service-prefix/i18n-prefix, supplied via the P object.
*/
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);
export function createIncentiveCard(P) {
class IncentiveCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_selectedChildId: { type: String },
_editMode: { type: Boolean },
_loading: { type: Object },
_editingBonus: { type: Object }, // bonus being edited (null = none)
_showNewForm: { type: Boolean },
_toast: { type: String },
_newForm: { type: Object },
};
}
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;
}
_tp(key, params) {
return this._t(P.i18n + "." + key, params);
}
constructor() {
super();
this._selectedChildId = null;
this._editMode = false;
this._loading = {};
this._editingBonus = null;
this._saving = false;
this._showNewForm = false;
this._toast = null;
this._newForm = { name: "", points: "", description: "", icon: P.icon };
}
setConfig(config) {
this.config = config;
}
static getConfigElement() {
return document.createElement(P.tag + "-editor");
}
static getStubConfig() {
return { entity: "sensor.taskmate_overview" };
}
static get styles() {
const base = css`
:host {
display: block;
--text-primary: var(--primary-text-color, #212121);
--text-secondary: var(--secondary-text-color, #757575);
--card-bg: var(--card-background-color, #fff);
--divider: var(--divider-color, #e0e0e0);
}
ha-card { overflow: hidden; }
/* ── Header ── */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--taskmate-header-bg, var(--bonus-green));
color: white;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-icon { --mdc-icon-size: 32px; opacity: 0.95; }
.header-title { font-size: 1.3rem; font-weight: 600; }
.bonus-count {
background: rgba(255,255,255,0.2);
padding: 4px 12px;
border-radius: 16px;
font-size: 0.9rem;
font-weight: 500;
}
.header-actions { display: flex; gap: 8px; }
.icon-btn {
background: rgba(255,255,255,0.18);
border: none;
color: white;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
--mdc-icon-size: 20px;
}
.icon-btn:hover { background: rgba(255,255,255,0.32); }
.icon-btn.active { background: rgba(255,255,255,0.35); }
/* ── Child tabs ── */
.child-tabs {
display: flex;
gap: 6px;
padding: 10px 16px 0;
overflow-x: auto;
scrollbar-width: none;
}
.child-tabs::-webkit-scrollbar { display: none; }
.child-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
background: var(--divider);
border: 2px solid transparent;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
color: var(--text-secondary);
transition: all 0.15s;
}
.child-tab ha-icon { --mdc-icon-size: 18px; }
.child-tab.selected {
background: var(--bonus-green-light);
border-color: var(--bonus-green);
color: var(--bonus-green);
}
/* ── Card body ── */
.card-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Bonus tile ── */
.bonus-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--card-bg);
border: 1px solid var(--divider);
border-radius: 12px;
transition: box-shadow 0.2s, transform 0.15s;
}
.bonus-row:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.09); transform: translateY(-1px); }
/* Flash animation when bonus is applied */
@keyframes flash-green {
0% { background: var(--bonus-green-light); }
40% { background: var(--bonus-green-flash); }
100% { background: var(--card-bg); }
}
.bonus-row.flashing { animation: flash-green 0.6s ease forwards; }
/* Points badge */
.points-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 64px;
padding: 10px 8px;
background: linear-gradient(135deg, var(--bonus-green) 0%, var(--bonus-green-dark) 100%);
border-radius: 10px;
flex-shrink: 0;
box-shadow: 0 2px 6px var(--bonus-green-shadow);
}
.points-badge ha-icon { --mdc-icon-size: 20px; color: white; margin-bottom: 2px; }
.points-value { font-size: 1.3rem; font-weight: 700; color: white; line-height: 1; }
.points-label { font-size: 0.62rem; font-weight: 600; color: rgba(255,255,255,0.88); text-transform: uppercase; letter-spacing: 0.4px; margin-top: 2px; }
/* Bonus info */
.bonus-info { flex: 1; min-width: 0; }
.bonus-name { font-size: 1.05rem; font-weight: 600; color: var(--text-primary); }
.bonus-description { font-size: 0.85rem; color: var(--text-secondary); margin-top: 2px; }
/* Apply button */
.apply-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
background: var(--bonus-green);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
flex-shrink: 0;
--mdc-icon-size: 16px;
}
.apply-btn:hover { background: var(--bonus-green-dark); }
.apply-btn:active { transform: scale(0.97); }
.apply-btn:disabled { opacity: 0.55; cursor: default; }
/* Edit mode actions */
.edit-actions { display: flex; gap: 6px; flex-shrink: 0; }
.edit-btn {
background: none;
border: 1px solid var(--divider);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
--mdc-icon-size: 18px;
transition: all 0.15s;
}
.edit-btn:hover { background: var(--divider); color: var(--text-primary); }
.edit-btn.delete:hover { background: var(--bonus-green-light); color: var(--bonus-green); border-color: var(--bonus-green); }
/* Inline edit form */
.edit-form {
background: var(--ha-card-background, #f5f5f5);
border: 1px solid var(--divider);
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
margin-top: -4px;
}
.form-row { display: flex; gap: 10px; }
.form-row.full { flex-direction: column; }
.form-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.form-field label { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.4px; }
.form-field input {
padding: 8px 10px;
border: 1px solid var(--divider);
border-radius: 8px;
font-size: 0.95rem;
background: var(--card-bg);
color: var(--text-primary);
width: 100%;
box-sizing: border-box;
}
.form-field input:focus { outline: 2px solid var(--bonus-green); border-color: transparent; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
.btn-save {
padding: 8px 18px;
background: var(--bonus-green);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-save:hover { background: var(--bonus-green-dark); }
.btn-cancel {
padding: 8px 14px;
background: none;
color: var(--text-secondary);
border: 1px solid var(--divider);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
}
.btn-cancel:hover { background: var(--divider); }
/* Add new button */
.add-bonus-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px dashed var(--divider);
border-radius: 12px;
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.15s;
--mdc-icon-size: 20px;
}
.add-bonus-btn:hover { border-color: var(--bonus-green); color: var(--bonus-green); background: var(--bonus-green-light); }
/* Empty state */
.empty-state {
text-align: center;
padding: 32px 16px;
color: var(--text-secondary);
}
.empty-state ha-icon { --mdc-icon-size: 48px; opacity: 0.35; display: block; margin: 0 auto 12px; }
.empty-state .empty-title { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
.empty-state .empty-sub { font-size: 0.85rem; }
/* Toast */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(0);
background: #333;
color: white;
padding: 10px 20px;
border-radius: 24px;
font-size: 0.92rem;
font-weight: 500;
z-index: 9999;
pointer-events: none;
animation: toast-in 0.25s ease, toast-out 0.3s ease 2s forwards;
white-space: normal;
max-width: calc(100vw - 32px);
text-align: center;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes toast-out {
to { opacity: 0; transform: translateX(-50%) translateY(8px); }
}
/* ══════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro)
Shared .tmd kit + tokens come from taskmate-design.js styles().
Only card-specific layout classes live here.
══════════════════════════════════════════════════════════════════ */
/* Child tab strip */
.d-tabs { display: flex; gap: 8px; margin-bottom: 13px; align-items: center;
overflow-x: auto; scrollbar-width: none; }
.d-tabs::-webkit-scrollbar { display: none; }
.d-tab { display: flex; align-items: center; gap: 6px; padding: 4px 12px 4px 4px;
border-radius: 999px; font-weight: 800; font-size: 13px; cursor: pointer;
white-space: nowrap; color: var(--tmd-text);
background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 16%, transparent);
border: 1px solid transparent; }
.d-tab .av { box-shadow: none; }
.d-tab.off { background: transparent; border-color: transparent; opacity: .55; }
.d-tab.off .av { opacity: .6; }
:host([data-tm-design="console"]) .d-tab { border-radius: 8px; font-size: 12px; font-weight: 700;
background: var(--tmd-surface-2); border-color: color-mix(in srgb, var(--ac, var(--tmd-accent)) 60%, transparent); }
:host([data-tm-design="cleanpro"]) .d-tab { border-radius: 9px; font-size: 12.5px; font-weight: 700;
background: var(--tmd-surface-2); border-color: var(--tmd-accent); }
/* Playroom rows */
.d-pl-list { display: flex; flex-direction: column; gap: 10px; }
.d-pl-row { display: flex; align-items: center; gap: 10px; background: var(--tmd-surface-2);
border-radius: 18px; padding: 11px; }
.d-ic { width: 40px; height: 40px; border-radius: 13px; display: grid; place-items: center;
font-size: 20px; flex: none; }
.d-ic ha-icon { --mdc-icon-size: 22px; color: var(--tmd-text); }
.d-pl-row .d-ic { background: color-mix(in srgb, var(--d-accent) 18%, transparent); }
.d-name { flex: 1; min-width: 0; font-weight: 800; font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.d-pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 19px;
color: var(--d-accent); flex: none; }
/* Console rows */
.d-cn-row { display: flex; align-items: center; gap: 10px; background: var(--tmd-surface-2);
border-radius: 9px; padding: 10px; margin-top: 9px;
border: 1px solid color-mix(in srgb, var(--d-accent) 38%, var(--tmd-border));
box-shadow: 0 0 14px color-mix(in srgb, var(--d-accent) 22%, transparent); }
.d-cn-row:first-of-type { margin-top: 0; }
.d-cn-ic { font-size: 18px; flex: none; }
.d-cn-ic ha-icon { --mdc-icon-size: 20px; color: var(--tmd-text); }
.d-cn-mid { flex: 1; min-width: 0; }
.d-cn-name { font-weight: 700; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.d-cn-sub { font-size: 10px; }
.d-cn-pts { font-size: 18px; color: var(--d-accent);
text-shadow: 0 0 10px color-mix(in srgb, var(--d-accent) 60%, transparent); flex: none; }
/* Clean Pro rows */
.d-cp-row { display: flex; align-items: center; gap: 11px; padding: 10px 0; }
.d-cp-ic { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
font-size: 17px; background: var(--tmd-surface-2); flex: none; }
.d-cp-ic ha-icon { --mdc-icon-size: 18px; color: var(--tmd-text); }
.d-cp-name { flex: 1; min-width: 0; font-weight: 600; font-size: 13.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.d-cp-pts { font-size: 14px; color: var(--d-accent); flex: none; }
/* Designed inline edit / new form (reuse classic .edit-form look on token surfaces) */
.d-form { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
border-radius: var(--tmd-radius-sm); padding: 12px; display: flex;
flex-direction: column; gap: 10px; margin: 10px 0 4px; }
.d-form .form-field input { background: var(--tmd-surface); color: var(--tmd-text);
border: 1px solid var(--tmd-border); }
.d-form .form-field label { color: var(--tmd-dim); }
.d-form-actions { display: flex; gap: 8px; justify-content: flex-end; }
.d-add { width: 100%; justify-content: center; }
.d-edit-actions { display: flex; gap: 6px; flex: none; }
.d-foot { text-align: center; font-size: 12px; color: var(--tmd-dim); padding-top: 8px; }
/* Scrollable list (parity with a long bonus list) */
.d-scroll { max-height: 360px; overflow-y: auto; }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
_getState() {
const entityId = this.config?.entity || "sensor.taskmate_overview";
return this.hass?.states[entityId];
}
_getAttrs() {
const entityId = this.config?.entity || "sensor.taskmate_overview";
if (window.__taskmate_attrs) {
return window.__taskmate_attrs(this.hass, entityId);
}
return this._getState()?.attributes || {};
}
_getChildren() {
return this._getAttrs().children || [];
}
_getBonuses() {
return this._getAttrs()[P.attr] || [];
}
_getSelectedChild() {
const children = this._getChildren();
if (!children.length) return null;
if (this._selectedChildId) return children.find(c => c.id === this._selectedChildId) || children[0];
return children[0];
}
_getPointsName() {
return this._getAttrs().points_name || this._t("common.stars");
}
_getVisibleBonuses() {
const child = this._getSelectedChild();
if (!child) return this._getBonuses();
return this._getBonuses().filter(b =>
!b.assigned_to?.length || b.assigned_to.includes(child.id)
);
}
_selectChild(id) {
this._selectedChildId = id;
this._editingBonus = null;
this._showNewForm = false;
}
async _applyBonus(bonus) {
const child = this._getSelectedChild();
if (!child) return;
const key = bonus.id;
if (this._loading[key]) return;
this._loading = { ...this._loading, [key]: true };
try {
await this.hass.callService("taskmate", `apply_${P.kind}`, {
[P.idKey]: bonus.id,
child_id: child.id,
});
this._showToast(this._tp('toast_applied', { points: bonus.points, pointsName: this._getPointsName(), childName: child.name }));
// Flash the row
const row = this.shadowRoot.querySelector(`[data-bonus-id="${bonus.id}"]`);
if (row) {
row.classList.add("flashing");
setTimeout(() => row.classList.remove("flashing"), 700);
}
} catch (e) {
this._showToast(this._tp('toast_apply_failed'));
} finally {
this._loading = { ...this._loading, [key]: false };
}
}
_showToast(msg) {
this._toast = null;
clearTimeout(this._toastTimer);
// Force re-render with new toast
this._toastTimer = setTimeout(() => {
this._toast = msg;
this._toastTimer = setTimeout(() => { this._toast = null; }, 2700);
}, 10);
}
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._toastTimer);
}
_startEdit(bonus) {
this._editingBonus = { ...bonus };
this._showNewForm = false;
}
_cancelEdit() {
this._editingBonus = null;
}
async _saveEdit() {
if (!this._editingBonus?.name || !this._editingBonus?.points) return;
if (this._saving) return;
this._saving = true;
try {
await this.hass.callService("taskmate", `update_${P.kind}`, {
[P.idKey]: this._editingBonus.id,
name: this._editingBonus.name,
points: parseInt(this._editingBonus.points, 10),
description: this._editingBonus.description || "",
icon: this._editingBonus.icon || P.icon,
});
this._editingBonus = null;
} catch (e) {
this._showToast(this._tp('toast_save_failed'));
} finally {
this._saving = false;
}
}
async _deleteBonus(id) {
try {
await this.hass.callService("taskmate", `remove_${P.kind}`, { [P.idKey]: id });
} catch (e) {
this._showToast(this._tp('toast_delete_failed'));
}
}
_openNewForm() {
this._showNewForm = true;
this._editingBonus = null;
this._newForm = { name: "", points: "", description: "", icon: P.icon };
}
async _saveNew() {
if (!this._newForm.name || !this._newForm.points) return;
if (this._saving) return;
this._saving = true;
try {
await this.hass.callService("taskmate", `add_${P.kind}`, {
name: this._newForm.name,
points: parseInt(this._newForm.points, 10),
description: this._newForm.description || "",
icon: this._newForm.icon || P.icon,
});
this._showNewForm = false;
this._newForm = { name: "", points: "", description: "", icon: P.icon };
this._showToast(this._tp('toast_saved'));
} catch (e) {
this._showToast(this._tp('toast_add_failed'));
} finally {
this._saving = false;
}
}
_renderChildTabs() {
const children = this._getChildren();
if (children.length <= 1) return html``;
const selected = this._getSelectedChild();
return html`
<div class="child-tabs">
${children.map(c => html`
<div class="child-tab ${selected?.id === c.id ? "selected" : ""}"
@click=${() => this._selectChild(c.id)}>
<ha-icon icon="${c.avatar || "mdi:account-circle"}"></ha-icon>
${c.name}
</div>
`)}
</div>
`;
}
_renderBonusRow(b) {
const isEditing = this._editingBonus?.id === b.id;
const isLoading = this._loading[b.id];
const child = this._getSelectedChild();
const pointsName = this._getPointsName();
return html`
<div class="bonus-row" data-bonus-id="${b.id}">
<div class="points-badge">
<ha-icon icon="${b.icon || P.icon}"></ha-icon>
<div class="points-value">${P.sign}${b.points}</div>
<div class="points-label">${pointsName}</div>
</div>
<div class="bonus-info">
<div class="bonus-name">${b.name}</div>
${b.description ? html`<div class="bonus-description">${b.description}</div>` : ""}
</div>
${this._editMode ? html`
<div class="edit-actions">
<button class="edit-btn" title="${this._tp('btn_edit_title')}" aria-label="${this._tp('btn_edit_title')}" @click=${() => this._startEdit(b)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="edit-btn delete" title="${this._tp('btn_delete_title')}" aria-label="${this._tp('btn_delete_title')}" @click=${() => this._deleteBonus(b.id)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>
` : html`
<button class="apply-btn"
?disabled=${isLoading || !child}
@click=${() => this._applyBonus(b)}>
${isLoading
? html`<ha-icon icon="mdi:loading" class="spin"></ha-icon>`
: html`<ha-icon icon="${P.applyIcon}"></ha-icon> ${this._t('common.apply')}`
}
</button>
`}
</div>
${isEditing ? this._renderEditForm() : ""}
`;
}
_renderEditForm() {
const b = this._editingBonus;
return html`
<div class="edit-form">
<div class="form-row">
<div class="form-field" style="flex:2">
<label>${this._tp('form_name_label')}</label>
<input type="text" .value=${b.name}
@input=${e => this._editingBonus = { ...b, name: e.target.value }} />
</div>
<div class="form-field" style="flex:1">
<label>${this._tp('form_points_label')}</label>
<input type="number" min="1" .value=${b.points}
@input=${e => this._editingBonus = { ...b, points: e.target.value }} />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>${this._tp('form_icon_label')}</label>
<input type="text" .value=${b.icon || P.icon}
@input=${e => this._editingBonus = { ...b, icon: e.target.value }} />
</div>
</div>
<div class="form-row full">
<div class="form-field">
<label>${this._tp('form_description_label')}</label>
<input type="text" .value=${b.description || ""}
@input=${e => this._editingBonus = { ...b, description: e.target.value }} />
</div>
</div>
<div class="form-actions">
<button class="btn-cancel" @click=${this._cancelEdit}>${this._t('common.cancel')}</button>
<button class="btn-save" @click=${this._saveEdit}>${this._t('common.save')}</button>
</div>
</div>
`;
}
_renderNewForm() {
const f = this._newForm;
return html`
<div class="edit-form">
<div class="form-row">
<div class="form-field" style="flex:2">
<label>${this._tp('form_name_label')}</label>
<input type="text" placeholder="${this._tp('form_name_placeholder')}" .value=${f.name}
@input=${e => this._newForm = { ...f, name: e.target.value }} />
</div>
<div class="form-field" style="flex:1">
<label>${this._tp('form_points_label')}</label>
<input type="number" min="1" placeholder="10" .value=${f.points}
@input=${e => this._newForm = { ...f, points: e.target.value }} />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>${this._tp('form_icon_label')}</label>
<input type="text" .value=${f.icon}
@input=${e => this._newForm = { ...f, icon: e.target.value }} />
</div>
</div>
<div class="form-row full">
<div class="form-field">
<label>${this._tp('form_description_label')}</label>
<input type="text" placeholder="${this._tp('form_description_placeholder')}" .value=${f.description}
@input=${e => this._newForm = { ...f, description: e.target.value }} />
</div>
</div>
<div class="form-actions">
<button class="btn-cancel" @click=${() => this._showNewForm = false}>${this._t('common.cancel')}</button>
<button class="btn-save" @click=${this._saveNew}>${this._tp('add_bonus')}</button>
</div>
</div>
`;
}
render() {
if (!this.hass || !this.config) return html``;
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity || "sensor.taskmate_overview")
: "classic";
if (design !== "classic") return this._renderDesigned(design);
const bonuses = this._getBonuses();
const visible = this._getVisibleBonuses();
const child = this._getSelectedChild();
return html`
<ha-card>
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, P.accent)}; --bonus-green: ${P.accent}; --bonus-green-dark: ${P.accentDark}; --bonus-green-light: ${P.accentLight}; --bonus-green-flash: ${P.accentFlash}; --bonus-green-shadow: ${P.accentShadow}; }</style>
<div class="card-header">
<div class="header-left">
<ha-icon class="header-icon" icon="${P.headerIcon}"></ha-icon>
<span class="header-title">${this.config.title || this._tp('default_title')}</span>
</div>
<div class="header-actions">
${bonuses.length ? html`
<span class="bonus-count">${bonuses.length}</span>
` : ""}
<button class="icon-btn ${this._editMode ? "active" : ""}" title="${this._tp('manage_title')}"
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
</div>
</div>
${this._renderChildTabs()}
<div class="card-content">
${visible.length === 0 && !this._showNewForm ? html`
<div class="empty-state">
<ha-icon icon="${P.icon}"></ha-icon>
<div class="empty-title">${this._tp('empty_title')}</div>
<div class="empty-sub">${this._tp('empty_sub')}</div>
</div>
` : ""}
${visible.map(b => this._renderBonusRow(b))}
${this._editMode ? html`
${this._showNewForm
? this._renderNewForm()
: html`
<button class="add-bonus-btn" @click=${this._openNewForm}>
<ha-icon icon="mdi:plus"></ha-icon>
${this._tp('new_bonus')}
</button>
`}
` : ""}
${child && !this._editMode ? html`
<div style="text-align:center; font-size:0.8rem; color:var(--text-secondary); padding-top:4px;">
${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}
</div>
` : ""}
</div>
</ha-card>
${this._toast ? html`<div class="toast">${this._toast}</div>` : ""}
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns/frag/14-bonuses.html.
══════════════════════════════════════════════════════════════════════ */
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) {
const a = child.avatar || "";
const inner = a.startsWith("mdi:")
? html`<ha-icon icon="${a}"></ha-icon>`
: a
? html`<img src="${a}" alt="${child.name}">`
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
}
_renderDesignTabs(design) {
const children = this._getChildren();
if (children.length <= 1) return "";
const selected = this._getSelectedChild();
const avSize = design === "playroom" ? 28 : 24;
return html`
<div class="d-tabs">
${children.map((c, i) => {
const tone = this._designTone(i);
const isSel = selected?.id === c.id;
// Always show the child's name on every tab (selected and not).
return html`<div class="d-tab ${isSel ? "" : "off"}" style="--ac:${tone}"
@click=${() => this._selectChild(c.id)}>
${this._av(c, tone, avSize)}${c.name}</div>`;
})}
</div>`;
}
_designIcon(b) {
const icon = b.icon || P.icon;
return icon.startsWith("mdi:")
? html`<ha-icon icon="${icon}"></ha-icon>`
: html`${icon}`;
}
_designApplyBtn(b, klass) {
const child = this._getSelectedChild();
const isLoading = this._loading[b.id];
return html`
<button class="btn ${klass}" ?disabled=${isLoading || !child}
@click=${() => this._applyBonus(b)}>
${isLoading
? html`<ha-icon icon="mdi:loading" class="spin"></ha-icon>`
: this._t('common.apply')}
</button>`;
}
_designEditActions(b) {
return html`
<div class="d-edit-actions">
<button class="btn ghost sm" title="${this._tp('btn_edit_title')}"
aria-label="${this._tp('btn_edit_title')}" @click=${() => this._startEdit(b)}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="btn ghost sm" title="${this._tp('btn_delete_title')}"
aria-label="${this._tp('btn_delete_title')}" @click=${() => this._deleteBonus(b.id)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>`;
}
_renderDesignForm(model, onField, onCancel, onSave, saveLabel) {
return html`
<div class="d-form">
<div class="form-row">
<div class="form-field" style="flex:2">
<label>${this._tp('form_name_label')}</label>
<input type="text" placeholder="${this._tp('form_name_placeholder')}" .value=${model.name || ""}
@input=${e => onField('name', e.target.value)} />
</div>
<div class="form-field" style="flex:1">
<label>${this._tp('form_points_label')}</label>
<input type="number" min="1" placeholder="10" .value=${model.points || ""}
@input=${e => onField('points', e.target.value)} />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>${this._tp('form_icon_label')}</label>
<input type="text" .value=${model.icon || P.icon}
@input=${e => onField('icon', e.target.value)} />
</div>
</div>
<div class="form-row full">
<div class="form-field">
<label>${this._tp('form_description_label')}</label>
<input type="text" placeholder="${this._tp('form_description_placeholder')}" .value=${model.description || ""}
@input=${e => onField('description', e.target.value)} />
</div>
</div>
<div class="d-form-actions">
<button class="btn ghost sm" @click=${onCancel}>${this._t('common.cancel')}</button>
<button class="btn sm" @click=${onSave}>${saveLabel}</button>
</div>
</div>`;
}
_renderDesignedRow(b, design) {
const isEditing = this._editingBonus?.id === b.id;
const tone = this._designTone(0);
const accent = P.designAccent;
const pv = P.dpts(b.points);
let row;
if (design === "playroom") {
row = html`
<div class="d-pl-row" style="--d-accent:${accent}" data-bonus-id="${b.id}">
<div class="d-ic">${this._designIcon(b)}</div>
<div class="d-name">${b.name}</div>
<div class="d-pts">${pv}</div>
${this._editMode ? this._designEditActions(b) : this._designApplyBtn(b, "good")}
</div>`;
} else if (design === "console") {
const pointsName = this._getPointsName();
row = html`
<div class="d-cn-row" style="--d-accent:${accent}" data-bonus-id="${b.id}">
<div class="d-cn-ic">${this._designIcon(b)}</div>
<div class="d-cn-mid">
<div class="d-cn-name">${b.name}</div>
<div class="num muted d-cn-sub">${pv} ${pointsName}${b.description ? html` · ${b.description}` : ""}</div>
</div>
<div class="num d-cn-pts">${pv}</div>
${this._editMode ? this._designEditActions(b) : this._designApplyBtn(b, "good sm")}
</div>`;
} else {
row = html`
<div class="d-cp-row" style="--d-accent:${accent}" data-bonus-id="${b.id}">
<div class="d-cp-ic">${this._designIcon(b)}</div>
<div class="d-cp-name">${b.name}</div>
<div class="num d-cp-pts">${pv}</div>
${this._editMode ? this._designEditActions(b) : this._designApplyBtn(b, "sm")}
</div>`;
}
const divider = design === "cleanpro" ? html`<div class="divide" style="margin:0"></div>` : "";
const editForm = isEditing
? this._renderDesignForm(
this._editingBonus,
(k, v) => { this._editingBonus = { ...this._editingBonus, [k]: v }; },
() => this._cancelEdit(),
() => this._saveEdit(),
this._t('common.save'))
: "";
return html`${row}${divider}${editForm}`;
}
_renderDesigned(design) {
const hd = _safeColor(this.config.header_color, P.designHd);
const bonuses = this._getBonuses();
const visible = this._getVisibleBonuses();
const child = this._getSelectedChild();
const tone = this._designTone(0);
const icon = P.designIcons[design] || P.designIcons.playroom;
const header = html`
<div class="tmd-hd" style="--ac:${tone}">
<span class="ic">${icon}</span>
<span class="tt">${this.config.title || this._tp('default_title')}${
bonuses.length ? html`<small>${bonuses.length}</small>` : ""}</span>
<button class="btn ghost sm" style="margin-left:auto"
title="${this._tp('manage_title')}"
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
</div>`;
const rows = design === "playroom"
? html`<div class="d-scroll d-pl-list">${visible.map(b => this._renderDesignedRow(b, design))}</div>`
: html`<div class="d-scroll">${visible.map(b => this._renderDesignedRow(b, design))}</div>`;
const body = html`
${this._renderDesignTabs(design)}
${visible.length === 0 && !this._showNewForm
? html`<div class="tmd-empty">${this._tp('empty_title')}</div>`
: rows}
${this._editMode
? (this._showNewForm
? this._renderDesignForm(
this._newForm,
(k, v) => { this._newForm = { ...this._newForm, [k]: v }; },
() => { this._showNewForm = false; },
() => this._saveNew(),
this._tp('add_bonus'))
: html`<button class="btn ghost sm d-add" style="margin-top:11px"
@click=${this._openNewForm}>
<ha-icon icon="mdi:plus"></ha-icon> ${this._tp('new_bonus')}
</button>`)
: ""}
${child && !this._editMode
? html`<div class="d-foot">${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}</div>`
: ""}`;
return html`
<ha-card class="tmd" style="--hd:${hd}">
${header}
<div class="tmd-bd">${body}</div>
</ha-card>
${this._toast ? html`<div class="toast">${this._toast}</div>` : ""}`;
}
}
class IncentiveCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
_tp(key, params) {
return this._t(P.i18n + "." + key, params);
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.info-note { font-size: 0.85rem; color: var(--secondary-text-color); background: var(--secondary-background-color, #f5f5f5); border-radius: 8px; padding: 10px 14px; line-height: 1.4; margin-bottom: 16px; display: flex; gap: 10px; align-items: flex-start; }
.info-note ha-icon { flex-shrink: 0; color: var(--primary-color); --mdc-icon-size: 20px; margin-top: 1px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
setConfig(config) { this.config = config; }
_buildSchema() {
return [
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'title', selector: { text: {} } },
{
name: 'card_design',
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: 'global', label: 'Use global default' }],
mode: 'dropdown',
},
},
},
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t('common.editor.overview_entity'),
title: this._t('common.editor.card_title'),
card_design: this._t('common.design.field_label'),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t('common.editor.overview_entity_helper'),
};
return helpers[entry.name] ?? '';
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || '',
title: this.config.title || '',
card_design: this.config.card_design || 'global',
};
return html`
<div class="info-note">
<ha-icon icon="mdi:information-outline"></ha-icon>
<span>${this._tp('editor.manage_note')}</span>
</div>
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker('header_color', P.accent)}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t('common.editor.header_colour'),
helper: this._t('common.editor.header_colour_helper'),
resetLabel: this._t('common.reset'),
onInput: (v) => this._update(key, v),
onPreset: (v) => this._update(key, v),
onReset: () => this._update(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (value === '' || value === null || value === undefined) delete newConfig[key];
else if (key === 'card_design' && value === 'global') delete newConfig[key];
else newConfig[key] = value;
}
this._fire(newConfig);
}
_update(key, value) {
this._fire({ ...this.config, [key]: value });
}
_fire(config) {
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config }, bubbles: true, composed: true }));
}
}
customElements.define(P.tag, IncentiveCard);
customElements.define(P.tag + "-editor", IncentiveCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: P.tag, name: P.cardName, description: P.cardDesc, preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, P.tag, "overview"),
});
console.info("%c TASKMATE " + P.bannerName + " CARD ",
"background:" + P.bannerColor + ";color:white;font-weight:bold;padding:2px 4px;border-radius:4px;");
}