Files
HomeAssistantVS/custom_components/taskmate/www/taskmate-activity-card.js
T
2026-07-08 10:43:39 -04:00

1344 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
}
if (entity.state === "unavailable" || entity.state === "unknown") {
return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.unavailable')}</div></div></ha-card>`;
}
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`
<ha-card>
<style>:host { --taskmate-header-bg: ${headerColor}; }</style>
${this._renderHeader(title, 0)}
<div class="empty-state">
<ha-icon icon="mdi:timeline-clock-outline"></ha-icon>
<div class="message">${this._t('activity.no_activity_yet')}</div>
<div class="submessage">${this._t('activity.completed_chores_will_appear')}</div>
</div>
</ha-card>
`;
}
const groups = this._groupByDay(filteredEvents);
return html`
<ha-card>
<style>:host { --taskmate-header-bg: ${headerColor}; }</style>
${this._renderHeader(title, filteredEvents.length, unfiltered.length)}
${this.config.show_filter_chips !== false ? this._renderFilterBar() : ''}
<div class="feed-container">
${filteredEvents.length === 0 ? html`
<div class="empty-state">
<ha-icon icon="mdi:filter-variant-remove"></ha-icon>
<div class="message">${this._t('activity.no_events_for_filter')}</div>
<div class="submessage">${this._t('activity.try_different_filter')}</div>
</div>
` : html`
${groups.map(([label, suffix, items]) => html`
<div class="date-label">${label}${suffix ? html`<span class="date-suffix">· ${suffix}</span>` : ''}</div>
${items.map(item => this._renderItem(item, childNames, pointsIcon, chorePointsMap))}
`)}
<div class="feed-end">${this._t('activity.feed_end')}</div>
`}
</div>
${this._renderConfirmDialog()}
</ha-card>
`;
}
_renderHeader(title, count) {
return html`
<div class="card-header">
<div class="header-content">
<span class="header-icon-chip"><ha-icon icon="mdi:timeline-clock"></ha-icon></span>
<span class="header-title">${title}</span>
<span class="header-meta">· ${this._t('activity.events_count', { count })}</span>
</div>
</div>
`;
}
_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`
<div class="filter-bar" role="tablist" aria-label="${this._t('activity.filter_aria_label')}">
${chips.map(c => html`
<button
type="button"
class="filter-chip ${this._filter === c.id ? 'active' : ''}"
role="tab"
aria-selected="${this._filter === c.id}"
@click=${() => this._setFilter(c.id)}
>
${c.icon ? html`<ha-icon icon="${c.icon}"></ha-icon>` : ''}
${c.label}
</button>
`)}
</div>
`;
}
_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`
<div class="activity-item t-${klass}">
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
<div class="activity-row">
<div class="activity-icon t-${klass}">
<ha-icon icon="${icon}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong>
${' '}${verb}${' '}<strong>${pts}</strong>
${item.reason ? html` <span class="reason">— ${displayReason}</span>` : ` ${this._t('activity.points_manually')}`}
</div>
<div class="activity-meta">
<span class="activity-time">${time}</span>
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
</div>
</div>
<span class="points-pill ${pillClass}">
${isAdd ? '+' : ''}${pts}
<ha-icon icon="${pointsIcon}"></ha-icon>
</span>
${this._txnReversible(reason)
? this._renderUndoButton('txn', item.transaction_id, displayReason || reason, childName, pts)
: ''}
</div>
</div>
`;
}
// ── 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`
<div class="activity-item t-${itemKlass}">
${this.config.accent_stripes !== false ? html`<div class="event-stripe"></div>` : ''}
<div class="activity-row">
<div class="activity-icon t-${itemKlass}">
<ha-icon icon="${isPending ? 'mdi:gift-outline' : 'mdi:gift'}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong>
${isPending ? ` ${this._t('activity.claimed')}` : ` ${this._t('activity.redeemed')}`}
<strong>${item.reward_name || this._t('activity.a_reward')}</strong>
${isPending ? html` <span class="activity-status pending">${this._t('activity.awaiting_approval')}</span>` : ''}
</div>
<div class="activity-meta">
<span class="activity-time">${time}</span>
${this.config.show_relative_time !== false ? html`<span class="activity-ago">${ago}</span>` : ''}
</div>
</div>
${pts ? html`
<span class="points-pill ${pillClass}">
${pts}
<ha-icon icon="${pointsIcon}"></ha-icon>
</span>
` : ''}
</div>
</div>
`;
}
// ── 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`
<div class="activity-item t-${status}">
<div class="event-stripe"></div>
<div class="activity-row">
<div class="activity-icon t-${status}">
<ha-icon icon="${iconMap[status]}"></ha-icon>
</div>
<div class="activity-body">
<div class="activity-title">
<strong>${childName}</strong> · ${choreName}
${status !== 'approved' ? html`
<span class="activity-status ${status}">${this._t('common.' + status)}</span>
` : ''}
</div>
<div class="activity-meta">
<span class="activity-time">${time}</span>
<span class="activity-ago">${ago}</span>
</div>
</div>
${pts ? html`
<span class="points-pill ${pillClass}">
+${pts}
<ha-icon icon="${pointsIcon}"></ha-icon>
</span>
` : ''}
${status === 'approved'
? this._renderUndoButton('chore', item.completion_id, choreName, childName, pts)
: ''}
</div>
</div>
`;
}
// ── 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`
<button
class="undo-btn"
type="button"
title=${this._t('activity.undo')}
aria-label=${this._t('activity.undo')}
?disabled=${loading}
@click=${() => { this._confirm = { kind, id, message }; }}
>
<ha-icon icon="mdi:undo-variant"></ha-icon>
</button>
`;
}
_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`
<div class="undo-ov" @click=${(e) => { if (e.target.classList.contains('undo-ov')) this._confirm = null; }}>
<div class="undo-modal" role="dialog" aria-modal="true">
<div class="undo-modal-title">${this._t('activity.undo_confirm_title')}</div>
<div class="undo-modal-body">${this._confirm.message}</div>
<div class="undo-modal-actions">
<button class="undo-modal-btn cancel" @click=${() => { this._confirm = null; }}>
${this._t('common.cancel')}
</button>
<button class="undo-modal-btn go" @click=${() => this._doUndo()}>
${this._t('activity.undo')}
</button>
</div>
</div>
</div>
`;
}
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`<strong>${childName}</strong> ${verb} <strong>${pts}</strong>${item.reason ? html` <span class="reason">— ${displayReason}</span>` : ''}`,
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`<strong>${childName}</strong> ${isPending ? this._t('activity.claimed') : this._t('activity.redeemed')} <strong>${rewardName}</strong>`,
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`<strong>${childName}</strong> ${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`<button class="btn ghost sm tmd-undo" ?disabled=${loading}
@click=${() => { this._confirm = { kind: undo.kind, id: undo.id, message }; }}>↩ ${label}</button>`;
}
_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`
<div class="tmd-filters">
${chips.map(c => html`
<button class="chip ${this._filter === c.id ? 'soft' : ''}"
@click=${() => this._setFilter(c.id)}>${c.label}</button>`)}
</div>`;
}
_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`
<div class="tmd-hd">
<span class="ic">📜</span>
<span class="tt">${title}${sub ? html`<small>${sub}</small>` : ''}</span>
${pill ? html`<span class="pill">${pill}</span>` : ''}
</div>`;
if (!entity) {
return html`<ha-card class="tmd" style="--hd:${hd}">${header()}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
}
if (entity.state === "unavailable" || entity.state === "unknown") {
return html`<ha-card class="tmd" style="--hd:${hd}">${header()}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div></ha-card>`;
}
const { childNames, chorePointsMap, pointsIcon, unfiltered, filteredEvents } = this._buildEvents(entity);
if (unfiltered.length === 0) {
return html`<ha-card class="tmd" style="--hd:${hd}">${header(this._t('activity.recent_events'))}
<div class="tmd-bd"><div class="tmd-empty">${this._t('activity.no_activity_yet')}</div></div></ha-card>`;
}
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`<div class="tmd-empty">${this._t('activity.no_events_for_filter')}</div>`
: design === 'console' ? this._designConsole(rows)
: design === 'cleanpro' ? this._designCleanpro(rows)
: this._designPlayroom(rows)}`;
return html`<ha-card class="tmd" style="--hd:${hd}">
${header(sub, pill)}
<div class="tmd-bd">${body}</div>
${this._renderConfirmDialog()}
</ha-card>`;
}
// 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} <span class="act-rel">· ${r.ago}</span>`
: html`${r.time}`;
}
_stripe(r) {
return this.config.accent_stripes !== false
? html`<div class="act-stripe" style="background:var(--tmd-${r.tone})"></div>` : '';
}
_designPlayroom(rows) {
return html`
<div class="grid act-pl">
${rows.map(r => html`
<div class="row act-pl-row" style="--ac:var(--tmd-${r.tone})">
${this._stripe(r)}
<div class="av act-pl-av" style="--av:38px;--ac:var(--tmd-${r.tone})">${r.emoji}</div>
<div class="act-pl-bub">
<div class="act-line">${r.text} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
<div class="row act-pl-foot">
<span class="muted act-ago">${this._timeMeta(r)}</span>
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
</div>
</div>
</div>`)}
</div>`;
}
_designConsole(rows) {
return html`
<div class="grid act-cn">
${rows.map(r => html`
<div class="row act-cn-row" style="--ac:var(--tmd-${r.tone})">
${this._stripe(r)}
<div class="av" style="--av:26px;--ac:var(--tmd-${r.tone})">${r.emoji}</div>
<div class="act-cn-mid">${r.plain} <span class="num" style="color:var(--tmd-${r.ptsClass})">${r.sign}${r.pts}</span></div>
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
<div class="num muted act-cn-time">${this._timeMeta(r)}</div>
</div>`)}
</div>`;
}
_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`
<div class="act-cp-rail">
<div class="act-cp-line"></div>
<div class="grid act-cp">
${rows.map(r => html`
<div class="act-cp-node">
<div class="act-cp-dot" style="--ac:${stripes ? `var(--tmd-${r.tone})` : 'var(--tmd-dim)'}">${r.emoji}</div>
<div class="row act-cp-head">
<div>
<div class="act-line">${r.text} <span style="color:var(--tmd-${r.ptsClass});font-weight:700">${r.sign}${r.pts}</span></div>
<div class="muted act-ago">${this._timeMeta(r)}</div>
</div>
${this._designUndoBtn(r.undo, this._t('activity.undo'))}
</div>
</div>`)}
</div>
</div>`;
}
_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`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker('header_color', '#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;"
);