/** * TaskMate Activity Feed Card * Scrollable timeline of recent events — completions, approvals, points, rewards. * * Version: 1.2.0 * Last Updated: 2026-06-21 */ 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); class TaskMateActivityCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _filter: { type: String, state: true }, _confirm: { type: Object, state: true }, _loading: { type: Object, state: true }, }; } constructor() { super(); this._filter = "all"; this._confirm = null; this._loading = {}; } // Reason prefixes for *derived* (automatic) transactions, which can't be // undone in isolation — mirrors the backend deny-list in coord_points.py. // Everything else (penalty, bonus, gift, manual add/remove) is reversible. static get _UNDO_DENY_PREFIXES() { return [ "Weekend bonus", "Streak milestone bonus", "Perfect week bonus", "Allocated to pool:", "Pool refund", "Points decay", "Savings interest", "Badge", ]; } _txnReversible(reason) { const r = reason || ""; return !TaskMateActivityCard._UNDO_DENY_PREFIXES.some(p => r.startsWith(p)); } 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; } // Transaction reasons are stored in English in the DB (e.g. "Penalty: Messy room"). // Map known prefixes to translation keys so they render in the user's language. _translateReason(reason) { if (!reason) return reason; const prefixMap = [ ['Allocated to pool:', 'activity.reason_allocated_to_pool'], ['Pool refund (reward expired):', 'activity.reason_pool_refund_expired'], ['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'], ['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'], ['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'], ['Penalty:', 'activity.reason_penalty'], ['Bonus:', 'activity.reason_bonus'], ]; for (const [prefix, key] of prefixMap) { if (reason.startsWith(prefix)) { const name = reason.slice(prefix.length).trim(); return this._t(key, { name }); } } if (reason.startsWith('Perfect week bonus!')) { return this._t('activity.reason_perfect_week'); } const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/); if (weekendMatch) { return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] }); } const streakMatch = reason.match(/^Streak milestone bonus \((\d+) day streak!\)$/); if (streakMatch) { return this._t('activity.reason_streak_milestone', { days: streakMatch[1] }); } return reason; } static get styles() { const base = css` :host { display: block; --act-purple: #9b59b6; --act-green: #2ecc71; --act-orange: #e67e22; --act-blue: #3498db; --act-red: #e74c3c; --act-gold: #f1c40f; --tm-stripe-approved: #16a34a; --tm-stripe-pending: #f59e0b; --tm-stripe-rejected: #ef4444; --tm-stripe-penalty: #ef4444; --tm-stripe-reward: #6d3df0; --tm-stripe-bonus: #16a34a; --tm-stripe-neutral: var(--divider-color, #e0e0e0); } ha-card { overflow: hidden; } /* ── Coloured header banner ─────────────────────────── */ .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; background: var(--taskmate-header-bg, #2471a3); color: #ffffff; } .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; } .header-icon-chip { width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.18); color: #ffffff; flex-shrink: 0; } .header-icon-chip ha-icon { --mdc-icon-size: 18px; } .header-title { font-size: 1.05rem; font-weight: 700; color: #ffffff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .header-meta { font-size: 0.78rem; color: rgba(255, 255, 255, 0.85); margin-left: 2px; white-space: nowrap; } /* ── Filter chips ──────────────────────────────────── */ .filter-bar { display: flex; gap: 6px; padding: 8px 12px; background: var(--card-background-color, #fff); border-bottom: 1px solid var(--divider-color, #ececf2); overflow-x: auto; scrollbar-width: none; } .filter-bar::-webkit-scrollbar { display: none; } .filter-chip { flex: 0 0 auto; font: inherit; font-size: 0.74rem; font-weight: 600; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--divider-color, #ececf2); background: var(--secondary-background-color, transparent); color: var(--secondary-text-color); cursor: pointer; transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } .filter-chip:hover { background: var(--secondary-background-color, rgba(0,0,0,0.03)); color: var(--primary-text-color); } .filter-chip.active { background: var(--primary-text-color); color: var(--card-background-color, #fff); border-color: var(--primary-text-color); } .filter-chip ha-icon { --mdc-icon-size: 14px; } /* ── Feed container ────────────────────────────────── */ .feed-container { max-height: 350px; overflow-y: auto; padding: 0; background: var(--card-background-color, #fff); } /* Sticky date label */ .date-label { position: sticky; top: 0; z-index: 2; font-size: 0.7rem; font-weight: 700; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 1px; padding: 9px 14px 7px; background: var(--secondary-background-color, #f6f4fb); border-bottom: 1px solid var(--divider-color, #ececf2); border-top: 1px solid var(--divider-color, #ececf2); } .date-label:first-child { border-top: 0; } .date-label .date-suffix { text-transform: none; letter-spacing: 0.2px; font-weight: 500; opacity: 0.75; margin-left: 4px; } /* ── Activity item ─────────────────────────────────── */ .activity-item { display: flex; align-items: stretch; gap: 0; border-bottom: 1px solid var(--divider-color, #f0f0f0); position: relative; background: var(--card-background-color, #fff); } .activity-item:last-child { border-bottom: none; } .event-stripe { width: 4px; flex-shrink: 0; background: var(--tm-stripe-neutral); } .activity-item.t-approved .event-stripe { background: var(--tm-stripe-approved); } .activity-item.t-pending .event-stripe { background: var(--tm-stripe-pending); } .activity-item.t-rejected .event-stripe { background: var(--tm-stripe-rejected); } .activity-item.t-penalty .event-stripe { background: var(--tm-stripe-penalty); } .activity-item.t-reward .event-stripe { background: var(--tm-stripe-reward); } .activity-item.t-bonus .event-stripe { background: var(--tm-stripe-bonus); } .activity-row { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px; } .activity-icon { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-weight: 700; } .activity-icon ha-icon { --mdc-icon-size: 16px; } .activity-icon.t-approved { background: rgba(22,163,74,0.13); color: #16a34a; } .activity-icon.t-pending { background: rgba(245,158,11,0.15); color: #c2740a; } .activity-icon.t-rejected { background: rgba(239,68,68,0.13); color: #dc2626; } .activity-icon.t-penalty { background: rgba(239,68,68,0.13); color: #dc2626; } .activity-icon.t-reward { background: rgba(109,61,240,0.13); color: #6d3df0; } .activity-icon.t-bonus { background: rgba(22,163,74,0.13); color: #16a34a; } .activity-body { flex: 1; min-width: 0; } .activity-title { font-size: 0.88rem; line-height: 1.3; color: var(--primary-text-color); } .activity-title strong { font-weight: 700; color: var(--primary-text-color); } .activity-title .reason { color: var(--secondary-text-color); font-style: normal; } .activity-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 0.72rem; color: var(--secondary-text-color); flex-wrap: wrap; } .activity-time { color: var(--secondary-text-color); } .activity-ago::before { content: "·"; margin-right: 4px; opacity: 0.6; } /* Status pill — kept only for non-default states (pending, rejected). */ .activity-status { display: inline-flex; align-items: center; gap: 3px; font-size: 0.66rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.4px; } .activity-status.pending { background: rgba(245,158,11,0.16); color: #b45309; } .activity-status.rejected { background: rgba(239,68,68,0.14); color: #b91c1c; } /* Right-aligned signed points pill */ .points-pill { display: inline-flex; align-items: center; gap: 3px; font-size: 0.95rem; font-weight: 800; line-height: 1; white-space: nowrap; flex-shrink: 0; color: var(--primary-text-color); } .points-pill ha-icon { --mdc-icon-size: 14px; opacity: 0.9; } .points-pill.gain { color: #16a34a; } .points-pill.spend { color: #6d3df0; } .points-pill.loss { color: #dc2626; } .points-pill.muted { color: var(--secondary-text-color); } /* ── Empty / error states ──────────────────────────── */ .empty-state, .error-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 36px 20px; color: var(--secondary-text-color); text-align: center; } .error-state { color: var(--error-color, #f44336); } .empty-state ha-icon, .error-state ha-icon { --mdc-icon-size: 44px; margin-bottom: 12px; opacity: 0.45; } .empty-state .message { font-size: 0.95rem; color: var(--primary-text-color); font-weight: 600; } .empty-state .submessage { font-size: 0.8rem; margin-top: 4px; } .feed-end { padding: 14px; text-align: center; color: var(--secondary-text-color); font-size: 0.72rem; border-top: 1px dashed var(--divider-color, #ececf2); opacity: 0.7; } /* ── Undo button ───────────────────────────────────── */ .undo-btn { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-left: 2px; border-radius: 50%; border: 1px solid var(--divider-color, #e0e0e0); background: transparent; color: var(--secondary-text-color); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } .undo-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.05)); color: var(--primary-text-color); } .undo-btn[disabled] { opacity: 0.4; cursor: default; } .undo-btn ha-icon { --mdc-icon-size: 16px; } .undo-confirm-body { font-size: 0.95rem; line-height: 1.4; color: var(--primary-text-color); } .undo-confirm-go { --mdc-theme-primary: var(--error-color, #d9534f); } /* ── Designed styles (playroom / console / cleanpro) ── */ .tmd-filters { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 13px; } .tmd-filters .chip { cursor: pointer; } .tmd-undo { padding: 4px 11px; } .act-line { font-weight: 700; font-size: 13.5px; line-height: 1.35; } .act-line .reason { font-weight: 400; color: var(--tmd-dim); } .act-ago { font-size: 11.5px; } .act-rel { opacity: 0.8; } /* Playroom — bubbly nodes */ .act-pl { gap: 11px; } .act-pl-row { gap: 11px; } .act-pl-av { font-size: 18px; } .act-pl-bub { flex: 1; min-width: 0; background: var(--tmd-surface-2); border-radius: 16px; padding: 10px 12px; } .act-pl-foot { justify-content: space-between; margin-top: 5px; gap: 8px; } /* Console — log feel, mono timestamps */ .act-cn { gap: 0; } .act-cn-row { gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--tmd-border); } .act-cn-row:last-child { border-bottom: none; } .act-cn-mid { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 600; } .act-cn-time { font-size: 11px; flex-shrink: 0; text-align: right; line-height: 1.3; white-space: nowrap; } /* Clean Pro — connecting rail */ .act-cp-rail { position: relative; padding-left: 26px; } .act-cp-line { position: absolute; left: 11px; top: 6px; bottom: 6px; width: 2px; background: var(--tmd-border); } .act-cp { gap: 14px; } .act-cp-node { position: relative; } .act-cp-dot { position: absolute; left: -26px; top: 0; width: 22px; height: 22px; border-radius: 50%; background: color-mix(in srgb, var(--ac, var(--tmd-accent)) 18%, transparent); color: var(--ac, var(--tmd-accent)); display: grid; place-items: center; font-size: 11px; line-height: 1; box-shadow: 0 0 0 3px var(--tmd-surface); } .act-cp-head { justify-content: space-between; align-items: flex-start; gap: 10px; } /* Coloured event stripe (honours accent_stripes) — vertically centred with the row */ .act-stripe { width: 4px; border-radius: 4px; flex: none; align-self: stretch; } .act-pl-row, .act-cn-row { align-items: center; } /* Vertical scroll (parity with classic) — extend to the card edge so the scrollbar sits at the edge, not floating inset by the body padding. */ .act-pl, .act-cn, .act-cp-rail { max-height: 360px; overflow-y: auto; margin-right: -15px; padding-right: 8px; } /* Undo confirm overlay (works in classic + designed; no mwc dependency) */ .undo-ov { position: fixed; inset: 0; z-index: 99; display: grid; place-items: center; background: rgba(0,0,0,0.45); padding: 16px; } .undo-modal { background: var(--card-background-color, #fff); color: var(--primary-text-color); border-radius: 16px; padding: 18px 18px 14px; max-width: 340px; width: 100%; box-shadow: 0 16px 48px rgba(0,0,0,0.35); } .undo-modal-title { font-weight: 800; font-size: 1.05rem; margin-bottom: 8px; } .undo-modal-body { font-size: 0.92rem; color: var(--secondary-text-color); line-height: 1.45; } .undo-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .undo-modal-btn { font: inherit; font-weight: 700; border: 0; cursor: pointer; padding: 9px 16px; border-radius: 10px; } .undo-modal-btn.cancel { background: var(--secondary-background-color, #ececec); color: var(--primary-text-color); } .undo-modal-btn.go { background: var(--error-color, #e74c3c); color: #fff; } `; 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"); this.config = { title: "", max_items: 30, child_id: null, header_color: '#2471a3', show_filter_chips: true, accent_stripes: true, show_relative_time: true, show_undo: true, ...config, }; } getCardSize() { return 4; } static getConfigElement() { return document.createElement("taskmate-activity-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview", title: "Activity" }; } _setFilter(filter) { if (this._filter === filter) return; this._filter = filter; } _eventBucket(item) { const t = item.type || "chore"; if (t === "points_added" || t === "points_removed") return "adjustments"; if (t === "reward" || t === "reward_claimed" || t === "reward_approved") return "rewards"; return "chores"; } _classifyItem(item) { const t = item.type || "chore"; if (t === "points_added") { const reason = item.reason || ""; if (reason.startsWith("Bonus:") || reason.startsWith("Perfect week bonus") || reason.startsWith("Weekend bonus") || reason.startsWith("Streak milestone bonus")) { return "bonus"; } return "approved"; } if (t === "points_removed") { const reason = item.reason || ""; if (reason.startsWith("Penalty:")) return "penalty"; return "reward"; } if (t === "reward_claimed") return item.approved ? "reward" : "pending"; if (t === "reward_approved") return "reward"; if (t === "reward") return "reward"; if (item.rejected) return "rejected"; if (item.approved) return "approved"; return "pending"; } // Shared data prep used by both the classic and designed render paths. _buildEvents(entity) { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const pointsIcon = attrs.points_icon || "mdi:star"; const children = attrs.children || []; const chores = attrs.chores || []; const childNames = {}; children.forEach(ch => { childNames[ch.id] = ch.name; }); const chorePointsMap = {}; chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; }); let completions = [...(attrs.recent_completions || attrs.todays_completions || [])]; const seen = new Set(); completions = completions.filter(comp => { if (seen.has(comp.completion_id)) return false; seen.add(comp.completion_id); return true; }); const transactions = (attrs.recent_transactions || []).map(t => ({ ...t, completed_at: t.created_at, })); let allEvents = [...completions, ...transactions]; if (this.config.child_id) { allEvents = allEvents.filter(e => e.child_id === this.config.child_id); } allEvents.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at)); const maxItems = this.config.max_items || 30; const unfiltered = allEvents.slice(0, maxItems); const filteredEvents = this._filter === "all" ? unfiltered : unfiltered.filter(e => this._eventBucket(e) === this._filter); return { childNames, chorePointsMap, pointsIcon, unfiltered, filteredEvents }; } 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 })}
`; } if (entity.state === "unavailable" || entity.state === "unknown") { return html`
${this._t('common.unavailable')}
`; } const { childNames, chorePointsMap, pointsIcon, unfiltered, filteredEvents } = this._buildEvents(entity); const title = this.config.title || this._t('activity.default_title'); const headerColor = _safeColor(this.config.header_color, '#2471a3'); if (unfiltered.length === 0) { return html` ${this._renderHeader(title, 0)}
${this._t('activity.no_activity_yet')}
${this._t('activity.completed_chores_will_appear')}
`; } const groups = this._groupByDay(filteredEvents); return html` ${this._renderHeader(title, filteredEvents.length, unfiltered.length)} ${this.config.show_filter_chips !== false ? this._renderFilterBar() : ''}
${filteredEvents.length === 0 ? html`
${this._t('activity.no_events_for_filter')}
${this._t('activity.try_different_filter')}
` : html` ${groups.map(([label, suffix, items]) => html`
${label}${suffix ? html`· ${suffix}` : ''}
${items.map(item => this._renderItem(item, childNames, pointsIcon, chorePointsMap))} `)}
${this._t('activity.feed_end')}
`}
${this._renderConfirmDialog()}
`; } _renderHeader(title, count) { return html`
${title} · ${this._t('activity.events_count', { count })}
`; } _renderFilterBar() { const chips = [ { id: "all", label: this._t('activity.filter_all'), icon: null }, { id: "chores", label: this._t('activity.filter_chores'), icon: "mdi:checkbox-marked-circle-outline" }, { id: "rewards", label: this._t('activity.filter_rewards'), icon: "mdi:gift-outline" }, { id: "adjustments", label: this._t('activity.filter_adjustments'), icon: "mdi:plus-minus-variant" }, ]; return html`
${chips.map(c => html` `)}
`; } _renderItem(item, childNames, pointsIcon, chorePointsMap) { const childName = childNames[item.child_id] || item.child_name || this._t('activity.unknown_child'); const type = item.type || "chore"; const time = this._formatTime(new Date(item.completed_at)); const ago = this._formatAgo(new Date(item.completed_at)); const klass = this._classifyItem(item); // ── Manual points transactions ──────────────────────── if (type === "points_added" || type === "points_removed") { const isAdd = type === "points_added"; const pts = Math.abs(item.points || 0); const reason = item.reason || ''; const isPenalty = reason.startsWith('Penalty:'); const isPoolAllocation = reason.startsWith('Allocated to pool:'); const isSpend = !isAdd && !isPenalty && isPoolAllocation; const verb = isAdd ? this._t('activity.received') : isSpend ? this._t('activity.spent') : this._t('activity.lost'); const pillClass = isAdd ? 'gain' : (isSpend ? 'spend' : 'loss'); const icon = isAdd ? 'mdi:star-plus' : (isPenalty ? 'mdi:minus-circle' : 'mdi:star-minus'); const displayReason = this._translateReason(item.reason); return html`
${this.config.accent_stripes !== false ? html`
` : ''}
${childName} ${' '}${verb}${' '}${pts} ${item.reason ? html` — ${displayReason}` : ` ${this._t('activity.points_manually')}`}
${time} ${this.config.show_relative_time !== false ? html`${ago}` : ''}
${isAdd ? '+' : '−'}${pts} ${this._txnReversible(reason) ? this._renderUndoButton('txn', item.transaction_id, displayReason || reason, childName, pts) : ''}
`; } // ── Reward claim events ─────────────────────────────── if (type === "reward_claimed" || type === "reward_approved") { const pts = Math.abs(item.points || 0); const isPending = type === "reward_claimed" && !item.approved; const pillClass = isPending ? 'muted' : 'spend'; const itemKlass = isPending ? 'pending' : 'reward'; return html`
${this.config.accent_stripes !== false ? html`
` : ''}
${childName} ${isPending ? ` ${this._t('activity.claimed')}` : ` ${this._t('activity.redeemed')}`} ${item.reward_name || this._t('activity.a_reward')} ${isPending ? html` ${this._t('activity.awaiting_approval')}` : ''}
${time} ${this.config.show_relative_time !== false ? html`${ago}` : ''}
${pts ? html` −${pts} ` : ''}
`; } // ── Chore completions ───────────────────────────────── const status = item.approved ? "approved" : item.rejected ? "rejected" : "pending"; const iconMap = { approved: 'mdi:checkbox-marked-circle', pending: 'mdi:clock-outline', rejected: 'mdi:close-circle', }; const pillClass = status === 'approved' ? 'gain' : status === 'rejected' ? 'loss' : 'muted'; const choreName = item.chore_name || this._t('activity.a_chore'); const pts = item.points !== undefined ? item.points : (chorePointsMap?.[item.chore_id] || 0); return html`
${childName} · ${choreName} ${status !== 'approved' ? html` ${this._t('common.' + status)} ` : ''}
${time} ${ago}
${pts ? html` +${pts} ` : ''} ${status === 'approved' ? this._renderUndoButton('chore', item.completion_id, choreName, childName, pts) : ''}
`; } // ── Undo affordance ────────────────────────────────────── // kind: 'chore' (undo an approval → pending) or 'txn' (reverse a points txn). _renderUndoButton(kind, id, detail, child, points) { if (!id || this.config.show_undo === false) return ''; const loading = !!this._loading[id]; const message = kind === 'chore' ? this._t('activity.undo_confirm_chore', { detail, child, points }) : this._t('activity.undo_confirm_txn', { detail, child, points }); return html` `; } _renderConfirmDialog() { if (!this._confirm) return ''; // Self-contained overlay (not ha-dialog/mwc-button, which can render without // visible action buttons if those components aren't loaded on the page). return html`
{ if (e.target.classList.contains('undo-ov')) this._confirm = null; }}>
`; } async _doUndo() { const pending = this._confirm; this._confirm = null; if (!pending) return; const { kind, id } = pending; if (this._loading[id]) return; this._loading = { ...this._loading, [id]: true }; this.requestUpdate(); try { if (kind === 'chore') { await this.hass.callService('taskmate', 'undo_chore_approval', { completion_id: id }); } else { await this.hass.callService('taskmate', 'undo_transaction', { transaction_id: id }); } } catch (error) { console.error('TaskMate undo failed:', error); if (this.hass.callService) { this.hass.callService('persistent_notification', 'create', { title: this._t('activity.undo_error_title'), message: this._t('activity.undo_error_body', { message: error.message }), notification_id: `taskmate_undo_error_${id}`, }); } } finally { this._loading = { ...this._loading, [id]: false }; } } /* ══════════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js Ported from docs/design/redesigns/frag/17-activity.html ══════════════════════════════════════════════════════════════════════ */ // Normalise a raw event into a descriptor the designed renderers consume. // Preserves the SAME undo affordance/handler used by the classic path. _describeEvent(item, childNames, pointsIcon, chorePointsMap) { const childName = childNames[item.child_id] || item.child_name || this._t('activity.unknown_child'); const type = item.type || "chore"; const ago = this._formatAgo(new Date(item.completed_at)) || this._formatTime(new Date(item.completed_at)); const time = this._formatTime(new Date(item.completed_at)); if (type === "points_added" || type === "points_removed") { const isAdd = type === "points_added"; const pts = Math.abs(item.points || 0); const reason = item.reason || ''; const isPenalty = reason.startsWith('Penalty:'); const isPoolAllocation = reason.startsWith('Allocated to pool:'); const isSpend = !isAdd && !isPenalty && isPoolAllocation; const displayReason = this._translateReason(item.reason); const verb = isAdd ? this._t('activity.received') : isSpend ? this._t('activity.spent') : this._t('activity.lost'); const sign = isAdd ? '+' : '−'; const tone = isAdd ? 'good' : (isSpend ? 'accent' : 'bad'); const emoji = isAdd ? '⭐' : (isPenalty ? '⚠️' : '➖'); return { childName, tone, emoji, sign, pts, text: html`${childName} ${verb} ${pts}${item.reason ? html` — ${displayReason}` : ''}`, plain: `${childName} ${verb} ${pts}`, ago, time, ptsClass: isAdd ? 'good' : (isSpend ? 'accent' : 'bad'), undo: this._txnReversible(reason) ? { kind: 'txn', id: item.transaction_id, detail: displayReason || reason, child: childName, points: pts } : null, }; } if (type === "reward_claimed" || type === "reward_approved") { const pts = Math.abs(item.points || 0); const isPending = type === "reward_claimed" && !item.approved; const rewardName = item.reward_name || this._t('activity.a_reward'); return { childName, tone: 'accent', emoji: '🎁', sign: '−', pts, text: html`${childName} ${isPending ? this._t('activity.claimed') : this._t('activity.redeemed')} ${rewardName}`, plain: `${childName} · ${rewardName}`, ago, time, ptsClass: 'accent', undo: null, }; } // Chore completion const status = item.approved ? "approved" : item.rejected ? "rejected" : "pending"; const choreName = item.chore_name || this._t('activity.a_chore'); const pts = item.points !== undefined ? item.points : (chorePointsMap?.[item.chore_id] || 0); const tone = status === 'approved' ? 'good' : status === 'rejected' ? 'bad' : 'warn'; const emoji = status === 'approved' ? '✅' : status === 'rejected' ? '❌' : '⏳'; return { childName, tone, emoji, sign: '+', pts, text: html`${childName} ${this._t('activity.completed')} ${choreName}`, plain: `${childName} · ${choreName}`, ago, time, ptsClass: status === 'approved' ? 'good' : status === 'rejected' ? 'bad' : 'muted', undo: status === 'approved' ? { kind: 'chore', id: item.completion_id, detail: choreName, child: childName, points: pts } : null, }; } _designUndoBtn(undo, label) { if (!undo || !undo.id || this.config.show_undo === false) return ''; const loading = !!this._loading[undo.id]; const message = undo.kind === 'chore' ? this._t('activity.undo_confirm_chore', { detail: undo.detail, child: undo.child, points: undo.points }) : this._t('activity.undo_confirm_txn', { detail: undo.detail, child: undo.child, points: undo.points }); return html``; } _designFilterChips() { const chips = [ { id: "all", label: this._t('activity.filter_all') }, { id: "chores", label: this._t('activity.filter_chores') }, { id: "rewards", label: this._t('activity.filter_rewards') }, { id: "adjustments", label: this._t('activity.filter_adjustments') }, ]; return html`
${chips.map(c => html` `)}
`; } _renderDesigned(design) { const entity = this.hass.states[this.config.entity]; const hd = _safeColor(this.config.header_color, '#16a085'); const title = this.config.title || this._t('activity.default_title'); const header = (sub, pill) => html`
📜 ${title}${sub ? html`${sub}` : ''} ${pill ? html`${pill}` : ''}
`; if (!entity) { return html`${header()}
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } if (entity.state === "unavailable" || entity.state === "unknown") { return html`${header()}
${this._t('common.unavailable')}
`; } const { childNames, chorePointsMap, pointsIcon, unfiltered, filteredEvents } = this._buildEvents(entity); if (unfiltered.length === 0) { return html`${header(this._t('activity.recent_events'))}
${this._t('activity.no_activity_yet')}
`; } const rows = filteredEvents.map(e => this._describeEvent(e, childNames, pointsIcon, chorePointsMap)); const sub = design === 'console' ? 'LIVE · STREAM' : this._t('activity.recent_events'); const pill = design === 'console' ? this._t('activity.events_count', { count: filteredEvents.length }) : ''; const body = html` ${this.config.show_filter_chips !== false ? this._designFilterChips() : ''} ${filteredEvents.length === 0 ? html`
${this._t('activity.no_events_for_filter')}
` : design === 'console' ? this._designConsole(rows) : design === 'cleanpro' ? this._designCleanpro(rows) : this._designPlayroom(rows)}`; return html` ${header(sub, pill)}
${body}
${this._renderConfirmDialog()}
`; } // Always show the absolute time; when show_relative_time is on, show the // relative "x ago" ALONGSIDE it (matches the classic activity-meta row). _timeMeta(r) { return this.config.show_relative_time !== false ? html`${r.time} · ${r.ago}` : html`${r.time}`; } _stripe(r) { return this.config.accent_stripes !== false ? html`
` : ''; } _designPlayroom(rows) { return html`
${rows.map(r => html`
${this._stripe(r)}
${r.emoji}
${r.text} ${r.sign}${r.pts}
${this._timeMeta(r)} ${this._designUndoBtn(r.undo, this._t('activity.undo'))}
`)}
`; } _designConsole(rows) { return html`
${rows.map(r => html`
${this._stripe(r)}
${r.emoji}
${r.plain} ${r.sign}${r.pts}
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
${this._timeMeta(r)}
`)}
`; } _designCleanpro(rows) { // Clean Pro conveys event colour through the timeline dot; honour // accent_stripes by neutralising the dot when stripes are disabled. const stripes = this.config.accent_stripes !== false; return html`
${rows.map(r => html`
${r.emoji}
${r.text} ${r.sign}${r.pts}
${this._timeMeta(r)}
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
`)}
`; } _groupByDay(items) { // Returns an array of [primaryLabel, dateSuffix, items] // primaryLabel: "Today" / "Yesterday" / weekday-short, dateSuffix: e.g. "Mon 13 May" const groups = new Map(); const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; const now = new Date(); const nowKey = now.toLocaleDateString("en-CA", { timeZone: tz }); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const yKey = yesterday.toLocaleDateString("en-CA", { timeZone: tz }); items.forEach(item => { const date = new Date(item.completed_at); const key = date.toLocaleDateString("en-CA", { timeZone: tz }); let primary; let suffix; if (key === nowKey) { primary = this._t('common.today'); suffix = date.toLocaleDateString(undefined, { timeZone: tz, weekday: "short", day: "numeric", month: "short" }); } else if (key === yKey) { primary = this._t('common.yesterday'); suffix = date.toLocaleDateString(undefined, { timeZone: tz, weekday: "short", day: "numeric", month: "short" }); } else { primary = date.toLocaleDateString(undefined, { timeZone: tz, weekday: "short" }); suffix = date.toLocaleDateString(undefined, { timeZone: tz, day: "numeric", month: "short" }); } const groupKey = key; if (!groups.has(groupKey)) groups.set(groupKey, { primary, suffix, items: [] }); groups.get(groupKey).items.push(item); }); return [...groups.values()].map(g => [g.primary, g.suffix, g.items]); } _formatTime(date) { return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } // "2h ago" / "5m ago" / "3d ago" — short relative label. _formatAgo(date) { const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return ""; const mins = Math.floor(diffMs / 60000); if (mins < 1) return this._t('activity.just_now'); if (mins < 60) return this._t('activity.minutes_ago', { count: mins }); const hours = Math.floor(mins / 60); if (hours < 24) return this._t('activity.hours_ago', { count: hours }); const days = Math.floor(hours / 24); if (days < 30) return this._t('activity.days_ago', { count: days }); return ""; } } // Card Editor class TaskMateActivityCardEditor 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 entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null; const children = entity?.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', }, }, }, { name: 'max_items', selector: { number: { min: 5, max: 200, mode: 'box' } } }, { name: 'show_filter_chips', selector: { boolean: {} } }, { name: 'accent_stripes', selector: { boolean: {} } }, { name: 'show_relative_time', selector: { boolean: {} } }, { name: 'show_undo', selector: { boolean: {} } }, ]; } _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'), child_id: this._t('common.editor.filter_by_child'), max_items: this._t('activity.editor.max_items'), show_filter_chips: this._t('activity.editor.show_filter_chips'), accent_stripes: this._t('activity.editor.accent_stripes'), show_relative_time: this._t('activity.editor.show_relative_time'), show_undo: this._t('activity.editor.show_undo'), }; return labels[entry.name] ?? entry.name; }; _computeHelper = (entry) => { const helpers = { entity: this._t('common.editor.overview_entity_helper'), child_id: this._t('activity.editor.filter_child_helper'), max_items: this._t('activity.editor.max_items_helper'), show_filter_chips: this._t('activity.editor.show_filter_chips_helper'), accent_stripes: this._t('activity.editor.accent_stripes_helper'), show_relative_time: this._t('activity.editor.show_relative_time_helper'), show_undo: this._t('activity.editor.show_undo_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', child_id: this.config.child_id || '__all__', max_items: this.config.max_items || 30, show_filter_chips: this.config.show_filter_chips !== false, accent_stripes: this.config.accent_stripes !== false, show_relative_time: this.config.show_relative_time !== false, show_undo: this.config.show_undo !== false, }; return html` ${this._renderColourPicker('header_color', '#2471a3')} `; } _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 }; // Booleans whose default is `true` — drop them from config when true to keep YAML minimal. const trueDefaultBooleans = new Set(['show_filter_chips', 'accent_stripes', 'show_relative_time']); for (const [key, value] of Object.entries(newValues)) { if ( value === '' || value === null || value === undefined || (key === 'child_id' && value === '__all__') ) { delete newConfig[key]; } else if (key === 'card_design' && value === 'global') { delete newConfig[key]; } else if (key === 'max_items' && value === 30) { delete newConfig[key]; } else if (trueDefaultBooleans.has(key) && value === true) { 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 === null || value === "" || value === undefined) delete newConfig[key]; this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, })); } } customElements.define("taskmate-activity-card", TaskMateActivityCard); customElements.define("taskmate-activity-card-editor", TaskMateActivityCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-activity-card", name: "TaskMate Activity Feed", description: "Timeline of recent chore completions and reward claims", preview: true, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-activity-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-activity-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE ACTIVITY CARD %c v" + _tmVersion + " ", "background:#2471a3;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;" );