Updated Scheduler and Maintainance Apps

This commit is contained in:
2026-07-08 12:00:20 -04:00
parent fefc2c8b5c
commit 0f7585754d
227 changed files with 2877 additions and 958 deletions
+53 -17
View File
@@ -297,6 +297,29 @@ async def _async_require_admin(hass: HomeAssistant, call: ServiceCall) -> None:
raise Unauthorized(context=call.context)
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``.
"""
if not call.context.user_id:
return
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise Unauthorized(context=call.context)
if user.is_admin:
return
coordinator = _get_coordinator(hass)
parent_ids = coordinator.storage.get_parent_user_ids() if coordinator else []
if call.context.user_id in parent_ids:
return
raise Unauthorized(context=call.context)
_AUDIT_TARGET_KEYS = (
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id",
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id",
@@ -405,6 +428,18 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# Unauthorized (not ValueError), so it is unaffected and still 401s.
return _safe(wrapped)
def _parent(handler):
"""Like _admin, but also allows non-admin users in parent_user_ids (#661).
Used for day-to-day parent actions. Structural config keeps _admin.
"""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
await _async_require_parent(hass, call)
await handler(call)
await _async_record_service_audit(hass, call)
return _safe(wrapped)
async def handle_complete_chore(call: ServiceCall) -> None:
"""Handle the complete_chore service call."""
coordinator = _get_coordinator(hass)
@@ -418,8 +453,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# Completing on behalf of a child (auto-approve + instant award) is a
# parent privilege. Enforce it on the backend, not just by hiding the
# control in the UI — the service is callable by any authenticated
# user. Normal child self-completion (as_parent omitted) stays open.
await _async_require_admin(hass, call)
# user. Admins and listed non-admin parents (#661) may do this;
# normal child self-completion (as_parent omitted) stays open.
await _async_require_parent(hass, call)
else:
await _async_require_linked_child(hass, call, coordinator, child_id)
try:
@@ -1038,7 +1074,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_APPROVE_CHORE,
_admin(handle_approve_chore),
_parent(handle_approve_chore),
schema=vol.Schema(
{
vol.Required("completion_id"): cv.string,
@@ -1049,7 +1085,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_APPROVE_ALL_CHORES,
_admin(handle_approve_all_chores),
_parent(handle_approve_all_chores),
schema=vol.Schema(
{
vol.Optional("completion_ids"): [cv.string],
@@ -1060,7 +1096,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_REJECT_CHORE,
_admin(handle_reject_chore),
_parent(handle_reject_chore),
schema=vol.Schema(
{
vol.Required("completion_id"): cv.string,
@@ -1071,21 +1107,21 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
hass.services.async_register(
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY,
_admin(handle_apply_mandatory_penalty), schema=_miss_schema,
_parent(handle_apply_mandatory_penalty), schema=_miss_schema,
)
hass.services.async_register(
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE,
_admin(handle_postpone_mandatory_chore), schema=_miss_schema,
_parent(handle_postpone_mandatory_chore), schema=_miss_schema,
)
hass.services.async_register(
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE,
_admin(handle_dismiss_mandatory_chore), schema=_miss_schema,
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema,
)
hass.services.async_register(
DOMAIN,
SERVICE_UNDO_TRANSACTION,
_admin(handle_undo_transaction),
_parent(handle_undo_transaction),
schema=vol.Schema(
{
vol.Required("transaction_id"): cv.string,
@@ -1096,7 +1132,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_UNDO_CHORE_APPROVAL,
_admin(handle_undo_chore_approval),
_parent(handle_undo_chore_approval),
schema=vol.Schema(
{
vol.Required("completion_id"): cv.string,
@@ -1118,7 +1154,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_GIFT_POINTS,
_admin(handle_gift_points),
_parent(handle_gift_points),
schema=vol.Schema(
{
vol.Required("from_child_id"): cv.string,
@@ -1131,7 +1167,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_RECORD_ALLOWANCE_PAYOUT,
_admin(handle_record_allowance_payout),
_parent(handle_record_allowance_payout),
schema=vol.Schema(
{
vol.Required("child_id"): cv.string,
@@ -1179,14 +1215,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_REJECT_REWARD,
_admin(handle_reject_reward),
_parent(handle_reject_reward),
schema=vol.Schema({ vol.Required("claim_id"): cv.string }),
)
hass.services.async_register(
DOMAIN,
SERVICE_APPROVE_REWARD,
_admin(handle_approve_reward),
_parent(handle_approve_reward),
schema=vol.Schema(
{
vol.Required("claim_id"): cv.string,
@@ -1210,7 +1246,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_ADD_POINTS,
_admin(handle_add_points),
_parent(handle_add_points),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1368,7 +1404,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_SKIP_CHORE,
_admin(handle_skip_chore),
_parent(handle_skip_chore),
schema=vol.Schema({vol.Required(ATTR_CHORE_ID): cv.string}),
)
@@ -1458,7 +1494,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
"award_badge_manually",
_admin(handle_award_badge_manually),
_parent(handle_award_badge_manually),
schema=vol.Schema({
vol.Required(ATTR_BADGE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "4.4.4"
"version": "4.5.0"
}
+2
View File
@@ -780,6 +780,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"points_icon": data.get("points_icon", "mdi:star"),
# Global default card-design style; cards read this when no per-card override (#design).
"card_design": settings.get("card_design", "classic"),
# Non-admin parent role (#661): cards unlock parent controls for these HA users.
"parent_user_ids": self.coordinator.storage.get_parent_user_ids(),
"children": _build_children_summary(self.coordinator, common),
"time_boundaries": time_boundaries,
"time_periods": time_periods,
+15
View File
@@ -766,6 +766,21 @@ class TaskMateStorage:
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
def get_parent_user_ids(self) -> list[str]:
"""HA user IDs granted the non-admin TaskMate parent role (#661)."""
raw = (self._data.get("settings", {}) or {}).get("parent_user_ids", [])
if not isinstance(raw, list):
return []
return [x for x in raw if isinstance(x, str) and x]
def set_parent_user_ids(self, ids: list[str]) -> None:
"""Replace the parent role list (deduped, order-preserving, strings only)."""
seen: list[str] = []
for x in ids or []:
if isinstance(x, str) and x and x not in seen:
seen.append(x)
self._data.setdefault("settings", {})["parent_user_ids"] = seen
# --- mandatory reminder escalation (FEAT-6) ---
def get_escalation_reminder_minutes(self) -> int:
"""Minutes after a mandatory miss before the child reminder escalates."""
+7 -1
View File
@@ -1311,6 +1311,7 @@ _UPDATE_SETTINGS_SCHEMA = {
vol.Optional("time_night_end"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_periods"): list,
vol.Optional("vacation_periods"): list,
vol.Optional("parent_user_ids"): [str],
}
@@ -1334,8 +1335,13 @@ async def _ws_update_settings(hass, connection, msg, coordinator):
return
storage.set_setting("vacation_periods", vacations)
changed.append("vacation_periods")
if "parent_user_ids" in msg:
# Non-admin parent role (#661). Admin-gated write (this whole handler is
# @_admin_only) so a parent can never grant/escalate the role.
storage.set_parent_user_ids(list(msg["parent_user_ids"]))
changed.append("parent_user_ids")
for k, v in msg.items():
if k in {"id", "type", "time_periods", "vacation_periods"}:
if k in {"id", "type", "time_periods", "vacation_periods", "parent_user_ids"}:
continue
if k == "points_name":
storage.set_points_name(v.strip())
@@ -777,6 +777,9 @@
"panel.settings_chore_rotation_title": "Aufgabenrotation",
"panel.settings_currency_hint": "Wie Punkte benannt und in der App angezeigt werden.",
"panel.settings_currency_title": "Währung",
"panel.settings_parents_title": "Eltern (ohne Admin-Rechte)",
"panel.settings_parents_hint": "Geben Sie einem Haushaltsmitglied ohne Admin-Rechte die tägliche Kontrolle — Aufgaben bestätigen, Punkte anpassen, Belohnungen freigeben — ohne Home-Assistant-Administratorrechte. Dieses Admin-Panel können sie weiterhin nicht öffnen und die Konfiguration nicht ändern.",
"panel.settings_parents_empty": "Keine Home-Assistant-Benutzer ohne Admin-Rechte gefunden. Erstellen Sie zuerst einen normalen HA-Benutzer und wählen Sie ihn dann hier aus.",
"panel.settings_evening_hint": "Standard 17:00 21:00",
"panel.settings_evening_label": "Abend",
"panel.settings_history_title": "Verlauf & Serien",
@@ -815,6 +815,9 @@
"panel.settings_chore_rotation_title": "Chore Rotation",
"panel.settings_currency_hint": "How points are named and shown across the app.",
"panel.settings_currency_title": "Currency",
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_evening_hint": "Default 17:00 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -815,6 +815,9 @@
"panel.settings_chore_rotation_title": "Chore Rotation",
"panel.settings_currency_hint": "How points are named and shown across the app.",
"panel.settings_currency_title": "Currency",
"panel.settings_parents_title": "Parents (no admin rights)",
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
"panel.settings_evening_hint": "Default 17:00 21:00",
"panel.settings_evening_label": "Evening",
"panel.settings_history_title": "History & streaks",
@@ -777,6 +777,9 @@
"panel.settings_chore_rotation_title": "Rotation des tâches",
"panel.settings_currency_hint": "Comment les points sont nommés et affichés dans l'application.",
"panel.settings_currency_title": "Monnaie",
"panel.settings_parents_title": "Parents (sans droits admin)",
"panel.settings_parents_hint": "Donnez à un membre du foyer non-administrateur le contrôle quotidien — valider les tâches, ajuster les points, confirmer les récompenses — sans droits dadministrateur Home Assistant. Il ne pourra toujours pas ouvrir ce panneau dadministration ni modifier la configuration.",
"panel.settings_parents_empty": "Aucun utilisateur Home Assistant non-administrateur trouvé. Créez dabord un utilisateur HA standard, puis cochez-le ici.",
"panel.settings_evening_hint": "Par défaut 17:00 21:00",
"panel.settings_evening_label": "Soir",
"panel.settings_history_title": "Historique et séries",
@@ -777,6 +777,9 @@
"panel.settings_chore_rotation_title": "Oppgaverotasjon",
"panel.settings_currency_hint": "Hvordan poeng navngis og vises i appen.",
"panel.settings_currency_title": "Valuta",
"panel.settings_parents_title": "Foreldre (uten adminrettigheter)",
"panel.settings_parents_hint": "Gi et husstandsmedlem uten adminrettigheter daglig kontroll — godkjenne oppgaver, justere poeng, bekrefte belønninger — uten Home Assistant-adminrettigheter. De kan fortsatt ikke åpne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fant ingen Home Assistant-brukere uten adminrettigheter. Opprett en vanlig HA-bruker først, og huk deretter av her.",
"panel.settings_evening_hint": "Standard 17:00 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og serier",
@@ -777,6 +777,9 @@
"panel.settings_chore_rotation_title": "Oppgåverotasjon",
"panel.settings_currency_hint": "Korleis poeng vert namngjeve og viste i appen.",
"panel.settings_currency_title": "Valuta",
"panel.settings_parents_title": "Foreldre (utan adminrettar)",
"panel.settings_parents_hint": "Gi eit husstandsmedlem utan adminrettar dagleg kontroll — godkjenne oppgåver, justere poeng, stadfeste løningar — utan Home Assistant-adminrettar. Dei kan framleis ikkje opne dette adminpanelet eller endre konfigurasjonen.",
"panel.settings_parents_empty": "Fann ingen Home Assistant-brukarar utan adminrettar. Opprett ein vanleg HA-brukar først, og hak deretter av her.",
"panel.settings_evening_hint": "Standard 17:00 21:00",
"panel.settings_evening_label": "Kveld",
"panel.settings_history_title": "Historikk og rekkjer",
@@ -777,6 +777,9 @@
"panel.settings_chore_rotation_title": "Rotação de Tarefas",
"panel.settings_currency_hint": "Como os pontos são nomeados e exibidos no app.",
"panel.settings_currency_title": "Moeda",
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro da família sem permissões de administrador o controle do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Ele ainda não poderá abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Nenhum usuário do Home Assistant sem permissões de administrador foi encontrado. Crie primeiro um usuário HA padrão e depois marque-o aqui.",
"panel.settings_evening_hint": "Padrão 17:00 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -782,6 +782,9 @@
"panel.settings_chore_rotation_title": "Rotação de Tarefas",
"panel.settings_currency_hint": "Como os pontos são nomeados e apresentados na aplicação.",
"panel.settings_currency_title": "Moeda",
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
"panel.settings_parents_hint": "Dê a um membro do agregado sem permissões de administrador o controlo do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Continuará sem poder abrir este painel de administração nem alterar a configuração.",
"panel.settings_parents_empty": "Não foram encontrados utilizadores do Home Assistant sem permissões de administrador. Crie primeiro um utilizador HA normal e depois selecione-o aqui.",
"panel.settings_evening_hint": "Predefinição 17:00 21:00",
"panel.settings_evening_label": "Noite",
"panel.settings_history_title": "Histórico e sequências",
@@ -66,6 +66,35 @@
window.__taskmate_attrs = resolveAttrs;
/**
* Non-admin parent role (#661). True for Home Assistant admins and for users
* the admin has designated as TaskMate parents (published as the
* parent_user_ids attribute on sensor.taskmate_overview). Parents get the
* day-to-day controls (approve/reject, complete-on-behalf, undo) without HA
* admin rights. Admin remains the only tier that can open the admin panel or
* change configuration.
*/
function isTaskmateParent(hass) {
if (!hass || !hass.user) return false;
if (hass.user.is_admin) return true;
let ids;
const ov = hass.states && hass.states["sensor.taskmate_overview"];
if (ov && ov.attributes && Array.isArray(ov.attributes.parent_user_ids)) {
ids = ov.attributes.parent_user_ids;
} else if (hass.states) {
// Overview sensor renamed: find whichever taskmate sensor carries the list.
for (const st of Object.values(hass.states)) {
if (st && st.attributes && Array.isArray(st.attributes.parent_user_ids)) {
ids = st.attributes.parent_user_ids;
break;
}
}
}
return Array.isArray(ids) && ids.includes(hass.user.id);
}
window.__taskmate_is_parent = isTaskmateParent;
/**
* Event-driven update guard for LitElement cards.
*
@@ -3385,8 +3385,8 @@ class TaskMateChildCard extends LitElement {
if (this._loading[bonusKey]) return;
// Undoing removes already-awarded points, so it is parent-only — short-circuit
// for non-admins with one friendly message instead of a doomed service call.
if (this.hass.user && !this.hass.user.is_admin) {
// for non-parents with one friendly message instead of a doomed service call.
if (!window.__taskmate_is_parent(this.hass)) {
this._notifyUndo(this._t("child.undo_not_allowed"));
return;
}
@@ -3839,11 +3839,11 @@ class TaskMateChildCard extends LitElement {
}
// Undoing a completion removes already-awarded points, so it is parent-only
// (this mirrors the admin gate on the reject_chore service). For a non-admin
// (this mirrors the parent gate on the reject_chore service). For a non-parent
// user the call always fails with a raw "Unauthorized" — HA shows its own
// snackbar and we used to add an error notification on top. Short-circuit
// with one clear, friendly message and never fire the doomed call.
if (this.hass.user && !this.hass.user.is_admin) {
if (!window.__taskmate_is_parent(this.hass)) {
this._notifyUndo(this._t("child.undo_not_allowed"));
return;
}
@@ -524,7 +524,7 @@ class TaskMateOverviewCard extends LitElement {
pendingApprovals = (attrs.chore_completions || completions.filter(c => !c.approved)).length;
}
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
const isParent = window.__taskmate_is_parent(this.hass);
// Aggregate today's progress across all children
let doneTotal = 0;
@@ -555,9 +555,9 @@ class TaskMateOverviewCard extends LitElement {
<div class="tmd-bd">
${pendingApprovals > 0 ? this._ovAlert(design, pendingApprovals) : ""}
<div class="ov-kids">
${kids.map((k) => this._ovKid(design, k, isAdmin, pointsIcon))}
${kids.map((k) => this._ovKid(design, k, isParent, pointsIcon))}
</div>
${isAdmin ? kids.filter(k => this._expanded[k.child.id]).map(k => this._ovBehalf(k, pointsIcon)) : ""}
${isParent ? kids.filter(k => this._expanded[k.child.id]).map(k => this._ovBehalf(k, pointsIcon)) : ""}
${this._ovToday(design, doneTotal, choreTotal, overallPct)}
</div>
</ha-card>`;
@@ -630,11 +630,11 @@ class TaskMateOverviewCard extends LitElement {
</div>`;
}
_ovKid(design, k, isAdmin, pointsIcon) {
_ovKid(design, k, isParent, 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;
const expandable = isParent;
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}">
@@ -737,7 +737,7 @@ class TaskMateOverviewCard extends LitElement {
// 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 isParent = window.__taskmate_is_parent(this.hass);
const outstanding = childChores.filter(c => {
const doneToday = completions.filter(
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
@@ -751,8 +751,8 @@ class TaskMateOverviewCard extends LitElement {
<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-main ${isParent ? 'tm-expandable' : ''}"
@click="${isParent ? () => { 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;">
@@ -787,7 +787,7 @@ class TaskMateOverviewCard extends LitElement {
` : 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`
${isParent && 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`
@@ -412,7 +412,12 @@ class TaskMatePanel extends HTMLElement {
if (!t) return;
const act = t.dataset.act;
if (act === "tab") { this._activeTab = t.dataset.tab; this._filter = ""; this._render(); return; }
if (act === "tab") {
this._activeTab = t.dataset.tab; this._filter = ""; this._render();
// Settings tab lists HA users for the parent-role picker (#661); load then repaint.
if (this._activeTab === "settings") this._ensureHaUsers().then(() => this._render());
return;
}
if (act === "close-dialog") { this._closeDialog(); return; }
if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; }
if (act === "pick-entity") { this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return; }
@@ -1899,6 +1904,13 @@ class TaskMatePanel extends HTMLElement {
root.querySelectorAll("ha-icon-picker[data-setting]").forEach(el => {
payload[el.dataset.setting] = el.value || "";
});
// Non-admin parent role (#661): collect the ticked HA users.
const parentBoxes = root.querySelectorAll("input[type=checkbox][data-parent-user]");
if (parentBoxes.length) {
payload.parent_user_ids = Array.from(parentBoxes)
.filter(el => el.checked)
.map(el => el.dataset.parentUser);
}
if (this._settingsEntityDraft) {
Object.assign(payload, this._settingsEntityDraft);
}
@@ -3721,6 +3733,21 @@ class TaskMatePanel extends HTMLElement {
</div>
</div>
<div class="tm-section">
<div class="tm-section-head"><div>
<h3>${this._t("panel.settings_parents_title")}</h3>
<p class="tm-meta">${this._t("panel.settings_parents_hint")}</p>
</div></div>
<div class="tm-section-body">
${(this._haUsers || []).filter(u => !u.is_admin).map(u => `
<label class="tm-setting-row" style="cursor:pointer">
<div class="tm-setting-label">${this._esc(u.name)}</div>
<input type="checkbox" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
</label>`).join("")
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
</div>
</div>
<div class="tm-section">
<div class="tm-section-head"><div><h3>${this._t("panel.settings_history_title")}</h3></div></div>
<div class="tm-section-body">
@@ -763,7 +763,7 @@ class TaskMateParentDashboardCard extends LitElement {
</div>`;
}
if (design === "cleanpro") {
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
const isParent = window.__taskmate_is_parent(this.hass);
return html`
<div class="row pd-ov-cp" style="--ac:${tone}">
${this._av(child.name, child.avatar, tone, 36)}
@@ -774,7 +774,7 @@ class TaskMateParentDashboardCard extends LitElement {
</div>
<div class="bar" style="margin-top:7px;height:8px"><i style="width:${pct}%"></i></div>
</div>
${isAdmin ? html`<button class="btn ghost round sm"
${isParent ? html`<button class="btn ghost round sm"
title="${this._t('dashboard.tab_points')}"
@click="${() => this._switchTab('points')}">+</button>` : ""}
</div>
@@ -982,7 +982,7 @@ class TaskMateParentDashboardCard extends LitElement {
const isComplete = total > 0 && approved >= total;
const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none";
const isAdmin = !!(this.hass && this.hass.user && this.hass.user.is_admin);
const isParent = window.__taskmate_is_parent(this.hass);
const outstanding = childChores.filter(c => {
const doneToday = completions.filter(
x => x.child_id === child.id && x.chore_id === c.id && !x.bonus_subtask_id
@@ -992,12 +992,12 @@ class TaskMateParentDashboardCard extends LitElement {
const isOpen = !!this._expanded[child.id];
return html`
<div class="child-tile ${isAdmin ? 'tm-expandable' : ''}">
<div class="child-tile ${isParent ? 'tm-expandable' : ''}">
<div class="child-avatar">
<ha-icon icon="${child.avatar || 'mdi:account-circle'}"></ha-icon>
</div>
<div class="child-tile-main"
@click="${isAdmin ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
@click="${isParent ? () => { this._expanded = { ...this._expanded, [child.id]: !isOpen }; this.requestUpdate(); } : null}">
<div class="child-tile-header">
<span class="child-tile-name">${child.name}</span>
<span class="points-pill">
@@ -1011,7 +1011,7 @@ class TaskMateParentDashboardCard extends LitElement {
</div>
<span class="progress-label">${approved}/${total}</span>
</div>
${isAdmin && isOpen ? html`
${isParent && 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`