117 files
This commit is contained in:
@@ -306,11 +306,13 @@ async def _async_require_admin(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
"""Reject user-initiated calls that aren't from an admin or a TaskMate parent.
|
||||
|
||||
Day-to-day parent actions (approve/reject, gift/adjust points, confirm
|
||||
rewards/allowance, award badges, complete-as-parent) accept non-admin HA
|
||||
users listed in ``parent_user_ids`` (issue #661). Context-less calls
|
||||
(automations, scripts) pass. Structural config stays on
|
||||
``_async_require_admin``.
|
||||
Day-to-day parent actions (approve/reject, gift/adjust points, apply a
|
||||
bonus or penalty, confirm rewards/allowance, award badges,
|
||||
complete-as-parent) accept non-admin HA users listed in ``parent_user_ids``
|
||||
(issue #661). Context-less calls (automations, scripts) pass. Structural
|
||||
config — including *defining* bonuses and penalties — stays on
|
||||
``_async_require_admin``; only applying an existing one is a parent action
|
||||
(issue #749).
|
||||
"""
|
||||
if not call.context.user_id:
|
||||
return
|
||||
@@ -1324,7 +1326,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REMOVE_POINTS,
|
||||
_admin(handle_remove_points),
|
||||
_parent(handle_remove_points),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
@@ -1399,7 +1401,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPLY_PENALTY,
|
||||
_admin(handle_apply_penalty),
|
||||
_parent(handle_apply_penalty),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
@@ -1443,7 +1445,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPLY_BONUS,
|
||||
_admin(handle_apply_bonus),
|
||||
_parent(handle_apply_bonus),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
|
||||
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.
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.
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.
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.0.3"
|
||||
"version": "5.0.4"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,23 @@
|
||||
|
||||
window.__taskmate_is_parent = isTaskmateParent;
|
||||
|
||||
/**
|
||||
* Stable id of a badge entry from the badges sensor.
|
||||
*
|
||||
* ChildBadgesSensor publishes earned/available entries keyed `badge_id`, but
|
||||
* several card templates were written against `b.id` — undefined on those
|
||||
* entries, so every "is this the badge that was just earned?" test compared
|
||||
* against the string "undefined" and never matched (#752). Read the id
|
||||
* through this helper so both spellings work.
|
||||
*/
|
||||
function badgeId(badge) {
|
||||
if (!badge) return "";
|
||||
const id = badge.badge_id != null ? badge.badge_id : badge.id;
|
||||
return id != null ? String(id) : "";
|
||||
}
|
||||
|
||||
window.__taskmate_badge_id = badgeId;
|
||||
|
||||
/**
|
||||
* Event-driven update guard for LitElement cards.
|
||||
*
|
||||
|
||||
@@ -36,58 +36,54 @@ class TaskMateBadgesCard extends LitElement {
|
||||
super();
|
||||
this._filterTier = "all";
|
||||
this._justEarned = null;
|
||||
this._unsubscribeEvents = null;
|
||||
this._subscribing = false;
|
||||
this._seenBadgeIds = null;
|
||||
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;
|
||||
});
|
||||
/* Newly-earned badges are detected by diffing the badges sensor's `earned`
|
||||
list across hass updates (#752). The card used to subscribe to the custom
|
||||
`taskmate_badge_earned` event, but Home Assistant refuses a custom-event
|
||||
subscription from a non-admin user and logs an error every time — so the
|
||||
highlight never fired for children, and the log filled up. `state_changed`
|
||||
is allowlisted for everyone, so the diff works for every user. */
|
||||
_trackEarnedBadges() {
|
||||
const earned = this.hass?.states?.[this.config?.entity]?.attributes?.earned;
|
||||
if (!Array.isArray(earned)) return false;
|
||||
const ids = new Set(earned.map(b => window.__taskmate_badge_id(b)).filter(Boolean));
|
||||
|
||||
// First observation is the baseline — never flash a badge earned earlier.
|
||||
if (this._seenBadgeIds === null) {
|
||||
this._seenBadgeIds = ids;
|
||||
return false;
|
||||
}
|
||||
const fresh = [...ids].filter(id => !this._seenBadgeIds.has(id));
|
||||
this._seenBadgeIds = ids;
|
||||
if (!fresh.length) return false;
|
||||
|
||||
this._justEarned = fresh[0];
|
||||
clearTimeout(this._justEarnedTimeout);
|
||||
this._justEarnedTimeout = setTimeout(() => {
|
||||
this._justEarned = null;
|
||||
this.requestUpdate();
|
||||
}, 1800);
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
const flashed = this._trackEarnedBadges();
|
||||
const relevant = window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
return flashed || relevant;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -557,7 +553,7 @@ class TaskMateBadgesCard extends LitElement {
|
||||
}
|
||||
|
||||
_renderEarned(b) {
|
||||
const justEarned = this._justEarned && String(this._justEarned) === String(b.id);
|
||||
const justEarned = !!this._justEarned && this._justEarned === window.__taskmate_badge_id(b);
|
||||
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>` : ''}
|
||||
|
||||
@@ -43,9 +43,11 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
shouldUpdate(changedProps) {
|
||||
if (changedProps.has("hass")) {
|
||||
return window.__taskmate_hasChanged
|
||||
const flashed = this._trackEarnedBadges();
|
||||
const relevant = window.__taskmate_hasChanged
|
||||
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
||||
: true;
|
||||
return flashed || relevant;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -66,19 +68,17 @@ class TaskMateChildCard extends LitElement {
|
||||
// Badge strip state
|
||||
this._earnedBadges = [];
|
||||
this._justEarnedBadge = null;
|
||||
this._badgeEventUnsub = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._subscribeBadgeEvents();
|
||||
this._seenBadgeIds = null;
|
||||
this._justEarnedTimeout = null;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopTimerTick();
|
||||
this._badgeEventUnsub?.();
|
||||
this._badgeEventUnsub = null;
|
||||
if (this._justEarnedTimeout) {
|
||||
clearTimeout(this._justEarnedTimeout);
|
||||
this._justEarnedTimeout = null;
|
||||
}
|
||||
if (this._audioContext) {
|
||||
this._audioContext.close().catch(() => {});
|
||||
this._audioContext = null;
|
||||
@@ -118,27 +118,37 @@ class TaskMateChildCard extends LitElement {
|
||||
return fn ? fn(this.hass, key, params) : key;
|
||||
}
|
||||
|
||||
_subscribeBadgeEvents() {
|
||||
if (this._badgeEventUnsub || this._badgeSubscribing) return;
|
||||
if (!this.hass?.connection) return;
|
||||
this._badgeSubscribing = true;
|
||||
this.hass.connection.subscribeEvents((event) => {
|
||||
const data = event.data || {};
|
||||
const configChild = this.config?.child_id;
|
||||
if (configChild && data.child_id && String(data.child_id) !== String(configChild)) return;
|
||||
if (data.badge_id) {
|
||||
this._justEarnedBadge = String(data.badge_id);
|
||||
this.requestUpdate();
|
||||
setTimeout(() => { this._justEarnedBadge = null; this.requestUpdate(); }, 1800);
|
||||
}
|
||||
}, "taskmate_badge_earned").then(unsub => {
|
||||
this._badgeSubscribing = false;
|
||||
if (!this.isConnected) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._badgeEventUnsub = unsub;
|
||||
}).catch(() => { this._badgeSubscribing = false; });
|
||||
/* Newly-earned badges are detected by diffing this child's badges sensor
|
||||
`earned` list across hass updates (#752). The card used to subscribe to the
|
||||
custom `taskmate_badge_earned` event, but Home Assistant refuses a
|
||||
custom-event subscription from a non-admin user — logging an error every
|
||||
time — so the strip never lit up for a child. `state_changed` is
|
||||
allowlisted for everyone, so the diff works for every user. */
|
||||
_trackEarnedBadges() {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
const child = (attrs.children || []).find(c => c.id === this.config?.child_id)
|
||||
|| (!this.config?.child_id && (attrs.children || [])[0]);
|
||||
const earned = this._resolveBadgesEntity(child)?.attributes?.earned;
|
||||
if (!Array.isArray(earned)) return false;
|
||||
const ids = new Set(earned.map(b => window.__taskmate_badge_id(b)).filter(Boolean));
|
||||
|
||||
// First observation is the baseline — never flash a badge earned earlier.
|
||||
if (this._seenBadgeIds === null) {
|
||||
this._seenBadgeIds = ids;
|
||||
return false;
|
||||
}
|
||||
const fresh = [...ids].filter(id => !this._seenBadgeIds.has(id));
|
||||
this._seenBadgeIds = ids;
|
||||
if (!fresh.length) return false;
|
||||
|
||||
this._justEarnedBadge = fresh[0];
|
||||
clearTimeout(this._justEarnedTimeout);
|
||||
this._justEarnedTimeout = setTimeout(() => {
|
||||
this._justEarnedBadge = null;
|
||||
this.requestUpdate();
|
||||
}, 1800);
|
||||
return true;
|
||||
}
|
||||
|
||||
_tierColor(tier) {
|
||||
@@ -874,7 +884,7 @@ class TaskMateChildCard extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Chore number wrapper (icon removed) */
|
||||
/* Chore number wrapper — holds the number-or-picture badge */
|
||||
.chore-number-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -902,6 +912,14 @@ class TaskMateChildCard extends LitElement {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* A chore picture sits where the digit would, so it has to carry the
|
||||
same white-on-colour weight the numeral gets from its text-shadow. */
|
||||
.chore-number-badge ha-icon {
|
||||
--mdc-icon-size: 22px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.chore-card:hover .chore-number-badge {
|
||||
transform: rotate(5deg) scale(1.1);
|
||||
}
|
||||
@@ -1137,10 +1155,6 @@ class TaskMateChildCard extends LitElement {
|
||||
filter: saturate(0.7);
|
||||
}
|
||||
|
||||
.chore-card.completed .chore-icon-container {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.chore-card.completed .chore-name {
|
||||
color: var(--primary-text-color);
|
||||
opacity: 0.7;
|
||||
@@ -1608,6 +1622,7 @@ class TaskMateChildCard extends LitElement {
|
||||
.chore-card { padding: 10px 12px; gap: 8px; min-height: 54px; flex-wrap: nowrap; }
|
||||
.chore-info { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.chore-number-badge { width: 30px; height: 30px; min-width: 30px; font-size: 1rem; }
|
||||
.chore-number-badge ha-icon { --mdc-icon-size: 18px; }
|
||||
.chore-name { font-size: 0.95rem; }
|
||||
.chore-points { font-size: 0.82rem; }
|
||||
.chore-checkbox { width: 34px; height: 34px; min-width: 34px; border-radius: 8px; }
|
||||
@@ -1969,6 +1984,15 @@ class TaskMateChildCard extends LitElement {
|
||||
.tmd-undochip[disabled] { opacity: 0.6; cursor: default; }
|
||||
.q-undo { background: var(--tmd-surface-2); color: var(--tmd-dim); border: 1px solid var(--tmd-border); padding: 7px 10px; font-size: 14px; }
|
||||
|
||||
/* Designed: an explicit chore picture in the glyph slot. The emoji it
|
||||
replaces is sized by font-size, which an ha-icon ignores, so each row
|
||||
type restates its own size. Inherits the row accent so the icon reads
|
||||
as part of the style rather than a pasted-in glyph. */
|
||||
.tmd-glyph-icon { color: var(--ac, var(--tmd-accent)); display: block; }
|
||||
.tmd-chore .ch-emoji .tmd-glyph-icon { --mdc-icon-size: 22px; }
|
||||
.tmd-quest .q-emoji .tmd-glyph-icon { --mdc-icon-size: 19px; }
|
||||
.tmd-check .c-emoji .tmd-glyph-icon { --mdc-icon-size: 18px; }
|
||||
|
||||
/* Designed: pending-points + countdown chips on the header/section */
|
||||
.tmd-pending {
|
||||
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
|
||||
@@ -2205,7 +2229,7 @@ class TaskMateChildCard extends LitElement {
|
||||
<div class="badge-strip" @click=${() => this._openBadgesView()} title="${this._t('badges.label')}">
|
||||
<span class="badge-strip-label">${this._t('badges.label')}</span>
|
||||
${earnedBadges.slice(0, 5).map(b => html`
|
||||
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? 'just-earned' : ''}"
|
||||
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? 'just-earned' : ''}"
|
||||
style="--t: ${this._tierColor(b.tier)}">
|
||||
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
|
||||
</div>
|
||||
@@ -2332,6 +2356,20 @@ class TaskMateChildCard extends LitElement {
|
||||
return "⭐";
|
||||
}
|
||||
|
||||
/** What a designed chore row shows in its glyph slot.
|
||||
*
|
||||
* An explicit picture wins over the keyword guess (#745) — otherwise
|
||||
* choosing mdi:watering-can changed nothing, because _choreEmoji only ever
|
||||
* matched on words. The guess stays as the fallback so chores with no
|
||||
* picture, which is all of them until someone sets one, look as they did.
|
||||
* The icon inherits the row's accent so it reads as part of the style.
|
||||
*/
|
||||
_choreGlyph(chore) {
|
||||
return chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}" class="tmd-glyph-icon"></ha-icon>`
|
||||
: this._choreEmoji(chore);
|
||||
}
|
||||
|
||||
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
|
||||
_isChoreDone(chore, child, todaysCompletions) {
|
||||
const childCompletionsToday = (todaysCompletions || []).filter(
|
||||
@@ -2411,7 +2449,7 @@ class TaskMateChildCard extends LitElement {
|
||||
return {
|
||||
chore, child, done, loading, onAct, index: i, dimmed,
|
||||
tone: this._designTone(i),
|
||||
emoji: this._choreEmoji(chore),
|
||||
glyph: this._choreGlyph(chore),
|
||||
points: chore.effective_points ?? chore.points,
|
||||
timed: chore.task_type === "timed",
|
||||
mandatory: chore.mandatory === true,
|
||||
@@ -2465,7 +2503,7 @@ class TaskMateChildCard extends LitElement {
|
||||
<div class="tmd-badges" @click=${() => this._openBadgesView()} title="${this._t("badges.label")}">
|
||||
<span class="lbl">${this._t("badges.label")}</span>
|
||||
${earnedBadges.slice(0, 5).map(b => html`
|
||||
<div class="tmd-badge-mini ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? "just-earned" : ""}"
|
||||
<div class="tmd-badge-mini ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? "just-earned" : ""}"
|
||||
style="--t:${this._tierColor(b.tier)}">
|
||||
<ha-icon icon="${b.icon || "mdi:medal"}"></ha-icon>
|
||||
</div>`)}
|
||||
@@ -2569,7 +2607,7 @@ class TaskMateChildCard extends LitElement {
|
||||
${rows.map(r => r.timed ? this._designTimed(r) : html`
|
||||
<div class="tmd-chore ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
|
||||
<div class="num-badge" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
|
||||
<span class="ch-emoji">${r.emoji}</span>
|
||||
<span class="ch-emoji">${r.glyph}</span>
|
||||
<div class="ch-mid">
|
||||
<div class="ch-name">${r.chore.name}</div>
|
||||
${r.done ? "" : html`<div class="chip soft" style="margin-top:3px">+${r.points} ⭐</div>`}
|
||||
@@ -2589,7 +2627,7 @@ class TaskMateChildCard extends LitElement {
|
||||
${rows.map(r => r.timed ? this._designTimed(r) : html`
|
||||
<div class="tmd-quest ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
|
||||
<div class="num q-num" style="${r.done ? "color:var(--tmd-good)" : ""}">${r.done ? "✓" : String(r.index + 1).padStart(2, "0")}</div>
|
||||
<span class="q-emoji">${r.emoji}</span>
|
||||
<span class="q-emoji">${r.glyph}</span>
|
||||
<div class="q-mid">
|
||||
<div class="q-name">${r.chore.name}</div>
|
||||
${r.done
|
||||
@@ -2611,7 +2649,7 @@ class TaskMateChildCard extends LitElement {
|
||||
${rows.map(r => r.timed ? this._designTimed(r) : html`
|
||||
<div class="tmd-check ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
|
||||
<div class="c-num" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
|
||||
<span class="c-emoji">${r.emoji}</span>
|
||||
<span class="c-emoji">${r.glyph}</span>
|
||||
<div class="c-mid">
|
||||
<div class="c-name">${r.chore.name}</div>
|
||||
${this._designChoreMeta(r)}
|
||||
@@ -3289,6 +3327,23 @@ class TaskMateChildCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The coloured badge at the head of a chore row.
|
||||
*
|
||||
* A chore's picture takes the digit's place when one is set (#745) — the
|
||||
* number is only positional, whereas the icon is what a child actually
|
||||
* recognises. Chores with no picture keep their number, which is every
|
||||
* chore that predates the field. Shared by the standard and timed rows so
|
||||
* a picture can't work on one and be invisible on the other.
|
||||
*/
|
||||
_choreNumberBadge(chore, colorClass, choreNumber) {
|
||||
return html`
|
||||
<div class="chore-number-badge ${colorClass}">
|
||||
${chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}"></ha-icon>`
|
||||
: choreNumber}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
|
||||
const isLoading = this._loading[chore.id];
|
||||
const isCelebrating = this._celebrating === chore.id;
|
||||
@@ -3398,7 +3453,7 @@ class TaskMateChildCard extends LitElement {
|
||||
>
|
||||
<div class="chore-info">
|
||||
<div class="chore-number-wrapper">
|
||||
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
|
||||
${this._choreNumberBadge(chore, colorClass, choreNumber)}
|
||||
</div>
|
||||
<div class="chore-details">
|
||||
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
|
||||
@@ -3525,7 +3580,7 @@ class TaskMateChildCard extends LitElement {
|
||||
<div class="timed-chore-card ${state} ${isLoading ? 'loading' : ''}">
|
||||
<div class="timed-top-row">
|
||||
<div class="chore-number-wrapper">
|
||||
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
|
||||
${this._choreNumberBadge(chore, colorClass, choreNumber)}
|
||||
</div>
|
||||
<div class="chore-details">
|
||||
<div class="chore-name">${chore.name}</div>
|
||||
|
||||
@@ -466,6 +466,28 @@ export function createIncentiveCard(P) {
|
||||
return this._getAttrs().points_name || this._t("common.stars");
|
||||
}
|
||||
|
||||
/* ── Roles (#749) ──────────────────────────────────────────────────────
|
||||
Applying an existing bonus/penalty is a day-to-day parent action, so
|
||||
admins *and* TaskMate parents get the Apply buttons. Defining one is
|
||||
structural config, so the manage controls (pencil, add/edit/delete) are
|
||||
admin-only — matching the `_parent` / `_admin` service gates. A child on
|
||||
a shared tablet sees the list with no buttons. */
|
||||
|
||||
_canApply() {
|
||||
return window.__taskmate_is_parent(this.hass);
|
||||
}
|
||||
|
||||
_canManage() {
|
||||
return !!this.hass?.user?.is_admin;
|
||||
}
|
||||
|
||||
_toggleEditMode() {
|
||||
if (!this._canManage()) return;
|
||||
this._editMode = !this._editMode;
|
||||
this._editingBonus = null;
|
||||
this._showNewForm = false;
|
||||
}
|
||||
|
||||
_getVisibleBonuses() {
|
||||
const child = this._getSelectedChild();
|
||||
if (!child) return this._getBonuses();
|
||||
@@ -482,7 +504,7 @@ export function createIncentiveCard(P) {
|
||||
|
||||
async _applyBonus(bonus) {
|
||||
const child = this._getSelectedChild();
|
||||
if (!child) return;
|
||||
if (!child || !this._canApply()) return;
|
||||
const key = bonus.id;
|
||||
if (this._loading[key]) return;
|
||||
this._loading = { ...this._loading, [key]: true };
|
||||
@@ -630,7 +652,7 @@ export function createIncentiveCard(P) {
|
||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||
</button>
|
||||
</div>
|
||||
` : html`
|
||||
` : this._canApply() ? html`
|
||||
<button class="apply-btn"
|
||||
?disabled=${isLoading || !child}
|
||||
@click=${() => this._applyBonus(b)}>
|
||||
@@ -639,7 +661,7 @@ export function createIncentiveCard(P) {
|
||||
: html`<ha-icon icon="${P.applyIcon}"></ha-icon> ${this._t('common.apply')}`
|
||||
}
|
||||
</button>
|
||||
`}
|
||||
` : ""}
|
||||
</div>
|
||||
${isEditing ? this._renderEditForm() : ""}
|
||||
`;
|
||||
@@ -745,10 +767,12 @@ export function createIncentiveCard(P) {
|
||||
${bonuses.length ? html`
|
||||
<span class="bonus-count">${bonuses.length}</span>
|
||||
` : ""}
|
||||
<button class="icon-btn ${this._editMode ? "active" : ""}" title="${this._tp('manage_title')}"
|
||||
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
${this._canManage() ? html`
|
||||
<button class="icon-btn ${this._editMode ? "active" : ""}" title="${this._tp('manage_title')}"
|
||||
@click=${this._toggleEditMode}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -776,7 +800,7 @@ export function createIncentiveCard(P) {
|
||||
`}
|
||||
` : ""}
|
||||
|
||||
${child && !this._editMode ? html`
|
||||
${child && !this._editMode && this._canApply() ? html`
|
||||
<div style="text-align:center; font-size:0.8rem; color:var(--text-secondary); padding-top:4px;">
|
||||
${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}
|
||||
</div>
|
||||
@@ -831,6 +855,7 @@ export function createIncentiveCard(P) {
|
||||
}
|
||||
|
||||
_designApplyBtn(b, klass) {
|
||||
if (!this._canApply()) return "";
|
||||
const child = this._getSelectedChild();
|
||||
const isLoading = this._loading[b.id];
|
||||
return html`
|
||||
@@ -955,11 +980,13 @@ export function createIncentiveCard(P) {
|
||||
<span class="ic">${icon}</span>
|
||||
<span class="tt">${this.config.title || this._tp('default_title')}${
|
||||
bonuses.length ? html`<small>${bonuses.length}</small>` : ""}</span>
|
||||
<button class="btn ghost sm" style="margin-left:auto"
|
||||
title="${this._tp('manage_title')}"
|
||||
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
${this._canManage() ? html`
|
||||
<button class="btn ghost sm" style="margin-left:auto"
|
||||
title="${this._tp('manage_title')}"
|
||||
@click=${this._toggleEditMode}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
` : ""}
|
||||
</div>`;
|
||||
|
||||
const rows = design === "playroom"
|
||||
@@ -984,7 +1011,7 @@ export function createIncentiveCard(P) {
|
||||
<ha-icon icon="mdi:plus"></ha-icon> ${this._tp('new_bonus')}
|
||||
</button>`)
|
||||
: ""}
|
||||
${child && !this._editMode
|
||||
${child && !this._editMode && this._canApply()
|
||||
? html`<div class="d-foot">${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}</div>`
|
||||
: ""}`;
|
||||
|
||||
|
||||
@@ -1403,12 +1403,14 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
|
||||
async _doSaveChore() {
|
||||
this._syncIconPickers();
|
||||
const d = this._dialog.data;
|
||||
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
|
||||
const wasAdd = this._dialog.mode === "add";
|
||||
const base = {
|
||||
name: d.name.trim(),
|
||||
description: d.description || "",
|
||||
icon: d.icon || "",
|
||||
points: Number(d.points) || 0,
|
||||
assigned_to: d.assigned_to || [],
|
||||
requires_approval: !!d.requires_approval,
|
||||
@@ -5933,8 +5935,20 @@ class TaskMatePanel extends HTMLElement {
|
||||
|
||||
_styles() {
|
||||
return `<style>
|
||||
/* Fill the panel area without depending on a percentage-height chain.
|
||||
HA nests us as taskmate-panel → ha-panel-custom → partial-panel-resolver
|
||||
→ ha-drawer, and the middle two are display:inline / height:auto. A
|
||||
height:100% here only resolves while some ancestor happens to carry a
|
||||
definite height; where it doesn't, the shell collapses to its content
|
||||
height and the nav column visibly stops partway down every short page
|
||||
(#754). A flex column of exactly 100dvh is immune to the ancestor
|
||||
chain: panel_custom gives the element the whole viewport — HA draws no
|
||||
toolbar above it — so 100dvh is the full panel area, not an overflow.
|
||||
It must be height, not min-height: the shell scrolls its own body,
|
||||
and a floor-only rule would let a long section (Settings) grow the
|
||||
panel past the viewport and scroll the nav column off the page. */
|
||||
taskmate-panel {
|
||||
display: block; height: 100%;
|
||||
display: flex; flex-direction: column; height: 100dvh;
|
||||
|
||||
--tm-bg: var(--primary-background-color, #fafafa);
|
||||
--tm-surface-0: var(--card-background-color, #fff);
|
||||
@@ -6002,7 +6016,11 @@ class TaskMatePanel extends HTMLElement {
|
||||
.tm-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--tm-sidebar-w) 1fr;
|
||||
height: 100%;
|
||||
/* Flex to fill the panel rather than height:100% — see the note on
|
||||
taskmate-panel above (#754). min-height:0 lets the grid shrink
|
||||
instead of being forced to its content height. */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user