/** * TaskMate Approvals Card * A custom Lovelace card for managing pending chore approvals */ 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; // Security defense-in-depth: only ever emit our own auth-gated photo endpoint // into an href/src. The backend already rejects foreign/crafted photo_url // values, but guard here too so a stored bad value can never become a // javascript:/external link in a parent's browser. const tmSafePhotoUrl = (u) => typeof u === "string" && u.startsWith("/api/taskmate/photo/") ? u : ""; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); class TaskMateApprovalsCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _loading: { 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; } constructor() { super(); this._loading = {}; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } static get styles() { const base = css` :host { display: block; } ha-card { overflow: hidden; } .card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; background: var(--taskmate-header-bg, #27ae60); color: white; } .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } .header-icon { --mdc-icon-size: 22px; color: white; opacity: 0.9; flex-shrink: 0; } .card-title { font-size: 1.05rem; font-weight: 600; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pending-count { background: #e74c3c; color: white; border-radius: 12px; padding: 3px 10px; font-size: 0.82rem; font-weight: 700; flex-shrink: 0; } .header-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .approve-all-btn { background: rgba(255, 255, 255, 0.2); color: white; border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 14px; padding: 5px 12px; font-size: 0.82rem; font-weight: 600; cursor: pointer; white-space: nowrap; } .approve-all-btn:hover { background: rgba(255, 255, 255, 0.3); } .approve-all-btn:disabled { opacity: 0.6; cursor: default; } .card-content { padding: 16px; } .day-group { margin-bottom: 20px; } .day-header { font-size: 0.95em; font-weight: 600; color: var(--primary-text-color); margin-bottom: 12px; padding: 8px 12px; background: var(--secondary-background-color); border-radius: 8px; } .time-group { margin-left: 8px; margin-bottom: 12px; } .time-header { font-size: 0.85em; font-weight: 500; color: var(--secondary-text-color); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } .time-header ha-icon { --mdc-icon-size: 16px; } .approval-item { display: flex; align-items: center; justify-content: space-between; padding: 12px; margin-bottom: 8px; background: var(--card-background-color); border: 1px solid var(--divider-color); border-radius: 8px; transition: box-shadow 0.2s ease; } .approval-item:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .approval-item.loading { opacity: 0.6; pointer-events: none; } .item-info { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } .chore-name { font-weight: 500; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .approval-photo { display: inline-block; margin-top: 4px; line-height: 0; } .approval-photo img { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; border: 1px solid var(--divider-color, #e0e0e0); } .item-details { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 0.85em; color: var(--secondary-text-color); } .child-name { display: flex; align-items: center; gap: 4px; } .points-badge { display: flex; align-items: center; gap: 4px; background: var(--accent-color, #ffc107); color: var(--text-primary-color, #000); padding: 2px 8px; border-radius: 12px; font-weight: 500; } .points-badge ha-icon { --mdc-icon-size: 14px; } .duration-badge { display: flex; align-items: center; gap: 4px; background: rgba(26, 188, 156, 0.15); color: #1abc9c; padding: 2px 8px; border-radius: 12px; font-weight: 500; font-size: 0.85em; } .duration-badge ha-icon { --mdc-icon-size: 14px; } .action-buttons { display: flex; gap: 8px; } .action-buttons.left { margin-right: 12px; } .action-buttons.right { margin-left: 12px; } .action-button { border: none; border-radius: 50%; width: 36px; height: 36px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.15s ease, box-shadow 0.15s ease; } .action-button:hover { transform: scale(1.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .action-button:active { transform: scale(0.95); } .action-button.approve { background: #4caf50; color: white; } .action-button.reject { background: #f44336; color: white; } /* Mandatory-miss review actions (#532) */ .action-button.penalty { background: var(--fun-red, #e74c3c); color: white; } .action-button.postpone { background: var(--fun-orange, #e67e22); color: white; } .action-button.dismiss { background: var(--fun-green, #2ecc71); color: white; } .approval-item.mandatory { border-left: 5px solid var(--fun-red, #e74c3c); } .approval-item.mandatory .penalty-note { display: block; margin-top: 2px; font-size: 0.78rem; font-weight: 700; color: var(--fun-red, #e74c3c); } .approval-item.mandatory .penalty-note.none { color: var(--secondary-text-color, #888); font-weight: 600; } .action-button ha-icon { --mdc-icon-size: 20px; } .action-button.loading { opacity: 0.6; cursor: not-allowed; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--secondary-text-color); text-align: center; } .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 16px; opacity: 0.5; } .empty-state .message { font-size: 1.1em; margin-bottom: 4px; } .empty-state .submessage { font-size: 0.9em; opacity: 0.8; } .error-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--error-color); text-align: center; } .error-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 16px; } .loading-spinner { display: flex; align-items: center; justify-content: center; padding: 40px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .spinner { width: 40px; height: 40px; border: 3px solid var(--divider-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 1s linear infinite; } /* ── Designed styles (playroom / console / cleanpro) ── Shared .tmd kit + design tokens come from taskmate-design.js styles(). Only card-specific layout classes are declared here. */ .ap-group-label { font-weight: 800; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin: 4px 0 9px; } .ap-group-label + .ap-group-label { margin-top: 14px; } /* Playroom — soft cards */ .ap-pl-item { background: var(--tmd-surface-2); border-radius: 18px; padding: 12px; } .ap-pl-item + .ap-pl-item { margin-top: 11px; } .ap-pl-title { font-weight: 800; } .ap-pl-sub { font-size: 12.5px; } .ap-pl-photo { width: 46px; height: 46px; border-radius: 14px; overflow: hidden; display: grid; place-items: center; background: var(--tmd-accent); color: #fff; font-size: 20px; flex: none; } .ap-pl-photo img { width: 100%; height: 100%; object-fit: cover; } .ap-gold { background: var(--tmd-gold); border-color: transparent; color: #3a2e26; } /* Console — compact HUD rows */ .ap-cn-item { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 10px; padding: 11px; } .ap-cn-item + .ap-cn-item { margin-top: 9px; } .ap-cn-title { font-weight: 700; } .ap-cn-sub { font-size: 11px; } .ap-cn-photo { width: 34px; height: 34px; border-radius: 8px; overflow: hidden; background: #0b1424; border: 1px solid var(--tmd-border); display: grid; place-items: center; font-size: 15px; color: var(--tmd-accent); flex: none; } .ap-cn-photo img { width: 100%; height: 100%; object-fit: cover; } /* Clean Pro — divided list */ .ap-cp-item { padding: 10px 0; } .ap-cp-title { font-weight: 600; } .ap-cp-sub { font-size: 12px; } .ap-cp-photo { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); display: grid; place-items: center; font-size: 14px; flex: none; } .ap-cp-photo img { width: 100%; height: 100%; object-fit: cover; } .ap-gold-soft { color: var(--tmd-gold); } .ap-d-photo-link { line-height: 0; text-decoration: none; } /* Scrollable list (parity with classic content) */ .ap-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; } setConfig(config) { if (!config.entity) { throw new Error("Please define an entity (pending_approvals sensor)"); } this.config = { title: "", header_color: '#27ae60', ...config, }; } getCardSize() { return 3; } static getConfigElement() { return document.createElement("taskmate-approvals-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview", title: "Pending Approvals", }; } 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) : "classic"; if (design !== "classic") return this._renderDesigned(design); const entity = this.hass.states[this.config.entity]; if (!entity) { return html`
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } // Support both the pending_approvals sensor (chore_completions) // and the overview sensor (todays_completions filtered to unapproved). // The resolver is used so pending_reward_claims / todays_completions // resolve from their new companion sensors when the card is pointed at // the overview entity. const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; // Prefer the full pending list (chore_completions, resolved from the // pending_approvals companion sensor) so approvals left pending from a // previous day still appear after a recurring chore resets at midnight. // Fall back to today's completions only on older backends that don't // expose chore_completions via the resolver. let completions = attrs.chore_completions; if (!completions) { completions = (attrs.todays_completions || []).filter(c => !c.approved); } const filteredCompletions = this._filterByChild(completions); const groupedByDay = this._groupByDay(filteredCompletions); // Pending reward claims: supported via either the pending_approvals sensor // (reward_claims attribute) or the rewards sensor (pending_reward_claims). let rewardClaims = entity.attributes.reward_claims || attrs.pending_reward_claims || []; const filteredClaims = this._filterClaimsByChild(rewardClaims); // Missed mandatory chores awaiting parent review (#532) const misses = this._filterMissesByChild(attrs.mandatory_misses || []); const totalPending = filteredCompletions.length + filteredClaims.length + misses.length; return html`
${this.config.title || this._t('approvals.default_title')}
${filteredCompletions.length > 0 ? html`` : ""} ${totalPending > 0 ? html`${totalPending}` : ""}
${totalPending === 0 ? this._renderEmptyState() : html` ${misses.length > 0 ? this._renderMandatoryMisses(misses) : ''} ${filteredClaims.length > 0 ? this._renderRewardClaims(filteredClaims) : ''} ${filteredCompletions.length > 0 ? this._renderApprovals(groupedByDay) : ''} `}
`; } /* ══════════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js Ported from docs/design/redesigns/frag/11-approvals.html. Wraps the SAME data + handlers as the classic path. ══════════════════════════════════════════════════════════════════════ */ _designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; } _av(name, avatar, tone, size) { const a = avatar || ""; const inner = a.startsWith("mdi:") ? html`` : a ? html`${name || ''}` : (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`
${inner}
`; } // Resolve a child's avatar from the overview sensor's children list (the // completion/claim payloads don't carry an avatar field). _childAvatar(childId) { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {}; const child = (attrs.children || []).find(c => c.id === childId); return child ? child.avatar : null; } // Flatten all pending items into a single ordered, day/time-grouped list so // the designed views can render approvals, reward claims and mandatory misses // together while reusing every existing handler. _designItems() { const entity = this.hass.states[this.config.entity]; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; let completions = attrs.chore_completions; if (!completions) completions = (attrs.todays_completions || []).filter(c => !c.approved); const filteredCompletions = this._filterByChild(completions); const rewardClaims = entity.attributes.reward_claims || attrs.pending_reward_claims || []; const filteredClaims = this._filterClaimsByChild(rewardClaims); const misses = this._filterMissesByChild(attrs.mandatory_misses || []); const items = []; misses.forEach((miss) => { const penalty = miss.penalty_points ?? 0; items.push({ kind: "miss", id: miss.id, title: miss.chore_name || "", childName: miss.child_name || "", childId: miss.child_id, points: penalty, penalty, sortTime: 0, }); }); filteredClaims.forEach((claim) => { const claimId = claim.claim_id || claim.id; items.push({ kind: "claim", id: claimId, title: claim.reward_name || "", childName: claim.child_name || "", childId: claim.child_id, points: claim.cost ?? 0, icon: claim.reward_icon || "mdi:gift", sortTime: claim.claimed_at ? new Date(claim.claimed_at).getTime() : 1, }); }); filteredCompletions.forEach((c) => { items.push({ kind: "completion", completion: c, id: c.completion_id, title: c.chore_name || "", childName: c.child_name || "", childId: c.child_id, points: c.points, photo: c.photo_url || "", completedAt: c.completed_at, timeCategory: c.time_category || "anytime", sortTime: c.completed_at ? new Date(c.completed_at).getTime() : 2, }); }); return items; } // Group designed items by day label, then by time-category, mirroring the // classic _groupByDay/_renderTimeCategories ordering. Non-completion items // (misses/claims) bucket under "Today" so they stay visible at the top. _designGrouped(items) { const groups = new Map(); const todayLabel = this._t('common.today'); items.forEach((it) => { let dayLabel = todayLabel; let cat = "anytime"; if (it.kind === "completion" && it.completedAt) { dayLabel = this._getDayLabel(new Date(it.completedAt)); cat = it.timeCategory || "anytime"; } if (!groups.has(dayLabel)) groups.set(dayLabel, new Map()); const cats = groups.get(dayLabel); if (!cats.has(cat)) cats.set(cat, []); cats.get(cat).push(it); }); // Build ordered list of { dayLabel, sub, items[] } sections. const sections = []; for (const [dayLabel, cats] of groups) { const ordered = [...cats.entries()].sort( ([a], [b]) => this._getTimeCategoryOrder(a) - this._getTimeCategoryOrder(b) ); for (const [cat, list] of ordered) { const sub = cat === "anytime" ? dayLabel : `${dayLabel} · ${this._getTimeCategoryLabel(cat)}`; sections.push({ label: sub, items: list }); } } return sections; } _renderDesigned(design) { const entity = this.hass.states[this.config.entity]; const hd = _safeColor(this.config.header_color, '#27ae60'); if (!entity) { return html` ${this._designHeader(hd, 0)}
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } const items = this._designItems(); const sections = this._designGrouped(items); let body; if (!items.length) { body = html`
${this._t('approvals.no_pending_approvals')}
`; } else { const rowFn = design === "playroom" ? (it, i) => this._apPlayroom(it, i) : design === "console" ? (it, i) => this._apConsole(it, i) : (it, i) => this._apCleanpro(it, i); let idx = 0; body = html`
${sections.map((sec) => html`
${sec.label}
${sec.items.map((it) => rowFn(it, idx++))} `)}
`; } const completions = items.filter((it) => it.kind === "completion").map((it) => it.completion); return html` ${this._designHeader(hd, items.length, completions)}
${body}
`; } _designHeader(hd, count, completions = []) { const title = this.config.title || this._t('approvals.default_title'); return html`
${title} ${completions.length > 0 ? html`` : ""} ${count > 0 ? html`${count}` : ""}
`; } // Approve/reject (or miss-review) buttons wired to the EXISTING handlers, // varying only label/shape per design. _designActions(it, shape) { const isLoading = !!this._loading[it.id]; if (it.kind === "completion") { const approve = () => this._handleApprove(it.completion); const reject = () => this._handleReject(it.completion); return this._apActionPair(approve, reject, isLoading, shape); } if (it.kind === "claim") { const approve = () => this._handleApproveReward(it.id); const reject = () => this._handleRejectReward(it.id); return this._apActionPair(approve, reject, isLoading, shape); } // mandatory miss: apply penalty (if any) / postpone / dismiss if (shape === "round") { return html` ${it.penalty > 0 ? html`` : ""} `; } const sm = shape === "sm" ? "sm" : ""; return html` ${it.penalty > 0 ? html`` : ""} `; } _apActionPair(approve, reject, isLoading, shape) { if (shape === "round") { return html` `; } if (shape === "ghost") { return html` `; } return html` `; } _apPhotoDesigned(it, cls, fallback) { const photoUrl = tmSafePhotoUrl(it.photo); if (it.kind !== "completion" || !photoUrl) { return it.kind === "claim" && it.icon ? html`
` : ""; } const cap = this._photoCaption(it.title, it.childName, it.completedAt); return html`
`; } // Build "Chore · Child · date" for the lightbox caption from whatever row // fields are present (any missing part is dropped). _photoCaption(chore, child, iso) { let when = ""; if (iso) { const d = new Date(iso); if (!isNaN(d)) when = d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } return [chore, child, when].filter(Boolean).join(" · "); } // Open the shared in-page lightbox; fall through to the href (new tab) if the // shared design layer somehow isn't loaded. _openPhoto(e, url, caption) { if (window.__taskmate_lightbox) { e.preventDefault(); window.__taskmate_lightbox(url, caption, this._t("common.close")); } } _apPlayroom(it, i) { const tone = this._designTone(i); return html`
${this._av(it.childName, this._childAvatar(it.childId), tone, 40)}
${it.title}
${it.childName}
${this._apPhotoDesigned(it, "ap-pl-photo")}
+${it.points} ⭐
${this._designActions(it, "full")}
`; } _apConsole(it, i) { const tone = this._designTone(i); return html`
${this._av(it.childName, this._childAvatar(it.childId), tone, 34)}
${it.title}
${(it.childName || '').toUpperCase()} · +${it.points} XP
${this._apPhotoDesigned(it, "ap-cn-photo")} ${this._designActions(it, "round")}
`; } _apCleanpro(it, i) { const tone = this._designTone(i); const time = it.completedAt ? new Date(it.completedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""; return html`
${this._av(it.childName, this._childAvatar(it.childId), tone, 38)}
${it.title}
${it.childName}${time ? html` · ${time}` : ""}
${this._apPhotoDesigned(it, "ap-cp-photo")} +${it.points} ${this._designActions(it, "ghost")}
`; } _filterByChild(completions) { if (!this.config.child_id) { return completions; } return completions.filter( (c) => c.child_id === this.config.child_id ); } _filterClaimsByChild(claims) { if (!this.config.child_id) return claims; return claims.filter((c) => c.child_id === this.config.child_id); } _renderRewardClaims(claims) { return html`
${this._t('approvals.reward_claims_section')}
${claims.map((claim) => this._renderClaimItem(claim))}
`; } _renderClaimItem(claim) { const claimId = claim.claim_id || claim.id; const isLoading = this._loading[claimId]; const rewardName = claim.reward_name || ''; const childName = claim.child_name || ''; const cost = claim.cost ?? 0; return html`
${rewardName}
${childName} ${cost}
`; } async _handleApproveReward(claimId) { await this._callClaimService('approve_reward', claimId); } async _handleRejectReward(claimId) { await this._callClaimService('reject_reward', claimId); } async _callClaimService(service, claimId) { if (this._loading[claimId]) return; this._loading = { ...this._loading, [claimId]: true }; this.requestUpdate(); try { await this.hass.callService('taskmate', service, { claim_id: claimId }); } catch (error) { console.error(`Failed to call ${service}:`, error); if (this.hass.callService) { this.hass.callService('persistent_notification', 'create', { title: this._t('approvals.error_title'), message: this._t('approvals.error_failed_service', { service: service.replace('_', ' '), message: error.message, }), notification_id: `taskmate_error_${claimId}`, }); } } finally { this._loading = { ...this._loading, [claimId]: false }; this.requestUpdate(); } } // ---- mandatory-miss review (#532) ------------------------------------ _filterMissesByChild(misses) { if (!this.config.child_id) return misses; return misses.filter((m) => m.child_id === this.config.child_id); } _renderMandatoryMisses(misses) { return html`
${this._t('approvals.mandatory_section')}
${misses.map((miss) => this._renderMissItem(miss))}
`; } _renderMissItem(miss) { const missId = miss.id; const isLoading = this._loading[missId]; const choreName = miss.chore_name || ''; const childName = miss.child_name || ''; const penalty = miss.penalty_points ?? 0; return html`
${choreName}
${childName}
${penalty > 0 ? html`${this._t('approvals.penalty_points', { points: penalty })}` : html`${this._t('approvals.no_penalty')}`}
${penalty > 0 ? html` ` : ''}
`; } async _handleApplyPenalty(missId) { await this._callMissService('apply_mandatory_penalty', missId); } async _handlePostpone(missId) { await this._callMissService('postpone_mandatory_chore', missId); } async _handleDismiss(missId) { await this._callMissService('dismiss_mandatory_chore', missId); } async _callMissService(service, missId) { if (this._loading[missId]) return; this._loading = { ...this._loading, [missId]: true }; this.requestUpdate(); try { await this.hass.callService('taskmate', service, { miss_id: missId }); } catch (error) { console.error(`Failed to call ${service}:`, error); if (this.hass.callService) { this.hass.callService('persistent_notification', 'create', { title: this._t('approvals.error_title'), message: this._t('approvals.error_failed_service', { service: service.replace(/_/g, ' '), message: error.message, }), notification_id: `taskmate_error_${missId}`, }); } } finally { this._loading = { ...this._loading, [missId]: false }; this.requestUpdate(); } } _groupByDay(completions) { const groups = {}; completions.forEach((completion) => { const date = new Date(completion.completed_at); const dayKey = this._getDayKey(date); if (!groups[dayKey]) { groups[dayKey] = { label: this._getDayLabel(date), date: date, timeCategories: {}, }; } const timeCategory = completion.time_category || "anytime"; if (!groups[dayKey].timeCategories[timeCategory]) { groups[dayKey].timeCategories[timeCategory] = []; } groups[dayKey].timeCategories[timeCategory].push(completion); }); // Sort groups by date (most recent first) const sortedGroups = Object.entries(groups).sort( ([, a], [, b]) => b.date - a.date ); return sortedGroups; } _getTimezone() { // Get timezone from Home Assistant config, fallback to browser timezone return this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; } _getLocale() { // Get locale from Home Assistant, fallback to browser locale return this.hass?.locale?.language || this.hass?.language || navigator.language || "en"; } _formatDateInTimezone(date, options = {}) { const timezone = this._getTimezone(); const locale = this._getLocale(); return date.toLocaleDateString(locale, { ...options, timeZone: timezone }); } _getDatePartsInTimezone(date) { const timezone = this._getTimezone(); // Get year, month, day in the HA timezone const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", }); // en-CA formats as YYYY-MM-DD const dateStr = formatter.format(date); const [year, month, day] = dateStr.split("-").map(Number); return { year, month, day }; } _getDayKey(date) { // Use HA timezone to determine the day key const { year, month, day } = this._getDatePartsInTimezone(date); return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } _getDayLabel(date) { const timezone = this._getTimezone(); // Get today's date parts in HA timezone const now = new Date(); const todayParts = this._getDatePartsInTimezone(now); const dateParts = this._getDatePartsInTimezone(date); // Calculate yesterday in HA timezone const yesterdayDate = new Date(now); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const yesterdayParts = this._getDatePartsInTimezone(yesterdayDate); // Compare date parts const isToday = dateParts.year === todayParts.year && dateParts.month === todayParts.month && dateParts.day === todayParts.day; const isYesterday = dateParts.year === yesterdayParts.year && dateParts.month === yesterdayParts.month && dateParts.day === yesterdayParts.day; if (isToday) { return this._t('common.today'); } else if (isYesterday) { return this._t('common.yesterday'); } else { return this._formatDateInTimezone(date, { month: "short", day: "numeric", }); } } // User-defined periods from the overview sensor; legacy fixed four when // the integration hasn't published time_periods yet. _getTimePeriods() { const entity = this.hass?.states?.[this.config?.entity]; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || entity?.attributes || {}; if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) { return attrs.time_periods.filter(p => p && p.id); } return [ { id: "morning", label: "", icon: "mdi:weather-sunset-up" }, { id: "afternoon", label: "", icon: "mdi:weather-sunny" }, { id: "evening", label: "", icon: "mdi:weather-sunset-down" }, { id: "night", label: "", icon: "mdi:weather-night" }, ]; } _getTimeCategoryIcon(category) { const period = this._getTimePeriods().find(p => p.id === category); if (period && period.icon) return period.icon; const icons = { morning: "mdi:weather-sunset-up", afternoon: "mdi:weather-sunny", evening: "mdi:weather-sunset-down", night: "mdi:weather-night", anytime: "mdi:clock-outline", }; return icons[category] || icons.anytime; } _getTimeCategoryLabel(category) { const period = this._getTimePeriods().find(p => p.id === category); if (period && period.label) return period.label; const keyMap = { morning: 'common.morning', afternoon: 'common.afternoon', evening: 'common.evening', night: 'common.night', anytime: 'common.anytime', }; return keyMap[category] ? this._t(keyMap[category]) : category; } _getTimeCategoryOrder(category) { const ids = this._getTimePeriods().map(p => p.id); const idx = ids.indexOf(category); if (idx >= 0) return idx; return category === "anytime" ? ids.length : ids.length + 1; } _renderEmptyState() { return html`
${this._t('approvals.all_caught_up')}
${this._t('approvals.no_pending_approvals')}
`; } _renderApprovals(groupedByDay) { return html` ${groupedByDay.map( ([dayKey, dayGroup]) => html`
${dayGroup.label}
${this._renderTimeCategories(dayGroup.timeCategories)}
` )} `; } _renderTimeCategories(timeCategories) { const sortedCategories = Object.entries(timeCategories).sort( ([a], [b]) => this._getTimeCategoryOrder(a) - this._getTimeCategoryOrder(b) ); return html` ${sortedCategories.map( ([category, completions]) => html`
${this._getTimeCategoryLabel(category)}
${completions.map((completion) => this._renderApprovalItem(completion))}
` )} `; } _renderApprovalItem(completion) { const isLoading = this._loading[completion.completion_id]; return html`
${completion.chore_name}
${completion.child_name} ${completion.timed_duration_seconds > 0 ? html` ${this._t('approvals.timed_duration', {count: Math.floor(completion.timed_duration_seconds / 60)})} ` : ''} ${completion.points}
${tmSafePhotoUrl(completion.photo_url) ? html` ` : ''}
`; } async _handleApprove(completion) { await this._callService("approve_chore", completion.completion_id); } async _handleReject(completion) { await this._callService("reject_chore", completion.completion_id); } async _handleApproveAll(completions) { const ids = (completions || []).map(c => c.completion_id).filter(Boolean); if (ids.length === 0) return; if (!window.confirm(this._t('approvals.approve_all_confirm', { count: ids.length }))) return; if (this._loading['__all__']) return; this._loading = { ...this._loading, '__all__': true }; this.requestUpdate(); try { await this.hass.callService('taskmate', 'approve_all_chores', { completion_ids: ids }); } catch (error) { console.error('Failed to approve all:', error); if (this.hass.callService) { this.hass.callService('persistent_notification', 'create', { title: this._t('approvals.error_title'), message: this._t('approvals.error_failed_service', { service: 'approve all', message: error.message }), notification_id: 'taskmate_error_approve_all', }); } } finally { this._loading = { ...this._loading, '__all__': false }; this.requestUpdate(); } } async _callService(service, completionId) { if (this._loading[completionId]) return; this._loading = { ...this._loading, [completionId]: true }; this.requestUpdate(); try { await this.hass.callService("taskmate", service, { completion_id: completionId, }); } catch (error) { console.error(`Failed to call ${service}:`, error); // Show error toast if available if (this.hass.callService) { this.hass.callService("persistent_notification", "create", { title: this._t('approvals.error_title'), message: this._t('approvals.error_failed_service', { service: service.replace("_", " "), message: error.message }), notification_id: `taskmate_error_${completionId}`, }); } } finally { this._loading = { ...this._loading, [completionId]: false }; this.requestUpdate(); } } } // Card Editor class TaskMateApprovalsCardEditor 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; } static get styles() { return css` :host { display: block; } ha-form { display: block; margin-bottom: 16px; } .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() { const overviewEntity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null; const children = overviewEntity?.attributes?.children || []; return [ { name: 'entity', selector: { entity: { domain: 'sensor' } } }, { name: 'title', selector: { text: {} } }, { name: 'child_id', selector: { select: { options: [ { value: '__all__', label: this._t('common.editor.filter_by_child_all') }, ...children.map((c) => ({ value: c.id, label: c.name })), ], mode: 'dropdown', }, }, }, { 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.entity'), title: this._t('common.title'), child_id: this._t('approvals.editor.child_id'), card_design: this._t('common.design.field_label'), }; return labels[entry.name] ?? entry.name; }; _computeHelper = (entry) => { const helpers = { entity: this._t('approvals.editor.entity_helper'), child_id: this._t('approvals.editor.child_id_helper'), }; return helpers[entry.name] ?? ''; }; render() { if (!this.hass || !this.config) return html``; const data = { entity: this.config.entity || '', title: this.config.title || '', child_id: this.config.child_id || '__all__', card_design: this.config.card_design || 'global', }; return html` ${this._renderColourPicker('header_color', '#27ae60')} `; } _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._updateConfig(key, v), onPreset: (v) => this._updateConfig(key, v), onReset: () => this._updateConfig(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 || (key === 'child_id' && value === '__all__') || (key === 'card_design' && value === 'global') ) { delete newConfig[key]; } else { newConfig[key] = value; } } this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: newConfig }, bubbles: true, composed: true, })); } _updateConfig(key, value) { const newConfig = { ...this.config, [key]: value }; if (value === undefined || value === "") { delete newConfig[key]; } const event = new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, }); this.dispatchEvent(event); } } // Register the cards customElements.define( "taskmate-approvals-card", TaskMateApprovalsCard ); customElements.define( "taskmate-approvals-card-editor", TaskMateApprovalsCardEditor ); // Register with Home Assistant window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-approvals-card", name: "TaskMate Approvals", description: "A card to manage pending chore approvals for TaskMate", preview: true, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-approvals-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-approvals-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE APPROVALS CARD %c v" + _tmVersion + " ", "background:#27ae60;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;" );