251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
+280 -14
View File
@@ -292,6 +292,7 @@ class TaskMatePanel extends HTMLElement {
// to lean on) and our shell was stripped out in the meantime.
if (!this._rendered || !this.querySelector(".tm-shell")) this._render();
this._bindHaPickers(false);
if (this._backdateCard) this._backdateCard.hass = value;
}
get hass() { return this._hass; }
set narrow(_v) {}
@@ -646,6 +647,9 @@ class TaskMatePanel extends HTMLElement {
if (act === "save-gift") { this._doGiftPoints(); return; }
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
if (act === "backdate-open") { this._openBackdateDialog(t.dataset.id); return; }
if (act === "save-backdate") { this._saveBackdateDialog(); return; }
if (act === "close-backdate") { this._closeDialog(true); this._fetchState(); return; }
if (act === "sound-preview-field") {
const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]');
if (sel) this._previewSound(sel.value);
@@ -1973,8 +1977,10 @@ class TaskMatePanel extends HTMLElement {
unavailability_entity: c.unavailability_entity || "",
pause_streak_when_unavailable: !!c.pause_streak_when_unavailable,
linked_user_id: c.linked_user_id || "",
picture_entity: c.picture_entity || "",
birthday: c.birthday || "",
age_group: c.age_group || "",
is_adult: !!c.is_adult,
presence_entity: c.presence_entity || "",
// Kiosk PIN (#930): write-only. The panel only knows whether one is set.
kiosk_pin: "", kiosk_pin_clear: false,
@@ -1985,8 +1991,8 @@ class TaskMatePanel extends HTMLElement {
name: "", avatar: "mdi:account-circle", availability_entity: "",
availability_inverted: false, unavailability_entity: "",
pause_streak_when_unavailable: false,
linked_user_id: "", presence_entity: "",
birthday: "", age_group: "",
linked_user_id: "", picture_entity: "", presence_entity: "",
birthday: "", age_group: "", is_adult: false,
kiosk_pin: "", kiosk_pin_clear: false, has_kiosk_pin: false,
} });
}
@@ -2035,14 +2041,16 @@ class TaskMatePanel extends HTMLElement {
availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted,
unavailability_entity: d.unavailability_entity || "",
pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "",
birthday: (d.birthday || "").trim(), age_group: d.age_group || "",
presence_entity: d.presence_entity || "" }
birthday: (d.birthday || "").trim(), age_group: d.age_group || "", is_adult: !!d.is_adult,
presence_entity: d.presence_entity || "",
picture_entity: d.picture_entity || "" }
: { type: "taskmate/update_child", child_id: d.id, name: d.name.trim(), avatar: d.avatar || "mdi:account-circle",
availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted,
unavailability_entity: d.unavailability_entity || "",
pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "",
birthday: (d.birthday || "").trim(), age_group: d.age_group || "",
presence_entity: d.presence_entity || "" };
birthday: (d.birthday || "").trim(), age_group: d.age_group || "", is_adult: !!d.is_adult,
presence_entity: d.presence_entity || "",
picture_entity: d.picture_entity || "" };
const { ok, err, res } = await this._callWS(payload);
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; }
// The PIN goes through its own command so it is hashed server-side and
@@ -3572,6 +3580,7 @@ class TaskMatePanel extends HTMLElement {
this._zoneCache = {};
this._applyDesign();
this._bindHaPickers();
this._mountBackdateCard();
if (this._palette) this._focusPalette();
return;
}
@@ -3632,6 +3641,7 @@ class TaskMatePanel extends HTMLElement {
}
this._applyDesign();
this._mountBackdateCard();
}
// A dialog plays its opening animation only when it first appears (#974).
@@ -4492,7 +4502,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-child-head">
<div class="tm-avatar">${this._mdi(child.avatar)}</div>
<div class="tm-child-name">
<h3>${this._esc(child.name || this._t("panel.child_unnamed"))}</h3>
<h3>${this._esc(child.name || this._t("panel.child_unnamed"))}${child.is_adult ? ` <span class="tm-pill tm-pill-accent">${this._t("panel.child_adult_pill")}</span>` : ""}</h3>
${this._idBadge(child.id)}
<div class="tm-meta">${child.availability_entity ? `<code title="${this._esc(child.availability_entity)}">${this._esc(child.availability_entity)}</code>${child.availability_inverted ? ` <em>${this._t("panel.child_inverted")}</em>` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} <code title="${this._esc(child.unavailability_entity)}">${this._esc(child.unavailability_entity)}</code>` : ""}</div>
</div>
@@ -4507,6 +4517,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-card-foot">
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_edit")}</button>
<button type="button" class="tm-btn tm-btn-sm" data-act="reorder-chores-for-child" data-id="${this._esc(child.id)}" title="${this._t("panel.chore_order_title")}">⇅ ${this._t("panel.chore_order_title")}</button>
<button type="button" class="tm-btn tm-btn-sm" data-act="backdate-open" data-id="${this._esc(child.id)}" title="${this._esc(this._t("panel.backdate_hint"))}">📅 ${this._t("panel.backdate_button")}</button>
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="delete-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_delete")}</button>
</div>
</article>
@@ -6484,9 +6495,10 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-dash-tile-f">
${found ? `<span class="tm-dash-made"><ha-icon icon="mdi:check"></ha-icon>${this._t("panel.dash_created")}</span>` : ""}
<span class="tm-dash-sp"></span>
${found ? `<button type="button" class="tm-btn tm-btn-sm" data-act="dash-open" data-v="${this._esc(found)}">${this._t("panel.dash_open")}</button>` : ""}
<button type="button" class="tm-btn tm-btn-sm ${found ? "" : "tm-btn-raised"}" data-act="dash-new" data-v="${kind}" ${off ? "disabled" : ""}>${this._t("panel.dash_create_btn")}</button>
<span class="tm-dash-acts">
${found ? `<button type="button" class="tm-btn tm-btn-sm" data-act="dash-open" data-v="${this._esc(found)}">${this._t("panel.dash_open")}</button>` : ""}
<button type="button" class="tm-btn tm-btn-sm ${found ? "" : "tm-btn-raised"}" data-act="dash-new" data-v="${kind}" ${off ? "disabled" : ""}>${this._t("panel.dash_create_btn")}</button>
</span>
</div>
</div>`;
}).join("");
@@ -6587,8 +6599,11 @@ class TaskMatePanel extends HTMLElement {
.tm-dash-tile-b { flex: 1; padding: 12px 14px; }
.tm-dash-tile-b p { margin: 0 0 8px; font-size: 13px; color: var(--tm-text-muted); }
.tm-dash-tile-b small { color: var(--tm-text-faint); font-size: 12px; }
.tm-dash-tile-f { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--tm-border-soft); }
.tm-dash-sp { flex: 1; }
/* Wraps rather than squeezing: with "Created" + Open + Create… a narrow
tile drops the buttons to their own right-aligned line (#1059). */
.tm-dash-tile-f { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--tm-border-soft); }
.tm-dash-acts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-inline-start: auto; }
.tm-dash-acts .tm-btn { flex: none; }
.tm-dash-made { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--tm-positive); white-space: nowrap; --mdc-icon-size: 14px; }
.tm-dash-kinds { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 14px; }
.tm-dash-kind { display: flex; align-items: center; gap: 10px; text-align: start; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--tm-border); background: var(--tm-surface-0); color: var(--tm-text); font-family: inherit; cursor: pointer; --mdc-icon-size: 22px; }
@@ -9688,6 +9703,242 @@ class TaskMatePanel extends HTMLElement {
}
}
// Log a past job: one done on an earlier day that nobody ticked. The
// backend (complete_chore with completed_date) re-checks every rule here —
// the last 7 days, the child's own non-rotating job, due that day, that
// day's limit — so this list is a convenience, not the gate.
// "Today" is Home Assistant's today: the backend checks the day against its
// own clock and zone, so a browser in another zone (or a tablet with a wrong
// clock) must not offer a day the backend would call today or too old.
_backdateDays() {
const tz = this._hass?.config?.time_zone;
const lang = this._hass?.locale?.language || this._hass?.language || undefined;
let todayKey;
try {
todayKey = new Date().toLocaleDateString("en-CA", tz ? { timeZone: tz } : undefined);
} catch (err) {
todayKey = new Date().toLocaleDateString("en-CA");
}
const [y, m, d0] = todayKey.split("-").map(Number);
const days = [];
for (let back = 1; back <= 7; back++) {
// Plain calendar arithmetic on the date parts, at noon UTC so no zone
// can push it onto a neighbouring day.
const d = new Date(Date.UTC(y, m - 1, d0 - back, 12));
days.push({
v: d.toISOString().slice(0, 10),
l: d.toLocaleDateString(lang, { weekday: "long", day: "numeric", month: "short", timeZone: "UTC" }),
dow: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][d.getUTCDay()],
});
}
return days;
}
_backdateChores(childId, dow, dayKey) {
// Once TaskMate has described the day, offer exactly what it would accept
// that hasn't already used up that week's quota. Until then, a first guess
// from the weekday alone (logging still checks everything).
const info = this._backdateInfo;
if (info && info.day === dayKey) {
const ids = (info.loggable && info.loggable[childId]) || [];
const week = (info.week_progress && info.week_progress[childId]) || {};
return (this._state.chores || []).filter(c => {
if (!ids.includes(c.id)) return false;
const target = Number(c.weekly_target) || 0;
return !target || (Number(week[c.id]) || 0) < target;
});
}
return (this._state.chores || []).filter(c => {
if (c.enabled === false) return false;
if ((c.disabled_for || []).includes(childId)) return false;
if ((c.assigned_to || []).length && !(c.assigned_to || []).includes(childId)) return false;
if ((c.assignment_mode || "everyone") !== "everyone") return false;
// TaskMate refuses these for a past day: an open-ended job needs the
// child's note and a price, and a team's members can't be reconstructed.
if (c.open_ended || c.team_size >= 2) return false;
// A weekly-target job has no fixed days: the child picks them.
if (Number(c.weekly_target) > 0) return true;
if ((c.schedule_mode || "specific_days") === "specific_days") {
const due = c.due_days || [];
return !due.length || due.includes(dow);
}
return true;
});
}
async _openBackdateDialog(childId) {
const child = (this._state.children || []).find(c => c.id === childId);
if (!child) return;
const day = this._backdateDays()[0];
const first = this._backdateChores(childId, day.dow, day.v)[0];
this._backdateKey = "";
this._backdateUseList = false;
this._backdateCardReady = undefined;
this._backdateInfo = null;
this._openDialog({ kind: "backdate", data: { child_id: childId, day: day.v, chore_id: first ? first.id : "" } });
this._backdateCardReady = await this._loadBackdateCard();
if (this._dialog && this._dialog.kind === "backdate") this._render();
}
// The day is shown with the dashboard's own sticker chart card, so jobs are
// ticked exactly as the children tick them. The panel isn't a dashboard and
// never loads dashboard resources, so the card (and the attribute helper it
// reads the sensors through) are imported here, at the same versioned URLs
// a dashboard uses — a browser that has both open runs each module once.
// If the card can't load, the dialog falls back to a plain job list.
async _loadBackdateCard() {
if (customElements.get("taskmate-sticker-chart-card")) return true;
try {
await import(`/taskmate/taskmate-attr-resolver.js?v=${PANEL_VERSION}`);
await import(`/taskmate/taskmate-sticker-chart-card.js?v=${PANEL_VERSION}`);
return !!customElements.get("taskmate-sticker-chart-card");
} catch (err) {
console.warn("TaskMate: sticker chart card unavailable in the panel", err);
return false;
}
}
_overviewEntity() {
const states = (this._hass && this._hass.states) || {};
return Object.keys(states).find(id => {
const a = id.startsWith("sensor.") && states[id].attributes;
return a && a.points_name !== undefined && Array.isArray(a.children);
}) || "";
}
// Called after every render: the card element is created once and moved
// into the dialog's placeholder, so a re-render never loses its state.
//
// Nothing here may throw: it runs inside the panel's render, so an error
// would leave the whole panel dead until a reload. If the card can't be
// built (its module hasn't defined it yet, or the overview sensor is
// missing), the dialog falls back to the plain job list instead.
_mountBackdateCard() {
const host = this.querySelector(".tm-backdate-host");
if (!host || !this._dialog || this._dialog.kind !== "backdate") return;
// Not defined yet: _openBackdateDialog re-renders once the import settles.
if (!customElements.get("taskmate-sticker-chart-card")) return;
try {
const entity = this._overviewEntity();
if (!entity) throw new Error("no TaskMate overview sensor");
const d = this._dialog.data;
if (!this._backdateCard) {
this._backdateCard = document.createElement("taskmate-sticker-chart-card");
this._backdateCard.addEventListener("taskmate-day-changed", () => this._refreshBackdateDay());
}
const card = this._backdateCard;
const key = `${d.child_id}|${d.day}`;
if (this._backdateKey !== key) {
card.setConfig({ entity, child_id: d.child_id, date: d.day, show_claim: false });
this._backdateKey = key;
// Nothing from the previous day may linger while this one loads.
card.dayLoggable = undefined;
card.dayWeekProgress = {};
card.dayCompletions = [];
this._refreshBackdateDay();
// Changing the day isn't an unsaved edit: every tick is saved as it's made.
this._dialogInitialHash = this._hashDialog();
}
card.hass = this._hass;
if (card.parentNode !== host) host.appendChild(card);
} catch (err) {
console.warn("TaskMate: sticker chart card couldn't be shown in the panel", err);
if (!this._backdateUseList) {
this._backdateUseList = true;
this._backdateCard = null;
// Re-render on the next turn rather than from inside a render.
setTimeout(() => { if (this._dialog && this._dialog.kind === "backdate") this._render(); }, 0);
}
}
}
// What TaskMate says about the chosen day: its completions, that week's
// weekly-target progress and the chores it would accept. Feeds both the
// card and the plain-list fallback.
async _refreshBackdateDay() {
const d = this._dialog && this._dialog.data;
if (!d) return;
const day = d.day;
this._backdateInfoPending = day;
const { ok, res, err } = await this._callWS({ type: "taskmate/day_completions", date: day });
if (this._backdateInfoPending === day) this._backdateInfoPending = null;
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
if (!this._dialog || this._dialog.kind !== "backdate" || this._dialog.data.day !== day) return;
this._backdateInfo = {
day,
loggable: res.loggable || {},
week_progress: res.week_progress || {},
};
const card = this._backdateCard;
if (card) {
card.dayLoggable = this._backdateInfo.loggable;
card.dayWeekProgress = this._backdateInfo.week_progress;
card.dayCompletions = res.completions || [];
} else {
this._render();
}
}
_renderBackdateDialog() {
const d = this._dialog.data;
const child = (this._state.children || []).find(c => c.id === d.child_id) || {};
const name = child.name || this._t("panel.child_unnamed");
const days = this._backdateDays();
const day = days.find(x => x.v === d.day) || days[0];
if (this._backdateCardReady !== false && !this._backdateUseList) {
return this._dialogShell(
this._t("panel.backdate_title", { name }),
[
`<p class="tm-field-hint">${this._esc(this._t("panel.backdate_hint"))}</p>`,
this._select(this._t("panel.backdate_day"), "day", day.v, days, "", true),
this._backdateCardReady ? `<div class="tm-backdate-host"></div>` : `<p class="tm-field-hint">…</p>`,
].join(""),
`<button type="button" class="tm-btn tm-btn-raised" data-act="close-backdate">${this._t("panel.backdate_finish")}</button>`
);
}
// The plain list needs the day described too; fetched once per day.
if ((!this._backdateInfo || this._backdateInfo.day !== day.v) && this._backdateInfoPending !== day.v) {
setTimeout(() => this._refreshBackdateDay(), 0);
}
const chores = this._backdateChores(d.child_id, day.dow, day.v);
if (!chores.some(c => c.id === d.chore_id)) d.chore_id = chores[0] ? chores[0].id : "";
return this._dialogShell(
this._t("panel.backdate_title", { name }),
[
`<p class="tm-field-hint">${this._esc(this._t("panel.backdate_hint_list"))}</p>`,
this._select(this._t("panel.backdate_day"), "day", day.v, days, "", true),
chores.length
? this._select(this._t("panel.backdate_chore"), "chore_id", d.chore_id,
chores.map(c => ({ v: c.id, l: `${c.name} (${c.points})` })))
: `<p class="tm-field-hint">${this._esc(this._t("panel.backdate_none", { name }))}</p>`,
].join(""),
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
<button type="button" class="tm-btn tm-btn-raised" data-act="save-backdate" ${chores.length ? "" : "disabled"}>${this._t("panel.backdate_save")}</button>`
);
}
async _saveBackdateDialog() {
const d = this._dialog.data;
if (!d.child_id || !d.chore_id || !d.day || this._backdateBusy) return;
this._backdateBusy = true;
try {
const { ok, err } = await this._callService("complete_chore", {
chore_id: d.chore_id, child_id: d.child_id, as_parent: true, completed_date: d.day,
});
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return; }
const child = (this._state.children || []).find(c => c.id === d.child_id) || {};
const chore = (this._state.chores || []).find(c => c.id === d.chore_id) || {};
const day = this._backdateDays().find(x => x.v === d.day);
this._closeDialog(true);
await this._fetchState();
this._showToast("ok", this._t("panel.backdate_done", {
chore: chore.name || "", name: child.name || "", day: day ? day.l : d.day,
}));
} finally {
this._backdateBusy = false;
}
}
_openSwapDialog(choreId) {
const c = (this._state.chores || []).find(x => x.id === choreId);
if (!c) return;
@@ -9729,6 +9980,7 @@ class TaskMatePanel extends HTMLElement {
if (this._dialog.kind === "swap") return this._renderSwapDialog();
if (this._dialog.kind === "gift") return this._renderGiftDialog();
if (this._dialog.kind === "adjust") return this._renderAdjustDialog();
if (this._dialog.kind === "backdate") return this._renderBackdateDialog();
if (this._dialog.kind === "child") return this._renderChildDialog();
if (this._dialog.kind === "chore") return this._renderChoreDialog();
if (this._dialog.kind === "reward") return this._renderRewardDialog();
@@ -9820,6 +10072,9 @@ class TaskMatePanel extends HTMLElement {
[
this._field(this._t("panel.child_name_label"), "name", d.name, "text", this._t("panel.child_name_placeholder")),
this._iconPickerField(this._t("panel.child_avatar_label"), "avatar", d.avatar),
// A grown-up's profile (#1061): auto-approved, hidden from cards by default.
this._switch(this._t("panel.child_adult_label"), "is_adult", !!d.is_adult,
this._esc(this._t("panel.child_adult_hint"))),
this._field(this._t("panel.child_birthday_label"), "birthday", d.birthday, "text",
this._esc(this._t("panel.child_birthday_hint"))),
// Setup wizard age group (#980): only steers chore suggestions.
@@ -9842,6 +10097,10 @@ class TaskMatePanel extends HTMLElement {
(this._haUsers || []).map(u => ({ v: u.id, l: u.is_admin ? `${u.name} (admin)` : u.name }))
),
this._t("panel.child_link_user_hint")),
// Any entity with a picture: normally the child's person, but an image
// or camera entity works the same way.
this._entityPickerField(this._t("panel.child_picture_entity_label"), "picture_entity", d.picture_entity,
[], this._t("panel.child_picture_entity_hint"), { withPicture: true }),
this._kioskPinField(d),
].join(""),
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
@@ -10912,13 +11171,13 @@ class TaskMatePanel extends HTMLElement {
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
}
_entityPickerField(label, name, value, domains, hintHtml = "") {
_entityPickerField(label, name, value, domains, hintHtml = "", { withPicture = false } = {}) {
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
return `
<div class="tm-field">
<span class="tm-field-label">${this._esc(label)}</span>
<div class="tm-ep" data-ep-field="${name}" data-ep-domains="${(domains || []).join(",")}">
<div class="tm-ep" data-ep-field="${name}" data-ep-domains="${(domains || []).join(",")}"${withPicture ? ' data-ep-picture="1"' : ""}>
${value
? `<div class="tm-ep-selected">
<ha-icon icon="${this._esc(icon)}" class="tm-ep-selected-icon"></ha-icon>
@@ -10945,6 +11204,13 @@ class TaskMatePanel extends HTMLElement {
const entities = Object.entries(this._hass.states)
.filter(([id, st]) => {
if (domains.length && !domains.some(d => id.startsWith(d + "."))) return false;
// A photo picker lists only entities that have a picture to show, and
// never TaskMate's own child sensors: they show the photo chosen here,
// so picking one would make the face its own source.
if (picker.dataset.epPicture) {
if (!st.attributes?.entity_picture) return false;
if (this._hass.entities?.[id]?.platform === "taskmate") return false;
}
if (!query) return true;
const fn = st.attributes?.friendly_name || "";
return id.toLowerCase().includes(query) || fn.toLowerCase().includes(query);