/**
* 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`