New apps Added
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* TaskMate attribute resolver.
|
||||
*
|
||||
* The overview sensor used to expose every data slice (chores, rewards,
|
||||
* recent activity, penalties, bonuses) as attributes of
|
||||
* sensor.taskmate_overview. That payload routinely exceeded Home Assistant's
|
||||
* 16 KB recorder limit, so the data is now split across companion sensors
|
||||
* (sensor.taskmate_chores, _rewards, _activity, _incentives).
|
||||
*
|
||||
* To keep existing Lovelace dashboards working unchanged, every card reads
|
||||
* attributes through window.__taskmate_attrs(hass, primaryEntityId), which
|
||||
* returns a merged attribute object: primary attributes plus each companion
|
||||
* sensor's attributes overlaid on top. If a companion sensor is missing
|
||||
* (e.g. older backend), its keys fall back to the primary sensor's
|
||||
* attributes. Cards therefore do not need to know which sensor owns which
|
||||
* attribute — lookup is transparent.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Fixed companion entity ids. TaskMate is a single-instance integration
|
||||
// (config_flow enforces unique_id == DOMAIN), so these ids are stable.
|
||||
const COMPANIONS = [
|
||||
"sensor.taskmate_chores",
|
||||
"sensor.taskmate_chore_availability",
|
||||
"sensor.taskmate_rewards",
|
||||
"sensor.taskmate_activity",
|
||||
"sensor.taskmate_incentives",
|
||||
// Full pending-approvals lists (chore_completions / reward_claims). These
|
||||
// are NOT filtered to today, so approval cards pointed at the overview
|
||||
// entity can still surface a completion left pending from a previous day.
|
||||
"sensor.pending_approvals",
|
||||
];
|
||||
|
||||
function mergedAttributes(hass, primaryEntityId) {
|
||||
if (!hass || !hass.states) return {};
|
||||
const merged = {};
|
||||
const primary = hass.states[primaryEntityId];
|
||||
if (primary && primary.attributes) {
|
||||
Object.assign(merged, primary.attributes);
|
||||
}
|
||||
for (const id of COMPANIONS) {
|
||||
const s = hass.states[id];
|
||||
if (s && s.attributes) {
|
||||
Object.assign(merged, s.attributes);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Cache merged attributes per hass/primary tuple so repeated card renders
|
||||
// during a single Home Assistant state update don't rebuild the object.
|
||||
let _cache = { stateRef: null, byPrimary: new Map() };
|
||||
|
||||
function resolveAttrs(hass, primaryEntityId) {
|
||||
if (!hass) return {};
|
||||
if (_cache.stateRef !== hass.states) {
|
||||
_cache = { stateRef: hass.states, byPrimary: new Map() };
|
||||
}
|
||||
if (!_cache.byPrimary.has(primaryEntityId)) {
|
||||
_cache.byPrimary.set(primaryEntityId, mergedAttributes(hass, primaryEntityId));
|
||||
}
|
||||
return _cache.byPrimary.get(primaryEntityId);
|
||||
}
|
||||
|
||||
window.__taskmate_attrs = resolveAttrs;
|
||||
|
||||
/**
|
||||
* Event-driven update guard for LitElement cards.
|
||||
*
|
||||
* HA sets a new `hass` object on every card whenever ANY entity changes.
|
||||
* Cards that don't filter will re-render for every light toggle, thermostat
|
||||
* update, etc. This helper compares only TaskMate-relevant entities so
|
||||
* cards skip renders that can't possibly change their output.
|
||||
*
|
||||
* Usage inside a card class:
|
||||
* shouldUpdate(changedProps) {
|
||||
* if (changedProps.has("hass")) {
|
||||
* return window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity);
|
||||
* }
|
||||
* return true;
|
||||
* }
|
||||
*/
|
||||
const TASKMATE_BUTTON_PREFIX = "button.taskmate_";
|
||||
|
||||
function hasRelevantChange(oldHass, newHass, primaryEntityId) {
|
||||
if (!oldHass || !oldHass.states || !newHass || !newHass.states) return true;
|
||||
if (oldHass.states === newHass.states) return false;
|
||||
|
||||
if (primaryEntityId) {
|
||||
if (oldHass.states[primaryEntityId] !== newHass.states[primaryEntityId]) return true;
|
||||
}
|
||||
|
||||
for (const id of COMPANIONS) {
|
||||
if (oldHass.states[id] !== newHass.states[id]) return true;
|
||||
}
|
||||
|
||||
for (const id in newHass.states) {
|
||||
if (id.startsWith(TASKMATE_BUTTON_PREFIX)) {
|
||||
if (oldHass.states[id] !== newHass.states[id]) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
window.__taskmate_hasChanged = hasRelevantChange;
|
||||
|
||||
/**
|
||||
* Find the entity id of a TaskMate button by (child_id, action, target_id).
|
||||
*
|
||||
* TaskMate creates per-child/per-chore and per-child/per-reward button
|
||||
* entities. Lovelace cards used to call the backend services directly
|
||||
* (e.g. taskmate.complete_chore), which bypassed the button entity and
|
||||
* left its last_pressed state unchanged — so HA state-trigger automations
|
||||
* on button.taskmate_<child>_complete_<chore> never fired. Cards now
|
||||
* resolve the matching button entity and call button.press, making the
|
||||
* card and the HA entity the same code path.
|
||||
*
|
||||
* action: "complete" (chore) | "claim" (reward)
|
||||
* target_id: chore_id or reward_id
|
||||
*/
|
||||
function findButtonEntity(hass, childId, action, targetId) {
|
||||
if (!hass || !hass.states || !childId || !targetId) return null;
|
||||
const targetKey = action === "claim" ? "reward_id" : "chore_id";
|
||||
for (const [entityId, state] of Object.entries(hass.states)) {
|
||||
if (!entityId.startsWith("button.taskmate_")) continue;
|
||||
const attrs = state && state.attributes;
|
||||
if (!attrs) continue;
|
||||
if (attrs.child_id !== childId) continue;
|
||||
if (attrs[targetKey] !== targetId) continue;
|
||||
// Disambiguate chore complete vs reward claim — both carry child_id,
|
||||
// but only one of chore_id / reward_id.
|
||||
if (action === "claim" && !("reward_id" in attrs)) continue;
|
||||
if (action === "complete" && !("chore_id" in attrs)) continue;
|
||||
return entityId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
window.__taskmate_find_button = findButtonEntity;
|
||||
|
||||
// Cold-cache fix: if any TaskMate cards already mounted before this module
|
||||
// finished loading, they fell back to entity.attributes (overview-only) and
|
||||
// rendered empty chore lists. Walk the DOM (crossing shadow roots, since HA
|
||||
// nests Lovelace inside several shadow trees) and force a re-render now
|
||||
// that window.__taskmate_attrs is defined.
|
||||
function _refreshTaskMateCards() {
|
||||
const stack = [document];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
if (node.tagName && node.tagName.startsWith("TASKMATE-")) {
|
||||
if (typeof node.requestUpdate === "function") {
|
||||
try { node.requestUpdate(); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (node.shadowRoot) stack.push(node.shadowRoot);
|
||||
const children = node.children;
|
||||
if (children) {
|
||||
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
queueMicrotask(_refreshTaskMateCards);
|
||||
})();
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* TaskMate Badges Card
|
||||
* Displays a full grid of achievement badges for a single child.
|
||||
* Earned badges show tier colour and earn date; locked badges show
|
||||
* closest-criterion progress bar.
|
||||
*
|
||||
* Config:
|
||||
* entity - (required) full entity id, e.g. sensor.taskmate_badges_mia
|
||||
* title - card title override
|
||||
*
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
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 DESIGN_HEADER = "#6c5ce7";
|
||||
|
||||
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
|
||||
|
||||
class TaskMateBadgesCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_filterTier: { type: String },
|
||||
_justEarned: { type: String },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._filterTier = "all";
|
||||
this._justEarned = null;
|
||||
this._unsubscribeEvents = null;
|
||||
this._subscribing = false;
|
||||
this._justEarnedTimeout = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._subscribeEvents();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeEvents?.();
|
||||
this._unsubscribeEvents = null;
|
||||
if (this._justEarnedTimeout) {
|
||||
clearTimeout(this._justEarnedTimeout);
|
||||
this._justEarnedTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
_subscribeEvents() {
|
||||
if (!this.hass) return;
|
||||
if (this._unsubscribeEvents || this._subscribing) return;
|
||||
this._subscribing = true;
|
||||
this.hass.connection.subscribeEvents((event) => {
|
||||
const data = event.data || {};
|
||||
if (data.child_id && this.config?.child_id && String(data.child_id) !== String(this.config.child_id)) return;
|
||||
if (data.badge_id) {
|
||||
this._justEarned = String(data.badge_id);
|
||||
this.requestUpdate();
|
||||
this._justEarnedTimeout = setTimeout(() => {
|
||||
this._justEarned = null;
|
||||
this.requestUpdate();
|
||||
}, 1800);
|
||||
}
|
||||
}, "taskmate_badge_earned").then(unsub => {
|
||||
this._subscribing = false;
|
||||
if (!this.isConnected) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._unsubscribeEvents = unsub;
|
||||
}).catch(() => {
|
||||
this._subscribing = false;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--t-bronze: #e08a3c;
|
||||
--t-silver: #a8b8c8;
|
||||
--t-gold: #ffc107;
|
||||
--t-platinum: #4dd9e8;
|
||||
|
||||
--t-bronze-bg: linear-gradient(135deg, #fdf0e4 0%, #f9dfc2 100%);
|
||||
--t-silver-bg: linear-gradient(135deg, #f0f3f7 0%, #dce4ec 100%);
|
||||
--t-gold-bg: linear-gradient(135deg, #fffde6 0%, #fff3b0 100%);
|
||||
--t-platinum-bg: linear-gradient(135deg, #e8fbfd 0%, #c4f0f6 100%);
|
||||
--t-locked-bg: linear-gradient(135deg, #f8f9fa 0%, #eef0f2 100%);
|
||||
}
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
/* ── Header ── */
|
||||
.badges-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.badges-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.25);
|
||||
border: 2px solid rgba(255,255,255,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
--mdc-icon-size: 26px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badges-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badges-count {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255,255,255,0.8);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.badges-filter {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.35);
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.badges-filter option {
|
||||
color: var(--primary-text-color, #333);
|
||||
background: var(--card-background-color, #fff);
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
/* ── Tier colour helpers ── */
|
||||
.tier-bronze { --t: var(--t-bronze); --t-glow: rgba(224,138,60,0.35); --t-bg: var(--t-bronze-bg); }
|
||||
.tier-silver { --t: var(--t-silver); --t-glow: rgba(168,184,200,0.35); --t-bg: var(--t-silver-bg); }
|
||||
.tier-gold { --t: var(--t-gold); --t-glow: rgba(255,193,7,0.40); --t-bg: var(--t-gold-bg); }
|
||||
.tier-platinum { --t: var(--t-platinum); --t-glow: rgba(77,217,232,0.40); --t-bg: var(--t-platinum-bg); }
|
||||
|
||||
/* ── Badge tile ── */
|
||||
.badge {
|
||||
background: var(--t-locked-bg);
|
||||
border-radius: 16px;
|
||||
padding: 18px 12px 14px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: 2px dashed var(--divider-color, #d0d5db);
|
||||
}
|
||||
|
||||
.badge.earned {
|
||||
background: var(--t-bg);
|
||||
border: 2.5px solid var(--t);
|
||||
border-style: solid;
|
||||
box-shadow: 0 4px 16px var(--t-glow), 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.badge.earned:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 8px 24px var(--t-glow), 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.badge.locked {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.badge.locked:hover {
|
||||
opacity: 0.65;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.badge.just-earned {
|
||||
animation: earn-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), earn-shimmer 1.5s ease-out 0.6s;
|
||||
}
|
||||
|
||||
@keyframes earn-pop {
|
||||
0% { transform: scale(0.3) rotate(-8deg); opacity: 0; }
|
||||
60% { transform: scale(1.12) rotate(2deg); opacity: 1; }
|
||||
100% { transform: scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes earn-shimmer {
|
||||
0% { box-shadow: 0 0 0 0 var(--t-glow), 0 4px 16px var(--t-glow); }
|
||||
50% { box-shadow: 0 0 0 12px transparent, 0 4px 24px var(--t-glow); }
|
||||
100% { box-shadow: 0 4px 16px var(--t-glow), 0 1px 3px rgba(0,0,0,0.06); }
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--mdc-icon-size: 30px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
color: var(--secondary-text-color);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.badge.earned .badge-icon {
|
||||
background: var(--t);
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px var(--t-glow);
|
||||
}
|
||||
|
||||
.badge.earned:hover .badge-icon {
|
||||
transform: scale(1.1) rotate(-5deg);
|
||||
}
|
||||
|
||||
.tier-gold.earned .badge-icon,
|
||||
.tier-bronze.earned .badge-icon {
|
||||
color: #2d2000;
|
||||
}
|
||||
|
||||
.badge-tier {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 800;
|
||||
color: var(--t, var(--secondary-text-color));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text-color);
|
||||
margin-bottom: 3px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.badge.locked .badge-name { color: var(--secondary-text-color); }
|
||||
|
||||
.badge-meta {
|
||||
font-size: 0.65rem;
|
||||
color: var(--secondary-text-color);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.earned .badge-meta {
|
||||
color: var(--t);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-progress {
|
||||
height: 7px;
|
||||
background: var(--divider-color, #ddd);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.badge-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--t), color-mix(in srgb, var(--t), white 25%));
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.badge-progress-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--secondary-text-color);
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-bonus {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: linear-gradient(135deg, var(--t), color-mix(in srgb, var(--t), black 15%));
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tier-gold .badge-bonus,
|
||||
.tier-bronze .badge-bonus {
|
||||
color: #2d2000;
|
||||
}
|
||||
|
||||
/* ── Empty / error states ── */
|
||||
.error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon {
|
||||
--mdc-icon-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Designed styles (playroom / console / cleanpro) ── */
|
||||
/* Shared .tmd kit + tokens come from taskmate-design.js styles(). */
|
||||
.bd-filter { gap: 6px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||
.bd-chip { cursor: pointer; }
|
||||
.bd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(82px, 1fr)); gap: 9px; max-height: 360px; overflow-y: auto; }
|
||||
.bd-tile {
|
||||
background: var(--tmd-surface-2);
|
||||
border-radius: 16px;
|
||||
padding: 11px 6px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.bd-tile.locked { opacity: 0.55; }
|
||||
.bd-bonus {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--tmd-good);
|
||||
}
|
||||
.bd-ic {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.bd-ic.earned {
|
||||
background: color-mix(in srgb, var(--bt, var(--tmd-accent)) 22%, var(--tmd-surface));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bt, var(--tmd-accent)) 55%, transparent);
|
||||
color: var(--bt, var(--tmd-accent));
|
||||
}
|
||||
.bd-ic.locked {
|
||||
background: var(--tmd-surface);
|
||||
box-shadow: inset 0 0 0 2px var(--tmd-border);
|
||||
color: var(--tmd-dim);
|
||||
}
|
||||
.bd-name { font-weight: 800; font-size: 11.5px; }
|
||||
.bd-tile.locked .bd-name { color: var(--tmd-dim); }
|
||||
.bd-date { font-size: 10px; }
|
||||
.bd-prog-label { font-size: 10px; margin-top: 3px; }
|
||||
|
||||
/* Console tile overrides */
|
||||
:host([data-tm-design="console"]) .bd-tile { border-radius: 8px; }
|
||||
:host([data-tm-design="console"]) .bd-tile.earned {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bt, var(--tmd-accent)) 50%, transparent),
|
||||
0 0 16px color-mix(in srgb, var(--bt, var(--tmd-accent)) 22%, transparent);
|
||||
}
|
||||
:host([data-tm-design="console"]) .bd-tile.locked {
|
||||
background: #0b1424;
|
||||
border: 1px solid var(--tmd-border);
|
||||
opacity: 1;
|
||||
}
|
||||
:host([data-tm-design="console"]) .bd-ic.earned {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--bt, var(--tmd-accent)),
|
||||
0 0 14px color-mix(in srgb, var(--bt, var(--tmd-accent)) 55%, transparent);
|
||||
}
|
||||
:host([data-tm-design="console"]) .bd-ic.locked {
|
||||
box-shadow: inset 0 0 0 1px var(--tmd-border);
|
||||
}
|
||||
:host([data-tm-design="console"]) .bd-name { font-weight: 700; font-size: 11px; }
|
||||
|
||||
/* Clean Pro tile overrides */
|
||||
:host([data-tm-design="cleanpro"]) .bd-tile {
|
||||
background: transparent;
|
||||
border: 1px solid var(--tmd-border);
|
||||
border-radius: 11px;
|
||||
}
|
||||
:host([data-tm-design="cleanpro"]) .bd-tile.locked { background: var(--tmd-surface-2); }
|
||||
:host([data-tm-design="cleanpro"]) .bd-ic { width: 40px; height: 40px; font-size: 20px; }
|
||||
:host([data-tm-design="cleanpro"]) .bd-ic.earned {
|
||||
background: color-mix(in srgb, var(--bt, var(--tmd-accent)) 14%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
:host([data-tm-design="cleanpro"]) .bd-ic.locked { box-shadow: none; }
|
||||
:host([data-tm-design="cleanpro"]) .bd-name { font-weight: 600; }
|
||||
`;
|
||||
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("taskmate-badges-card: 'entity' is required (e.g. sensor.taskmate_badges_mia)");
|
||||
}
|
||||
this.config = { title: null, ...config };
|
||||
// Extract child_id from entity name for event matching:
|
||||
// sensor.taskmate_badges_<slug> → slug
|
||||
const match = config.entity.match(/sensor\.taskmate_badges_(.+)/);
|
||||
this._childSlug = match ? match[1] : null;
|
||||
}
|
||||
|
||||
getCardSize() { return 5; }
|
||||
|
||||
static getConfigElement() { return document.createElement("taskmate-badges-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview" };
|
||||
}
|
||||
|
||||
_tierLabel(tier) {
|
||||
return this._t("badge.tier_" + (tier || "bronze"));
|
||||
}
|
||||
|
||||
_formatDate(iso) {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
|
||||
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('badges.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 attrs = entity.attributes || {};
|
||||
const isPreview = !attrs.earned && !attrs.available;
|
||||
const earned = isPreview
|
||||
? [{ id: "s1", name: "Early Bird", icon: "mdi:weather-sunny", tier: "gold", point_bonus: 10, earned_at: new Date().toISOString() },
|
||||
{ id: "s2", name: "Streak Star", icon: "mdi:fire", tier: "silver", point_bonus: 5, earned_at: new Date().toISOString() }]
|
||||
: (attrs.earned || []);
|
||||
const available = isPreview
|
||||
? [{ id: "s3", name: "Helping Hand", icon: "mdi:hand-heart", tier: "bronze", point_bonus: 5, progress_pct: 60, progress_label: "3 / 5" }]
|
||||
: (attrs.available || []);
|
||||
const childName = isPreview ? "Child" : (attrs.child_name || attrs.name || "");
|
||||
const childAvatar = attrs.child_avatar || "mdi:account-circle";
|
||||
const totalBadges = attrs.total_badges || (earned.length + available.length);
|
||||
|
||||
const filter = this._filterTier;
|
||||
const filteredEarned = filter === "all" ? earned : earned.filter(b => b.tier === filter);
|
||||
const filteredLocked = filter === "all" ? available : available.filter(b => b.tier === filter);
|
||||
|
||||
const latestEarned = earned.length > 0 ? earned[0] : null;
|
||||
const countLabel = earned.length > 0 && latestEarned
|
||||
? this._t("badges.count_label_latest", { earned: earned.length, total: totalBadges, latest: latestEarned.name })
|
||||
: this._t("badges.count_label", { earned: earned.length, total: totalBadges });
|
||||
|
||||
const title = this.config.title || (childName ? this._t("badges.title_with_name", { name: childName }) : this._t("badges.default_title"));
|
||||
|
||||
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, {
|
||||
title, childName, earned, available, totalBadges,
|
||||
filteredEarned, filteredLocked, filter,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="badges-head">
|
||||
<div class="badges-head-left">
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${childAvatar}"></ha-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="badges-title">${title}</div>
|
||||
<div class="badges-count">${countLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<select class="badges-filter"
|
||||
.value=${filter}
|
||||
@change=${(e) => { this._filterTier = e.target.value; }}>
|
||||
<option value="all">${this._t("badges.all_tiers")}</option>
|
||||
<option value="bronze">${this._t("badge.tier_bronze")}</option>
|
||||
<option value="silver">${this._t("badge.tier_silver")}</option>
|
||||
<option value="gold">${this._t("badge.tier_gold")}</option>
|
||||
<option value="platinum">${this._t("badge.tier_platinum")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="badge-grid">
|
||||
${filteredEarned.map(b => this._renderEarned(b))}
|
||||
${filteredLocked.map(b => this._renderLocked(b))}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderEarned(b) {
|
||||
const justEarned = this._justEarned && String(this._justEarned) === String(b.id);
|
||||
return html`
|
||||
<div class="badge earned tier-${b.tier} ${justEarned ? 'just-earned' : ''}">
|
||||
${b.point_bonus > 0 ? html`<div class="badge-bonus">+${b.point_bonus}</div>` : ''}
|
||||
<div class="badge-icon">
|
||||
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
|
||||
</div>
|
||||
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
|
||||
<div class="badge-name">${b.name}</div>
|
||||
<div class="badge-meta">${this._t("badge.earned_date", { date: this._formatDate(b.earned_at) })}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderLocked(b) {
|
||||
// progress: either provided directly, or computed from closest_criterion
|
||||
const pct = (b.progress_pct != null) ? b.progress_pct
|
||||
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
|
||||
const progressLabel = b.progress_label
|
||||
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
|
||||
|
||||
return html`
|
||||
<div class="badge locked tier-${b.tier}">
|
||||
${b.point_bonus > 0 ? html`<div class="badge-bonus" style="opacity:0.4">+${b.point_bonus}</div>` : ''}
|
||||
<div class="badge-icon">
|
||||
<ha-icon icon="${b.icon || 'mdi:medal-outline'}"></ha-icon>
|
||||
</div>
|
||||
<div class="badge-tier">${this._tierLabel(b.tier)}</div>
|
||||
<div class="badge-name">${b.name}</div>
|
||||
${progressLabel ? html`
|
||||
<div class="badge-progress">
|
||||
<div class="badge-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<div class="badge-progress-label">${progressLabel}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Designed-style render methods are added to the prototype below.
|
||||
TaskMateBadgesCard.prototype._tierTone = function (tier) {
|
||||
// Map badge tiers onto shared design tokens (kit defines --tmd-gold).
|
||||
switch (tier) {
|
||||
case "platinum": return "var(--tmd-accent)";
|
||||
case "gold": return "var(--tmd-gold)";
|
||||
case "silver": return "var(--tmd-dim)";
|
||||
case "bronze": return "var(--tmd-c6)";
|
||||
default: return "var(--tmd-accent)";
|
||||
}
|
||||
};
|
||||
|
||||
TaskMateBadgesCard.prototype._designFilters = function (filter) {
|
||||
const tiers = ["all", "bronze", "silver", "gold", "platinum"];
|
||||
return html`
|
||||
<div class="row bd-filter">
|
||||
${tiers.map((t) => html`
|
||||
<button class="chip bd-chip ${t === filter ? "soft" : ""}"
|
||||
@click="${() => { this._filterTier = t; this.requestUpdate(); }}">
|
||||
${t === "all" ? this._t("badges.all_tiers") : this._tierLabel(t)}
|
||||
</button>`)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
TaskMateBadgesCard.prototype._designEarnedTile = function (b) {
|
||||
const tone = this._tierTone(b.tier);
|
||||
const a = b.icon || "mdi:medal";
|
||||
const icon = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>` : a;
|
||||
return html`
|
||||
<div class="bd-tile earned" style="--bt:${tone}">
|
||||
${b.point_bonus > 0 ? html`<div class="bd-bonus">+${b.point_bonus}</div>` : ""}
|
||||
<div class="bd-ic earned" style="--bt:${tone}">${icon}</div>
|
||||
<div class="bd-name">${b.name}</div>
|
||||
<div class="muted bd-date">${this._formatDate(b.earned_at)}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
TaskMateBadgesCard.prototype._designLockedTile = function (b) {
|
||||
const tone = this._tierTone(b.tier);
|
||||
const pct = (b.progress_pct != null) ? b.progress_pct
|
||||
: (b.closest_criterion ? Math.min(100, Math.round((b.closest_criterion.current / b.closest_criterion.target) * 100)) : 0);
|
||||
const progressLabel = b.progress_label
|
||||
|| (b.closest_criterion ? `${b.closest_criterion.current} / ${b.closest_criterion.target}` : null);
|
||||
const a = b.icon || "mdi:medal-outline";
|
||||
const icon = a.startsWith("mdi:") ? html`<ha-icon icon="${a}"></ha-icon>` : a;
|
||||
return html`
|
||||
<div class="bd-tile locked" style="--bt:${tone}">
|
||||
${b.point_bonus > 0 ? html`<div class="bd-bonus" style="opacity:0.45">+${b.point_bonus}</div>` : ""}
|
||||
<div class="bd-ic locked">${icon}</div>
|
||||
<div class="bd-name">${b.name}</div>
|
||||
${progressLabel ? html`
|
||||
<div class="bar" style="height:7px;margin-top:6px"><i style="width:${pct}%"></i></div>
|
||||
<div class="muted bd-prog-label">${progressLabel}</div>` : ""}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
TaskMateBadgesCard.prototype._renderDesigned = function (design, d) {
|
||||
const hd = _safeColor(this.config.header_color, DESIGN_HEADER);
|
||||
const ic = design === "console" ? "✦" : design === "cleanpro" ? "◈" : "🎖️";
|
||||
const sub = d.childName;
|
||||
const countPill = this._t("badges.count_label", { earned: d.earned.length, total: d.totalBadges });
|
||||
|
||||
return html`
|
||||
<ha-card class="tmd" style="--hd:${hd}">
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">${ic}</span>
|
||||
<span class="tt">${d.title}${sub ? html`<small>${sub}</small>` : ""}</span>
|
||||
<span class="pill">${countPill}</span>
|
||||
</div>
|
||||
<div class="tmd-bd">
|
||||
${this._designFilters(d.filter)}
|
||||
${(d.filteredEarned.length + d.filteredLocked.length) === 0
|
||||
? html`<div class="tmd-empty">${this._t("badges.count_label", { earned: 0, total: d.totalBadges })}</div>`
|
||||
: html`<div class="bd-grid">
|
||||
${d.filteredEarned.map((b) => this._designEarnedTile(b))}
|
||||
${d.filteredLocked.map((b) => this._designLockedTile(b))}
|
||||
</div>`}
|
||||
</div>
|
||||
</ha-card>`;
|
||||
};
|
||||
|
||||
// Editor: entity + per-card design override.
|
||||
class TaskMateBadgesCardEditor extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
setConfig(config) { this.config = config; }
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_buildSchema() {
|
||||
return [
|
||||
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{
|
||||
name: 'card_design',
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: 'global', label: 'Use global default' }],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('common.entity'),
|
||||
title: this._t('common.editor.card_title'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
card_design: this.config.card_design || 'global',
|
||||
};
|
||||
return html`
|
||||
<p style="padding:0 0 8px;color:var(--secondary-text-color)">${this._t('badges.editor_help')}</p>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${this._buildSchema()}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@value-changed=${this._formChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
delete newConfig[key];
|
||||
} else if (key === 'card_design' && value === 'global') {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-badges-card", TaskMateBadgesCard);
|
||||
customElements.define("taskmate-badges-card-editor", TaskMateBadgesCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-badges-card",
|
||||
name: "TaskMate Badges",
|
||||
description: "Achievement badge grid for a single child",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-badges-card", "overview"),
|
||||
});
|
||||
|
||||
const _tmBadgesVersion = new URLSearchParams(
|
||||
Array.from(document.querySelectorAll('script[src*="/taskmate-badges-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE BADGES CARD %c v" + _tmBadgesVersion + " ",
|
||||
"background:#9b59b6;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;"
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TaskMate Bonuses Card — thin wrapper over the shared incentive base (QUAL-2).
|
||||
*/
|
||||
import { createIncentiveCard } from "./taskmate-incentive-card.js";
|
||||
|
||||
createIncentiveCard({
|
||||
tag: "taskmate-bonuses-card",
|
||||
kind: "bonus",
|
||||
idKey: "bonus_id",
|
||||
i18n: "bonuses",
|
||||
attr: "bonuses",
|
||||
icon: "mdi:star-circle-outline",
|
||||
headerIcon: "mdi:star-circle",
|
||||
applyIcon: "mdi:plus-circle-outline",
|
||||
sign: "+",
|
||||
dpts: (n) => `+${n}`,
|
||||
accent: "#2e7d32",
|
||||
accentDark: "#1b5e20",
|
||||
accentLight: "rgba(46, 125, 50, 0.12)",
|
||||
accentFlash: "rgba(46,125,50,0.25)",
|
||||
accentShadow: "rgba(46,125,50,0.3)",
|
||||
designAccent: "var(--tmd-good)",
|
||||
designHd: "#2e7d32",
|
||||
designIcons: { console: "▲", cleanpro: "+", playroom: "🎁" },
|
||||
cardName: "TaskMate Bonuses",
|
||||
cardDesc: "Apply point-awarding bonuses to children",
|
||||
bannerName: "BONUSES",
|
||||
bannerColor: "#f39c12",
|
||||
});
|
||||
@@ -0,0 +1,923 @@
|
||||
/**
|
||||
* TaskMate Calendar Card
|
||||
* One-day view of the chores assigned to each child. Each child gets a
|
||||
* section with the chores scheduled for the selected day, colour-coded
|
||||
* by completion state. Prev/Next/Today buttons step through days.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
const DAY_NAMES = [
|
||||
"monday", "tuesday", "wednesday", "thursday",
|
||||
"friday", "saturday", "sunday",
|
||||
];
|
||||
|
||||
const WINDOW_DAYS = {
|
||||
every_2_days: 2,
|
||||
weekly: 7,
|
||||
every_2_weeks: 14,
|
||||
monthly: 30,
|
||||
every_3_months: 90,
|
||||
every_6_months: 180,
|
||||
};
|
||||
|
||||
function ymd(date, tz) {
|
||||
return date.toLocaleDateString("en-CA", { timeZone: tz });
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function diffDays(a, b) {
|
||||
const ms = 24 * 60 * 60 * 1000;
|
||||
return Math.round((a.getTime() - b.getTime()) / ms);
|
||||
}
|
||||
|
||||
class TaskMateCalendarCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_dayOffset: { type: Number, state: true },
|
||||
};
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._dayOffset = 0;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--cal-green: #2ecc71;
|
||||
--cal-amber: #f39c12;
|
||||
--cal-grey: #bdc3c7;
|
||||
--cal-blue: #3498db;
|
||||
}
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, #3498db);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
|
||||
.header-title { font-size: 1.2rem; font-weight: 600; }
|
||||
|
||||
.day-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.day-nav button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.day-nav button:hover { background: rgba(255,255,255,0.3); }
|
||||
.day-nav ha-icon { --mdc-icon-size: 18px; }
|
||||
.day-label {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.child-block {
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.child-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #9b59b6, #a569bd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.child-avatar ha-icon { --mdc-icon-size: 20px; color: white; }
|
||||
|
||||
.child-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--primary-text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.child-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chore-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chore-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-background-color, white);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--cal-grey);
|
||||
}
|
||||
.chore-row.approved { border-left-color: var(--cal-green); }
|
||||
.chore-row.pending { border-left-color: var(--cal-amber); }
|
||||
.chore-row.rotating { opacity: 0.6; font-style: italic; }
|
||||
|
||||
.chore-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color); }
|
||||
.chore-icon.approved { color: var(--cal-green); }
|
||||
.chore-icon.pending { color: var(--cal-amber); }
|
||||
|
||||
.chore-name {
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.chore-points {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chore-points ha-icon { --mdc-icon-size: 14px; color: #f1c40f; }
|
||||
|
||||
.no-chores {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 4px 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.approved { background: var(--cal-green); }
|
||||
.dot.pending { background: var(--cal-amber); }
|
||||
.dot.due { background: var(--cal-grey); }
|
||||
|
||||
.error-state, .empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 40px 20px;
|
||||
color: var(--secondary-text-color); text-align: center;
|
||||
}
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon {
|
||||
--mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Designed styles (playroom / console / cleanpro) ── */
|
||||
.cal-nav { justify-content: center; gap: 10px; margin-bottom: 13px; }
|
||||
.cal-nav-btn { width: 32px; height: 32px; font-size: 16px; }
|
||||
.cal-nav-cur { cursor: pointer; font-size: 13px; padding: 6px 16px; }
|
||||
.cal-nav-cn { justify-content: space-between; margin-bottom: 12px; }
|
||||
.cal-nav-cn .cal-nav-cur { font-size: 13px; color: var(--tmd-accent); padding: 0; }
|
||||
.cal-grid { gap: 11px; }
|
||||
.cal-head { margin-bottom: 8px; }
|
||||
.cal-name { font-weight: 800; }
|
||||
.cal-chips { gap: 7px; flex-wrap: wrap; }
|
||||
.cal-chip { white-space: nowrap; }
|
||||
.cal-empty-line { font-size: 12px; font-style: italic; }
|
||||
.cal-card-pl { background: var(--tmd-surface-2); border-radius: 18px; padding: 11px 12px; }
|
||||
|
||||
/* Console */
|
||||
.cal-grid-cn { gap: 9px; }
|
||||
.cal-card-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 8px; padding: 10px 11px; }
|
||||
.cal-head-cn { margin-bottom: 7px; }
|
||||
.cal-name-cn { font-weight: 700; font-size: 12.5px; }
|
||||
.cal-count { margin-left: auto; font-size: 10px; }
|
||||
|
||||
/* Clean Pro */
|
||||
.cal-grid-cp { gap: 0; }
|
||||
.cal-row-cp { align-items: flex-start; padding: 11px 0; border-bottom: 1px solid var(--tmd-border); }
|
||||
.cal-row-cp:last-child { border-bottom: none; }
|
||||
.cal-row-mid { flex: 1; min-width: 0; }
|
||||
.cal-name-cp { font-weight: 600; font-size: 13px; margin-bottom: 5px; }
|
||||
`;
|
||||
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(this._t("calendar.error.entity_required"));
|
||||
this.config = {
|
||||
title: null,
|
||||
child_id: null,
|
||||
header_color: "#3498db",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 4; }
|
||||
static getConfigElement() { return document.createElement("taskmate-calendar-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "Task Calendar" };
|
||||
}
|
||||
|
||||
_shiftDay(delta) { this._dayOffset = (this._dayOffset || 0) + delta; }
|
||||
_resetDay() { this._dayOffset = 0; }
|
||||
|
||||
_getSelectedDay(tz) {
|
||||
const today = new Date();
|
||||
const selected = addDays(today, this._dayOffset || 0);
|
||||
const dow = selected.getDay(); // 0 = Sunday
|
||||
const dayNameIdx = dow === 0 ? 6 : dow - 1;
|
||||
return {
|
||||
key: ymd(selected, tz),
|
||||
dow: DAY_NAMES[dayNameIdx],
|
||||
date: selected,
|
||||
todayKey: ymd(today, tz),
|
||||
};
|
||||
}
|
||||
|
||||
_isChoreScheduledOn(chore, dayDow, dayDate, todayKey, tz) {
|
||||
if (chore.enabled === false) return false;
|
||||
|
||||
const scheduleMode = chore.schedule_mode || "specific_days";
|
||||
const createdDate = chore.created_date || "";
|
||||
|
||||
if (scheduleMode === "one_shot") {
|
||||
if (!createdDate) return false;
|
||||
return createdDate === ymd(dayDate, tz);
|
||||
}
|
||||
|
||||
if (createdDate) {
|
||||
try {
|
||||
const created = new Date(createdDate + "T00:00:00");
|
||||
if (dayDate < new Date(created.toDateString())) return false;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (scheduleMode === "specific_days") {
|
||||
const dueDays = Array.isArray(chore.due_days) ? chore.due_days : [];
|
||||
if (dueDays.length === 0) return true;
|
||||
return dueDays.includes(dayDow);
|
||||
}
|
||||
|
||||
if (scheduleMode === "recurring") {
|
||||
const recurrence = chore.recurrence || "weekly";
|
||||
const recurrenceDay = (chore.recurrence_day || "").toLowerCase();
|
||||
const recurrenceStart = chore.recurrence_start || "";
|
||||
// Fall back to created_date as the cadence anchor for interval
|
||||
// recurrences with no explicit start, so they still project (ERR-2).
|
||||
const anchorStr = recurrenceStart || createdDate;
|
||||
|
||||
if (recurrenceDay && (recurrence === "weekly" || recurrence === "every_2_weeks")) {
|
||||
if (recurrenceDay !== dayDow) return false;
|
||||
if (recurrence === "every_2_weeks" && recurrenceStart) {
|
||||
try {
|
||||
const anchor = new Date(recurrenceStart + "T00:00:00");
|
||||
const diff = diffDays(dayDate, anchor);
|
||||
if (diff < 0) return false;
|
||||
if (Math.floor(diff / 7) % 2 !== 0) return false;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (recurrence === "every_2_days" && anchorStr) {
|
||||
try {
|
||||
const anchor = new Date(anchorStr + "T00:00:00");
|
||||
const diff = diffDays(dayDate, anchor);
|
||||
if (diff < 0) return false;
|
||||
return diff % 2 === 0;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
const monthSteps = { monthly: 1, every_3_months: 3, every_6_months: 6 }[recurrence];
|
||||
if (monthSteps && anchorStr) {
|
||||
try {
|
||||
const anchor = new Date(anchorStr + "T00:00:00");
|
||||
if (dayDate < anchor) return false;
|
||||
const monthsDiff =
|
||||
(dayDate.getFullYear() - anchor.getFullYear()) * 12 +
|
||||
(dayDate.getMonth() - anchor.getMonth());
|
||||
if (monthsDiff % monthSteps !== 0) return false;
|
||||
// Clamp the anchor day for shorter months (e.g. 31st -> Feb 28th)
|
||||
const lastDay = new Date(dayDate.getFullYear(), dayDate.getMonth() + 1, 0).getDate();
|
||||
return dayDate.getDate() === Math.min(anchor.getDate(), lastDay);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
const windowDays = WINDOW_DAYS[recurrence] || 7;
|
||||
if (windowDays === 7 || windowDays === 14) {
|
||||
const todayDow = new Date(todayKey + "T00:00:00").getDay();
|
||||
const todayIdx = todayDow === 0 ? 6 : todayDow - 1;
|
||||
return dayDow === DAY_NAMES[todayIdx];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_isAssignedTo(chore, childId) {
|
||||
const assignedTo = Array.isArray(chore.assigned_to) ? chore.assigned_to : [];
|
||||
if (assignedTo.length === 0) return true;
|
||||
return assignedTo.includes(childId);
|
||||
}
|
||||
|
||||
_rotationRenderMode(chore, childId, dayKey, todayKey) {
|
||||
const mode = chore.assignment_mode || "everyone";
|
||||
if (mode === "everyone") return "active";
|
||||
const current = chore.assignment_current_child_id || "";
|
||||
if (dayKey === todayKey) {
|
||||
return current === childId ? "active" : "hidden";
|
||||
}
|
||||
return "rotating";
|
||||
}
|
||||
|
||||
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 tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
|
||||
if (this.config.child_id) {
|
||||
children = children.filter((c) => c.id === this.config.child_id);
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="empty-state">
|
||||
<ha-icon icon="mdi:account-off"></ha-icon>
|
||||
<div>${this._t("common.no_children")}</div>
|
||||
</div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const day = this._getSelectedDay(tz);
|
||||
|
||||
const rawCompletions = attrs.recent_completions
|
||||
|| attrs.todays_completions
|
||||
|| [];
|
||||
const seen = new Set();
|
||||
const dayCompletions = [];
|
||||
rawCompletions.forEach((c) => {
|
||||
const id = c.completion_id || `${c.chore_id}:${c.child_id}:${c.completed_at}`;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
if (!c.completed_at) return;
|
||||
if (ymd(new Date(c.completed_at), tz) !== day.key) return;
|
||||
dayCompletions.push(c);
|
||||
});
|
||||
|
||||
const dayLabel = day.date.toLocaleDateString(undefined, {
|
||||
weekday: "long", month: "short", day: "numeric",
|
||||
});
|
||||
const isToday = day.key === day.todayKey;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, "#3498db")}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:calendar-account"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t("calendar.default_title")}</span>
|
||||
</div>
|
||||
<div class="day-nav">
|
||||
<button @click=${() => this._shiftDay(-1)} title=${this._t("calendar.prev_day")}>
|
||||
<ha-icon icon="mdi:chevron-left"></ha-icon>
|
||||
</button>
|
||||
<span class="day-label">${isToday ? this._t("common.today") : dayLabel}</span>
|
||||
<button @click=${() => this._shiftDay(1)} title=${this._t("calendar.next_day")}>
|
||||
<ha-icon icon="mdi:chevron-right"></ha-icon>
|
||||
</button>
|
||||
${!isToday ? html`
|
||||
<button @click=${() => this._resetDay()} title=${this._t("common.today")}>
|
||||
<ha-icon icon="mdi:calendar-today"></ha-icon>
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
${children.map((child) => this._renderChildBlock(child, day, chores, dayCompletions, pointsIcon, tz))}
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="dot approved"></span>${this._t("common.approved")}</span>
|
||||
<span class="legend-item"><span class="dot pending"></span>${this._t("common.pending")}</span>
|
||||
<span class="legend-item"><span class="dot due"></span>${this._t("calendar.legend_due")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
// Shared per-child chore extraction used by both classic and designed paths.
|
||||
// Returns { items: [{chore, state, stateIcon, rotation}], dueCount, doneCount }.
|
||||
_computeChildChores(child, day, chores, dayCompletions, tz) {
|
||||
const items = [];
|
||||
let dueCount = 0;
|
||||
let doneCount = 0;
|
||||
|
||||
chores.forEach((chore) => {
|
||||
if (!this._isAssignedTo(chore, child.id)) return;
|
||||
|
||||
const disabledFor = Array.isArray(chore.disabled_for) ? chore.disabled_for : [];
|
||||
if (disabledFor.includes(child.id)) return;
|
||||
|
||||
if (!this._isChoreScheduledOn(chore, day.dow, day.date, day.todayKey, tz)) return;
|
||||
|
||||
const rotation = this._rotationRenderMode(chore, child.id, day.key, day.todayKey);
|
||||
if (rotation === "hidden") return;
|
||||
|
||||
const comp = dayCompletions.find(
|
||||
(c) => c.chore_id === chore.id && c.child_id === child.id,
|
||||
);
|
||||
let state = "due";
|
||||
let stateIcon = "mdi:circle-outline";
|
||||
if (comp) {
|
||||
if (comp.approved) { state = "approved"; stateIcon = "mdi:check-circle"; doneCount++; }
|
||||
else { state = "pending"; stateIcon = "mdi:clock-outline"; }
|
||||
}
|
||||
dueCount++;
|
||||
|
||||
items.push({ chore, state, stateIcon, rotation });
|
||||
});
|
||||
|
||||
return { items, dueCount, doneCount };
|
||||
}
|
||||
|
||||
_renderChildBlock(child, day, chores, dayCompletions, pointsIcon, tz) {
|
||||
const { items, dueCount, doneCount } = this._computeChildChores(child, day, chores, dayCompletions, tz);
|
||||
const rows = items.map(({ chore, state, stateIcon, rotation }) => {
|
||||
const rotatingClass = rotation === "rotating" ? " rotating" : "";
|
||||
const title = `${chore.name}${rotation === "rotating" ? ` · ${this._t("calendar.rotating")}` : ""}`;
|
||||
|
||||
return html`
|
||||
<div class="chore-row ${state}${rotatingClass}" title=${title}>
|
||||
<ha-icon class="chore-icon ${state}" icon="${stateIcon}"></ha-icon>
|
||||
<span class="chore-name">${chore.name}</span>
|
||||
<span class="chore-points">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>${chore.points}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
const summary = dueCount === 0
|
||||
? this._t("calendar.no_chores_today")
|
||||
: this._t("calendar.child_summary", { done: doneCount, total: dueCount });
|
||||
|
||||
return html`
|
||||
<div class="child-block">
|
||||
<div class="child-head">
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${child.avatar || "mdi:account-circle"}"></ha-icon>
|
||||
</div>
|
||||
<span class="child-name">${child.name}</span>
|
||||
<span class="child-summary">${summary}</span>
|
||||
</div>
|
||||
<div class="chore-list">
|
||||
${rows.length > 0 ? rows : html`<div class="no-chores">${this._t("calendar.no_chores_today")}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns/frag/18-calendar.html
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = child.avatar || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: (child.name || "?").split(" ").map((w) => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
_renderDesigned(design) {
|
||||
const hd = _safeColor(this.config.header_color, "#3498db");
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
|
||||
const wrap = (sub, pill, body) => html`
|
||||
<ha-card class="tmd" style="--hd:${hd}">
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">📅</span>
|
||||
<span class="tt">${this.config.title || this._t("calendar.default_title")}${sub ? html`<small>${sub}</small>` : ""}</span>
|
||||
${pill ? html`<span class="pill">${pill}</span>` : ""}
|
||||
</div>
|
||||
<div class="tmd-bd">${body}</div>
|
||||
</ha-card>`;
|
||||
|
||||
if (!entity) {
|
||||
return wrap("", "", html`<div class="tmd-empty">${this._t("common.entity_not_found", { entity: this.config.entity })}</div>`);
|
||||
}
|
||||
if (entity.state === "unavailable" || entity.state === "unknown") {
|
||||
return wrap("", "", html`<div class="tmd-empty">${this._t("common.unavailable")}</div>`);
|
||||
}
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
if (this.config.child_id) children = children.filter((c) => c.id === this.config.child_id);
|
||||
|
||||
if (children.length === 0) {
|
||||
return wrap("", "", html`<div class="tmd-empty">${this._t("common.no_children")}</div>`);
|
||||
}
|
||||
|
||||
const day = this._getSelectedDay(tz);
|
||||
const rawCompletions = attrs.recent_completions || attrs.todays_completions || [];
|
||||
const seen = new Set();
|
||||
const dayCompletions = [];
|
||||
rawCompletions.forEach((c) => {
|
||||
const id = c.completion_id || `${c.chore_id}:${c.child_id}:${c.completed_at}`;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
if (!c.completed_at) return;
|
||||
if (ymd(new Date(c.completed_at), tz) !== day.key) return;
|
||||
dayCompletions.push(c);
|
||||
});
|
||||
|
||||
const dayLabel = day.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||||
const dayShort = day.date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
const isToday = day.key === day.todayKey;
|
||||
|
||||
const blocks = children.map((child, i) => {
|
||||
const tone = this._designTone(i);
|
||||
const { items, dueCount, doneCount } = this._computeChildChores(child, day, chores, dayCompletions, tz);
|
||||
return { child, tone, items, dueCount, doneCount };
|
||||
});
|
||||
|
||||
const totalPending = blocks.reduce((s, b) => s + b.items.filter((it) => it.state !== "approved").length, 0);
|
||||
|
||||
const nav = this._designDayNav(design, isToday, dayLabel);
|
||||
const body = html`
|
||||
${nav}
|
||||
${design === "console"
|
||||
? this._calConsole(blocks, pointsIcon)
|
||||
: design === "cleanpro"
|
||||
? this._calCleanpro(blocks, pointsIcon)
|
||||
: this._calPlayroom(blocks, pointsIcon)}`;
|
||||
|
||||
const sub = isToday ? this._t("common.today") : dayShort;
|
||||
const pill = design === "console" && totalPending
|
||||
? this._t("calendar.child_summary", { done: 0, total: totalPending }) : "";
|
||||
return wrap(sub, pill, body);
|
||||
}
|
||||
|
||||
_designDayNav(design, isToday, dayLabel) {
|
||||
if (design === "console") {
|
||||
return html`
|
||||
<div class="row cal-nav-cn">
|
||||
<button class="btn ghost sm" @click=${() => this._shiftDay(-1)}>‹ ${this._t("calendar.prev_day")}</button>
|
||||
<span class="num cal-nav-cur" @click=${() => this._resetDay()}>${isToday ? this._t("common.today") : dayLabel}</span>
|
||||
<button class="btn ghost sm" @click=${() => this._shiftDay(1)}>${this._t("calendar.next_day")} ›</button>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="row cal-nav">
|
||||
<button class="btn round ghost cal-nav-btn" title=${this._t("calendar.prev_day")} @click=${() => this._shiftDay(-1)}>‹</button>
|
||||
<span class="chip soft cal-nav-cur" @click=${() => this._resetDay()}>${isToday ? this._t("common.today") : dayLabel}</span>
|
||||
<button class="btn round ghost cal-nav-btn" title=${this._t("calendar.next_day")} @click=${() => this._shiftDay(1)}>›</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// state → token colour mapping per spec: done=good, pending=warn, due/unassigned=dim
|
||||
_choreChip(item, glyph) {
|
||||
const map = { approved: "good", pending: "warn" };
|
||||
const tok = map[item.state] || "dim";
|
||||
const dim = item.state === "due";
|
||||
return html`<span class="chip cal-chip ${dim ? "muted" : ""}"
|
||||
style="${dim ? "" : `color:var(--tmd-${tok});background:color-mix(in srgb,var(--tmd-${tok}) 16%,transparent);border-color:transparent`}">${glyph[item.state] || glyph.due} ${item.chore.name}</span>`;
|
||||
}
|
||||
|
||||
_calPlayroom(blocks, pointsIcon) {
|
||||
const glyph = { approved: "✓", pending: "◌", due: "—" };
|
||||
return html`
|
||||
<div class="grid cal-grid">
|
||||
${blocks.map((b) => html`
|
||||
<div class="cal-card-pl" style="--ac:${b.tone}">
|
||||
<div class="row cal-head">${this._av(b.child, b.tone, 34)}<div class="cal-name">${b.child.name}</div></div>
|
||||
<div class="row cal-chips">
|
||||
${b.items.length
|
||||
? b.items.map((it) => this._choreChip(it, glyph))
|
||||
: html`<span class="muted cal-empty-line">${this._t("calendar.no_chores_today")}</span>`}
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_calConsole(blocks, pointsIcon) {
|
||||
const glyph = { approved: "▮", pending: "▯", due: "▢" };
|
||||
return html`
|
||||
<div class="grid cal-grid-cn">
|
||||
${blocks.map((b) => html`
|
||||
<div class="cal-card-cn" style="--ac:${b.tone}">
|
||||
<div class="row cal-head-cn">
|
||||
${this._av(b.child, b.tone, 24)}
|
||||
<div class="cal-name-cn">${b.child.name}</div>
|
||||
<span class="num muted cal-count">${b.doneCount}/${b.dueCount}</span>
|
||||
</div>
|
||||
<div class="row cal-chips">
|
||||
${b.items.length
|
||||
? b.items.map((it) => this._choreChip(it, glyph))
|
||||
: html`<span class="muted cal-empty-line">${this._t("calendar.no_chores_today")}</span>`}
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_calCleanpro(blocks, pointsIcon) {
|
||||
const glyph = { approved: "●", pending: "○", due: "—" };
|
||||
return html`
|
||||
<div class="grid cal-grid-cp">
|
||||
${blocks.map((b) => html`
|
||||
<div class="row cal-row-cp" style="--ac:${b.tone}">
|
||||
${this._av(b.child, b.tone, 32)}
|
||||
<div class="cal-row-mid">
|
||||
<div class="cal-name-cp">${b.child.name}</div>
|
||||
<div class="row cal-chips">
|
||||
${b.items.length
|
||||
? b.items.map((it) => this._choreChip(it, glyph))
|
||||
: html`<span class="muted cal-empty-line">${this._t("calendar.no_chores_today")}</span>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Card Editor
|
||||
class TaskMateCalendarCardEditor 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: "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: "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",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t("common.editor.overview_entity"),
|
||||
title: this._t("calendar.editor.title"),
|
||||
card_design: this._t("common.design.field_label"),
|
||||
child_id: this._t("common.editor.filter_by_child"),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t("common.editor.overview_entity_helper"),
|
||||
child_id: this._t("calendar.editor.child_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__",
|
||||
};
|
||||
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", "#3498db")}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._updateConfig(key, v),
|
||||
onPreset: (v) => this._updateConfig(key, v),
|
||||
onReset: () => this._updateConfig(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (
|
||||
value === "" || value === null || value === undefined
|
||||
|| (key === "child_id" && value === "__all__")
|
||||
) {
|
||||
delete newConfig[key];
|
||||
} else if (key === "card_design" && value === "global") {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-calendar-card", TaskMateCalendarCard);
|
||||
customElements.define("taskmate-calendar-card-editor", TaskMateCalendarCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-calendar-card",
|
||||
name: "TaskMate Calendar",
|
||||
description: "One-day view of chores assigned to each child",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-calendar-card", "overview"),
|
||||
});
|
||||
|
||||
const _tmVersion = new URLSearchParams(
|
||||
Array.from(document.querySelectorAll('script[src*="/taskmate-calendar-card.js"]'))
|
||||
.map((s) => s.src.split("?")[1]).find(Boolean) || "",
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE CALENDAR CARD %c v" + _tmVersion + " ",
|
||||
"background:#3498db;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;",
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* TaskMate Config Flow Sound Preview
|
||||
* Plays sound preview when the completion_sound dropdown value changes
|
||||
* in the config flow (add/edit chore screens).
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Debug logging gate. Set window.__taskmate_config_sounds_debug = true in the
|
||||
// browser console to enable the chatty console.debug/warn output.
|
||||
const debugLog = (...args) => {
|
||||
if (window.__taskmate_config_sounds_debug) console.debug(...args);
|
||||
};
|
||||
const debugWarn = (...args) => {
|
||||
if (window.__taskmate_config_sounds_debug) console.warn(...args);
|
||||
};
|
||||
|
||||
// Audio context for sound preview (lazy initialized)
|
||||
let audioContext = null;
|
||||
|
||||
/**
|
||||
* Get or create AudioContext
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// ============== SOUND IMPLEMENTATIONS ==============
|
||||
// These are copies from taskmate-child-card.js to keep this file standalone
|
||||
|
||||
function playCoinSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.frequency.value = 1318.5;
|
||||
osc1.type = 'square';
|
||||
gain1.gain.setValueAtTime(0.5, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.1);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.frequency.value = 1975.5;
|
||||
osc2.type = 'square';
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
|
||||
osc2.start(startTime + 0.08);
|
||||
osc2.stop(startTime + 0.25);
|
||||
}
|
||||
|
||||
function playLevelUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5];
|
||||
const duration = 0.12;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'square';
|
||||
|
||||
const noteStart = startTime + i * duration;
|
||||
gain.gain.setValueAtTime(0.6, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.15);
|
||||
});
|
||||
|
||||
const chordNotes = [523.25, 659.25, 783.99];
|
||||
const chordStart = startTime + notes.length * duration;
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'triangle';
|
||||
gain.gain.setValueAtTime(0.3, chordStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
|
||||
osc.start(chordStart);
|
||||
osc.stop(chordStart + 0.55);
|
||||
});
|
||||
}
|
||||
|
||||
function playFanfareSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.2;
|
||||
|
||||
const pattern = [
|
||||
{ freq: 392.00, duration: 0.1, delay: 0 },
|
||||
{ freq: 392.00, duration: 0.1, delay: 0.12 },
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.24 },
|
||||
{ freq: 329.63, duration: 0.15, delay: 0.42 },
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.6 },
|
||||
{ freq: 523.25, duration: 0.4, delay: 0.78 },
|
||||
];
|
||||
|
||||
pattern.forEach(({ freq, duration, delay }) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sawtooth';
|
||||
|
||||
const noteStart = startTime + delay;
|
||||
gain.gain.setValueAtTime(0.5, noteStart);
|
||||
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
function playChimeSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
const fundamental = 880;
|
||||
const harmonics = [1, 2, 3, 4.2];
|
||||
|
||||
harmonics.forEach((harmonic, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = fundamental * harmonic;
|
||||
osc.type = 'sine';
|
||||
|
||||
const amplitude = 0.5 / (i + 1);
|
||||
const decayTime = 0.8 / (i + 1);
|
||||
|
||||
gain.gain.setValueAtTime(amplitude, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + decayTime + 0.1);
|
||||
});
|
||||
}
|
||||
|
||||
function playPowerUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'sawtooth';
|
||||
osc1.frequency.setValueAtTime(200, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
|
||||
gain1.gain.setValueAtTime(0.4, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.4);
|
||||
|
||||
const sparkleNotes = [1318.5, 1567.98, 1975.5];
|
||||
sparkleNotes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
|
||||
const noteStart = startTime + 0.25 + i * 0.05;
|
||||
gain.gain.setValueAtTime(0.3, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
function playUndoSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(311.13, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25);
|
||||
gain1.gain.setValueAtTime(0.6, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.35);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(233.08, startTime + 0.3);
|
||||
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7);
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
|
||||
osc2.start(startTime + 0.3);
|
||||
osc2.stop(startTime + 0.8);
|
||||
|
||||
const osc3 = ctx.createOscillator();
|
||||
const gain3 = ctx.createGain();
|
||||
osc3.connect(gain3);
|
||||
gain3.connect(masterGain);
|
||||
osc3.type = 'sine';
|
||||
osc3.frequency.setValueAtTime(116.54, startTime + 0.5);
|
||||
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
|
||||
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
|
||||
osc3.start(startTime + 0.5);
|
||||
osc3.stop(startTime + 0.85);
|
||||
}
|
||||
|
||||
function playFartSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.9;
|
||||
|
||||
const noiseLength = ctx.sampleRate * 0.5;
|
||||
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < noiseLength; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
noiseData[i] = (lastOut + (0.04 * white)) / 1.04;
|
||||
lastOut = noiseData[i];
|
||||
noiseData[i] *= 8;
|
||||
}
|
||||
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(200, startTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(80, startTime + 0.4);
|
||||
filter.Q.value = 2;
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0, startTime);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.01);
|
||||
noiseGain.gain.setValueAtTime(1.0, startTime + 0.15);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
|
||||
|
||||
noiseSource.connect(filter);
|
||||
filter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noiseSource.start(startTime);
|
||||
noiseSource.stop(startTime + 0.5);
|
||||
|
||||
const bass = ctx.createOscillator();
|
||||
const bassGain = ctx.createGain();
|
||||
bass.type = 'sawtooth';
|
||||
bass.frequency.setValueAtTime(80, startTime);
|
||||
bass.frequency.exponentialRampToValueAtTime(40, startTime + 0.4);
|
||||
bassGain.gain.setValueAtTime(0, startTime);
|
||||
bassGain.gain.linearRampToValueAtTime(0.8, startTime + 0.02);
|
||||
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
|
||||
bass.connect(bassGain);
|
||||
bassGain.connect(masterGain);
|
||||
bass.start(startTime);
|
||||
bass.stop(startTime + 0.5);
|
||||
|
||||
const flap = ctx.createOscillator();
|
||||
const flapGain = ctx.createGain();
|
||||
flap.type = 'square';
|
||||
flap.frequency.setValueAtTime(25, startTime);
|
||||
flap.frequency.exponentialRampToValueAtTime(12, startTime + 0.4);
|
||||
flapGain.gain.setValueAtTime(0.6, startTime);
|
||||
flapGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4);
|
||||
flap.connect(flapGain);
|
||||
flapGain.connect(masterGain);
|
||||
flap.start(startTime);
|
||||
flap.stop(startTime + 0.45);
|
||||
|
||||
const sub = ctx.createOscillator();
|
||||
const subGain = ctx.createGain();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(50, startTime);
|
||||
sub.frequency.exponentialRampToValueAtTime(25, startTime + 0.3);
|
||||
subGain.gain.setValueAtTime(0.7, startTime);
|
||||
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
sub.connect(subGain);
|
||||
subGain.connect(masterGain);
|
||||
sub.start(startTime);
|
||||
sub.stop(startTime + 0.4);
|
||||
}
|
||||
|
||||
function playFartLongSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.85;
|
||||
|
||||
const noiseLength = ctx.sampleRate * 2.0;
|
||||
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < noiseLength; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
noiseData[i] = (lastOut + (0.05 * white)) / 1.05;
|
||||
lastOut = noiseData[i];
|
||||
noiseData[i] *= 7;
|
||||
}
|
||||
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(250, startTime);
|
||||
filter.frequency.linearRampToValueAtTime(180, startTime + 0.3);
|
||||
filter.frequency.linearRampToValueAtTime(280, startTime + 0.5);
|
||||
filter.frequency.linearRampToValueAtTime(150, startTime + 0.9);
|
||||
filter.frequency.linearRampToValueAtTime(220, startTime + 1.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(60, startTime + 1.8);
|
||||
filter.Q.value = 3;
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0, startTime);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.02);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.7, startTime + 0.4);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.6);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.5, startTime + 1.0);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.9, startTime + 1.3);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.9);
|
||||
|
||||
noiseSource.connect(filter);
|
||||
filter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noiseSource.start(startTime);
|
||||
noiseSource.stop(startTime + 2.0);
|
||||
|
||||
const bass = ctx.createOscillator();
|
||||
const bassGain = ctx.createGain();
|
||||
bass.type = 'sawtooth';
|
||||
bass.frequency.setValueAtTime(90, startTime);
|
||||
bass.frequency.linearRampToValueAtTime(70, startTime + 0.5);
|
||||
bass.frequency.linearRampToValueAtTime(100, startTime + 0.8);
|
||||
bass.frequency.linearRampToValueAtTime(50, startTime + 1.3);
|
||||
bass.frequency.exponentialRampToValueAtTime(30, startTime + 1.8);
|
||||
bassGain.gain.setValueAtTime(0.8, startTime);
|
||||
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.85);
|
||||
bass.connect(bassGain);
|
||||
bassGain.connect(masterGain);
|
||||
bass.start(startTime);
|
||||
bass.stop(startTime + 1.9);
|
||||
|
||||
const flutter = ctx.createOscillator();
|
||||
const flutterGain = ctx.createGain();
|
||||
flutter.type = 'square';
|
||||
flutter.frequency.setValueAtTime(35, startTime);
|
||||
flutter.frequency.linearRampToValueAtTime(20, startTime + 0.8);
|
||||
flutter.frequency.linearRampToValueAtTime(28, startTime + 1.0);
|
||||
flutter.frequency.exponentialRampToValueAtTime(6, startTime + 1.8);
|
||||
flutterGain.gain.setValueAtTime(0.7, startTime);
|
||||
flutterGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.8);
|
||||
flutter.connect(flutterGain);
|
||||
flutterGain.connect(masterGain);
|
||||
flutter.start(startTime);
|
||||
flutter.stop(startTime + 1.85);
|
||||
|
||||
const sub = ctx.createOscillator();
|
||||
const subGain = ctx.createGain();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(45, startTime);
|
||||
sub.frequency.exponentialRampToValueAtTime(20, startTime + 1.7);
|
||||
subGain.gain.setValueAtTime(0.6, startTime);
|
||||
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.75);
|
||||
sub.connect(subGain);
|
||||
subGain.connect(masterGain);
|
||||
sub.start(startTime);
|
||||
sub.stop(startTime + 1.8);
|
||||
|
||||
const squeakTimes = [0.5, 1.2];
|
||||
squeakTimes.forEach((delay) => {
|
||||
const squeak = ctx.createOscillator();
|
||||
const squeakGain = ctx.createGain();
|
||||
squeak.type = 'triangle';
|
||||
squeak.frequency.setValueAtTime(180, startTime + delay);
|
||||
squeak.frequency.exponentialRampToValueAtTime(120, startTime + delay + 0.15);
|
||||
squeakGain.gain.setValueAtTime(0.4, startTime + delay);
|
||||
squeakGain.gain.exponentialRampToValueAtTime(0.01, startTime + delay + 0.15);
|
||||
squeak.connect(squeakGain);
|
||||
squeakGain.connect(masterGain);
|
||||
squeak.start(startTime + delay);
|
||||
squeak.stop(startTime + delay + 0.2);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound by name
|
||||
*/
|
||||
function playSound(soundName) {
|
||||
if (!soundName || soundName === 'none') {
|
||||
debugLog('[TaskMate Config] No sound to play');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
switch (soundName) {
|
||||
case 'coin': playCoinSound(ctx, now); break;
|
||||
case 'levelup': playLevelUpSound(ctx, now); break;
|
||||
case 'fanfare': playFanfareSound(ctx, now); break;
|
||||
case 'chime': playChimeSound(ctx, now); break;
|
||||
case 'powerup': playPowerUpSound(ctx, now); break;
|
||||
case 'undo': playUndoSound(ctx, now); break;
|
||||
case 'fart': playFartSound(ctx, now); break;
|
||||
case 'fart_long': playFartLongSound(ctx, now); break;
|
||||
default:
|
||||
debugWarn(`[TaskMate Config] Unknown sound: ${soundName}`);
|
||||
playCoinSound(ctx, now);
|
||||
}
|
||||
} catch (e) {
|
||||
debugWarn('[TaskMate Config] Error playing sound:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Valid sound names
|
||||
const VALID_SOUNDS = ['coin', 'levelup', 'fanfare', 'chime', 'powerup', 'undo', 'fart', 'fart_long', 'none'];
|
||||
|
||||
/**
|
||||
* Map display text to sound value
|
||||
*/
|
||||
function textToSoundValue(text) {
|
||||
if (!text) return null;
|
||||
const normalized = text.toLowerCase().trim().replace(/\s+/g, '_');
|
||||
const map = {
|
||||
'no_sound': 'none',
|
||||
'no sound': 'none',
|
||||
'coin': 'coin',
|
||||
'levelup': 'levelup',
|
||||
'level_up': 'levelup',
|
||||
'fanfare': 'fanfare',
|
||||
'chime': 'chime',
|
||||
'powerup': 'powerup',
|
||||
'power_up': 'powerup',
|
||||
'undo': 'undo',
|
||||
'fart': 'fart',
|
||||
'fart_long': 'fart_long',
|
||||
};
|
||||
return map[normalized] || (VALID_SOUNDS.includes(normalized) ? normalized : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listener to a select element
|
||||
*/
|
||||
function attachSoundChangeListener(element) {
|
||||
if (element.dataset.taskmateSoundListener) return;
|
||||
element.dataset.taskmateSoundListener = 'true';
|
||||
|
||||
// Listen for various change events
|
||||
const handleChange = (e) => {
|
||||
const value = e.target?.value || e.detail?.value;
|
||||
const soundValue = textToSoundValue(value) || value;
|
||||
|
||||
debugLog('[TaskMate Config] Sound changed to:', soundValue);
|
||||
|
||||
if (soundValue && soundValue !== 'none' && VALID_SOUNDS.includes(soundValue)) {
|
||||
playSound(soundValue);
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('change', handleChange);
|
||||
element.addEventListener('value-changed', handleChange);
|
||||
element.addEventListener('selected', handleChange);
|
||||
element.addEventListener('click', (e) => {
|
||||
// For dropdown items being clicked
|
||||
const item = e.target.closest('mwc-list-item, ha-list-item, [role="option"]');
|
||||
if (item) {
|
||||
const value = item.value || item.getAttribute('value') || item.textContent;
|
||||
const soundValue = textToSoundValue(value);
|
||||
if (soundValue && soundValue !== 'none') {
|
||||
setTimeout(() => playSound(soundValue), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugLog('[TaskMate Config] Attached sound change listener to element');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sound selectors and attach listeners
|
||||
*/
|
||||
function findAndEnhanceSoundSelectors() {
|
||||
// Look for any select/dropdown that might be for sounds
|
||||
const selectors = document.querySelectorAll('ha-select, mwc-select, ha-combo-box, select');
|
||||
|
||||
selectors.forEach(selector => {
|
||||
// Check if this selector has sound-related options
|
||||
const options = selector.querySelectorAll('mwc-list-item, ha-list-item, option, [role="option"]');
|
||||
let isSoundSelector = false;
|
||||
|
||||
options.forEach(opt => {
|
||||
const text = (opt.value || opt.getAttribute('value') || opt.textContent || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => text.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Also check the selector's current value
|
||||
const currentValue = (selector.value || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => currentValue.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
|
||||
if (isSoundSelector) {
|
||||
attachSoundChangeListener(selector);
|
||||
|
||||
// Also attach to inner elements if present
|
||||
const innerSelect = selector.shadowRoot?.querySelector('select, mwc-menu, mwc-list');
|
||||
if (innerSelect) {
|
||||
attachSoundChangeListener(innerSelect);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also look for ha-selector elements (HA's custom form selectors)
|
||||
document.querySelectorAll('ha-selector').forEach(haSelector => {
|
||||
const innerSelect = haSelector.shadowRoot?.querySelector('ha-select, mwc-select, select') ||
|
||||
haSelector.querySelector('ha-select, mwc-select, select');
|
||||
if (innerSelect) {
|
||||
const options = innerSelect.querySelectorAll('mwc-list-item, [role="option"]');
|
||||
let isSoundSelector = false;
|
||||
options.forEach(opt => {
|
||||
const text = (opt.value || opt.textContent || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => text.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
});
|
||||
if (isSoundSelector) {
|
||||
attachSoundChangeListener(innerSelect);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for DOM changes and enhance new sound selectors
|
||||
*/
|
||||
function startObserver() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldScan = false;
|
||||
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
shouldScan = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldScan) {
|
||||
// Debounce scanning
|
||||
clearTimeout(window._taskmateScanTimeout);
|
||||
window._taskmateScanTimeout = setTimeout(findAndEnhanceSoundSelectors, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
window._taskmateSoundObserver = observer;
|
||||
|
||||
debugLog('[TaskMate Config] Sound change observer started');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
if (window._taskmateConfigSoundsInit) return;
|
||||
window._taskmateConfigSoundsInit = true;
|
||||
findAndEnhanceSoundSelectors();
|
||||
startObserver();
|
||||
|
||||
// Slow periodic scan as a safety net for dynamically loaded content the
|
||||
// MutationObserver doesn't catch (e.g. dialogs rendered outside body's light DOM)
|
||||
window._taskmateConfigSoundsPoll = setInterval(findAndEnhanceSoundSelectors, 10000);
|
||||
|
||||
const cleanup = () => {
|
||||
if (window._taskmateConfigSoundsPoll) {
|
||||
clearInterval(window._taskmateConfigSoundsPoll);
|
||||
window._taskmateConfigSoundsPoll = null;
|
||||
}
|
||||
if (window._taskmateScanTimeout) {
|
||||
clearTimeout(window._taskmateScanTimeout);
|
||||
window._taskmateScanTimeout = null;
|
||||
}
|
||||
if (window._taskmateSoundObserver) {
|
||||
window._taskmateSoundObserver.disconnect();
|
||||
window._taskmateSoundObserver = null;
|
||||
}
|
||||
};
|
||||
// pagehide covers SPA navigation in HA; beforeunload covers full reloads.
|
||||
window.addEventListener('pagehide', cleanup, { once: true });
|
||||
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => setTimeout(init, 500));
|
||||
|
||||
// Listen for taskmate_preview_sound events fired by the preview_sound service
|
||||
document.addEventListener('taskmate_preview_sound', (e) => {
|
||||
const sound = e.detail?.sound || e.detail?.data?.sound;
|
||||
if (sound) playSound(sound);
|
||||
});
|
||||
|
||||
// Also listen via window for HA event bus forwarding
|
||||
window.addEventListener('taskmate_preview_sound', (e) => {
|
||||
const sound = e.detail?.sound || e.detail?.data?.sound;
|
||||
if (sound) playSound(sound);
|
||||
});
|
||||
|
||||
console.info('[TaskMate] Config sound preview module loaded - sounds will play on selection change');
|
||||
})();
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* TaskMate shared design layer.
|
||||
*
|
||||
* Injects design-token CSS (custom properties) keyed by [data-tm-design],
|
||||
* and exposes helpers for cards to resolve the active design + build the
|
||||
* editor dropdown. CSS custom properties inherit across shadow-DOM
|
||||
* boundaries, so a card only needs to set data-tm-design on its host and
|
||||
* consume var(--tmd-*) inside its shadow styles.
|
||||
*
|
||||
* Designs:
|
||||
* classic — current look; no tokens (cards fall back to HA theme vars)
|
||||
* playroom — warm, rounded, picture-book
|
||||
* console — dark gamified HUD
|
||||
* cleanpro — calm productivity / SaaS
|
||||
*
|
||||
* Mirrors window.__taskmate_localize: exposed globally, no ES module imports.
|
||||
*/
|
||||
(function () {
|
||||
const IDS = ["classic", "playroom", "console", "cleanpro"];
|
||||
|
||||
// Fonts must be loaded at the document level (an @import inside a shadow
|
||||
// stylesheet is honoured, but loading once globally avoids N duplicate fetches).
|
||||
const FONT_IMPORT =
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700;800&family=Manrope:wght@500;600;700;800&display=swap');";
|
||||
|
||||
// Design tokens. IMPORTANT: these are :host-scoped so they can be applied
|
||||
// INSIDE each card's shadow root. A document-level [data-tm-design] rule
|
||||
// cannot reach a card host nested in HA's shadow trees, but :host() matches
|
||||
// the host carrying the attribute wherever it lives. Custom properties then
|
||||
// inherit down into the card's shadow DOM. Tokens mirror
|
||||
// docs/design/redesigns/taskmate-redesigns.html (.dir-a / .dir-b / .dir-c).
|
||||
const TOKENS = `
|
||||
:host([data-tm-design="playroom"]),[data-tm-design="playroom"]{
|
||||
--tmd-bg:#FFF7EC;--tmd-surface:#FFFFFF;--tmd-surface-2:#FFF0DB;--tmd-border:#F0DFC4;
|
||||
--tmd-text:#3A2E26;--tmd-dim:#9C8676;
|
||||
--tmd-accent:#7C5CE6;--tmd-accent2:#FFC23C;--tmd-good:#36C58E;--tmd-warn:#FFB020;--tmd-bad:#FF6B6B;--tmd-gold:#FFC23C;
|
||||
--tmd-radius:22px;--tmd-radius-sm:14px;--tmd-shadow:0 10px 24px rgba(123,92,230,.12);--tmd-hd-text:#fff;
|
||||
--tmd-font-display:"Baloo 2",cursive;--tmd-font-body:"Nunito",sans-serif;--tmd-font-mono:"Baloo 2",cursive;
|
||||
--tmd-c1:#FF6B6B;--tmd-c2:#4ECDC4;--tmd-c3:#FFB020;--tmd-c4:#6C8DFF;--tmd-c5:#C77DFF;--tmd-c6:#45D483;
|
||||
}
|
||||
:host([data-tm-design="console"]),[data-tm-design="console"]{
|
||||
--tmd-bg:#EEF2F8;--tmd-surface:#FFFFFF;--tmd-surface-2:#E9EEF5;--tmd-border:#D3DCE7;
|
||||
--tmd-text:#0F1B2D;--tmd-dim:#5C6B80;
|
||||
--tmd-accent:#0E9BC4;--tmd-accent2:#E0357F;--tmd-good:#1AA06A;--tmd-warn:#C98800;--tmd-bad:#E0476A;--tmd-gold:#C98800;
|
||||
--tmd-radius:12px;--tmd-radius-sm:8px;--tmd-shadow:0 6px 18px rgba(18,32,52,.12);--tmd-hd-text:#fff;
|
||||
--tmd-font-display:"Space Grotesk",sans-serif;--tmd-font-body:"Inter",sans-serif;--tmd-font-mono:"JetBrains Mono",monospace;
|
||||
--tmd-c1:#0E9BC4;--tmd-c2:#E0357F;--tmd-c3:#C98800;--tmd-c4:#6A4BE0;--tmd-c5:#1AA06A;--tmd-c6:#E0703D;
|
||||
}
|
||||
:host([data-tm-design="cleanpro"]),[data-tm-design="cleanpro"]{
|
||||
--tmd-bg:#F6F7F9;--tmd-surface:#FFFFFF;--tmd-surface-2:#F1F3F6;--tmd-border:#E5E8EC;
|
||||
--tmd-text:#1A2230;--tmd-dim:#6B7585;
|
||||
--tmd-accent:#4F6BED;--tmd-accent2:#0FB5A8;--tmd-good:#16A36B;--tmd-warn:#E0A100;--tmd-bad:#DC4C4C;--tmd-gold:#E0A100;
|
||||
--tmd-radius:14px;--tmd-radius-sm:9px;--tmd-shadow:0 1px 2px rgba(16,24,40,.06),0 8px 18px rgba(16,24,40,.06);--tmd-hd-text:#fff;
|
||||
--tmd-font-display:"Manrope",sans-serif;--tmd-font-body:"Inter",sans-serif;--tmd-font-mono:"Inter",sans-serif;
|
||||
--tmd-c1:#4F6BED;--tmd-c2:#0FB5A8;--tmd-c3:#E0A100;--tmd-c4:#7A5AF0;--tmd-c5:#E0567A;--tmd-c6:#2BA84A;
|
||||
}
|
||||
|
||||
/* ── Dark-mode variants ──────────────────────────────────────────────────
|
||||
Cards stamp data-tm-dark on the host when HA is in dark mode. Each design has
|
||||
a light base (above) and a dark override here, so all three follow HA's
|
||||
light/dark setting. */
|
||||
:host([data-tm-design="console"][data-tm-dark]),[data-tm-design="console"][data-tm-dark]{
|
||||
--tmd-bg:#0E1320;--tmd-surface:#161D2E;--tmd-surface-2:#1E2740;--tmd-border:#2A3550;
|
||||
--tmd-text:#EAF0FF;--tmd-dim:#8A97B8;
|
||||
--tmd-accent:#3DDBFF;--tmd-accent2:#FF4D9D;--tmd-good:#4BE08B;--tmd-warn:#FFCB45;--tmd-bad:#FF5A7A;--tmd-gold:#FFCB45;
|
||||
--tmd-shadow:0 10px 28px rgba(0,0,0,.5);
|
||||
--tmd-c1:#3DDBFF;--tmd-c2:#FF4D9D;--tmd-c3:#FFCB45;--tmd-c4:#7C5CFF;--tmd-c5:#4BE08B;--tmd-c6:#FF8A3D;
|
||||
}
|
||||
:host([data-tm-design="playroom"][data-tm-dark]),[data-tm-design="playroom"][data-tm-dark]{
|
||||
--tmd-bg:#241A2B;--tmd-surface:#2C2235;--tmd-surface-2:#382B43;--tmd-border:#473754;
|
||||
--tmd-text:#F6ECEF;--tmd-dim:#B6A3B4;--tmd-accent:#A88BFF;
|
||||
--tmd-shadow:0 10px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
:host([data-tm-design="cleanpro"][data-tm-dark]),[data-tm-design="cleanpro"][data-tm-dark]{
|
||||
--tmd-bg:#0F141A;--tmd-surface:#181D25;--tmd-surface-2:#212733;--tmd-border:#2C3340;
|
||||
--tmd-text:#E6EAF1;--tmd-dim:#97A1B0;--tmd-accent:#6E86F5;--tmd-accent2:#1FC7B8;
|
||||
--tmd-shadow:0 1px 2px rgba(0,0,0,.4),0 8px 18px rgba(0,0,0,.35);
|
||||
}`;
|
||||
|
||||
// Shared component kit, themed by the tokens above. Every designed card
|
||||
// consumes these via __taskmate_design.styles() and adds only its own layout
|
||||
// classes. Headers stay FULL-COLOUR (var(--hd)) in every style per house rule.
|
||||
const KIT = `
|
||||
.tmd{overflow:hidden;font-family:var(--tmd-font-body);color:var(--tmd-text);background:var(--tmd-surface);border-radius:var(--tmd-radius);box-shadow:var(--tmd-shadow)}
|
||||
.tmd-hd{display:flex;align-items:center;gap:11px;padding:13px 15px;background:var(--hd,var(--tmd-accent));color:#fff}
|
||||
:host([data-tm-design="playroom"]) .tmd-hd{background:linear-gradient(135deg,var(--hd,var(--tmd-accent)),color-mix(in srgb,var(--hd,var(--tmd-accent)) 72%,#fff))}
|
||||
.tmd-hd .ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:rgba(255,255,255,.22);font-size:17px;flex:none}
|
||||
.tmd-hd .tt{font-family:var(--tmd-font-display);font-weight:800;font-size:16px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tmd-hd .tt small{display:block;font-family:var(--tmd-font-body);font-weight:600;font-size:11px;opacity:.85;margin-top:1px}
|
||||
.tmd-hd .pill{margin-left:auto;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:rgba(255,255,255,.25);text-transform:capitalize}
|
||||
.tmd-hd .cnt{margin-left:auto;min-width:22px;height:22px;padding:0 6px;border-radius:999px;background:var(--tmd-bad);color:#fff;font-size:12px;font-weight:800;display:grid;place-items:center}
|
||||
.tmd-bd{padding:15px}
|
||||
.tmd-empty{text-align:center;padding:26px 12px;color:var(--tmd-dim);font-size:.9rem}
|
||||
.av{width:var(--av,42px);height:var(--av,42px);border-radius:50%;flex:none;display:grid;place-items:center;font-family:var(--tmd-font-display);font-weight:800;color:#fff;font-size:calc(var(--av,42px)*.4);line-height:1;text-align:center;background:var(--ac,var(--tmd-accent));box-shadow:0 2px 6px rgba(0,0,0,.18);overflow:hidden}
|
||||
:host([data-tm-design="console"]) .av{box-shadow:0 0 0 1px color-mix(in srgb,var(--ac,var(--tmd-accent)) 70%,transparent),0 0 14px color-mix(in srgb,var(--ac,var(--tmd-accent)) 35%,transparent)}
|
||||
.av ha-icon{--mdc-icon-size:calc(var(--av,42px)*.55);color:#fff}
|
||||
.av img{width:100%;height:100%;object-fit:cover;border-radius:50%}
|
||||
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:700;padding:4px 9px;border-radius:999px;background:var(--tmd-surface-2);color:var(--tmd-text);border:1px solid var(--tmd-border)}
|
||||
.chip.soft{background:color-mix(in srgb,var(--tmd-accent) 14%,transparent);border-color:transparent;color:var(--tmd-accent)}
|
||||
.num{font-family:var(--tmd-font-mono);font-weight:800;letter-spacing:-.01em}
|
||||
.big{font-family:var(--tmd-font-display);font-weight:800;line-height:1}
|
||||
.bar{height:10px;border-radius:999px;background:var(--tmd-surface-2);overflow:hidden;border:1px solid var(--tmd-border)}
|
||||
.bar>i{display:block;height:100%;border-radius:999px;background:var(--tmd-accent)}
|
||||
:host([data-tm-design="console"]) .bar>i{background:linear-gradient(90deg,var(--tmd-accent),var(--tmd-accent2));box-shadow:0 0 12px color-mix(in srgb,var(--tmd-accent) 60%,transparent)}
|
||||
.row{display:flex;align-items:center;gap:10px}
|
||||
.muted{color:var(--tmd-dim)}
|
||||
.divide{height:1px;background:var(--tmd-border);margin:12px 0}
|
||||
.lead{color:var(--tmd-accent)}
|
||||
.lead-dot{color:var(--tmd-gold)}
|
||||
.btn{font:inherit;font-family:var(--tmd-font-display);font-weight:800;font-size:13.5px;line-height:1;border:0;cursor:pointer;padding:9px 14px;border-radius:var(--tmd-radius-sm);background:var(--tmd-accent);color:#fff;display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
:host([data-tm-design="console"]) .btn{color:#06101c;background:linear-gradient(135deg,var(--tmd-accent),color-mix(in srgb,var(--tmd-accent) 60%,var(--tmd-accent2)));box-shadow:0 0 14px color-mix(in srgb,var(--tmd-accent) 45%,transparent)}
|
||||
.btn.ghost{background:var(--tmd-surface-2);color:var(--tmd-text);border:1px solid var(--tmd-border)}
|
||||
:host([data-tm-design="console"]) .btn.ghost{box-shadow:none}
|
||||
.btn.good{background:var(--tmd-good);color:#06301f}
|
||||
.btn.bad{background:var(--tmd-bad);color:#3a0d0d}
|
||||
.btn.round{border-radius:50%;width:38px;height:38px;padding:0;justify-content:center;font-size:18px}
|
||||
.btn.sm{padding:6px 10px;font-size:12.5px}
|
||||
.stat{background:var(--tmd-surface-2);border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);padding:10px 11px}
|
||||
.stat .k{font-size:11px;font-weight:700;color:var(--tmd-dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
.stat .v{font-family:var(--tmd-font-display);font-weight:800;font-size:20px;margin-top:2px}
|
||||
.grid{display:grid;gap:10px}`;
|
||||
|
||||
if (!document.getElementById("taskmate-design-fonts")) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.id = "taskmate-design-fonts";
|
||||
styleEl.textContent = FONT_IMPORT;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* The design tokens as a Lit CSSResult, for a card to include in its own
|
||||
* `static get styles()` so the :host token rules live inside the card's
|
||||
* shadow root (where they actually apply). Built lazily so the Lit `css`
|
||||
* tag — borrowed from HA's own card base — is resolved after HA has loaded.
|
||||
*/
|
||||
let _tokenStyles = null;
|
||||
function styles() {
|
||||
if (_tokenStyles) return _tokenStyles;
|
||||
const base = customElements.get("hui-masonry-view") || customElements.get("hui-view");
|
||||
if (!base) return null;
|
||||
const css = Object.getPrototypeOf(base).prototype.css;
|
||||
if (!css) return null;
|
||||
const cssText = TOKENS + "\n" + KIT;
|
||||
const arr = [cssText];
|
||||
arr.raw = [cssText]; // satisfy css() implementations that read strings.raw
|
||||
try { _tokenStyles = css(arr); } catch (_e) { _tokenStyles = null; }
|
||||
return _tokenStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global default design. Read the card's own entity first (the overview
|
||||
* sensor exposes card_design at top level); otherwise scan TaskMate sensors
|
||||
* so the default resolves even for cards bound to child-specific entities.
|
||||
*/
|
||||
function _globalDesign(hass, entity) {
|
||||
if (!hass || !hass.states) return "classic";
|
||||
const own = hass.states[entity] && hass.states[entity].attributes;
|
||||
if (own && IDS.includes(own.card_design)) return own.card_design;
|
||||
for (const eid in hass.states) {
|
||||
if (eid.indexOf("sensor.taskmate") !== 0) continue;
|
||||
const a = hass.states[eid].attributes;
|
||||
if (a && IDS.includes(a.card_design)) return a.card_design;
|
||||
}
|
||||
return "classic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active design for a card.
|
||||
* Precedence: per-card config.card_design → global default → classic.
|
||||
*/
|
||||
function resolve(hass, config, entity) {
|
||||
const perCard = config && config.card_design;
|
||||
if (perCard && perCard !== "global" && IDS.includes(perCard)) return perCard;
|
||||
return _globalDesign(hass, entity);
|
||||
}
|
||||
|
||||
// Relative luminance (0..1) of a CSS colour string, or null if unparseable.
|
||||
function _luminance(c) {
|
||||
if (!c) return null;
|
||||
c = c.trim();
|
||||
let r, g, b;
|
||||
let m = c.match(/^#([0-9a-f]{3})$/i);
|
||||
if (m) { const h = m[1]; r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16); }
|
||||
else if ((m = c.match(/^#([0-9a-f]{6})$/i))) { r = parseInt(m[1].slice(0, 2), 16); g = parseInt(m[1].slice(2, 4), 16); b = parseInt(m[1].slice(4, 6), 16); }
|
||||
else if ((m = c.match(/rgba?\(\s*([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)/i))) { r = +m[1]; g = +m[2]; b = +m[3]; }
|
||||
else return null;
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the card currently in dark mode? Order:
|
||||
* 1. an explicitly-selected dark theme (hass.themes.darkMode)
|
||||
* 2. the ACTUAL rendered background brightness (covers the default theme
|
||||
* following the OS, custom themes, etc.) read from the card's own scope
|
||||
* 3. the OS preference as a last resort.
|
||||
* `el` is the card host; its inherited --card/--primary-background-color
|
||||
* reflects whatever theme is live around it.
|
||||
*/
|
||||
function isDark(hass, el) {
|
||||
if (hass && hass.themes && hass.themes.darkMode) return true;
|
||||
if (el && typeof getComputedStyle === "function") {
|
||||
try {
|
||||
const cs = getComputedStyle(el);
|
||||
const bg = (cs.getPropertyValue("--card-background-color")
|
||||
|| cs.getPropertyValue("--primary-background-color")
|
||||
|| cs.backgroundColor || "").trim();
|
||||
const lum = _luminance(bg);
|
||||
if (lum !== null) return lum < 0.5;
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the design AND stamp the host element: data-tm-design + (in dark
|
||||
* mode) data-tm-dark. Cards call this once at the top of render() and branch
|
||||
* on the returned id. Returns "classic" when no design applies.
|
||||
*/
|
||||
function apply(el, hass, config, entity) {
|
||||
const design = resolve(hass, config, entity);
|
||||
if (el) {
|
||||
el.setAttribute("data-tm-design", design);
|
||||
el.toggleAttribute("data-tm-dark", design !== "classic" && isDark(hass, el));
|
||||
}
|
||||
return design;
|
||||
}
|
||||
|
||||
/** ha-form select options for a per-card design override (includes "use global"). */
|
||||
function editorOptions(t) {
|
||||
const label = (k, fallback) => (t ? t("common.design." + k) : fallback);
|
||||
return [
|
||||
{ value: "global", label: label("use_global", "Use global default") },
|
||||
{ value: "classic", label: label("classic", "Classic") },
|
||||
{ value: "playroom", label: label("playroom", "Playroom") },
|
||||
{ value: "console", label: label("console", "Console") },
|
||||
{ value: "cleanpro", label: label("cleanpro", "Clean Pro") },
|
||||
];
|
||||
}
|
||||
|
||||
// Raw token + kit CSS for non-Lit consumers (e.g. the admin panel, which
|
||||
// renders to light DOM). Inject into a <style> and set data-tm-design on a
|
||||
// wrapper element; the [data-tm-design] selector variant applies there.
|
||||
const cssText = TOKENS + "\n" + KIT;
|
||||
|
||||
// ── Shared header-colour picker (QUAL-3) ───────────────────────────────
|
||||
// The same ~30-line colour-picker editor block was copy-pasted into 17 card
|
||||
// editors. They render it via a thin `_renderColourPicker` wrapper that calls
|
||||
// this helper, passing their own update method as onInput/onPreset/onReset.
|
||||
const _DEFAULT_COLOUR_PRESETS = ["#e67e22", "#27ae60", "#9b59b6", "#f1c40f", "#e74c3c", "#34495e"];
|
||||
|
||||
function colourPicker(opts) {
|
||||
const base = customElements.get("hui-masonry-view") || customElements.get("hui-view");
|
||||
const html = base ? Object.getPrototypeOf(base).prototype.html : null;
|
||||
if (!html) return "";
|
||||
const { defaultValue, current, label, helper, resetLabel, onInput, onPreset, onReset } = opts;
|
||||
const presets = [defaultValue, ...(opts.presets || _DEFAULT_COLOUR_PRESETS)];
|
||||
const isActive = (c) => String(c).toLowerCase() === String(current).toLowerCase();
|
||||
return html`
|
||||
<div class="colour-field">
|
||||
<span class="colour-field-label">${label}</span>
|
||||
<div class="colour-field-body">
|
||||
<label class="colour-swatch-wrapper">
|
||||
<input type="color" .value=${current} @input=${(e) => onInput(e.target.value)} />
|
||||
<span class="colour-swatch-preview" style="background:${current}"></span>
|
||||
</label>
|
||||
<span class="colour-hex">${current}</span>
|
||||
<div class="colour-presets">
|
||||
${presets.map((p) => html`
|
||||
<button class="preset-swatch ${isActive(p) ? "active" : ""}"
|
||||
style="background:${p}" title=${p}
|
||||
@click=${(e) => { e.preventDefault(); onPreset(p); }}></button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="colour-reset"
|
||||
@click=${(e) => { e.preventDefault(); onReset(); }}>${resetLabel}</button>
|
||||
</div>
|
||||
<div class="colour-helper">${helper}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.__taskmate_design = { IDS, resolve, isDark, apply, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
|
||||
|
||||
// ── Card-picker entity suggestions (HA 2026.6+) ────────────────────────────
|
||||
// Custom cards may add getEntitySuggestion(hass, entityId) to their
|
||||
// window.customCards entry; when a user picks an entity in the card picker,
|
||||
// HA shows matching cards in a "Community" section. This shared helper lets
|
||||
// every TaskMate card opt in with a one-liner. We identify a TaskMate sensor
|
||||
// by its attribute SIGNATURE rather than its entity_id, so suggestions still
|
||||
// work after an entity_id rename and across multi-instance setups (e.g.
|
||||
// sensor.taskmate_overview_2). getEntitySuggestion runs at pick-time, by
|
||||
// which point every card module (and getStubConfig) is defined — so reading
|
||||
// this global from a card's registration closure is load-order safe.
|
||||
//
|
||||
// role "overview" — the hub sensor (sensor.taskmate_overview): carries
|
||||
// points_name + the children[] summary. Almost every card
|
||||
// reads from it.
|
||||
// role "activity" — the activity sensor (sensor.taskmate_activity):
|
||||
// recent_completions[] + photo_gallery. Used by the photo
|
||||
// gallery card.
|
||||
window.__taskmate_suggest = window.__taskmate_suggest || function (hass, entityId, type, role) {
|
||||
if (!entityId || typeof entityId !== "string" || !entityId.startsWith("sensor.")) return null;
|
||||
const a = hass && hass.states && hass.states[entityId] && hass.states[entityId].attributes;
|
||||
if (!a) return null;
|
||||
const isOverview = a.points_name !== undefined && Array.isArray(a.children);
|
||||
const isActivity = Array.isArray(a.recent_completions) && a.photo_gallery !== undefined;
|
||||
if (!(role === "activity" ? isActivity : isOverview)) return null;
|
||||
// Mirror the card's own stub defaults (mode/title/days/…) so a suggested
|
||||
// card lands configured exactly as if added manually, just on this entity.
|
||||
let stub = {};
|
||||
try {
|
||||
const el = customElements.get(type);
|
||||
if (el && typeof el.getStubConfig === "function") stub = el.getStubConfig() || {};
|
||||
} catch (e) { stub = {}; }
|
||||
return { config: Object.assign({}, stub, { type: "custom:" + type, entity: entityId }) };
|
||||
};
|
||||
|
||||
// ── Chore-photo lightbox ───────────────────────────────────────────────────
|
||||
// A single shared, design-AGNOSTIC image viewer for evidence photos. It is
|
||||
// mounted on document.body (NOT inside any card's shadow root), so it paints
|
||||
// above every card, the admin panel, and HA's own stacking contexts no matter
|
||||
// how deeply the trigger is nested or whether an ancestor clips overflow.
|
||||
// Callers pass an ALREADY safety-checked URL (tmSafePhotoUrl/_safePhotoUrl),
|
||||
// an optional caption string, and an optional localised close label. Closes on
|
||||
// backdrop click, the ✕ button, or Escape. Deliberately uses no --tmd-* tokens
|
||||
// so it looks right under all four design styles.
|
||||
window.__taskmate_lightbox = window.__taskmate_lightbox || (function () {
|
||||
let overlay = null, imgEl = null, capEl = null, closeBtn = null, lastFocus = null;
|
||||
|
||||
function onKey(e) { if (e.key === "Escape") { e.stopPropagation(); close(); } }
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove("open");
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.setTimeout(() => { if (overlay) overlay.style.display = "none"; if (imgEl) imgEl.src = ""; }, 180);
|
||||
if (lastFocus && typeof lastFocus.focus === "function") { try { lastFocus.focus(); } catch (e) {} }
|
||||
lastFocus = null;
|
||||
}
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "tm-lightbox";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.innerHTML =
|
||||
"<style>" +
|
||||
".tm-lightbox{position:fixed;inset:0;z-index:2147483000;display:none;flex-direction:column;" +
|
||||
"align-items:center;justify-content:center;gap:14px;box-sizing:border-box;" +
|
||||
"padding:max(16px,env(safe-area-inset-top)) 16px max(16px,env(safe-area-inset-bottom));" +
|
||||
"background:rgba(8,10,14,.86);opacity:0;transition:opacity .18s ease;" +
|
||||
"font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;}" +
|
||||
".tm-lightbox.open{opacity:1;}" +
|
||||
".tm-lightbox img{max-width:92vw;max-height:80vh;object-fit:contain;border-radius:10px;" +
|
||||
"background:#111;box-shadow:0 18px 60px rgba(0,0,0,.6);}" +
|
||||
".tm-lightbox .tm-lb-cap{color:#f4f6fb;font-size:14px;font-weight:600;text-align:center;" +
|
||||
"max-width:92vw;line-height:1.4;text-shadow:0 1px 2px rgba(0,0,0,.6);}" +
|
||||
".tm-lightbox .tm-lb-close{position:absolute;top:14px;right:16px;width:42px;height:42px;border:none;" +
|
||||
"border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font-size:22px;line-height:1;" +
|
||||
"cursor:pointer;transition:background .15s;}" +
|
||||
".tm-lightbox .tm-lb-close:hover{background:rgba(255,255,255,.28);}" +
|
||||
"@media (prefers-reduced-motion:reduce){.tm-lightbox{transition:none;}}" +
|
||||
"</style>" +
|
||||
"<button class=\"tm-lb-close\" type=\"button\">✕</button>" +
|
||||
"<img alt=\"\">" +
|
||||
"<div class=\"tm-lb-cap\"></div>";
|
||||
document.body.appendChild(overlay);
|
||||
imgEl = overlay.querySelector("img");
|
||||
capEl = overlay.querySelector(".tm-lb-cap");
|
||||
closeBtn = overlay.querySelector(".tm-lb-close");
|
||||
closeBtn.addEventListener("click", close);
|
||||
// Only a click on the dimmed backdrop itself (not image/caption/button) closes.
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
}
|
||||
|
||||
return function (src, caption, closeLabel) {
|
||||
if (!src) return;
|
||||
if (!overlay) build();
|
||||
lastFocus = document.activeElement;
|
||||
imgEl.src = src;
|
||||
if (caption) { capEl.textContent = caption; capEl.style.display = ""; }
|
||||
else { capEl.textContent = ""; capEl.style.display = "none"; }
|
||||
closeBtn.setAttribute("aria-label", closeLabel || "Close");
|
||||
closeBtn.title = closeLabel || "Close";
|
||||
overlay.style.display = "flex";
|
||||
void overlay.offsetWidth; // force reflow so the opacity transition runs from 0
|
||||
overlay.classList.add("open");
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
closeBtn.focus();
|
||||
};
|
||||
})();
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* TaskMate Family Goal Card
|
||||
* Shows the shared family co-op goal (combined points across all children) with
|
||||
* a progress bar toward the target and the reward. Reads the overview sensor's
|
||||
* `family_goal` attribute.
|
||||
*/
|
||||
|
||||
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 TaskMateFamilyGoalCard extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config || !config.entity) {
|
||||
throw new Error("A TaskMate overview entity is required");
|
||||
}
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview" };
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
if (!entity) {
|
||||
return html`<ha-card><div class="empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
|
||||
}
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const goal = attrs.family_goal || {};
|
||||
const pointsName = attrs.points_name || this._t("common.points");
|
||||
|
||||
if (!goal.enabled || !goal.target) {
|
||||
return html`<ha-card><div class="empty"><ha-icon icon="mdi:flag-checkered"></ha-icon><div>${this._t("family_goal.disabled")}</div></div></ha-card>`;
|
||||
}
|
||||
|
||||
const progress = Number(goal.progress) || 0;
|
||||
const target = Number(goal.target) || 0;
|
||||
const pct = target > 0 ? Math.min(100, Math.round((progress / target) * 100)) : 0;
|
||||
const achieved = !!goal.achieved || progress >= target;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --tm-goal-accent: ${_safeColor(this.config.header_color, "#16a085")}; }</style>
|
||||
<div class="head">
|
||||
<ha-icon icon="${achieved ? "mdi:trophy" : "mdi:flag-checkered"}"></ha-icon>
|
||||
<span class="title">${this.config.title || goal.name || this._t("family_goal.default_title")}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="bar"><div class="fill ${achieved ? "done" : ""}" style="width:${pct}%"></div></div>
|
||||
<div class="stats">
|
||||
<span>${progress} / ${target} ${pointsName}</span>
|
||||
<span>${pct}%</span>
|
||||
</div>
|
||||
${achieved
|
||||
? html`<div class="reward done">🎉 ${this._t("family_goal.reached", { reward: goal.reward || "" })}</div>`
|
||||
: goal.reward
|
||||
? html`<div class="reward">${this._t("family_goal.reward_label", { reward: goal.reward })}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
.head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 6px; font-weight: 600; }
|
||||
.head ha-icon { color: var(--tm-goal-accent); }
|
||||
.body { padding: 4px 16px 16px; }
|
||||
.bar { height: 14px; border-radius: 8px; background: var(--secondary-background-color, #eee); overflow: hidden; }
|
||||
.fill { height: 100%; background: var(--tm-goal-accent); transition: width .4s ease; }
|
||||
.fill.done { background: #d4ac0d; }
|
||||
.stats { display: flex; justify-content: space-between; font-size: 0.82rem; color: var(--secondary-text-color); margin-top: 6px; }
|
||||
.reward { margin-top: 8px; font-size: 0.85rem; color: var(--primary-text-color); }
|
||||
.reward.done { font-weight: 600; color: #d4ac0d; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 16px; color: var(--secondary-text-color); }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-family-goal-card", TaskMateFamilyGoalCard);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-family-goal-card",
|
||||
name: "TaskMate Family Goal",
|
||||
description: "Shared family goal progress toward a combined points target",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-family-goal-card", "overview"),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* TaskMate Leaderboard Card
|
||||
* Competitive multi-child ranking showing points, streaks, and weekly activity.
|
||||
* Adapts gracefully for single-child households (shows personal bests instead).
|
||||
*
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
const RANK_COLOURS = ["#f1c40f", "#bdc3c7", "#cd7f32", "#9b59b6", "#3498db"];
|
||||
const RANK_LABELS = ["🥇", "🥈", "🥉"];
|
||||
|
||||
class TaskMateLeaderboardCard extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host { display: block; }
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, #b7950b);
|
||||
color: white; gap: 12px;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
|
||||
.header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.period-badge {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 10px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* Rank row */
|
||||
.rank-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 14px;
|
||||
transition: box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rank-row:hover { box-shadow: 0 3px 12px rgba(0,0,0,0.08); }
|
||||
|
||||
.rank-row.first {
|
||||
border-color: #f1c40f;
|
||||
background: linear-gradient(135deg, rgba(241,196,15,0.06) 0%, var(--card-background-color, #fff) 100%);
|
||||
}
|
||||
|
||||
.rank-row.second {
|
||||
border-color: #bdc3c7;
|
||||
background: linear-gradient(135deg, rgba(189,195,199,0.06) 0%, var(--card-background-color, #fff) 100%);
|
||||
}
|
||||
|
||||
.rank-row.third {
|
||||
border-color: #cd7f32;
|
||||
background: linear-gradient(135deg, rgba(205,127,50,0.06) 0%, var(--card-background-color, #fff) 100%);
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 44px; height: 44px; min-width: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
|
||||
|
||||
.rank-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.rank-name {
|
||||
font-size: 1rem; font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rank-stats {
|
||||
display: flex; align-items: center; flex-wrap: wrap;
|
||||
gap: 8px; font-size: 0.78rem; color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
font-size: 0.75rem; color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.stat-chip ha-icon { --mdc-icon-size: 13px; }
|
||||
|
||||
.rank-score {
|
||||
text-align: right; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 1.4rem; font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.65rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* Tie indicator */
|
||||
.tie-line {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--divider-color, #e0e0e0), transparent);
|
||||
margin: -4px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tie-line .tie-label {
|
||||
position: absolute; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
padding: 0 6px;
|
||||
font-size: 0.65rem; font-weight: 700;
|
||||
color: var(--secondary-text-color);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Solo mode (1 child) */
|
||||
.solo-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.personal-best-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pb-icon { --mdc-icon-size: 20px; }
|
||||
.pb-label { flex: 1; font-size: 0.88rem; color: var(--primary-text-color); }
|
||||
.pb-value { font-size: 0.95rem; font-weight: 700; color: var(--primary-text-color); }
|
||||
|
||||
/* Footer */
|
||||
.card-footer {
|
||||
padding: 10px 18px;
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-top: 1px solid var(--divider-color, #e0e0e0);
|
||||
display: flex; justify-content: center;
|
||||
font-size: 0.78rem; color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* Season champion banner (FEAT-2) */
|
||||
.season-champion-banner {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 18px;
|
||||
background: linear-gradient(90deg, #f9e79f33, transparent);
|
||||
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||||
font-size: 0.82rem; color: var(--primary-text-color);
|
||||
}
|
||||
.season-champion-banner ha-icon { color: #d4ac0d; --mdc-icon-size: 18px; }
|
||||
|
||||
/* Error / empty */
|
||||
.error-state, .empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 40px 20px;
|
||||
color: var(--secondary-text-color); text-align: center;
|
||||
}
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-content { padding: 10px; gap: 8px; }
|
||||
.rank-row { padding: 10px 12px; gap: 10px; }
|
||||
.rank-badge { font-size: 1.3rem; width: 28px; }
|
||||
.child-avatar { width: 38px; height: 38px; min-width: 38px; }
|
||||
.child-avatar ha-icon { --mdc-icon-size: 22px; }
|
||||
.rank-name { font-size: 0.95rem; }
|
||||
.score-value { font-size: 1.2rem; }
|
||||
}
|
||||
|
||||
/* ── Designed layouts (playroom / console / cleanpro) ── */
|
||||
/* Shared .tmd kit + tokens come from taskmate-design.js styles(). */
|
||||
|
||||
/* Playroom — podium */
|
||||
.lb-podium { display: grid; grid-template-columns: 1fr 1.15fr 1fr; gap: 9px; align-items: end; }
|
||||
.lb-pod { background: var(--tmd-surface-2); border-radius: 18px; padding: 12px 8px 11px;
|
||||
text-align: center; }
|
||||
.lb-pod.win { border-radius: 20px; padding: 14px 8px 13px; box-shadow: var(--tmd-shadow); }
|
||||
.lb-pod .medal { font-size: 22px; }
|
||||
.lb-pod.win .medal { font-size: 28px; }
|
||||
.lb-pod .av { margin: 6px auto; }
|
||||
.lb-pod-name { font-weight: 800; font-size: 13px; }
|
||||
.lb-pod.win .lb-pod-name { font-size: 14px; }
|
||||
.lb-pod-score { font-size: 22px; color: var(--tmd-accent); margin-top: 2px; }
|
||||
.lb-pod.win .lb-pod-score { font-size: 28px; }
|
||||
.lb-pod-score span { font-size: 11px; }
|
||||
.lb-pod.win .lb-pod-score span { font-size: 13px; }
|
||||
.lb-list { display: flex; flex-direction: column; gap: 9px; }
|
||||
.lb-list .lb-row { background: var(--tmd-surface-2); border-radius: 18px; padding: 11px 13px; }
|
||||
|
||||
/* Console — ranked ladder */
|
||||
.lb-cn { display: flex; flex-direction: column; gap: 9px; }
|
||||
.lb-cn-row { display: flex; align-items: center; gap: 10px; background: var(--tmd-surface-2);
|
||||
border: 1px solid var(--tmd-border); border-radius: 10px; padding: 11px; }
|
||||
.lb-cn-row.win { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tmd-gold) 40%, transparent); }
|
||||
.lb-cn-rank { font-size: 20px; width: 30px; color: var(--tmd-dim); }
|
||||
.lb-cn-rank.win { color: var(--tmd-gold); }
|
||||
.lb-cn-mid { flex: 1; min-width: 0; }
|
||||
.lb-cn-name { font-weight: 700; }
|
||||
.lb-cn-meta { font-size: 10px; gap: 8px; margin-top: 5px; }
|
||||
.lb-cn-right { text-align: right; }
|
||||
.lb-cn-score { font-size: 23px; }
|
||||
.lb-cn-score.win { color: var(--tmd-accent); }
|
||||
.lb-cn-unit { font-size: 10px; }
|
||||
|
||||
/* Clean Pro — compact list */
|
||||
.lb-cp { display: flex; flex-direction: column; }
|
||||
.lb-cp-row { display: flex; align-items: center; gap: 10px; padding: 9px 4px; }
|
||||
.lb-cp-rank { width: 26px; justify-content: center; padding: 4px 0; }
|
||||
.lb-cp-rank.win { background: color-mix(in srgb, var(--tmd-gold) 16%, transparent);
|
||||
border-color: transparent; color: var(--tmd-gold); }
|
||||
.lb-cp-mid { flex: 1; min-width: 0; }
|
||||
.lb-cp-name { font-weight: 600; }
|
||||
.lb-cp-meta { font-size: 11.5px; gap: 10px; margin-top: 2px; }
|
||||
.lb-cp-right { text-align: right; }
|
||||
.lb-cp-score { font-size: 19px; }
|
||||
.lb-cp-unit { font-size: 10.5px; }
|
||||
`;
|
||||
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: "",
|
||||
sort_by: "points", // "points" | "streak" | "weekly" | "career"
|
||||
show_streak: true,
|
||||
show_weekly: true,
|
||||
show_career: true,
|
||||
header_color: '#b7950b',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 4; }
|
||||
static getConfigElement() { return document.createElement("taskmate-leaderboard-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "Leaderboard" };
|
||||
}
|
||||
|
||||
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 attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const children = [...(attrs.children || [])];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t('common.points');
|
||||
|
||||
if (children.length === 0) return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:account-group"></ha-icon><div>${this._t('common.no_children')}</div></div></ha-card>`;
|
||||
|
||||
// Build weekly points from recent_completions
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const weeklyPoints = this._buildWeeklyPoints(attrs, tz);
|
||||
|
||||
// Sort children
|
||||
const sortBy = this.config.sort_by || "points";
|
||||
const sorted = [...children].sort((a, b) => {
|
||||
if (sortBy === "streak") return (b.current_streak || 0) - (a.current_streak || 0);
|
||||
if (sortBy === "weekly") return (weeklyPoints[b.id] || 0) - (weeklyPoints[a.id] || 0);
|
||||
if (sortBy === "career") return (b.career_score || 0) - (a.career_score || 0);
|
||||
if (sortBy === "season") return (b.season_points || 0) - (a.season_points || 0);
|
||||
return (b.points || 0) - (a.points || 0);
|
||||
});
|
||||
|
||||
const sortLabels = { points: this._t('leaderboard.sort_all_time_points'), streak: this._t('leaderboard.sort_current_streak'), weekly: this._t('leaderboard.sort_this_week'), career: this._t('leaderboard.sort_career_score'), season: this._t('leaderboard.sort_this_month') };
|
||||
const periodLabel = sortLabels[sortBy] || sortLabels.points;
|
||||
|
||||
// Season champion banner (FEAT-2): show the most recent recorded champion.
|
||||
const champions = attrs.season_champions || [];
|
||||
const lastChampion = champions.length ? champions[0] : null;
|
||||
|
||||
// Solo mode
|
||||
if (children.length === 1) {
|
||||
return this._renderSolo(sorted[0], weeklyPoints, pointsIcon, pointsName, periodLabel);
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#b7950b')}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:trophy"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t('leaderboard.default_title')}</span>
|
||||
</div>
|
||||
<span class="period-badge">${periodLabel}</span>
|
||||
</div>
|
||||
${lastChampion ? html`
|
||||
<div class="season-champion-banner">
|
||||
<ha-icon icon="mdi:crown"></ha-icon>
|
||||
<span>${this._t('leaderboard.last_champion', { name: lastChampion.child_name })}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card-content">
|
||||
${sorted.map((child, idx) => {
|
||||
const prevChild = idx > 0 ? sorted[idx - 1] : null;
|
||||
const isTie = prevChild && this._isTie(child, prevChild, sortBy, weeklyPoints);
|
||||
return html`
|
||||
${isTie ? html`<div class="tie-line"><span class="tie-label">${this._t('leaderboard.tie')}</span></div>` : ''}
|
||||
${this._renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName)}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
${this._t('leaderboard.ranked_by', { period: periodLabel.toLowerCase() })}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_isTie(a, b, sortBy, weeklyPoints) {
|
||||
if (sortBy === "streak") return (a.current_streak || 0) === (b.current_streak || 0);
|
||||
if (sortBy === "weekly") return (weeklyPoints[a.id] || 0) === (weeklyPoints[b.id] || 0);
|
||||
if (sortBy === "career") return (a.career_score || 0) === (b.career_score || 0);
|
||||
if (sortBy === "season") return (a.season_points || 0) === (b.season_points || 0);
|
||||
return (a.points || 0) === (b.points || 0);
|
||||
}
|
||||
|
||||
_renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName) {
|
||||
const rankClass = idx === 0 ? "first" : idx === 1 ? "second" : idx === 2 ? "third" : "";
|
||||
const avatarColour = RANK_COLOURS[idx % RANK_COLOURS.length];
|
||||
const rankEmoji = idx < 3 ? RANK_LABELS[idx] : null;
|
||||
const rankNum = idx + 1;
|
||||
|
||||
let scoreValue, scoreLabel;
|
||||
if (sortBy === "streak") {
|
||||
scoreValue = child.current_streak || 0;
|
||||
scoreLabel = this._t('common.day_streak');
|
||||
} else if (sortBy === "weekly") {
|
||||
scoreValue = weeklyPoints[child.id] || 0;
|
||||
scoreLabel = this._t('common.this_week');
|
||||
} else if (sortBy === "career") {
|
||||
scoreValue = child.career_score || 0;
|
||||
scoreLabel = this._t('leaderboard.career_score');
|
||||
} else if (sortBy === "season") {
|
||||
scoreValue = child.season_points || 0;
|
||||
scoreLabel = this._t('leaderboard.sort_this_month');
|
||||
} else {
|
||||
scoreValue = child.points || 0;
|
||||
scoreLabel = pointsName;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="rank-row ${rankClass}">
|
||||
${rankEmoji
|
||||
? html`<div class="rank-badge">${rankEmoji}</div>`
|
||||
: html`<div class="rank-number">${this._t('leaderboard.ordinal_' + rankNum)}</div>`}
|
||||
|
||||
<div class="child-avatar" style="background: linear-gradient(135deg, ${avatarColour} 0%, ${avatarColour}cc 100%);">
|
||||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||||
</div>
|
||||
|
||||
<div class="rank-info">
|
||||
<div class="rank-name">${child.name}</div>
|
||||
<div class="rank-stats">
|
||||
${this.config.show_streak !== false && sortBy !== "streak" ? html`
|
||||
<span class="stat-chip">
|
||||
<ha-icon icon="mdi:fire" style="color: #e67e22;"></ha-icon>
|
||||
${this._t('common.d_streak', { count: child.current_streak || 0 })}
|
||||
</span>
|
||||
` : ''}
|
||||
${this.config.show_weekly !== false && sortBy !== "weekly" ? html`
|
||||
<span class="stat-chip">
|
||||
<ha-icon icon="mdi:calendar-week" style="color: #3498db;"></ha-icon>
|
||||
${weeklyPoints[child.id] || 0} ${this._t('common.this_week')}
|
||||
</span>
|
||||
` : ''}
|
||||
${sortBy !== "points" ? html`
|
||||
<span class="stat-chip">
|
||||
<ha-icon icon="${pointsIcon}" style="color: #f1c40f;"></ha-icon>
|
||||
${child.points || 0} ${this._t('common.total')}
|
||||
</span>
|
||||
` : ''}
|
||||
${this.config.show_career !== false && sortBy !== "career" ? html`
|
||||
<span class="stat-chip">
|
||||
<ha-icon icon="mdi:trophy-variant" style="color: #27ae60;"></ha-icon>
|
||||
${child.career_score || 0} ${this._t('leaderboard.career_label')}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rank-score" style="color: ${avatarColour};">
|
||||
<div class="score-value">${scoreValue}</div>
|
||||
<div class="score-label">${scoreLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSolo(child, weeklyPoints, pointsIcon, pointsName, periodLabel) {
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
const totalChores = child.total_chores_completed || 0;
|
||||
const bestStreak = child.best_streak || 0;
|
||||
const weekly = weeklyPoints[child.id] || 0;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#b7950b')}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:trophy"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t('leaderboard.default_title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="rank-row first">
|
||||
<div class="rank-badge">🥇</div>
|
||||
<div class="child-avatar" style="background: linear-gradient(135deg, #f1c40f 0%, #e67e22 100%);">
|
||||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||||
</div>
|
||||
<div class="rank-info">
|
||||
<div class="rank-name">${child.name}</div>
|
||||
<div class="rank-stats">
|
||||
<span class="stat-chip">
|
||||
<ha-icon icon="mdi:fire" style="color:#e67e22;"></ha-icon>
|
||||
${this._t('common.d_streak', { count: child.current_streak || 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-score" style="color:#f1c40f;">
|
||||
<div class="score-value">${child.points || 0}</div>
|
||||
<div class="score-label">${pointsName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solo-header">${this._t('leaderboard.personal_bests')}</div>
|
||||
<div class="personal-best-row">
|
||||
<ha-icon class="pb-icon" icon="mdi:fire" style="color:#e67e22;"></ha-icon>
|
||||
<span class="pb-label">${this._t('leaderboard.best_streak')}</span>
|
||||
<span class="pb-value">${this._t('leaderboard.best_streak_value', { count: bestStreak })}</span>
|
||||
</div>
|
||||
<div class="personal-best-row">
|
||||
<ha-icon class="pb-icon" icon="mdi:checkbox-multiple-marked" style="color:#3498db;"></ha-icon>
|
||||
<span class="pb-label">${this._t('leaderboard.total_chores_completed')}</span>
|
||||
<span class="pb-value">${totalChores}</span>
|
||||
</div>
|
||||
<div class="personal-best-row">
|
||||
<ha-icon class="pb-icon" icon="mdi:calendar-week" style="color:#9b59b6;"></ha-icon>
|
||||
<span class="pb-label">${this._t('leaderboard.points_this_week')}</span>
|
||||
<span class="pb-value">${weekly}</span>
|
||||
</div>
|
||||
<div class="personal-best-row">
|
||||
<ha-icon class="pb-icon" icon="${pointsIcon}" style="color:#f1c40f;"></ha-icon>
|
||||
<span class="pb-label">${this._t('leaderboard.total_points_earned')}</span>
|
||||
<span class="pb-value">${child.total_points_earned || child.points || 0}</span>
|
||||
</div>
|
||||
<div class="personal-best-row">
|
||||
<ha-icon class="pb-icon" icon="mdi:trophy-variant" style="color:#27ae60;"></ha-icon>
|
||||
<span class="pb-label">${this._t('leaderboard.career_score')}</span>
|
||||
<span class="pb-value">${child.career_score || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_buildWeeklyPoints(attrs, tz) {
|
||||
const result = {};
|
||||
const completions = attrs.recent_completions || attrs.todays_completions || [];
|
||||
const choreMap = {};
|
||||
(attrs.chores || []).forEach(ch => { choreMap[ch.id] = ch.points || 0; });
|
||||
|
||||
const today = new Date();
|
||||
const weekDays = new Set();
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
weekDays.add(d.toLocaleDateString("en-CA", { timeZone: tz }));
|
||||
}
|
||||
|
||||
completions
|
||||
.filter(c => c.approved)
|
||||
.forEach(c => {
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
if (weekDays.has(day)) {
|
||||
result[c.child_id] = (result[c.child_id] || 0) +
|
||||
(c.points !== undefined ? c.points : (choreMap[c.chore_id] || 0));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns/frag/04-leaderboard.html.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = child.avatar || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
_renderDesigned(design) {
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
const hd = _safeColor(this.config.header_color, '#b7950b');
|
||||
|
||||
if (!entity) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${this._designHeader(hd, '')}
|
||||
<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}">
|
||||
${this._designHeader(hd, '')}
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const children = [...(attrs.children || [])];
|
||||
const pointsName = attrs.points_name || this._t('common.points');
|
||||
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const weeklyPoints = this._buildWeeklyPoints(attrs, tz);
|
||||
const sortBy = this.config.sort_by || "points";
|
||||
|
||||
const sortLabels = { points: this._t('leaderboard.sort_all_time_points'), streak: this._t('leaderboard.sort_current_streak'), weekly: this._t('leaderboard.sort_this_week'), career: this._t('leaderboard.sort_career_score') };
|
||||
const periodLabel = sortLabels[sortBy] || sortLabels.points;
|
||||
|
||||
const header = this._designHeader(hd, periodLabel);
|
||||
|
||||
if (children.length === 0) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${header}
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.no_children')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const scoreOf = (c) => {
|
||||
if (sortBy === "streak") return c.current_streak || 0;
|
||||
if (sortBy === "weekly") return weeklyPoints[c.id] || 0;
|
||||
if (sortBy === "career") return c.career_score || 0;
|
||||
return c.points || 0;
|
||||
};
|
||||
const scoreUnit =
|
||||
sortBy === "streak" ? this._t('common.day_streak') :
|
||||
sortBy === "weekly" ? this._t('common.this_week') :
|
||||
sortBy === "career" ? this._t('leaderboard.career_score') :
|
||||
pointsName;
|
||||
|
||||
const sorted = [...children].sort((a, b) => scoreOf(b) - scoreOf(a));
|
||||
const rows = sorted.map((child, idx) => ({
|
||||
child,
|
||||
idx,
|
||||
tone: this._designTone(idx),
|
||||
score: scoreOf(child),
|
||||
streak: child.current_streak || 0,
|
||||
weekly: weeklyPoints[child.id] || 0,
|
||||
points: child.points || 0,
|
||||
career: child.career_score || 0,
|
||||
}));
|
||||
|
||||
const ctx = { sortBy, pointsName };
|
||||
const body =
|
||||
design === "playroom" ? this._lbPlayroom(rows, scoreUnit, ctx) :
|
||||
design === "console" ? this._lbConsole(rows, scoreUnit, ctx) :
|
||||
this._lbCleanpro(rows, scoreUnit, ctx);
|
||||
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${body}</div></ha-card>`;
|
||||
}
|
||||
|
||||
_designHeader(hd, periodLabel) {
|
||||
const title = this.config.title || this._t('leaderboard.default_title');
|
||||
return html`
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">🏆</span>
|
||||
<span class="tt">${title}</span>
|
||||
${periodLabel ? html`<span class="pill">${periodLabel}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Mirrors the classic rank-stats: each chip is shown only when its toggle is
|
||||
// on AND it is not the metric currently being ranked (sortBy).
|
||||
_lbMetaChips(r, ctx, cls) {
|
||||
const sortBy = ctx.sortBy;
|
||||
const chips = [];
|
||||
if (this.config.show_streak !== false && sortBy !== "streak") chips.push(html`<span>🔥 ${r.streak}</span>`);
|
||||
if (this.config.show_weekly !== false && sortBy !== "weekly") chips.push(html`<span>📅 ${r.weekly}</span>`);
|
||||
if (sortBy !== "points") chips.push(html`<span>⭐ ${r.points}</span>`);
|
||||
if (this.config.show_career !== false && sortBy !== "career") chips.push(html`<span>🏆 ${r.career}</span>`);
|
||||
if (!chips.length) return "";
|
||||
return html`<div class="row muted ${cls}">${chips}</div>`;
|
||||
}
|
||||
|
||||
_lbMeta(r, ctx) { return this._lbMetaChips(r, ctx, "lb-cn-meta"); }
|
||||
|
||||
_lbPlayroom(rows, scoreUnit, ctx) {
|
||||
const MEDAL = ["🥇", "🥈", "🥉"];
|
||||
if (rows.length >= 3) {
|
||||
const [first, second, third] = rows;
|
||||
const pod = (r, win) => html`
|
||||
<div class="lb-pod ${win ? "win" : ""}" style="--ac:${r.tone}">
|
||||
<div class="medal">${MEDAL[r.idx]}</div>
|
||||
${this._av(r.child, r.tone, win ? 58 : 46)}
|
||||
<div class="lb-pod-name">${r.child.name}</div>
|
||||
<div class="big lb-pod-score">${r.score.toLocaleString()}<span>⭐</span></div>
|
||||
${win && this.config.show_streak !== false && r.streak
|
||||
? html`<div class="chip soft" style="margin-top:7px">🔥 ${this._t('common.d_streak', { count: r.streak })}</div>` : ""}
|
||||
</div>`;
|
||||
return html`
|
||||
<div class="lb-podium">
|
||||
${pod(second, false)}${pod(first, true)}${pod(third, false)}
|
||||
</div>
|
||||
${rows.length > 3 ? html`
|
||||
<div class="lb-list" style="margin-top:11px">
|
||||
${rows.slice(3).map((r) => html`
|
||||
<div class="row lb-row" style="--ac:${r.tone}">
|
||||
<div class="num" style="width:26px">${r.idx + 1}</div>
|
||||
${this._av(r.child, r.tone, 38)}
|
||||
<div style="flex:1;min-width:0"><div class="lb-pod-name">${r.child.name}</div></div>
|
||||
<div class="big" style="color:var(--tmd-accent)">${r.score.toLocaleString()}</div>
|
||||
</div>`)}
|
||||
</div>` : ""}`;
|
||||
}
|
||||
return html`
|
||||
<div class="lb-list">
|
||||
${rows.map((r) => html`
|
||||
<div class="row lb-row" style="--ac:${r.tone}">
|
||||
${r.idx < 3 ? html`<div class="medal" style="font-size:22px">${MEDAL[r.idx]}</div>`
|
||||
: html`<div class="num" style="width:26px">${r.idx + 1}</div>`}
|
||||
${this._av(r.child, r.tone, 44)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="lb-pod-name">${r.child.name}</div>
|
||||
${this._lbMeta(r, ctx)}
|
||||
</div>
|
||||
<div class="big" style="font-size:22px;color:var(--tmd-accent)">${r.score.toLocaleString()}</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_lbConsole(rows, scoreUnit, ctx) {
|
||||
const max = Math.max(...rows.map((r) => r.score), 1);
|
||||
return html`
|
||||
<div class="lb-cn">
|
||||
${rows.map((r) => html`
|
||||
<div class="lb-cn-row ${r.idx === 0 ? "win" : ""}" style="--ac:${r.tone}">
|
||||
<div class="num lb-cn-rank ${r.idx === 0 ? "win" : ""}">#${r.idx + 1}</div>
|
||||
${this._av(r.child, r.tone, 38)}
|
||||
<div class="lb-cn-mid">
|
||||
<div class="lb-cn-name">${r.child.name}</div>
|
||||
<div class="bar" style="margin-top:6px"><i style="width:${Math.round((r.score / max) * 100)}%"></i></div>
|
||||
${this._lbMeta(r, ctx)}
|
||||
</div>
|
||||
<div class="lb-cn-right">
|
||||
<div class="num lb-cn-score ${r.idx === 0 ? "win" : ""}">${r.score.toLocaleString()}</div>
|
||||
<div class="muted lb-cn-unit">${scoreUnit}</div>
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_lbCleanpro(rows, scoreUnit, ctx) {
|
||||
return html`
|
||||
<div class="lb-cp">
|
||||
${rows.map((r, i) => html`
|
||||
${i > 0 ? html`<div class="divide" style="margin:4px 0"></div>` : ""}
|
||||
<div class="lb-cp-row" style="--ac:${r.tone}">
|
||||
<span class="chip lb-cp-rank ${r.idx === 0 ? "win" : ""}">${r.idx + 1}</span>
|
||||
${this._av(r.child, r.tone, 36)}
|
||||
<div class="lb-cp-mid">
|
||||
<div class="lb-cp-name">${r.child.name}</div>
|
||||
${this._lbMetaChips(r, ctx, "lb-cp-meta")}
|
||||
</div>
|
||||
<div class="lb-cp-right">
|
||||
<div class="num lb-cp-score">${r.score.toLocaleString()}</div>
|
||||
<div class="muted lb-cp-unit">${scoreUnit}</div>
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskMateLeaderboardCardEditor 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() {
|
||||
return [
|
||||
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{
|
||||
name: 'sort_by',
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{ value: 'points', label: this._t('leaderboard.editor.sort_option_points') },
|
||||
{ value: 'streak', label: this._t('leaderboard.editor.sort_option_streak') },
|
||||
{ value: 'weekly', label: this._t('leaderboard.editor.sort_option_weekly') },
|
||||
{ value: 'career', label: this._t('leaderboard.editor.sort_option_career') },
|
||||
],
|
||||
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: 'show_streak', selector: { boolean: {} } },
|
||||
{ name: 'show_weekly', selector: { boolean: {} } },
|
||||
{ name: 'show_career', selector: { boolean: {} } },
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('leaderboard.editor.entity_label'),
|
||||
title: this._t('leaderboard.editor.title_label'),
|
||||
sort_by: this._t('leaderboard.editor.rank_by_label'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
show_streak: this._t('leaderboard.editor.show_streak'),
|
||||
show_weekly: this._t('leaderboard.editor.show_weekly'),
|
||||
show_career: this._t('leaderboard.editor.show_career'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t('leaderboard.editor.entity_helper'),
|
||||
sort_by: this._t('leaderboard.editor.rank_by_helper'),
|
||||
};
|
||||
return helpers[entry.name] ?? '';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
sort_by: this.config.sort_by || 'points',
|
||||
card_design: this.config.card_design || 'global',
|
||||
show_streak: this.config.show_streak !== false,
|
||||
show_weekly: this.config.show_weekly !== false,
|
||||
show_career: this.config.show_career !== 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', '#b7950b')}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._update(key, v),
|
||||
onPreset: (v) => this._update(key, v),
|
||||
onReset: () => this._update(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
delete newConfig[key];
|
||||
} else if ((key === 'show_streak' || key === 'show_weekly' || key === 'show_career') && value === true) {
|
||||
delete newConfig[key];
|
||||
} else if (key === 'card_design' && value === 'global') {
|
||||
delete newConfig[key];
|
||||
} else if (key === 'sort_by' && value === 'points') {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_update(key, value) {
|
||||
const cfg = { ...this.config, [key]: value };
|
||||
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: cfg }, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-leaderboard-card", TaskMateLeaderboardCard);
|
||||
customElements.define("taskmate-leaderboard-card-editor", TaskMateLeaderboardCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-leaderboard-card",
|
||||
name: "TaskMate Leaderboard",
|
||||
description: "Multi-child competitive ranking by points, streak, or weekly activity",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-leaderboard-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-leaderboard-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE LEADERBOARD CARD %c v" + _tmVersion + " ",
|
||||
"background:#b7950b;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;"
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* TaskMate i18n Module
|
||||
* Lightweight translation loader for TaskMate custom cards.
|
||||
*
|
||||
* Usage in a card:
|
||||
* // loaded via window.__taskmate_localize (registered globally)
|
||||
*
|
||||
* localize(this.hass, 'common.today') → "Today"
|
||||
* localize(this.hass, 'common.points_received',
|
||||
* { count: 5, name: 'Stars' }) → "5 Stars received"
|
||||
*
|
||||
* Language resolution: nb → nb.json → en-GB.json → key
|
||||
* The base (fallback) language is en-GB since that is the source locale.
|
||||
*/
|
||||
|
||||
const _cache = {}; // lang → { key: value } (null = tried and failed)
|
||||
const _pending = {}; // lang → Promise
|
||||
const _BASE = '/taskmate/locales';
|
||||
const _FALLBACK = 'en-GB'; // base language file
|
||||
|
||||
/**
|
||||
* Load a locale file by language code. Returns cached result if available.
|
||||
* Caches failures (as null) so we don't retry on every render.
|
||||
*/
|
||||
async function _loadLocale(lang) {
|
||||
if (lang in _cache) return _cache[lang];
|
||||
if (_pending[lang]) return _pending[lang];
|
||||
|
||||
_pending[lang] = (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${_BASE}/${lang}.json?v=${Date.now()}`);
|
||||
if (!resp.ok) throw new Error(resp.status);
|
||||
const data = await resp.json();
|
||||
_cache[lang] = data;
|
||||
_refreshTaskMateCards();
|
||||
return data;
|
||||
} catch {
|
||||
_cache[lang] = null;
|
||||
return null;
|
||||
} finally {
|
||||
delete _pending[lang];
|
||||
}
|
||||
})();
|
||||
|
||||
return _pending[lang];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve language to a list of codes to try (most specific first).
|
||||
* e.g. "en-GB" → ["en-GB"]
|
||||
* "nb" → ["nb"]
|
||||
* "pt-BR" → ["pt-BR", "pt"]
|
||||
*/
|
||||
function _langCandidates(lang) {
|
||||
const candidates = [lang];
|
||||
if (lang.includes('-')) {
|
||||
candidates.push(lang.split('-')[0]);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous lookup — returns the translated string or the fallback.
|
||||
* Triggers async load in the background if the locale isn't cached yet.
|
||||
*
|
||||
* @param {object} hass - Home Assistant hass object (for hass.language)
|
||||
* @param {string} key - Dot-notation key, e.g. 'common.today'
|
||||
* @param {object} [params] - Replacement params, e.g. { count: 5 }
|
||||
* @returns {string}
|
||||
*/
|
||||
function localize(hass, key, params) {
|
||||
const lang = (hass && hass.language) || _FALLBACK;
|
||||
const candidates = _langCandidates(lang);
|
||||
|
||||
// Try each candidate language, then fall back to base language
|
||||
let str = undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (_cache[candidate] && _cache[candidate][key]) {
|
||||
str = _cache[candidate][key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (str === undefined && _cache[_FALLBACK]) {
|
||||
str = _cache[_FALLBACK][key];
|
||||
}
|
||||
if (str === undefined) {
|
||||
str = key;
|
||||
}
|
||||
|
||||
// Trigger background loads for any language not yet in cache
|
||||
for (const candidate of candidates) {
|
||||
if (!(candidate in _cache)) _loadLocale(candidate);
|
||||
}
|
||||
if (!(_FALLBACK in _cache)) _loadLocale(_FALLBACK);
|
||||
|
||||
// Replace {placeholder} tokens
|
||||
if (params && typeof str === 'string') {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-load a locale so translations are ready before first render.
|
||||
* Call this in connectedCallback() of your card.
|
||||
*
|
||||
* @param {object} hass - Home Assistant hass object
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function loadTranslations(hass) {
|
||||
const lang = (hass && hass.language) || _FALLBACK;
|
||||
const candidates = _langCandidates(lang);
|
||||
await Promise.all([
|
||||
_loadLocale(_FALLBACK),
|
||||
...candidates.filter(c => c !== _FALLBACK).map(c => _loadLocale(c)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Walk the DOM across shadow roots and trigger a re-render on TaskMate cards.
|
||||
// Called after locale files load so cards pick up the new translations.
|
||||
function _refreshTaskMateCards() {
|
||||
const stack = [document];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
if (node.tagName && node.tagName.startsWith('TASKMATE-')) {
|
||||
if (typeof node.requestUpdate === 'function') {
|
||||
try { node.requestUpdate(); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (node.shadowRoot) stack.push(node.shadowRoot);
|
||||
const children = node.children;
|
||||
if (children) {
|
||||
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally so cards can access without ES module imports
|
||||
window.__taskmate_localize = localize;
|
||||
window.__taskmate_loadTranslations = loadTranslations;
|
||||
|
||||
// Pre-load the fallback locale so translations are ready as soon as possible.
|
||||
// Cards that render before this completes will show raw keys momentarily,
|
||||
// then _loadLocale calls _refreshTaskMateCards to re-render them.
|
||||
_loadLocale(_FALLBACK);
|
||||
@@ -0,0 +1,996 @@
|
||||
/**
|
||||
* TaskMate Overview Card
|
||||
* At-a-glance parent dashboard showing all children's points,
|
||||
* today's chore completion progress, and pending approvals.
|
||||
*
|
||||
* Version: 1.0.0
|
||||
* Last Updated: 2026-03-18
|
||||
*/
|
||||
|
||||
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 TaskMateOverviewCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
_loading: { type: Object },
|
||||
_expanded: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._loading = {};
|
||||
this._expanded = {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--ov-purple: #9b59b6;
|
||||
--ov-purple-light: #a569bd;
|
||||
--ov-gold: #f1c40f;
|
||||
--ov-green: #2ecc71;
|
||||
--ov-orange: #e67e22;
|
||||
--ov-red: #e74c3c;
|
||||
--ov-blue: #3498db;
|
||||
}
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, #8e44ad);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
|
||||
.header-title { font-size: 1.2rem; font-weight: 600; }
|
||||
|
||||
.pending-badge {
|
||||
background: var(--ov-red);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
animation: badge-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); }
|
||||
}
|
||||
|
||||
.pending-badge ha-icon { --mdc-icon-size: 14px; }
|
||||
|
||||
.card-content {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Child tile */
|
||||
.child-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 14px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.child-tile:hover {
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--ov-purple) 0%, var(--ov-purple-light) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-avatar ha-icon { --mdc-icon-size: 28px; color: white; }
|
||||
|
||||
.child-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.child-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
color: var(--primary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.points-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(241,196,15,0.15);
|
||||
color: var(--ov-orange);
|
||||
border-radius: 10px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.points-pill ha-icon { --mdc-icon-size: 14px; color: var(--ov-gold); }
|
||||
|
||||
.pending-points-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: rgba(230,126,34,0.12);
|
||||
color: var(--ov-orange);
|
||||
border-radius: 10px;
|
||||
padding: 2px 7px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.pending-points-pill ha-icon { --mdc-icon-size: 12px; }
|
||||
|
||||
/* Chore progress bar */
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-bar-fill.complete {
|
||||
background: linear-gradient(90deg, var(--ov-green), #27ae60);
|
||||
}
|
||||
|
||||
.progress-bar-fill.partial {
|
||||
background: linear-gradient(90deg, var(--ov-blue), #2980b9);
|
||||
}
|
||||
|
||||
.progress-bar-fill.none {
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-label.complete { color: var(--ov-green); }
|
||||
|
||||
/* Approval item in tile */
|
||||
.approvals-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(231,76,60,0.12);
|
||||
color: var(--ov-red);
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.approvals-chip ha-icon { --mdc-icon-size: 13px; }
|
||||
|
||||
/* Footer summary row */
|
||||
.summary-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 10px 16px;
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-top: 1px solid var(--divider-color, #e0e0e0);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.summary-stat-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.summary-stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; }
|
||||
|
||||
/* Complete on behalf (admin) */
|
||||
.tm-outstanding { margin-top: 8px; border-top: 1px dashed var(--divider-color, #e0e0e0); padding-top: 8px; }
|
||||
.tm-outstanding-hdr { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 6px; }
|
||||
.tm-outstanding-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; }
|
||||
.tm-outstanding-row + .tm-outstanding-row { border-top: 1px solid var(--divider-color, #eee); }
|
||||
.tm-outstanding-name { flex: 1; font-size: 0.9rem; }
|
||||
.tm-outstanding-pts { opacity: 0.6; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 2px; }
|
||||
.btn-complete-behalf { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; border: none; cursor: pointer; background: #2e9e5b; color: #fff; font: 600 0.75rem/1 inherit; padding: 6px 10px; border-radius: 8px; }
|
||||
.btn-complete-behalf:hover { background: #27894e; }
|
||||
.btn-complete-behalf:active { transform: scale(0.96); }
|
||||
.btn-complete-behalf[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
.child-main.tm-expandable { cursor: pointer; }
|
||||
.tm-all-done { font-size: 0.85rem; opacity: 0.6; font-style: italic; padding: 4px 0; }
|
||||
|
||||
/* Shared .tmd kit + design tokens are provided by taskmate-design.js styles(). */
|
||||
|
||||
/* Overview — pending-approvals callout */
|
||||
.ov-alert { display: flex; align-items: center; gap: 11px; border-radius: 16px; padding: 11px 13px; margin-bottom: 12px;
|
||||
background: color-mix(in srgb, var(--tmd-bad) 16%, transparent); }
|
||||
.ov-alert.cn { border: 1px solid var(--tmd-bad); border-radius: 8px; padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--tmd-bad) 18%, transparent); }
|
||||
.ov-alert.cp { border: 1px solid color-mix(in srgb, var(--tmd-bad) 35%, transparent); border-radius: var(--tmd-radius-sm);
|
||||
padding: 10px 12px; background: color-mix(in srgb, var(--tmd-bad) 9%, transparent); }
|
||||
.ov-alert .emoji { font-size: 22px; line-height: 1; flex: none; }
|
||||
.ov-alert .mid { flex: 1; min-width: 0; }
|
||||
.ov-alert .ttl { font-weight: 800; color: var(--tmd-bad); }
|
||||
.ov-alert.cn .ttl { font-family: var(--tmd-font-mono); font-size: 13px; }
|
||||
.ov-alert.cp .ttl { font-weight: 600; }
|
||||
.ov-alert .sub { font-size: 12px; }
|
||||
.ov-alert.cn .sub { font-family: var(--tmd-font-mono); font-size: 10px; }
|
||||
.ov-alert .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--tmd-bad); flex: none; }
|
||||
|
||||
/* Overview — children grid */
|
||||
.ov-kids { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-height: 360px; overflow-y: auto; }
|
||||
.ov-kid { text-align: center; padding: 11px; border-radius: 16px; background: var(--tmd-surface-2); position: relative; }
|
||||
.ov-kid.tm-clickable { cursor: pointer; }
|
||||
.ov-kid-flags { position: absolute; top: 6px; right: 7px; display: flex; gap: 4px; }
|
||||
.ov-kid-flag { font-size: 9.5px; font-weight: 800; padding: 1px 5px; border-radius: 999px; line-height: 1.5; }
|
||||
.ov-kid-flag.pend { background: color-mix(in srgb, var(--tmd-warn) 22%, transparent); color: var(--tmd-warn); }
|
||||
.ov-kid-flag.wait { background: color-mix(in srgb, var(--tmd-bad) 20%, transparent); color: var(--tmd-bad); }
|
||||
.ov-behalf { margin-top: 10px; text-align: left; background: var(--tmd-surface-2); border-radius: var(--tmd-radius-sm); padding: 9px 11px; }
|
||||
.ov-behalf-hdr { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--tmd-dim); margin-bottom: 6px; }
|
||||
.ov-behalf-done { font-size: 12px; font-style: italic; color: var(--tmd-dim); padding: 3px 0; }
|
||||
.ov-behalf-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
||||
.ov-behalf-row + .ov-behalf-row { border-top: 1px solid var(--tmd-border); }
|
||||
.ov-behalf-name { flex: 1; font-size: 12.5px; font-weight: 600; min-width: 0; }
|
||||
.ov-behalf .btn.good.sm { padding: 4px 9px; }
|
||||
.ov-kid.cn { border: 1px solid var(--tmd-border); border-radius: 10px; }
|
||||
.ov-kid.cp { border: 1px solid var(--tmd-border); border-radius: var(--tmd-radius-sm); }
|
||||
.ov-kid .pts { font-family: var(--tmd-font-display); font-weight: 800; font-size: 20px; line-height: 1; color: var(--tmd-accent); }
|
||||
.ov-kid .pts.num { font-family: var(--tmd-font-mono); font-size: 18px; }
|
||||
.ov-kid .nm { font-weight: 800; font-size: 12.5px; margin-top: 3px; }
|
||||
.ov-kid .nm.cn { font-family: var(--tmd-font-mono); font-size: 9px; color: var(--tmd-dim); letter-spacing: .06em; font-weight: 600; }
|
||||
.ov-kid .nm.cp { font-weight: 600; font-size: 12px; color: var(--tmd-dim); }
|
||||
.ov-kid .bar { margin-top: 7px; }
|
||||
.ov-kid.cn .bar { height: 7px; }
|
||||
.ov-kid.cp .bar { height: 6px; }
|
||||
|
||||
/* Overview — today summary */
|
||||
.ov-today { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px;
|
||||
border-radius: 16px; padding: 11px 13px; background: var(--tmd-surface-2); }
|
||||
.ov-today.cn { border: 1px solid var(--tmd-border); border-radius: 10px; }
|
||||
.ov-today.cp { background: transparent; padding: 0; margin-top: 0; }
|
||||
.ov-today .lbl { font-weight: 800; }
|
||||
.ov-today.cn .lbl { font-family: var(--tmd-font-mono); font-size: 11px; letter-spacing: .06em; color: var(--tmd-dim); font-weight: 600; }
|
||||
.ov-today.cp .lbl { font-weight: 600; font-size: 12.5px; color: var(--tmd-dim); }
|
||||
.ov-today .val { font-family: var(--tmd-font-display); font-weight: 800; font-size: 18px; color: var(--tmd-accent); }
|
||||
.ov-today.cn .val { font-family: var(--tmd-font-mono); color: var(--tmd-accent2); }
|
||||
.ov-today.cp .val { font-family: var(--tmd-font-mono); font-size: 15px; font-weight: 700; color: var(--tmd-text); }
|
||||
`;
|
||||
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: "TaskMate",
|
||||
approvals_entity: null,
|
||||
header_color: '#8e44ad',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 3; }
|
||||
static getConfigElement() { return document.createElement("taskmate-overview-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "TaskMate" };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
|
||||
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 design = window.__taskmate_design
|
||||
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
||||
: "classic";
|
||||
if (design !== "classic") return this._renderDesigned(design, entity);
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const completions = [...(attrs.todays_completions || [])];
|
||||
const chorePointsMap = {};
|
||||
chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t("common.stars");
|
||||
|
||||
// Pending approvals — from approvals entity if configured, else from the
|
||||
// full pending list (resolved via companion) so the count keeps including
|
||||
// completions left pending from a previous day; fall back to today's
|
||||
// completions only on older backends.
|
||||
let pendingApprovals = 0;
|
||||
if (this.config.approvals_entity) {
|
||||
const appEntity = this.hass.states[this.config.approvals_entity];
|
||||
pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0;
|
||||
} else {
|
||||
pendingApprovals = (attrs.chore_completions || completions.filter(c => !c.approved)).length;
|
||||
}
|
||||
|
||||
// Total points across all children
|
||||
const totalPoints = children.reduce((sum, c) => sum + (c.points || 0), 0);
|
||||
// Only count approved completions
|
||||
const totalCompletedToday = completions.filter(c => c.approved).length;
|
||||
|
||||
if (children.length === 0) {
|
||||
return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:account-group"></ha-icon><div>${this._t('common.no_children')}</div></div></ha-card>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#8e44ad')}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:home-heart"></ha-icon>
|
||||
<span class="header-title">${this.config.title}</span>
|
||||
</div>
|
||||
${pendingApprovals > 0 ? html`
|
||||
<div class="pending-badge">
|
||||
<ha-icon icon="mdi:clock-alert"></ha-icon>
|
||||
${this._t('overview.pending_count', { count: pendingApprovals })}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
${children.map(child => this._renderChildTile(child, chores, completions, pointsIcon, pointsName))}
|
||||
</div>
|
||||
|
||||
<div class="summary-footer">
|
||||
<div class="summary-stat">
|
||||
<span class="summary-stat-value">${children.length}</span>
|
||||
<span class="summary-stat-label">${this._t('overview.footer_children')}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-stat-value">${totalCompletedToday}</span>
|
||||
<span class="summary-stat-label">${this._t('overview.footer_done_today')}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-stat-value">${totalPoints}</span>
|
||||
<span class="summary-stat-label">${this._t('overview.footer_total_points', { pointsName })}</span>
|
||||
</div>
|
||||
${pendingApprovals > 0 ? html`
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-stat-value" style="color: var(--ov-red);">${pendingApprovals}</span>
|
||||
<span class="summary-stat-label">${this._t('common.pending')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns/frag/13-overview.html.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = child.avatar || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
/** Per-child today progress, mirroring the classic tile filtering exactly. */
|
||||
_childProgress(child, chores, completions, attrs) {
|
||||
const childChores = this._childChoresToday(child, chores, attrs);
|
||||
const childChoreIds = new Set(childChores.map(c => c.id));
|
||||
const childCompletions = completions.filter(c => c.child_id === child.id);
|
||||
const completed = childCompletions.filter(c => c.approved && childChoreIds.has(c.chore_id)).length;
|
||||
const total = childChores.length;
|
||||
const pct = total > 0 ? Math.min(Math.round((completed / total) * 100), 100) : 0;
|
||||
return { completed, total, pct };
|
||||
}
|
||||
|
||||
_renderDesigned(design, entity) {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const completions = [...(attrs.todays_completions || [])];
|
||||
const hd = _safeColor(this.config.header_color, "#8e44ad");
|
||||
|
||||
if (children.length === 0) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${this._ovHeader(design)}
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.no_children')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
let pendingApprovals = 0;
|
||||
if (this.config.approvals_entity) {
|
||||
const appEntity = this.hass.states[this.config.approvals_entity];
|
||||
pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0;
|
||||
} else {
|
||||
pendingApprovals = (attrs.chore_completions || completions.filter(c => !c.approved)).length;
|
||||
}
|
||||
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
|
||||
// Aggregate today's progress across all children
|
||||
let doneTotal = 0;
|
||||
let choreTotal = 0;
|
||||
const kids = children.map((child, i) => {
|
||||
const p = this._childProgress(child, chores, completions, attrs);
|
||||
doneTotal += p.completed;
|
||||
choreTotal += p.total;
|
||||
// Outstanding chores for the admin "complete on behalf" affordance (mirrors classic tile).
|
||||
const childChores = this._childChoresToday(child, chores, attrs);
|
||||
const outstanding = childChores.filter(c => {
|
||||
const doneToday = completions.filter(
|
||||
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
|
||||
).length;
|
||||
return doneToday < (c.daily_limit || 1);
|
||||
});
|
||||
const childPending = completions.filter(c => c.child_id === child.id && !c.approved).length;
|
||||
return {
|
||||
child, points: child.points || 0, pct: p.pct, tone: this._designTone(i),
|
||||
pendingPoints: child.pending_points || 0, childPending, outstanding,
|
||||
};
|
||||
});
|
||||
const overallPct = choreTotal > 0 ? Math.round((doneTotal / choreTotal) * 100) : 0;
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${this._ovHeader(design, pendingApprovals)}
|
||||
<div class="tmd-bd">
|
||||
${pendingApprovals > 0 ? this._ovAlert(design, pendingApprovals) : ""}
|
||||
<div class="ov-kids">
|
||||
${kids.map((k) => this._ovKid(design, k, isAdmin, pointsIcon))}
|
||||
</div>
|
||||
${isAdmin ? kids.filter(k => this._expanded[k.child.id]).map(k => this._ovBehalf(k, pointsIcon)) : ""}
|
||||
${this._ovToday(design, doneTotal, choreTotal, overallPct)}
|
||||
</div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
/** Chores assigned to + due/available for a child today — shared by classic tile and designed kid. */
|
||||
_childChoresToday(child, chores, attrs) {
|
||||
const todayDow = attrs.today_day_of_week ||
|
||||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
|
||||
const availability = attrs.chore_availability || {};
|
||||
return chores.filter(c => {
|
||||
const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : [];
|
||||
const assigned = at.length === 0 || at.includes(String(child.id));
|
||||
if (!assigned) return false;
|
||||
if (c.schedule_mode !== 'recurring') {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
if (c.schedule_mode === 'recurring') {
|
||||
const perChild = availability[c.id];
|
||||
if (perChild && perChild[child.id] === false) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
_ovHeader(design, pending) {
|
||||
const icon = design === "playroom" ? "👀" : design === "console" ? "◉" : "◳";
|
||||
const sub = design === "console"
|
||||
? this._t("overview.designed.sub_console")
|
||||
: design === "cleanpro"
|
||||
? this._t("overview.designed.sub_cleanpro")
|
||||
: this._t("overview.designed.sub_playroom");
|
||||
return html`
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">${icon}</span>
|
||||
<span class="tt">${this.config.title}<small>${sub}</small></span>
|
||||
${design === "console" && pending > 0
|
||||
? html`<span class="cnt">${pending}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_ovAlert(design, pending) {
|
||||
if (design === "console") {
|
||||
return html`
|
||||
<div class="ov-alert cn">
|
||||
<span class="emoji">⚠</span>
|
||||
<div class="mid">
|
||||
<div class="ttl num">${this._t("overview.designed.pending_review", { count: pending })}</div>
|
||||
<div class="sub muted num">${this._t("overview.designed.action_required")}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (design === "cleanpro") {
|
||||
return html`
|
||||
<div class="ov-alert cp">
|
||||
<span class="dot"></span>
|
||||
<div class="mid"><span class="ttl">${this._t("overview.designed.approvals_waiting", { count: pending })}</span></div>
|
||||
<button class="btn sm" style="background:var(--tmd-bad)">${this._t("overview.designed.review")}</button>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="ov-alert">
|
||||
<span class="emoji">🔔</span>
|
||||
<div class="mid">
|
||||
<div class="ttl">${this._t("overview.designed.waiting_for_you", { count: pending })}</div>
|
||||
<div class="sub muted">${this._t("overview.designed.tap_to_check")}</div>
|
||||
</div>
|
||||
<span class="cnt">${pending}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_ovKid(design, k, isAdmin, pointsIcon) {
|
||||
const cls = design === "console" ? "cn" : design === "cleanpro" ? "cp" : "";
|
||||
const nameUpper = design === "console" ? `${k.child.name.toUpperCase()} · ${k.pct}%` : k.child.name;
|
||||
const isOpen = !!this._expanded[k.child.id];
|
||||
const expandable = isAdmin;
|
||||
return html`
|
||||
<div class="ov-kid ${cls} ${expandable ? 'tm-clickable' : ''}" style="--ac:${k.tone}"
|
||||
@click="${expandable ? () => { this._expanded = { ...this._expanded, [k.child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
${(k.pendingPoints > 0 || k.childPending > 0) ? html`
|
||||
<div class="ov-kid-flags">
|
||||
${k.pendingPoints > 0 ? html`<span class="ov-kid-flag pend">⏳+${k.pendingPoints}</span>` : ""}
|
||||
${k.childPending > 0 ? html`<span class="ov-kid-flag wait">${k.childPending}</span>` : ""}
|
||||
</div>` : ""}
|
||||
${this._av(k.child, k.tone, design === "console" ? 34 : design === "cleanpro" ? 38 : 42)}
|
||||
<div class="pts ${design === "console" ? "num" : ""}">${k.points.toLocaleString()}</div>
|
||||
<div class="nm ${cls}">${nameUpper}</div>
|
||||
<div class="bar"><i style="width:${k.pct}%"></i></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Admin "complete on behalf" panel — same handler as the classic tile. */
|
||||
_ovBehalf(k, pointsIcon) {
|
||||
return html`
|
||||
<div class="ov-behalf" @click="${(e) => e.stopPropagation()}">
|
||||
<div class="ov-behalf-hdr">${this._t('common.complete_on_behalf_heading')} · ${k.child.name}</div>
|
||||
${k.outstanding.length === 0 ? html`
|
||||
<div class="ov-behalf-done">${this._t('common.complete_on_behalf_all_done')}</div>
|
||||
` : k.outstanding.map(c => {
|
||||
const key = `behalf_${k.child.id}_${c.id}`;
|
||||
const loading = !!(this._loading && this._loading[key]);
|
||||
return html`
|
||||
<div class="ov-behalf-row">
|
||||
<span class="ov-behalf-name">${c.name}</span>
|
||||
<span class="muted num">${c.points}</span>
|
||||
<button class="btn good sm" ?disabled="${loading}"
|
||||
title="${this._t('common.complete_on_behalf_tooltip', { name: k.child.name })}"
|
||||
@click="${() => this._handleCompleteOnBehalf(c.id, k.child.id)}">✓</button>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_ovToday(design, done, total, pct) {
|
||||
const cls = design === "console" ? "cn" : design === "cleanpro" ? "cp" : "";
|
||||
if (design === "cleanpro") {
|
||||
return html`
|
||||
<div class="divide"></div>
|
||||
<div class="ov-today cp">
|
||||
<span class="lbl">${this._t("overview.designed.todays_progress")}</span>
|
||||
<span class="val num">${this._t("overview.designed.progress_detail", { done, total, pct })}</span>
|
||||
</div>`;
|
||||
}
|
||||
const lbl = design === "console"
|
||||
? this._t("overview.designed.daily_completion")
|
||||
: html`🌟 ${this._t("overview.designed.todays_progress")}`;
|
||||
return html`
|
||||
<div class="ov-today ${cls}">
|
||||
<span class="lbl">${lbl}</span>
|
||||
<span class="val">${done} / ${total}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderChildTile(child, chores, completions, pointsIcon, pointsName) {
|
||||
// Avatar now included directly in children array from the overview sensor
|
||||
const avatar = child.avatar || "mdi:account-circle";
|
||||
|
||||
// Get today's day of week from sensor (e.g. "monday")
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || {};
|
||||
const todayDow = attrs.today_day_of_week ||
|
||||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
|
||||
const availability = attrs.chore_availability || {};
|
||||
|
||||
// Chores assigned to this child, due today, and available (recurrence window open)
|
||||
const childChores = chores.filter(c => {
|
||||
// Assignment check
|
||||
const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : [];
|
||||
const assigned = at.length === 0 || at.includes(String(child.id));
|
||||
if (!assigned) return false;
|
||||
|
||||
// Mode A: due days check
|
||||
if (c.schedule_mode !== 'recurring') {
|
||||
const dueDays = Array.isArray(c.due_days) ? c.due_days : [];
|
||||
if (dueDays.length > 0 && !dueDays.includes(todayDow)) return false;
|
||||
}
|
||||
|
||||
// Mode B: recurrence availability check
|
||||
if (c.schedule_mode === 'recurring') {
|
||||
const perChild = availability[c.id];
|
||||
if (perChild && perChild[child.id] === false) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// All completions today for this child
|
||||
const childCompletions = completions.filter(c => c.child_id === child.id);
|
||||
// Only approved completions count, and only for chores relevant today
|
||||
const childChoreIds = new Set(childChores.map(c => c.id));
|
||||
const childApprovedCompletions = childCompletions.filter(c => c.approved && childChoreIds.has(c.chore_id));
|
||||
const completedCount = childApprovedCompletions.length;
|
||||
const totalChores = childChores.length;
|
||||
const percentage = totalChores > 0 ? Math.min((completedCount / totalChores) * 100, 100) : 0;
|
||||
const isComplete = totalChores > 0 && completedCount >= totalChores;
|
||||
|
||||
// Pending approvals for this child
|
||||
const childPending = childCompletions.filter(c => !c.approved).length;
|
||||
|
||||
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
|
||||
const outstanding = childChores.filter(c => {
|
||||
const doneToday = completions.filter(
|
||||
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
|
||||
).length;
|
||||
return doneToday < (c.daily_limit || 1);
|
||||
});
|
||||
const isOpen = !!this._expanded[child.id];
|
||||
|
||||
return html`
|
||||
<div class="child-tile">
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${avatar}"></ha-icon>
|
||||
</div>
|
||||
<div class="child-main ${isAdmin ? 'tm-expandable' : ''}"
|
||||
@click="${isAdmin ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
|
||||
<div class="child-name-row">
|
||||
<span class="child-name">${child.name}</span>
|
||||
<div style="display:flex;gap:5px;align-items:center;flex-shrink:0;">
|
||||
${child.pending_points > 0 ? html`
|
||||
<span class="pending-points-pill">
|
||||
<ha-icon icon="mdi:timer-sand"></ha-icon>+${child.pending_points}
|
||||
</span>
|
||||
` : ''}
|
||||
<span class="points-pill">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
${child.points}
|
||||
</span>
|
||||
${childPending > 0 ? html`
|
||||
<span class="approvals-chip">
|
||||
<ha-icon icon="mdi:clock-alert"></ha-icon>${childPending}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${totalChores > 0 ? html`
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill ${isComplete ? 'complete' : percentage > 0 ? 'partial' : 'none'}"
|
||||
style="width: ${percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-label ${isComplete ? 'complete' : ''}">
|
||||
${completedCount}/${totalChores}
|
||||
</span>
|
||||
</div>
|
||||
` : html`
|
||||
<div style="font-size:0.8rem;color:var(--secondary-text-color);opacity:0.7;">${this._t('common.no_chores_today')}</div>
|
||||
`}
|
||||
${isAdmin && isOpen ? html`
|
||||
<div class="tm-outstanding" @click="${(e) => e.stopPropagation()}">
|
||||
<div class="tm-outstanding-hdr">${this._t('common.complete_on_behalf_heading')}</div>
|
||||
${outstanding.length === 0 ? html`
|
||||
<div class="tm-all-done">${this._t('common.complete_on_behalf_all_done')}</div>
|
||||
` : outstanding.map(c => {
|
||||
const key = `behalf_${child.id}_${c.id}`;
|
||||
const loading = !!(this._loading && this._loading[key]);
|
||||
return html`
|
||||
<div class="tm-outstanding-row">
|
||||
<span class="tm-outstanding-name">${c.name}</span>
|
||||
<span class="tm-outstanding-pts">
|
||||
<ha-icon icon="${pointsIcon}" style="--mdc-icon-size:14px;"></ha-icon>${c.points}
|
||||
</span>
|
||||
<button class="btn-complete-behalf" ?disabled="${loading}"
|
||||
title="${this._t('common.complete_on_behalf_tooltip', { name: child.name })}"
|
||||
@click="${() => this._handleCompleteOnBehalf(c.id, child.id)}">
|
||||
<ha-icon icon="mdi:check" style="--mdc-icon-size:16px;"></ha-icon>
|
||||
${this._t('common.complete_on_behalf')}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async _handleCompleteOnBehalf(choreId, childId) {
|
||||
const key = `behalf_${childId}_${choreId}`;
|
||||
if (this._loading[key]) return;
|
||||
this._loading = { ...this._loading, [key]: true };
|
||||
this.requestUpdate();
|
||||
try {
|
||||
await this.hass.callService("taskmate", "complete_chore", {
|
||||
chore_id: choreId,
|
||||
child_id: childId,
|
||||
as_parent: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (this.hass && this.hass.callService) {
|
||||
this.hass.callService("persistent_notification", "create", {
|
||||
title: this._t('common.error'),
|
||||
message: String(e && e.message ? e.message : e),
|
||||
notification_id: `taskmate_overview_behalf_${key}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this._loading = { ...this._loading, [key]: false };
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card Editor
|
||||
class TaskMateOverviewCardEditor extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_buildSchema() {
|
||||
return [
|
||||
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{ name: 'approvals_entity', selector: { entity: { domain: 'sensor' } } },
|
||||
{
|
||||
name: 'card_design',
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: 'global', label: 'Use global default' }],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('overview.editor.entity_label'),
|
||||
title: this._t('overview.editor.title_label'),
|
||||
approvals_entity: this._t('overview.editor.approvals_entity_label'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t('overview.editor.entity_helper'),
|
||||
approvals_entity: this._t('overview.editor.approvals_entity_helper'),
|
||||
};
|
||||
return helpers[entry.name] ?? '';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
approvals_entity: this.config.approvals_entity || '',
|
||||
card_design: this.config.card_design || 'global',
|
||||
};
|
||||
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', '#8e44ad')}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._updateConfig(key, v),
|
||||
onPreset: (v) => this._updateConfig(key, v),
|
||||
onReset: () => this._updateConfig(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (value === '' || value === null || value === undefined) delete newConfig[key];
|
||||
else if (key === 'card_design' && value === 'global') delete newConfig[key];
|
||||
else newConfig[key] = value;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (!value) delete newConfig[key];
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-overview-card", TaskMateOverviewCard);
|
||||
customElements.define("taskmate-overview-card-editor", TaskMateOverviewCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-overview-card",
|
||||
name: "TaskMate Overview",
|
||||
description: "At-a-glance parent dashboard for all children",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-overview-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-overview-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE OVERVIEW CARD %c v" + _tmVersion + " ",
|
||||
"background:#8e44ad;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;"
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TaskMate Penalties Card — thin wrapper over the shared incentive base (QUAL-2).
|
||||
*/
|
||||
import { createIncentiveCard } from "./taskmate-incentive-card.js";
|
||||
|
||||
createIncentiveCard({
|
||||
tag: "taskmate-penalties-card",
|
||||
kind: "penalty",
|
||||
idKey: "penalty_id",
|
||||
i18n: "penalties",
|
||||
attr: "penalties",
|
||||
icon: "mdi:alert-circle-outline",
|
||||
headerIcon: "mdi:alert-circle-outline",
|
||||
applyIcon: "mdi:minus-circle-outline",
|
||||
sign: "",
|
||||
dpts: (n) => `−${Math.abs(n)}`,
|
||||
accent: "#e74c3c",
|
||||
accentDark: "#c0392b",
|
||||
accentLight: "rgba(231, 76, 60, 0.12)",
|
||||
accentFlash: "rgba(231,76,60,0.25)",
|
||||
accentShadow: "rgba(231,76,60,0.3)",
|
||||
designAccent: "var(--tmd-bad)",
|
||||
designHd: "#c0392b",
|
||||
designIcons: { console: "▼", cleanpro: "-", playroom: "⚠️" },
|
||||
cardName: "TaskMate Penalties",
|
||||
cardDesc: "Apply point-deduction penalties to children",
|
||||
bannerName: "PENALTIES",
|
||||
bannerColor: "#922b21",
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* TaskMate Photo Gallery Card
|
||||
* A history grid of chore-evidence photos. Reads the activity sensor's
|
||||
* `photo_gallery` attribute and renders thumbnails. A card's <img> can't send a
|
||||
* bearer token, so each photo path is signed via the `auth/sign_path` WS command
|
||||
* before use.
|
||||
*/
|
||||
|
||||
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 TaskMatePhotoGalleryCard extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object }, _signed: { state: true } };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._signed = {}; // photo_url -> signed path
|
||||
this._inflight = new Set();
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config || !config.entity) {
|
||||
throw new Error("A TaskMate activity entity is required");
|
||||
}
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_activity" };
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
// Open the shared in-page lightbox; fall through to the href if it's absent.
|
||||
_openPhoto(e, url, caption) {
|
||||
if (window.__taskmate_lightbox) {
|
||||
e.preventDefault();
|
||||
window.__taskmate_lightbox(url, caption, this._t("common.close"));
|
||||
}
|
||||
}
|
||||
|
||||
_gallery() {
|
||||
const entity = this.hass && this.hass.states[this.config.entity];
|
||||
const items = (entity && entity.attributes && entity.attributes.photo_gallery) || [];
|
||||
return this.config.max ? items.slice(0, Number(this.config.max)) : items;
|
||||
}
|
||||
|
||||
async _ensureSigned(items) {
|
||||
for (const it of items) {
|
||||
const url = it.photo_url;
|
||||
if (!url || this._signed[url] || this._inflight.has(url)) continue;
|
||||
this._inflight.add(url);
|
||||
try {
|
||||
const res = await this.hass.callWS({ type: "auth/sign_path", path: url, expires: 3600 });
|
||||
if (res && res.path) {
|
||||
this._signed = { ...this._signed, [url]: res.path };
|
||||
}
|
||||
} catch (e) {
|
||||
// Leave unsigned; the tile shows a placeholder.
|
||||
console.warn("taskmate: sign_path failed", e);
|
||||
} finally {
|
||||
this._inflight.delete(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
this._ensureSigned(this._gallery());
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
if (!entity) {
|
||||
return html`<ha-card><div class="empty"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div></div></ha-card>`;
|
||||
}
|
||||
const items = this._gallery();
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --tm-gallery-header: ${_safeColor(this.config.header_color, "#5d6d7e")}; }</style>
|
||||
<div class="card-header" style="background: var(--tm-gallery-header);">
|
||||
<ha-icon icon="mdi:image-multiple"></ha-icon>
|
||||
<span>${this.config.title || this._t("gallery.default_title")}</span>
|
||||
</div>
|
||||
${items.length === 0
|
||||
? html`<div class="empty"><ha-icon icon="mdi:camera-off"></ha-icon><div>${this._t("gallery.empty")}</div></div>`
|
||||
: html`
|
||||
<div class="grid">
|
||||
${items.map((it) => this._tile(it))}
|
||||
</div>`}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_tile(it) {
|
||||
const src = this._signed[it.photo_url];
|
||||
const when = this._formatDate(it.completed_at);
|
||||
return html`
|
||||
<div class="tile" title="${it.chore_name} — ${it.child_name}">
|
||||
${src
|
||||
? html`<a href="${src}" target="_blank" rel="noopener"
|
||||
@click="${(e) => this._openPhoto(e, src, [it.chore_name, it.child_name, when].filter(Boolean).join(" · "))}"><img src="${src}" alt="${it.chore_name}" loading="lazy"></a>`
|
||||
: html`<div class="ph"><ha-icon icon="mdi:image"></ha-icon></div>`}
|
||||
<div class="cap">
|
||||
<span class="who">${it.child_name}</span>
|
||||
<span class="what">${it.chore_name}</span>
|
||||
<span class="when">${when}${it.approved ? "" : " · " + this._t("gallery.pending")}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_formatDate(iso) {
|
||||
if (!iso) return "";
|
||||
const tz = (this.hass && this.hass.config && this.hass.config.time_zone) || undefined;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString(undefined, { timeZone: tz, month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
ha-card { overflow: hidden; }
|
||||
.card-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px; color: #fff; font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid; gap: 10px; padding: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
}
|
||||
.tile { display: flex; flex-direction: column; border-radius: 8px; overflow: hidden; background: var(--secondary-background-color, #f5f5f5); }
|
||||
.tile img, .tile .ph {
|
||||
width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block;
|
||||
}
|
||||
.tile .ph { display: flex; align-items: center; justify-content: center; color: var(--secondary-text-color); }
|
||||
.cap { padding: 6px 8px; display: flex; flex-direction: column; gap: 1px; font-size: 0.72rem; }
|
||||
.cap .who { font-weight: 600; }
|
||||
.cap .what { color: var(--primary-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cap .when { color: var(--secondary-text-color); }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 16px; color: var(--secondary-text-color); }
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-photo-gallery-card", TaskMatePhotoGalleryCard);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-photo-gallery-card",
|
||||
name: "TaskMate Photo Gallery",
|
||||
description: "History grid of chore-evidence photos",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-photo-gallery-card", "activity"),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,996 @@
|
||||
/**
|
||||
* TaskMate Reward Progress Card
|
||||
* Full-screen motivational display showing a single reward's progress.
|
||||
* Designed for wall tablets as a persistent motivation display.
|
||||
*
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
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 TaskMateRewardProgressCard extends LitElement {
|
||||
static get properties() {
|
||||
return { hass: { type: Object }, config: { type: Object } };
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host { display: block; }
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, var(--primary-color));
|
||||
color: var(--text-primary-color, #fff);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
|
||||
.header-title {
|
||||
font-size: 1.1rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-content { padding: 20px 18px; }
|
||||
|
||||
/* Reward hero section */
|
||||
.reward-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 12px 0 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reward-icon-wrap {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
animation: hero-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.reward-icon-wrap ha-icon { --mdc-icon-size: 52px; color: var(--text-primary-color, #fff); }
|
||||
|
||||
.reward-name {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reward-description {
|
||||
font-size: 0.9rem;
|
||||
color: var(--secondary-text-color);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Children progress blocks */
|
||||
.children-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.child-progress-block {
|
||||
background: var(--secondary-background-color, #f8f8f8);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.child-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.child-progress-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.child-avatar ha-icon { --mdc-icon-size: 24px; color: var(--text-primary-color, #fff); }
|
||||
|
||||
.child-progress-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.child-points-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.child-progress-cost {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cost-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cost-value ha-icon { --mdc-icon-size: 16px; color: #ca8a04; }
|
||||
|
||||
/* Big animated progress bar */
|
||||
.big-progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.big-progress-bar {
|
||||
height: 22px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.big-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 11px;
|
||||
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
|
||||
.big-progress-fill.affordable {
|
||||
background: var(--success-color, #4caf50);
|
||||
}
|
||||
|
||||
.big-progress-fill.close {
|
||||
background: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.big-progress-fill.far {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.big-progress-fill.complete {
|
||||
background: var(--success-color, #4caf50);
|
||||
}
|
||||
|
||||
.progress-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.progress-have {
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.progress-need {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.progress-pct {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.progress-pct.affordable { color: var(--success-color, #4caf50); }
|
||||
.progress-pct.close { color: var(--warning-color, #ff9800); }
|
||||
.progress-pct.far { color: var(--primary-color); }
|
||||
|
||||
/* Can afford badge */
|
||||
.can-afford-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
|
||||
color: var(--success-color, #4caf50);
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
animation: pulse-green 2s ease-in-out infinite;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.can-afford-badge ha-icon { --mdc-icon-size: 16px; }
|
||||
|
||||
@keyframes pulse-green {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success-color, #4caf50) 30%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--success-color, #4caf50) 0%, transparent); }
|
||||
}
|
||||
|
||||
/* Jackpot section */
|
||||
.jackpot-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #ca8a04;
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.jackpot-badge ha-icon { --mdc-icon-size: 14px; }
|
||||
|
||||
/* Availability badges (low stock / sold out / expiring / expired) */
|
||||
.availability-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.availability-badge.badge-sold-out {
|
||||
background: color-mix(in srgb, var(--secondary-text-color, #757575) 20%, transparent);
|
||||
color: var(--secondary-text-color, #757575);
|
||||
}
|
||||
.availability-badge.badge-expired {
|
||||
background: color-mix(in srgb, var(--secondary-text-color, #757575) 20%, transparent);
|
||||
color: var(--secondary-text-color, #757575);
|
||||
}
|
||||
.availability-badge.badge-low-stock {
|
||||
background: color-mix(in srgb, var(--error-color, #db4437) 18%, transparent);
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
.availability-badge.badge-expiring-soon {
|
||||
background: color-mix(in srgb, var(--warning-color, #ff9800) 20%, transparent);
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.jackpot-pool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, #ca8a04 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ca8a04 25%, transparent);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.jackpot-pool-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.jackpot-contributors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jackpot-contributor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 20px;
|
||||
padding: 4px 10px 4px 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.jackpot-contributor .mini-avatar {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #ca8a04;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.jackpot-contributor .mini-avatar ha-icon { --mdc-icon-size: 13px; color: var(--text-primary-color, #fff); }
|
||||
|
||||
/* Error / empty */
|
||||
.error-state, .empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 40px 20px;
|
||||
color: var(--secondary-text-color); text-align: center;
|
||||
}
|
||||
.error-state { color: var(--error-color, #db4437); }
|
||||
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
.card-content { padding: 14px 12px; }
|
||||
.reward-icon-wrap { width: 70px; height: 70px; }
|
||||
.reward-icon-wrap ha-icon { --mdc-icon-size: 40px; }
|
||||
.reward-name { font-size: 1.3rem; }
|
||||
.big-progress-bar { height: 18px; border-radius: 9px; }
|
||||
.child-progress-block { padding: 12px; }
|
||||
}
|
||||
|
||||
/* ── Designed (playroom / console / cleanpro) — shared .tmd kit comes
|
||||
from taskmate-design.js styles(); only card-specific layout below. ── */
|
||||
.rp-wrap { text-align: center; }
|
||||
.rp-hero-emoji { font-size: 60px; line-height: 1; margin: 4px 0;
|
||||
filter: drop-shadow(0 6px 10px rgba(0,0,0,.15)); }
|
||||
.rp-hero-icon { width: 84px; height: 84px; border-radius: 50%; margin: 4px auto;
|
||||
display: grid; place-items: center; background: var(--tmd-accent);
|
||||
box-shadow: var(--tmd-shadow); }
|
||||
.rp-hero-icon ha-icon { --mdc-icon-size: 46px; color: #fff; }
|
||||
:host([data-tm-design="console"]) .rp-hero-emoji {
|
||||
filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tmd-accent) 70%, transparent)); }
|
||||
.rp-name { font-size: 24px; color: var(--tmd-accent); }
|
||||
.rp-desc { font-size: 13px; margin-top: 4px; max-width: 280px; margin-left: auto; margin-right: auto; }
|
||||
.rp-jackpot { margin-top: 8px; background: var(--tmd-gold); color: #3a2e26;
|
||||
border-color: transparent; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-size: 11px; }
|
||||
.rp-avail { margin-top: 8px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-size: 11px; border-color: transparent; }
|
||||
.rp-avail-dim { background: color-mix(in srgb, var(--tmd-dim) 20%, transparent); color: var(--tmd-dim); }
|
||||
.rp-avail-bad { background: color-mix(in srgb, var(--tmd-bad) 18%, transparent); color: var(--tmd-bad); }
|
||||
.rp-avail-warn { background: color-mix(in srgb, var(--tmd-warn) 20%, transparent); color: var(--tmd-warn); }
|
||||
.rp-cost { margin-top: 8px; background: var(--tmd-gold); color: #3a2e26;
|
||||
border-color: transparent; font-weight: 800; }
|
||||
.rp-bar { height: 24px; margin-top: 16px; }
|
||||
.rp-pct { color: var(--tmd-accent); }
|
||||
.rp-pct-c { font-size: 38px; margin-top: 12px; }
|
||||
.rp-need-c { font-weight: 800; font-size: 15px; margin-top: 2px; }
|
||||
.rp-stat { justify-content: space-between; margin-top: 10px; }
|
||||
.rp-stat .rp-pct { font-size: 34px; }
|
||||
.rp-stat-r { text-align: right; }
|
||||
.rp-frac { font-size: 18px; }
|
||||
.rp-need { font-size: 12px; }
|
||||
.rp-kids { grid-template-columns: repeat(var(--n, 3), 1fr); gap: 9px; margin-top: 16px; }
|
||||
.rp-kid { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||||
border-radius: var(--tmd-radius-sm); padding: 10px; display: flex;
|
||||
flex-direction: column; align-items: center; gap: 4px; }
|
||||
.rp-kid .av { margin: 0 auto 2px; }
|
||||
.rp-kid-pts { font-size: 20px; color: var(--tmd-accent); }
|
||||
.rp-kid-pts span { font-size: 12px; margin-left: 1px; }
|
||||
.rp-kid-name { font-size: 12px; font-weight: 800; }
|
||||
.rp-kid-bar { width: 100%; height: 7px; margin-top: 3px; }
|
||||
`;
|
||||
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(this._t('reward_progress.error.entity_required'));
|
||||
this.config = {
|
||||
title: null,
|
||||
reward_id: null,
|
||||
child_id: null,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 5; }
|
||||
static getConfigElement() { return document.createElement("taskmate-reward-progress-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "Reward Goal" };
|
||||
}
|
||||
|
||||
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('reward_progress.unavailable')}</div></div></ha-card>`;
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const rewards = attrs.rewards || [];
|
||||
const children = attrs.children || [];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t("common.points");
|
||||
|
||||
// Pick reward
|
||||
let reward = this.config.reward_id
|
||||
? rewards.find(r => r.id === this.config.reward_id)
|
||||
: rewards[0];
|
||||
|
||||
if (!reward) return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:gift-outline"></ha-icon><div>${this._t('reward_progress.no_rewards')}</div></div></ha-card>`;
|
||||
|
||||
// Which children to show
|
||||
let showChildren = children;
|
||||
if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id);
|
||||
if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id));
|
||||
if (!showChildren.length) showChildren = children;
|
||||
|
||||
const isJackpot = reward.is_jackpot;
|
||||
const isSoldOut = reward.is_sold_out === true;
|
||||
const isExpired = reward.is_expired === true;
|
||||
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
|
||||
? reward.days_until_expiry
|
||||
: null;
|
||||
let availabilityLabel = '';
|
||||
let availabilityClass = '';
|
||||
if (isExpired) {
|
||||
availabilityLabel = this._t('rewards.expired');
|
||||
availabilityClass = 'badge-expired';
|
||||
} else if (isSoldOut) {
|
||||
availabilityLabel = this._t('rewards.sold_out');
|
||||
availabilityClass = 'badge-sold-out';
|
||||
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
|
||||
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
|
||||
availabilityClass = 'badge-low-stock';
|
||||
} else if (typeof daysUntilExpiry === 'number' && daysUntilExpiry >= 0 && daysUntilExpiry <= 7) {
|
||||
availabilityLabel = daysUntilExpiry === 0
|
||||
? this._t('rewards.expires_today')
|
||||
: (daysUntilExpiry === 1
|
||||
? this._t('rewards.expires_in_days', { count: daysUntilExpiry })
|
||||
: this._t('rewards.expires_in_days_plural', { count: daysUntilExpiry }));
|
||||
availabilityClass = 'badge-expiring-soon';
|
||||
}
|
||||
|
||||
const headerColor = _safeColor(this.config.header_color, '');
|
||||
const headerStyle = headerColor
|
||||
? `--taskmate-header-bg: ${headerColor};`
|
||||
: '';
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
${headerStyle ? html`<style>:host { ${headerStyle} }</style>` : ''}
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:trophy-outline"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t('reward_progress.default_title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="reward-hero">
|
||||
<div class="reward-icon-wrap">
|
||||
<ha-icon icon="${reward.icon || 'mdi:gift'}"></ha-icon>
|
||||
</div>
|
||||
<div class="reward-name">${reward.name}</div>
|
||||
${reward.description ? html`<div class="reward-description">${reward.description}</div>` : ''}
|
||||
${availabilityLabel ? html`
|
||||
<div class="availability-badge ${availabilityClass}">${availabilityLabel}</div>
|
||||
` : ''}
|
||||
${isJackpot ? html`
|
||||
<div class="jackpot-badge">
|
||||
<ha-icon icon="mdi:star-shooting"></ha-icon>
|
||||
${this._t('reward_progress.jackpot_reward')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${isJackpot
|
||||
? this._renderJackpot(reward, showChildren, pointsIcon, pointsName)
|
||||
: html`
|
||||
<div class="children-section">
|
||||
${showChildren.map(child => this._renderChildProgress(child, reward, pointsIcon, pointsName))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderChildProgress(child, reward, pointsIcon, pointsName) {
|
||||
const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
|
||||
const have = child.points || 0;
|
||||
const pct = Math.min(100, Math.round((have / cost) * 100));
|
||||
const canAfford = have >= cost;
|
||||
const close = pct >= 70;
|
||||
const cls = canAfford ? "complete" : close ? "close" : "far";
|
||||
const pctCls = canAfford ? "affordable" : close ? "close" : "far";
|
||||
|
||||
return html`
|
||||
<div class="child-progress-block">
|
||||
<div class="child-progress-header">
|
||||
<div class="child-progress-left">
|
||||
<div class="child-avatar">
|
||||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="child-progress-name">${child.name}</div>
|
||||
<div class="child-points-label">${have} ${pointsName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child-progress-cost">
|
||||
<div class="cost-label">${this._t('common.goal')}</div>
|
||||
<div class="cost-value">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
${cost}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="big-progress-wrap">
|
||||
<div class="big-progress-bar">
|
||||
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<div class="progress-stat-row">
|
||||
<span class="progress-have">${have} / ${cost} ${pointsName}</span>
|
||||
${canAfford
|
||||
? html`<span class="progress-need">${this._t('reward_progress.ready_to_claim_emoji')}</span>`
|
||||
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - have })}</span>`}
|
||||
<span class="progress-pct ${pctCls}">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${canAfford ? html`
|
||||
<div class="can-afford-badge">
|
||||
<ha-icon icon="mdi:check-circle"></ha-icon>
|
||||
${this._t('reward_progress.ready_to_claim')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderJackpot(reward, children, pointsIcon, pointsName) {
|
||||
const cost = reward.calculated_costs
|
||||
? Object.values(reward.calculated_costs)[0] ?? reward.cost
|
||||
: reward.cost;
|
||||
|
||||
const totalHave = children.reduce((s, c) => s + (c.points || 0), 0);
|
||||
const pct = Math.min(100, Math.round((totalHave / cost) * 100));
|
||||
const canAfford = totalHave >= cost;
|
||||
const close = pct >= 70;
|
||||
const cls = canAfford ? "complete" : close ? "close" : "far";
|
||||
const pctCls = canAfford ? "affordable" : close ? "close" : "far";
|
||||
|
||||
return html`
|
||||
<div class="children-section">
|
||||
<div class="child-progress-block">
|
||||
<div class="jackpot-pool">
|
||||
<div class="jackpot-pool-title">${this._t('reward_progress.combined_points_pool')}</div>
|
||||
<div class="jackpot-contributors">
|
||||
${children.map(child => html`
|
||||
<div class="jackpot-contributor">
|
||||
<div class="mini-avatar">
|
||||
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
|
||||
</div>
|
||||
<span>${child.name}: ${child.points}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="child-progress-header" style="margin-top:4px">
|
||||
<div>
|
||||
<div class="child-progress-name">${this._t('reward_progress.total_pool')}</div>
|
||||
<div class="child-points-label">${totalHave} ${pointsName} ${this._t('reward_progress.combined')}</div>
|
||||
</div>
|
||||
<div class="child-progress-cost">
|
||||
<div class="cost-label">${this._t('common.goal')}</div>
|
||||
<div class="cost-value">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
${cost}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="big-progress-wrap">
|
||||
<div class="big-progress-bar">
|
||||
<div class="big-progress-fill ${cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<div class="progress-stat-row">
|
||||
<span class="progress-have">${totalHave} / ${cost} ${pointsName}</span>
|
||||
${canAfford
|
||||
? html`<span class="progress-need">${this._t('reward_progress.ready')}</span>`
|
||||
: html`<span class="progress-need">${this._t('reward_progress.more_needed', { amount: cost - totalHave })}</span>`}
|
||||
<span class="progress-pct ${pctCls}">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${canAfford ? html`
|
||||
<div class="can-afford-badge">
|
||||
<ha-icon icon="mdi:check-circle"></ha-icon>
|
||||
${this._t('reward_progress.ready_to_claim')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns (§card-reward-progress). Wall display
|
||||
for ONE reward, FIXED cost only.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = (child && child.avatar) || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: ((child && child.name) || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
_renderDesigned(design) {
|
||||
const hd = _safeColor(this.config.header_color, '#00897b');
|
||||
const wrap = (body, icon) => html`
|
||||
<ha-card class="tmd" style="--hd:${hd}">
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">🎯</span>
|
||||
<span class="tt">${this.config.title || this._t('reward_progress.default_title')}<small>${this._t('common.goal')}</small></span>
|
||||
</div>
|
||||
<div class="tmd-bd">${body}</div>
|
||||
</ha-card>`;
|
||||
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
if (!entity) return wrap(html`<div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div>`);
|
||||
if (entity.state === "unavailable" || entity.state === "unknown")
|
||||
return wrap(html`<div class="tmd-empty">${this._t('reward_progress.unavailable')}</div>`);
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
const rewards = attrs.rewards || [];
|
||||
const children = attrs.children || [];
|
||||
const pointsName = attrs.points_name || this._t("common.points");
|
||||
|
||||
let reward = this.config.reward_id
|
||||
? rewards.find(r => r.id === this.config.reward_id)
|
||||
: rewards[0];
|
||||
if (!reward) return wrap(html`<div class="tmd-empty">${this._t('reward_progress.no_rewards')}</div>`);
|
||||
|
||||
let showChildren = children;
|
||||
if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id);
|
||||
if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id));
|
||||
if (!showChildren.length) showChildren = children;
|
||||
|
||||
const isJackpot = reward.is_jackpot;
|
||||
let cost, have, contributors;
|
||||
if (isJackpot) {
|
||||
cost = reward.calculated_costs ? (Object.values(reward.calculated_costs)[0] ?? reward.cost) : reward.cost;
|
||||
have = showChildren.reduce((s, c) => s + (c.points || 0), 0);
|
||||
contributors = showChildren.map((c, i) => ({ child: c, points: c.points || 0, tone: this._designTone(i) }));
|
||||
} else {
|
||||
const ref = showChildren[0];
|
||||
cost = (ref && reward.calculated_costs?.[ref.id]) ?? reward.cost;
|
||||
contributors = showChildren.map((c, i) => ({
|
||||
child: c, points: c.points || 0, tone: this._designTone(i),
|
||||
cost: reward.calculated_costs?.[c.id] ?? reward.cost,
|
||||
}));
|
||||
have = contributors.length === 1 ? contributors[0].points
|
||||
: contributors.reduce((s, c) => s + c.points, 0);
|
||||
if (contributors.length === 1) cost = contributors[0].cost;
|
||||
}
|
||||
const pct = cost > 0 ? Math.min(100, Math.round((have / cost) * 100)) : 0;
|
||||
const remaining = Math.max(0, cost - have);
|
||||
|
||||
// Availability badge — same precedence as the classic render.
|
||||
const isSoldOut = reward.is_sold_out === true;
|
||||
const isExpired = reward.is_expired === true;
|
||||
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number') ? reward.days_until_expiry : null;
|
||||
let availabilityLabel = '';
|
||||
let availabilityTone = '';
|
||||
if (isExpired) {
|
||||
availabilityLabel = this._t('rewards.expired');
|
||||
availabilityTone = 'dim';
|
||||
} else if (isSoldOut) {
|
||||
availabilityLabel = this._t('rewards.sold_out');
|
||||
availabilityTone = 'dim';
|
||||
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
|
||||
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
|
||||
availabilityTone = 'bad';
|
||||
} else if (typeof daysUntilExpiry === 'number' && daysUntilExpiry >= 0 && daysUntilExpiry <= 7) {
|
||||
availabilityLabel = daysUntilExpiry === 0
|
||||
? this._t('rewards.expires_today')
|
||||
: (daysUntilExpiry === 1
|
||||
? this._t('rewards.expires_in_days', { count: daysUntilExpiry })
|
||||
: this._t('rewards.expires_in_days_plural', { count: daysUntilExpiry }));
|
||||
availabilityTone = 'warn';
|
||||
}
|
||||
|
||||
return wrap(html`${this._designBody(reward, design, cost, have, pct, remaining, contributors, pointsName, availabilityLabel, availabilityTone)}`);
|
||||
}
|
||||
|
||||
_designBody(reward, design, cost, have, pct, remaining, contributors, pointsName, availabilityLabel, availabilityTone) {
|
||||
const icon = reward.icon || "mdi:gift";
|
||||
const isIcon = typeof icon === "string" && icon.startsWith("mdi:");
|
||||
const hero = isIcon
|
||||
? html`<div class="rp-hero-icon"><ha-icon icon="${icon}"></ha-icon></div>`
|
||||
: html`<div class="rp-hero-emoji">${icon}</div>`;
|
||||
|
||||
return html`
|
||||
<div class="rp-wrap">
|
||||
${hero}
|
||||
<div class="big rp-name">${reward.name}</div>
|
||||
${reward.description ? html`<div class="muted rp-desc">${reward.description}</div>` : ''}
|
||||
${reward.is_jackpot ? html`<div class="chip rp-jackpot">🎰 ${this._t('reward_progress.jackpot_reward')}</div>` : ''}
|
||||
${availabilityLabel ? html`<div class="chip rp-avail rp-avail-${availabilityTone}">${availabilityLabel}</div>` : ''}
|
||||
<div class="chip rp-cost">⭐ ${cost} ${pointsName}</div>
|
||||
<div class="bar rp-bar"><i style="width:${pct}%;${pct >= 100 ? 'background:var(--tmd-good)' : ''}"></i></div>
|
||||
${design === "cleanpro"
|
||||
? html`<div class="row rp-stat">
|
||||
<div class="big rp-pct">${pct}%</div>
|
||||
<div class="rp-stat-r">
|
||||
<div class="num rp-frac">${have} / ${cost}</div>
|
||||
<div class="muted rp-need">${this._t('reward_progress.more_needed', { amount: remaining })}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="big rp-pct rp-pct-c">${pct}%</div>
|
||||
<div class="rp-need rp-need-c">${remaining > 0
|
||||
? this._t('reward_progress.more_needed', { amount: remaining })
|
||||
: this._t('reward_progress.ready_to_claim')}</div>`}
|
||||
|
||||
<div class="grid rp-kids" style="--n:${contributors.length}">
|
||||
${contributors.map((c) => html`
|
||||
<div class="rp-kid" style="--ac:${c.tone}">
|
||||
${this._av(c.child, c.tone, 36)}
|
||||
<div class="big rp-kid-pts">${c.points}<span>⭐</span></div>
|
||||
<div class="rp-kid-name">${c.child.name}</div>
|
||||
${design === "cleanpro" && !reward.is_jackpot && c.cost > 0
|
||||
? html`<div class="bar rp-kid-bar"><i style="width:${Math.min(100, Math.round((c.points / c.cost) * 100))}%;background:${c.tone}"></i></div>` : ''}
|
||||
</div>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TaskMateRewardProgressCardEditor 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 attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || (this.config?.entity ? this.hass?.states?.[this.config.entity]?.attributes : null) || {};
|
||||
const rewards = attrs.rewards || [];
|
||||
const children = attrs.children || [];
|
||||
return [
|
||||
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
|
||||
{ name: 'title', selector: { text: {} } },
|
||||
{
|
||||
name: 'reward_id',
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{ value: '', label: this._t('reward_progress.editor.first_available') },
|
||||
...rewards.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'child_id',
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{ value: '', label: this._t('reward_progress.editor.child_filter_all') },
|
||||
...children.map((c) => ({ value: c.id, label: c.name })),
|
||||
],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'card_design',
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: 'global', label: 'Use global default' }],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('common.editor.overview_entity'),
|
||||
title: this._t('common.editor.card_title'),
|
||||
reward_id: this._t('reward_progress.editor.reward_to_display'),
|
||||
child_id: this._t('common.editor.filter_by_child'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t('common.editor.overview_entity_helper'),
|
||||
reward_id: this._t('reward_progress.editor.reward_helper'),
|
||||
child_id: this._t('reward_progress.editor.child_helper'),
|
||||
};
|
||||
return helpers[entry.name] ?? '';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
reward_id: this.config.reward_id || '',
|
||||
child_id: this.config.child_id || '',
|
||||
card_design: this.config.card_design || 'global',
|
||||
};
|
||||
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', '')}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._update(key, v),
|
||||
onPreset: (v) => this._update(key, v),
|
||||
onReset: () => this._update(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (value === '' || value === null || value === undefined) delete newConfig[key];
|
||||
else if (key === 'card_design' && value === 'global') delete newConfig[key];
|
||||
else newConfig[key] = value;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_update(key, value) {
|
||||
const cfg = { ...this.config, [key]: value };
|
||||
if (!value) delete cfg[key];
|
||||
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: cfg }, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-reward-progress-card", TaskMateRewardProgressCard);
|
||||
customElements.define("taskmate-reward-progress-card-editor", TaskMateRewardProgressCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-reward-progress-card",
|
||||
name: "TaskMate Reward Progress",
|
||||
description: "Full-screen motivational reward progress display",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-reward-progress-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-reward-progress-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE REWARD PROGRESS CARD %c v" + _tmVersion + " ",
|
||||
"background:#03a9f4;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;"
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* TaskMate Streak & Achievement Card
|
||||
* Shows each child's consecutive day streak and milestone badges.
|
||||
* Streaks are calculated client-side from completion data.
|
||||
*
|
||||
* Version: 1.0.0
|
||||
* Last Updated: 2026-03-18
|
||||
*/
|
||||
|
||||
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 TaskMateStreakCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--str-purple: #9b59b6;
|
||||
--str-gold: #f1c40f;
|
||||
--str-orange: #e67e22;
|
||||
--str-green: #2ecc71;
|
||||
--str-red: #e74c3c;
|
||||
--str-fire: #ff6b35;
|
||||
}
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, #e74c3c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
|
||||
.header-title { font-size: 1.2rem; font-weight: 600; }
|
||||
|
||||
.card-content {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Child streak tile */
|
||||
.streak-tile {
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.streak-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.child-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--str-purple) 0%, #a569bd 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
|
||||
|
||||
.streak-info { flex: 1; }
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.streak-count-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.streak-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.streak-number.hot { color: var(--str-fire); }
|
||||
.streak-number.warm { color: var(--str-orange); }
|
||||
.streak-number.cold { color: var(--secondary-text-color); }
|
||||
|
||||
.streak-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.streak-emoji { font-size: 1.4rem; }
|
||||
|
||||
/* Streak bar - visual days */
|
||||
.streak-days {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 16px 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.day-dot.active { background: var(--str-fire); }
|
||||
.day-dot.today-active { background: var(--str-green); transform: scale(1.3); }
|
||||
.day-dot.inactive { background: var(--divider-color, #e0e0e0); }
|
||||
|
||||
/* Achievements */
|
||||
.achievements-section {
|
||||
padding: 0 16px 14px;
|
||||
border-top: 1px solid var(--divider-color, #f0f0f0);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.achievements-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
min-width: 56px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.badge:hover { transform: scale(1.05); }
|
||||
|
||||
.badge.earned {
|
||||
background: linear-gradient(135deg, rgba(241,196,15,0.2), rgba(230,126,34,0.2));
|
||||
border: 1px solid rgba(241,196,15,0.4);
|
||||
}
|
||||
|
||||
.badge.locked { opacity: 0.35; filter: grayscale(1); }
|
||||
|
||||
.badge-emoji { font-size: 1.5rem; }
|
||||
.badge-name {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge.earned .badge-name { color: var(--str-orange); }
|
||||
|
||||
/* Empty / error */
|
||||
.error-state, .empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 40px 20px;
|
||||
color: var(--secondary-text-color); text-align: center;
|
||||
}
|
||||
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* ── Designed layouts (playroom / console / cleanpro) ── */
|
||||
/* Shared .tmd kit + tokens come from taskmate-design.js styles(). */
|
||||
|
||||
.sk-grid { display: flex; flex-direction: column; gap: 11px; }
|
||||
.sk-tile { background: var(--tmd-surface-2); border-radius: 18px; padding: 12px; }
|
||||
.sk-name { font-weight: 800; font-size: 14px; }
|
||||
.sk-sub { font-size: 12px; }
|
||||
.sk-count { font-size: 26px; color: var(--tmd-accent); }
|
||||
.sk-dots { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.sk-dot { width: 15px; height: 15px; border-radius: 50%; background: var(--tmd-border); }
|
||||
.sk-dot.on { background: var(--tmd-good); }
|
||||
.sk-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
|
||||
/* Console */
|
||||
.sk-grid-cn { display: flex; flex-direction: column; gap: 9px; }
|
||||
.sk-tile-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||||
border-radius: 10px; padding: 11px; }
|
||||
.sk-combo-label { font-size: 10px; }
|
||||
.sk-combo { font-size: 24px; color: var(--tmd-accent); }
|
||||
.sk-segs { display: flex; gap: 4px; margin-top: 9px; }
|
||||
.sk-seg { flex: 1; height: 8px; border-radius: 2px; background: #0b1424; }
|
||||
.sk-seg.on { background: var(--tmd-accent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--tmd-accent) 70%, transparent); }
|
||||
.sk-badges-cn { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
|
||||
|
||||
/* Clean Pro */
|
||||
.sk-grid-cp { display: flex; flex-direction: column; gap: 13px; }
|
||||
.sk-row-cp .num { font-size: 17px; }
|
||||
.sk-segs-cp { display: flex; gap: 5px; margin-top: 8px; }
|
||||
.sk-seg-cp { flex: 1; height: 9px; border-radius: 3px; background: var(--tmd-surface-2); }
|
||||
.sk-seg-cp.on { background: var(--tmd-good); }
|
||||
.sk-badges-cp { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }
|
||||
`;
|
||||
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(this._t('streak.error.entity_required'));
|
||||
this.config = {
|
||||
title: null,
|
||||
child_id: null,
|
||||
streak_days_shown: 14,
|
||||
header_color: '#e74c3c',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 4; }
|
||||
static getConfigElement() { return document.createElement("taskmate-streak-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "Streaks & Achievements" };
|
||||
}
|
||||
|
||||
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 attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
// Use recent_completions (all-time history, last 50) for streak/achievement calculation
|
||||
const completions = [...(attrs.recent_completions || attrs.todays_completions || [])];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const chores = attrs.chores || [];
|
||||
|
||||
if (this.config.child_id) {
|
||||
children = children.filter(c => c.id === this.config.child_id);
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:account-group"></ha-icon><div>${this._t('common.no_children')}</div></div></ha-card>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#e74c3c')}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:fire"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t('streak.default_title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${children.map(child => this._renderStreakTile(child, completions, chores, pointsIcon, entity))}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStreakTile(child, completions, chores, pointsIcon, entity_ref) {
|
||||
const childCompletions = completions.filter(c => c.child_id === child.id);
|
||||
// Use backend-calculated streak if available, fall back to client calculation
|
||||
const streak = child.current_streak !== undefined
|
||||
? child.current_streak
|
||||
: this._calculateStreak(childCompletions, chores, child.id);
|
||||
const daysShown = this.config.streak_days_shown || 14;
|
||||
const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown);
|
||||
const achievements = this._getAchievements(child, childCompletions, streak, entity_ref);
|
||||
|
||||
// Avatar now included directly in children array from the overview sensor
|
||||
const avatar = child.avatar || "mdi:account-circle";
|
||||
|
||||
const streakClass = streak >= 7 ? "hot" : streak >= 3 ? "warm" : "cold";
|
||||
const streakEmoji = streak >= 14 ? "🔥🔥" : streak >= 7 ? "🔥" : streak >= 3 ? "⚡" : streak >= 1 ? "✨" : "💤";
|
||||
|
||||
return html`
|
||||
<div class="streak-tile">
|
||||
<div class="streak-header">
|
||||
<div class="child-avatar"><ha-icon icon="${avatar}"></ha-icon></div>
|
||||
<div class="streak-info">
|
||||
<div class="child-name">${child.name}</div>
|
||||
<div class="streak-count-row">
|
||||
<span class="streak-number ${streakClass}">${streak}</span>
|
||||
<span class="streak-label">${this._t('streak.day_streak')}</span>
|
||||
<span class="streak-emoji">${streakEmoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="streak-days">
|
||||
${dayDots.map(dot => html`
|
||||
<div class="day-dot ${dot.cssClass}" title="${dot.label}"></div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${achievements.length > 0 ? html`
|
||||
<div class="achievements-section">
|
||||
<div class="achievements-label">${this._t('streak.achievements')}</div>
|
||||
<div class="badges">
|
||||
${achievements.map(a => html`
|
||||
<div class="badge ${a.earned ? 'earned' : 'locked'}" title="${a.description}">
|
||||
<span class="badge-emoji">${a.emoji}</span>
|
||||
<span class="badge-name">${a.name}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_calculateStreak(completions, chores, childId) {
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Build set of unique days with at least one completion
|
||||
const daysWithCompletion = new Set();
|
||||
completions.forEach(c => {
|
||||
if (!c.completed_at) return;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
daysWithCompletion.add(day);
|
||||
});
|
||||
|
||||
// Walk backwards from today counting consecutive days
|
||||
let streak = 0;
|
||||
const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
|
||||
const today = new Date(todayKey + "T12:00:00"); // noon avoids DST edge cases
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toLocaleDateString("en-CA");
|
||||
if (daysWithCompletion.has(key)) {
|
||||
streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
_buildDayDots(completions, chores, childId, days) {
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const daysWithCompletion = new Set();
|
||||
completions.forEach(c => {
|
||||
if (!c.completed_at) return;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
daysWithCompletion.add(day);
|
||||
});
|
||||
|
||||
const dots = [];
|
||||
const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
|
||||
const today = new Date(todayKey + "T12:00:00"); // noon avoids DST edge cases
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toLocaleDateString("en-CA");
|
||||
const active = daysWithCompletion.has(key);
|
||||
const isToday = key === todayKey;
|
||||
dots.push({
|
||||
cssClass: active ? (isToday ? "today-active" : "active") : "inactive",
|
||||
label: d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }),
|
||||
});
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
_getAchievements(child, completions, streak, entity_ref) {
|
||||
// Prefer backend-tracked totals over client-visible completions
|
||||
const totalCompletions = child.total_chores_completed !== undefined
|
||||
? child.total_chores_completed
|
||||
: (entity_ref?.attributes?.total_completions_all_time || completions.filter(comp => comp.child_id === child.id).length);
|
||||
const totalPoints = (child.total_points_earned !== undefined ? child.total_points_earned : child.points) || 0;
|
||||
const bestStreak = child.best_streak || streak || 0;
|
||||
|
||||
const milestones = [
|
||||
{ id: "first", name: this._t('streak.achievement.first_name'), emoji: "🌟", description: this._t('streak.achievement.first_desc'), earned: totalCompletions >= 1 },
|
||||
{ id: "ten", name: this._t('streak.achievement.ten_name'), emoji: "🏅", description: this._t('streak.achievement.ten_desc'), earned: totalCompletions >= 10 },
|
||||
{ id: "fifty", name: this._t('streak.achievement.fifty_name'), emoji: "🥈", description: this._t('streak.achievement.fifty_desc'), earned: totalCompletions >= 50 },
|
||||
{ id: "hundred", name: this._t('streak.achievement.hundred_name'), emoji: "🥇", description: this._t('streak.achievement.hundred_desc'), earned: totalCompletions >= 100 },
|
||||
{ id: "streak3", name: this._t('streak.achievement.streak3_name'), emoji: "⚡", description: this._t('streak.achievement.streak3_desc'), earned: bestStreak >= 3 },
|
||||
{ id: "streak7", name: this._t('streak.achievement.streak7_name'), emoji: "🔥", description: this._t('streak.achievement.streak7_desc'), earned: bestStreak >= 7 },
|
||||
{ id: "streak14", name: this._t('streak.achievement.streak14_name'), emoji: "🔥🔥", description: this._t('streak.achievement.streak14_desc'), earned: bestStreak >= 14 },
|
||||
{ id: "streak30", name: this._t('streak.achievement.streak30_name'), emoji: "💎", description: this._t('streak.achievement.streak30_desc'), earned: bestStreak >= 30 },
|
||||
{ id: "points50", name: this._t('streak.achievement.points50_name'), emoji: "🎯", description: this._t('streak.achievement.points50_desc'), earned: totalPoints >= 50 },
|
||||
{ id: "points100", name: this._t('streak.achievement.points100_name'), emoji: "💰", description: this._t('streak.achievement.points100_desc'), earned: totalPoints >= 100 },
|
||||
];
|
||||
|
||||
// Show earned ones + next locked milestone
|
||||
const earned = milestones.filter(m => m.earned);
|
||||
const nextLocked = milestones.find(m => !m.earned);
|
||||
return nextLocked ? [...earned, nextLocked] : earned;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns/frag/05-streak.html.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = child.avatar || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
_streakEmoji(streak) {
|
||||
return streak >= 14 ? "🔥🔥" : streak >= 7 ? "🔥" : streak >= 3 ? "⚡" : streak >= 1 ? "✨" : "💤";
|
||||
}
|
||||
|
||||
_designHeader(hd, design, onFire) {
|
||||
const title = this.config.title || this._t('streak.default_title');
|
||||
const sub =
|
||||
design === "playroom" ? this._t('streak.day_streak') :
|
||||
design === "console" ? this._t('streak.achievements') :
|
||||
"";
|
||||
return html`
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">🔥</span>
|
||||
<span class="tt">${title}${sub ? html`<small>${sub}</small>` : ""}</span>
|
||||
${design === "console" && onFire
|
||||
? html`<span class="pill">${this._t('common.d_streak', { count: onFire })}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_streakData(child, completions, chores, entity_ref) {
|
||||
const childCompletions = completions.filter(c => c.child_id === child.id);
|
||||
const streak = child.current_streak !== undefined
|
||||
? child.current_streak
|
||||
: this._calculateStreak(childCompletions, chores, child.id);
|
||||
const daysShown = this.config.streak_days_shown || 14;
|
||||
const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown);
|
||||
const achievements = this._getAchievements(child, childCompletions, streak, entity_ref);
|
||||
return { streak, dayDots, achievements };
|
||||
}
|
||||
|
||||
_renderDesigned(design) {
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
const hd = _safeColor(this.config.header_color, '#e74c3c');
|
||||
|
||||
if (!entity) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${this._designHeader(hd, design, 0)}
|
||||
<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}">
|
||||
${this._designHeader(hd, design, 0)}
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
const completions = [...(attrs.recent_completions || attrs.todays_completions || [])];
|
||||
const chores = attrs.chores || [];
|
||||
|
||||
if (this.config.child_id) {
|
||||
children = children.filter(c => c.id === this.config.child_id);
|
||||
}
|
||||
|
||||
const rows = children.map((child, idx) => ({
|
||||
child,
|
||||
tone: this._designTone(idx),
|
||||
...this._streakData(child, completions, chores, entity),
|
||||
}));
|
||||
|
||||
const onFire = rows.filter(r => r.streak >= 7).length;
|
||||
const header = this._designHeader(hd, design, onFire);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${header}
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.no_children')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const body =
|
||||
design === "playroom" ? this._skPlayroom(rows) :
|
||||
design === "console" ? this._skConsole(rows) :
|
||||
this._skCleanpro(rows);
|
||||
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">${header}<div class="tmd-bd">${body}</div></ha-card>`;
|
||||
}
|
||||
|
||||
_earnedBadges(achievements) {
|
||||
return achievements.filter(a => a.earned);
|
||||
}
|
||||
|
||||
// Classic shows earned badges PLUS the next locked milestone. _getAchievements
|
||||
// already returns exactly that set, so the designed badge rows render it whole,
|
||||
// dimming the locked entry to match the classic .badge.locked treatment.
|
||||
_designBadges(achievements, wrapClass, locked) {
|
||||
if (!achievements.length) return "";
|
||||
return html`
|
||||
<div class="${wrapClass}">
|
||||
${achievements.map((a) => html`
|
||||
<span class="chip ${a.earned ? (locked.soft ? "soft" : "") : "locked"}"
|
||||
title="${a.description}"
|
||||
style="${a.earned ? "" : "opacity:0.45;filter:grayscale(1)"}">
|
||||
${locked.dot && a.earned ? html`<span style="color:var(--tmd-accent)">●</span> ` : html`${a.emoji} `}${a.name}
|
||||
</span>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_skPlayroom(rows) {
|
||||
return html`
|
||||
<div class="sk-grid">
|
||||
${rows.map((r) => html`
|
||||
<div class="sk-tile" style="--ac:${r.tone}">
|
||||
<div class="row">
|
||||
${this._av(r.child, r.tone, 42)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="sk-name">${r.child.name}</div>
|
||||
<div class="muted sk-sub">${this._t('streak.day_streak')}</div>
|
||||
</div>
|
||||
<div class="big sk-count">${r.streak} ${this._streakEmoji(r.streak)}</div>
|
||||
</div>
|
||||
<div class="sk-dots">
|
||||
${r.dayDots.map((d) => html`<i class="sk-dot ${d.cssClass !== "inactive" ? "on" : ""}" title="${d.label}"></i>`)}
|
||||
</div>
|
||||
${this._designBadges(r.achievements, "sk-badges", { soft: true })}
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_skConsole(rows) {
|
||||
return html`
|
||||
<div class="sk-grid-cn">
|
||||
${rows.map((r) => html`
|
||||
<div class="sk-tile-cn" style="--ac:${r.tone}">
|
||||
<div class="row">
|
||||
${this._av(r.child, r.tone, 36)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:700">${r.child.name}</div>
|
||||
<div class="muted sk-combo-label">${this._t('streak.achievements')}</div>
|
||||
</div>
|
||||
<div class="num sk-combo">x${r.streak} ${this._streakEmoji(r.streak)}</div>
|
||||
</div>
|
||||
<div class="sk-segs">
|
||||
${r.dayDots.map((d) => html`<i class="sk-seg ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
|
||||
</div>
|
||||
${this._designBadges(r.achievements, "sk-badges-cn", {})}
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_skCleanpro(rows) {
|
||||
const tone = (streak) => streak >= 7 ? "var(--tmd-good)" : streak >= 3 ? "var(--tmd-warn)" : "var(--tmd-dim)";
|
||||
return html`
|
||||
<div class="sk-grid-cp">
|
||||
${rows.map((r, i) => html`
|
||||
${i > 0 ? html`<div class="divide" style="margin:0"></div>` : ""}
|
||||
<div class="sk-row-cp" style="--ac:${r.tone}">
|
||||
<div class="row">
|
||||
${this._av(r.child, r.tone, 34)}
|
||||
<div style="flex:1;min-width:0"><div style="font-weight:600">${r.child.name}</div></div>
|
||||
<div class="num" style="color:${tone(r.streak)}">${this._streakEmoji(r.streak)} ${this._t('common.d_streak', { count: r.streak })}</div>
|
||||
</div>
|
||||
<div class="sk-segs-cp">
|
||||
${r.dayDots.map((d) => html`<i class="sk-seg-cp ${d.cssClass !== "inactive" ? "on" : ""}"></i>`)}
|
||||
</div>
|
||||
${this._designBadges(r.achievements, "sk-badges-cp", { dot: true })}
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Card Editor
|
||||
class TaskMateStreakCardEditor 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: 'streak_days_shown', selector: { number: { min: 1, max: 100, mode: 'box' } } },
|
||||
{
|
||||
name: 'card_design',
|
||||
selector: {
|
||||
select: {
|
||||
options: window.__taskmate_design
|
||||
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
||||
: [{ value: 'global', label: 'Use global default' }],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('common.editor.overview_entity'),
|
||||
title: this._t('streak.editor.title'),
|
||||
child_id: this._t('common.editor.filter_by_child'),
|
||||
streak_days_shown: this._t('streak.editor.days_shown'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t('common.editor.overview_entity_helper'),
|
||||
child_id: this._t('streak.editor.child_helper'),
|
||||
streak_days_shown: this._t('streak.editor.days_shown_helper'),
|
||||
};
|
||||
return helpers[entry.name] ?? '';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
child_id: this.config.child_id || '__all__',
|
||||
streak_days_shown: this.config.streak_days_shown || 14,
|
||||
card_design: this.config.card_design || 'global',
|
||||
};
|
||||
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', '#e74c3c')}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._updateConfig(key, v),
|
||||
onPreset: (v) => this._updateConfig(key, v),
|
||||
onReset: () => this._updateConfig(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (
|
||||
value === '' || value === null || value === undefined
|
||||
|| (key === 'child_id' && value === '__all__')
|
||||
) {
|
||||
delete newConfig[key];
|
||||
} else if (key === 'streak_days_shown' && value === 14) {
|
||||
delete newConfig[key];
|
||||
} else if (key === 'card_design' && value === 'global') {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-streak-card", TaskMateStreakCard);
|
||||
customElements.define("taskmate-streak-card-editor", TaskMateStreakCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-streak-card",
|
||||
name: "TaskMate Streaks & Achievements",
|
||||
description: "Consecutive day streaks and milestone badges for each child",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-streak-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-streak-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE STREAK CARD %c v" + _tmVersion + " ",
|
||||
"background:#e74c3c;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;"
|
||||
);
|
||||
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* TaskMate Weekly Summary Card
|
||||
* Current week at a glance: days completed, points per day as a bar chart,
|
||||
* rewards claimed this week, and a per-child breakdown.
|
||||
*
|
||||
* Version: 1.0.0
|
||||
* Last Updated: 2026-03-18
|
||||
*/
|
||||
|
||||
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 TaskMateWeeklyCard extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
config: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_t(key, params) {
|
||||
const fn = window.__taskmate_localize;
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
const base = css`
|
||||
:host {
|
||||
display: block;
|
||||
--wk-purple: #9b59b6;
|
||||
--wk-green: #2ecc71;
|
||||
--wk-orange: #e67e22;
|
||||
--wk-blue: #3498db;
|
||||
--wk-gold: #f1c40f;
|
||||
--wk-red: #e74c3c;
|
||||
}
|
||||
|
||||
ha-card { overflow: hidden; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--taskmate-header-bg, #27ae60);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content { display: flex; align-items: center; gap: 10px; }
|
||||
.header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
|
||||
.header-title { font-size: 1.2rem; font-weight: 600; }
|
||||
.week-label {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Summary stats row */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-text-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.green { color: var(--wk-green); }
|
||||
.stat-value.orange { color: var(--wk-orange); }
|
||||
.stat-value.purple { color: var(--wk-purple); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Daily bar chart */
|
||||
.chart-section { }
|
||||
|
||||
.section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 3px;
|
||||
transition: height 0.4s ease;
|
||||
}
|
||||
|
||||
.bar-fill.today {
|
||||
background: linear-gradient(180deg, var(--wk-green) 0%, #27ae60 100%);
|
||||
}
|
||||
|
||||
.bar-fill.past {
|
||||
background: linear-gradient(180deg, var(--wk-blue) 0%, #2980b9 100%);
|
||||
}
|
||||
|
||||
.bar-fill.future {
|
||||
background: var(--divider-color, #e8e8e8);
|
||||
}
|
||||
|
||||
.bar-fill.zero {
|
||||
background: var(--divider-color, #e8e8e8);
|
||||
min-height: 3px !important;
|
||||
height: 3px !important;
|
||||
}
|
||||
|
||||
.bar-day {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.bar-day.today {
|
||||
color: var(--wk-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Per-child breakdown */
|
||||
.children-section { }
|
||||
|
||||
.child-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--divider-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.child-row:last-child { border-bottom: none; }
|
||||
|
||||
.child-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--wk-purple), #a569bd);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.child-avatar ha-icon { --mdc-icon-size: 20px; color: white; }
|
||||
|
||||
.child-info { flex: 1; min-width: 0; }
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.child-week-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 2px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--secondary-text-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.child-week-stats span { display: flex; align-items: center; gap: 3px; }
|
||||
.child-week-stats ha-icon { --mdc-icon-size: 13px; }
|
||||
|
||||
.week-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.week-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--wk-green), #27ae60);
|
||||
}
|
||||
|
||||
/* Error / empty */
|
||||
.error-state, .empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 40px 20px;
|
||||
color: var(--secondary-text-color); text-align: center;
|
||||
}
|
||||
|
||||
.error-state { color: var(--error-color, #f44336); }
|
||||
.error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* Shared .tmd kit + design tokens are provided by taskmate-design.js styles(). */
|
||||
|
||||
/* Weekly — designed stat grid (playroom/console/cleanpro) */
|
||||
.wk-d-stats { display: grid; gap: 9px; }
|
||||
.wk-d-stats.g4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
|
||||
.wk-d-stats.g2 { grid-template-columns: 1fr 1fr; }
|
||||
.wk-pl-stat { background: var(--tmd-surface-2); border-radius: 16px; padding: 11px; text-align: center; }
|
||||
.wk-pl-stat .v { font-family: var(--tmd-font-display); font-weight: 800; font-size: 24px; line-height: 1; color: var(--tmd-accent); }
|
||||
.wk-pl-stat .k { font-size: 11px; font-weight: 800; margin-top: 4px; }
|
||||
|
||||
/* Weekly — chart wrap */
|
||||
.wk-chart-pl { background: var(--tmd-surface-2); border-radius: 18px; padding: 13px; margin-top: 11px; }
|
||||
.wk-chart-cn { background: var(--tmd-surface-2); border: 1px solid var(--tmd-border); border-radius: 8px; padding: 12px; margin-top: 9px; }
|
||||
.wk-chart-cp { margin-top: 14px; }
|
||||
.wk-chart-title { font-weight: 800; font-size: 13px; margin-bottom: 10px; }
|
||||
.wk-chart-title.cn { font-family: var(--tmd-font-mono); font-size: 11px; margin-bottom: 10px; color: var(--tmd-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.wk-chart-title.cp { font-size: 12px; margin-bottom: 10px; color: var(--tmd-dim); font-weight: 600; }
|
||||
.wk-bars { display: flex; align-items: flex-end; gap: 7px; }
|
||||
.wk-bars.cn { gap: 6px; height: 92px; }
|
||||
.wk-bars.pl { gap: 7px; height: 96px; }
|
||||
.wk-bars.cp { gap: 8px; height: 84px; }
|
||||
.wk-bcol { flex: 1; text-align: center; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
|
||||
.wk-bcol .lbl { font-size: 10px; font-weight: 800; margin-top: 5px; }
|
||||
.wk-bcol .lbl.cn { font-family: var(--tmd-font-mono); font-size: 9px; color: var(--tmd-dim); font-weight: 600; }
|
||||
.wk-bcol .lbl.cp { font-size: 10px; color: var(--tmd-dim); font-weight: 600; }
|
||||
.wk-bcol .val { font-size: 9px; font-weight: 700; color: var(--tmd-dim); min-height: 12px; }
|
||||
.wk-fill { border-radius: 8px 8px 4px 4px; min-height: 3px; background: var(--tmd-accent); }
|
||||
.wk-fill.today { background: var(--tmd-good); }
|
||||
.wk-fill.cn { border-radius: 3px; background: linear-gradient(180deg, var(--tmd-accent), color-mix(in srgb, var(--tmd-accent) 50%, var(--tmd-accent2))); box-shadow: 0 0 10px color-mix(in srgb, var(--tmd-accent) 50%, transparent); }
|
||||
.wk-fill.cn.today { background: linear-gradient(180deg, var(--tmd-accent2), var(--tmd-accent)); box-shadow: 0 0 14px color-mix(in srgb, var(--tmd-accent2) 60%, transparent); }
|
||||
.wk-fill.cp { border-radius: 4px; background: var(--tmd-accent); }
|
||||
.wk-fill.cp.today { background: var(--tmd-accent2); }
|
||||
|
||||
/* Weekly — per-child rows */
|
||||
.wk-kids { display: grid; gap: 8px; margin-top: 11px; }
|
||||
.wk-kids .name { flex: 1; min-width: 0; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wk-kids.cp .name { font-weight: 600; font-size: 13px; }
|
||||
`;
|
||||
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(this._t('weekly.error.entity_required'));
|
||||
this.config = {
|
||||
title: null,
|
||||
child_id: null,
|
||||
header_color: '#27ae60',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
getCardSize() { return 4; }
|
||||
static getConfigElement() { return document.createElement("taskmate-weekly-card-editor"); }
|
||||
static getStubConfig() {
|
||||
return { entity: "sensor.taskmate_overview", title: "This Week" };
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
const entity = this.hass.states[this.config.entity];
|
||||
if (design !== "classic") return this._renderDesigned(design, 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 tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const pointsIcon = attrs.points_icon || "mdi:star";
|
||||
const pointsName = attrs.points_name || this._t("common.stars");
|
||||
|
||||
// Use recent_completions (last 50 all-time) for full week view
|
||||
let allCompletions = [...(attrs.recent_completions || attrs.todays_completions || [])];
|
||||
const seen = new Set();
|
||||
allCompletions = allCompletions.filter(comp => {
|
||||
if (seen.has(comp.completion_id)) return false;
|
||||
seen.add(comp.completion_id); return true;
|
||||
});
|
||||
|
||||
if (this.config.child_id) {
|
||||
children = children.filter(c => c.id === this.config.child_id);
|
||||
allCompletions = allCompletions.filter(c => c.child_id === this.config.child_id);
|
||||
}
|
||||
|
||||
// Build week dates (Mon–Sun or Sun–Sat based on HA locale)
|
||||
const weekDays = this._getWeekDays(tz);
|
||||
const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
|
||||
|
||||
// Group completions by day
|
||||
const completionsByDay = {};
|
||||
allCompletions.forEach(c => {
|
||||
if (!c.completed_at) return;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
if (!completionsByDay[day]) completionsByDay[day] = [];
|
||||
completionsByDay[day].push(c);
|
||||
});
|
||||
|
||||
// Only show completions within this week
|
||||
const weekCompletions = allCompletions.filter(c => {
|
||||
if (!c.completed_at) return false;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
return weekDays.some(d => d.key === day);
|
||||
});
|
||||
|
||||
// Build chore points lookup — completions don't carry points directly
|
||||
const chorePointsMap = {};
|
||||
chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
|
||||
|
||||
// Only count approved completions for all stats
|
||||
const approvedWeekCompletions = weekCompletions.filter(c => c.approved);
|
||||
|
||||
const weekPoints = approvedWeekCompletions
|
||||
.reduce((sum, c) => sum + (c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0)), 0);
|
||||
const weekChores = approvedWeekCompletions.length;
|
||||
const daysActive = new Set(approvedWeekCompletions.map(c =>
|
||||
new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz })
|
||||
)).size;
|
||||
|
||||
// Bar chart also uses approved completions only
|
||||
const approvedCompletionsByDay = {};
|
||||
approvedWeekCompletions.forEach(c => {
|
||||
if (!c.completed_at) return;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
if (!approvedCompletionsByDay[day]) approvedCompletionsByDay[day] = [];
|
||||
approvedCompletionsByDay[day].push(c);
|
||||
});
|
||||
|
||||
// Max completions in a day for chart scale
|
||||
const maxPerDay = Math.max(1, ...weekDays.map(d => (approvedCompletionsByDay[d.key] || []).length));
|
||||
const weekLabel = this._getWeekLabel(weekDays, tz);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#27ae60')}; }</style>
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<ha-icon class="header-icon" icon="mdi:calendar-week"></ha-icon>
|
||||
<span class="header-title">${this.config.title || this._t('weekly.default_title')}</span>
|
||||
</div>
|
||||
<span class="week-label">${weekLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<!-- Summary stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value green">${weekChores}</span>
|
||||
<span class="stat-label">${this._t('weekly.stat_chores')}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value orange">${weekPoints}</span>
|
||||
<span class="stat-label">${pointsName}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value purple">${daysActive}/7</span>
|
||||
<span class="stat-label">${this._t('weekly.stat_days_active')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily bar chart -->
|
||||
<div class="chart-section">
|
||||
<div class="section-label">${this._t('weekly.section_chores_per_day')}</div>
|
||||
<div class="bar-chart">
|
||||
${weekDays.map(day => {
|
||||
const count = (approvedCompletionsByDay[day.key] || []).length;
|
||||
const isToday = day.key === todayKey;
|
||||
const isFuture = day.key > todayKey;
|
||||
const heightPct = isFuture ? 0 : Math.round((count / maxPerDay) * 60);
|
||||
const barClass = isFuture ? "future" : isToday ? "today" : count === 0 ? "zero" : "past";
|
||||
return html`
|
||||
<div class="bar-col">
|
||||
<span class="bar-value">${!isFuture && count > 0 ? count : ''}</span>
|
||||
<div class="bar-fill ${barClass}" style="height: ${isFuture ? 3 : Math.max(3, heightPct)}px"></div>
|
||||
<span class="bar-day ${isToday ? 'today' : ''}">${day.short}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-child breakdown -->
|
||||
${children.length > 0 ? html`
|
||||
<div class="children-section">
|
||||
<div class="section-label">${this._t('weekly.section_children_this_week')}</div>
|
||||
${children.map(child => {
|
||||
// Only count approved completions for all per-child stats
|
||||
const childApprovedCompletions = approvedWeekCompletions.filter(c => c.child_id === child.id);
|
||||
const childPoints = childApprovedCompletions
|
||||
.reduce((s, comp) => s + (comp.points !== undefined ? comp.points : (chorePointsMap[comp.chore_id] || 0)), 0);
|
||||
const childChoreCount = childApprovedCompletions.length;
|
||||
const childDaysActive = new Set(childApprovedCompletions.map(c =>
|
||||
new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz })
|
||||
)).size;
|
||||
|
||||
// Avatar now included directly in children array from the overview sensor
|
||||
const avatar = child.avatar || "mdi:account-circle";
|
||||
|
||||
const pct = Math.min((childDaysActive / 7) * 100, 100);
|
||||
|
||||
return html`
|
||||
<div class="child-row">
|
||||
<div class="child-avatar"><ha-icon icon="${avatar}"></ha-icon></div>
|
||||
<div class="child-info">
|
||||
<div class="child-name">${child.name}</div>
|
||||
<div class="child-week-stats">
|
||||
<span><ha-icon icon="mdi:checkbox-marked-circle" style="color:var(--wk-green)"></ha-icon>${this._t('weekly.child_chores', { count: childChoreCount })}</span>
|
||||
<span><ha-icon icon="${pointsIcon}" style="color:var(--wk-gold)"></ha-icon>${childPoints} ${pointsName}</span>
|
||||
<span><ha-icon icon="mdi:calendar-check" style="color:var(--wk-blue)"></ha-icon>${this._t('weekly.child_days', { count: childDaysActive })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="week-progress-bar" title="${this._t('weekly.child_days_active_title', { count: childDaysActive })}">
|
||||
<div class="week-progress-fill" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
|
||||
Ported from docs/design/redesigns/frag/10-weekly.html.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
const a = child.avatar || "";
|
||||
const inner = a.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${a}"></ha-icon>`
|
||||
: a
|
||||
? html`<img src="${a}" alt="${child.name}">`
|
||||
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return html`<div class="av" style="--av:${size}px;--ac:${tone}">${inner}</div>`;
|
||||
}
|
||||
|
||||
_weekData(entity, tz) {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
|
||||
let children = attrs.children || [];
|
||||
const chores = attrs.chores || [];
|
||||
const pointsName = attrs.points_name || this._t("common.stars");
|
||||
const rewardsClaimed = (attrs.rewards_claimed || attrs.recent_rewards || []);
|
||||
|
||||
let allCompletions = [...(attrs.recent_completions || attrs.todays_completions || [])];
|
||||
const seen = new Set();
|
||||
allCompletions = allCompletions.filter(comp => {
|
||||
if (seen.has(comp.completion_id)) return false;
|
||||
seen.add(comp.completion_id); return true;
|
||||
});
|
||||
|
||||
if (this.config.child_id) {
|
||||
children = children.filter(c => c.id === this.config.child_id);
|
||||
allCompletions = allCompletions.filter(c => c.child_id === this.config.child_id);
|
||||
}
|
||||
|
||||
const weekDays = this._getWeekDays(tz);
|
||||
const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
|
||||
|
||||
const chorePointsMap = {};
|
||||
chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
|
||||
|
||||
const weekCompletions = allCompletions.filter(c => {
|
||||
if (!c.completed_at) return false;
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
return weekDays.some(d => d.key === day);
|
||||
});
|
||||
const approved = weekCompletions.filter(c => c.approved);
|
||||
const ptsOf = (c) => (c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0));
|
||||
|
||||
const weekPoints = approved.reduce((s, c) => s + ptsOf(c), 0);
|
||||
const weekChores = approved.length;
|
||||
const daysActive = new Set(approved.map(c =>
|
||||
new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz })
|
||||
)).size;
|
||||
|
||||
// Rewards claimed this week
|
||||
let weekRewards = 0;
|
||||
rewardsClaimed.forEach(r => {
|
||||
const ts = r.claimed_at || r.timestamp || r.completed_at;
|
||||
if (!ts) return;
|
||||
const day = new Date(ts).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
if (this.config.child_id && r.child_id && r.child_id !== this.config.child_id) return;
|
||||
if (weekDays.some(d => d.key === day)) weekRewards += 1;
|
||||
});
|
||||
|
||||
// Points per day for the chart
|
||||
const pointsByDay = {};
|
||||
approved.forEach(c => {
|
||||
const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
|
||||
pointsByDay[day] = (pointsByDay[day] || 0) + ptsOf(c);
|
||||
});
|
||||
const maxPts = Math.max(1, ...weekDays.map(d => pointsByDay[d.key] || 0));
|
||||
|
||||
// Per-child weekly totals
|
||||
const kids = children.map(child => {
|
||||
const cc = approved.filter(c => c.child_id === child.id);
|
||||
return { child, points: cc.reduce((s, c) => s + ptsOf(c), 0) };
|
||||
}).sort((a, b) => b.points - a.points);
|
||||
|
||||
return { weekDays, todayKey, pointsByDay, maxPts, weekPoints, weekChores, daysActive, weekRewards, pointsName, kids };
|
||||
}
|
||||
|
||||
_renderDesigned(design, entity) {
|
||||
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const hd = _safeColor(this.config.header_color, "#27ae60");
|
||||
|
||||
if (!entity) {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
<div class="tmd-hd"><span class="ic">📅</span><span class="tt">${this.config.title || this._t("weekly.default_title")}</span></div>
|
||||
<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}">
|
||||
<div class="tmd-hd"><span class="ic">📅</span><span class="tt">${this.config.title || this._t("weekly.default_title")}</span></div>
|
||||
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
const d = this._weekData(entity, tz);
|
||||
const weekLabel = this._getWeekLabel(d.weekDays, tz);
|
||||
const title = this.config.title || this._t("weekly.default_title");
|
||||
|
||||
const icon = design === "playroom" ? "📅" : design === "console" ? "▦" : "▤";
|
||||
const header = html`
|
||||
<div class="tmd-hd">
|
||||
<span class="ic">${icon}</span>
|
||||
<span class="tt">${title}<small>${weekLabel}</small></span>
|
||||
${design === "console"
|
||||
? html`<span class="pill">${d.weekPoints} ${d.pointsName}</span>` : ""}
|
||||
</div>`;
|
||||
|
||||
const stats = [
|
||||
{ k: this._t("weekly.stat_days_active"), v: `${d.daysActive}/7` },
|
||||
{ k: d.pointsName, v: d.weekPoints },
|
||||
{ k: this._t("weekly.stat_rewards"), v: d.weekRewards },
|
||||
{ k: this._t("weekly.stat_chores"), v: d.weekChores },
|
||||
];
|
||||
|
||||
const cnLike = design !== "playroom"; // console + cleanpro use the .stat kit tile
|
||||
const statsBlock = cnLike
|
||||
? html`<div class="wk-d-stats g4">
|
||||
${stats.map(s => html`
|
||||
<div class="stat">
|
||||
<div class="k">${s.k}</div>
|
||||
<div class="v ${design === "console" ? "num" : ""}"
|
||||
style="${design === "console" ? "color:var(--tmd-accent)" : ""}">${s.v}</div>
|
||||
</div>`)}
|
||||
</div>`
|
||||
: html`<div class="wk-d-stats g2">
|
||||
${stats.map(s => html`
|
||||
<div class="wk-pl-stat"><div class="v">${s.v}</div><div class="k">${s.k}</div></div>`)}
|
||||
</div>`;
|
||||
|
||||
const chart = this._wkChart(design, d);
|
||||
const kids = this._wkKids(design, d);
|
||||
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${header}
|
||||
<div class="tmd-bd">
|
||||
${statsBlock}
|
||||
${chart}
|
||||
${kids}
|
||||
</div>
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
_wkChart(design, d) {
|
||||
const wrapClass = design === "playroom" ? "wk-chart-pl" : design === "console" ? "wk-chart-cn" : "wk-chart-cp";
|
||||
const titleClass = design === "playroom" ? "" : design === "console" ? "cn" : "cp";
|
||||
const barsClass = design === "playroom" ? "pl" : design === "console" ? "cn" : "cp";
|
||||
const lblMode = design === "console" ? "cn" : design === "cleanpro" ? "cp" : "";
|
||||
return html`
|
||||
<div class="${wrapClass}">
|
||||
<div class="wk-chart-title ${titleClass}">${this._t("weekly.section_points_per_day")}</div>
|
||||
<div class="wk-bars ${barsClass}">
|
||||
${d.weekDays.map(day => {
|
||||
const pts = d.pointsByDay[day.key] || 0;
|
||||
const isToday = day.key === d.todayKey;
|
||||
const isFuture = day.key > d.todayKey;
|
||||
const pct = isFuture ? 0 : Math.round((pts / d.maxPts) * 100);
|
||||
const h = isFuture ? 3 : Math.max(pts > 0 ? 8 : 3, pct);
|
||||
return html`
|
||||
<div class="wk-bcol">
|
||||
${design === "playroom" ? html`<span class="val">${!isFuture && pts > 0 ? pts : ""}</span>` : ""}
|
||||
<div class="wk-fill ${barsClass} ${isToday ? "today" : ""}" style="height:${h}%"></div>
|
||||
<span class="lbl ${lblMode}">${design === "console" ? day.short.toUpperCase() : day.short}</span>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_wkKids(design, d) {
|
||||
if (!d.kids.length) return "";
|
||||
const wrapClass = design === "cleanpro" ? "wk-kids cp" : "wk-kids";
|
||||
const valColor = design === "cleanpro" ? "var(--tmd-good)" : "var(--tmd-accent)";
|
||||
const av = design === "playroom" ? 34 : design === "console" ? 30 : 28;
|
||||
return html`
|
||||
${design === "cleanpro" ? html`<div class="divide"></div>` : ""}
|
||||
<div class="${wrapClass}">
|
||||
${d.kids.map((r, i) => {
|
||||
const tone = this._designTone(i);
|
||||
return html`
|
||||
<div class="row" style="--ac:${tone}">
|
||||
${this._av(r.child, tone, av)}
|
||||
<div class="name">${r.child.name}</div>
|
||||
${design === "playroom"
|
||||
? html`<div class="chip soft">+${r.points} ⭐</div>`
|
||||
: html`<div class="num" style="color:${valColor}">+${r.points}</div>`}
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_getWeekDays(tz) {
|
||||
const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
|
||||
const today = new Date(todayKey + "T12:00:00"); // noon avoids DST edge cases
|
||||
const todayDay = today.getDay(); // 0=Sun
|
||||
// Start week on Monday
|
||||
const mondayOffset = (todayDay === 0 ? -6 : 1 - todayDay);
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + mondayOffset);
|
||||
|
||||
const days = [];
|
||||
const shortNames = [
|
||||
this._t('weekly.day_mon'), this._t('weekly.day_tue'), this._t('weekly.day_wed'),
|
||||
this._t('weekly.day_thu'), this._t('weekly.day_fri'), this._t('weekly.day_sat'),
|
||||
this._t('weekly.day_sun'),
|
||||
];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(monday);
|
||||
d.setDate(monday.getDate() + i);
|
||||
days.push({
|
||||
key: d.toLocaleDateString("en-CA"),
|
||||
short: shortNames[i],
|
||||
date: d,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
_getWeekLabel(weekDays, tz) {
|
||||
const first = weekDays[0].date;
|
||||
const last = weekDays[6].date;
|
||||
const fmt = { month: "short", day: "numeric" };
|
||||
return `${first.toLocaleDateString(undefined, fmt)} – ${last.toLocaleDateString(undefined, fmt)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Card Editor
|
||||
class TaskMateWeeklyCardEditor 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
_computeLabel = (entry) => {
|
||||
const labels = {
|
||||
entity: this._t('common.editor.overview_entity'),
|
||||
title: this._t('weekly.editor.title'),
|
||||
child_id: this._t('common.editor.filter_by_child'),
|
||||
card_design: this._t('common.design.field_label'),
|
||||
};
|
||||
return labels[entry.name] ?? entry.name;
|
||||
};
|
||||
|
||||
_computeHelper = (entry) => {
|
||||
const helpers = {
|
||||
entity: this._t('common.editor.overview_entity_helper'),
|
||||
child_id: this._t('weekly.editor.child_helper'),
|
||||
};
|
||||
return helpers[entry.name] ?? '';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.hass || !this.config) return html``;
|
||||
const data = {
|
||||
entity: this.config.entity || '',
|
||||
title: this.config.title || '',
|
||||
child_id: this.config.child_id || '__all__',
|
||||
card_design: this.config.card_design || 'global',
|
||||
};
|
||||
return html`
|
||||
<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', '#27ae60')}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderColourPicker(key, defaultValue) {
|
||||
const d = window.__taskmate_design;
|
||||
const current = this.config[key] || defaultValue;
|
||||
if (!d || !d.colourPicker) return html``;
|
||||
return d.colourPicker({
|
||||
defaultValue, current,
|
||||
label: this._t('common.editor.header_colour'),
|
||||
helper: this._t('common.editor.header_colour_helper'),
|
||||
resetLabel: this._t('common.reset'),
|
||||
onInput: (v) => this._updateConfig(key, v),
|
||||
onPreset: (v) => this._updateConfig(key, v),
|
||||
onReset: () => this._updateConfig(key, defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
_formChanged(e) {
|
||||
const newValues = e.detail.value || {};
|
||||
const newConfig = { ...this.config };
|
||||
for (const [key, value] of Object.entries(newValues)) {
|
||||
if (
|
||||
value === '' || value === null || value === undefined
|
||||
|| (key === 'child_id' && value === '__all__')
|
||||
|| (key === 'card_design' && value === 'global')
|
||||
) {
|
||||
delete newConfig[key];
|
||||
} else {
|
||||
newConfig[key] = value;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_updateConfig(key, value) {
|
||||
const newConfig = { ...this.config, [key]: value };
|
||||
if (value === null || value === '' || value === undefined) delete newConfig[key];
|
||||
this.dispatchEvent(new CustomEvent('config-changed', {
|
||||
detail: { config: newConfig }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("taskmate-weekly-card", TaskMateWeeklyCard);
|
||||
customElements.define("taskmate-weekly-card-editor", TaskMateWeeklyCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "taskmate-weekly-card",
|
||||
name: "TaskMate Weekly Summary",
|
||||
description: "Week at a glance — chores, points, and daily bar chart",
|
||||
preview: true,
|
||||
getEntitySuggestion: (hass, entityId) =>
|
||||
window.__taskmate_suggest(hass, entityId, "taskmate-weekly-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-weekly-card.js"]'))
|
||||
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
||||
).get("v") || "?";
|
||||
console.info(
|
||||
"%c TASKMATE WEEKLY CARD %c v" + _tmVersion + " ",
|
||||
"background:#27ae60;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
||||
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
||||
);
|
||||
Reference in New Issue
Block a user