` : ""}
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns/frag/03-child.html.
Reuses the real chore data + the existing complete/undo handlers, and the
classic sub-renderers for timed chores, bonus subtasks, celebration,
confetti and photo capture — so sounds/timers/photo/swap/badges all run in
designed mode too. Honours every setConfig + editor option.
══════════════════════════════════════════════════════════════════════ */
// The per-child badges sensor is entity-id'd from the child NAME
// ("TaskMate Badges" → sensor.taskmate__badges), NOT the
// child_id — so resolve it robustly or the badge strip never finds it.
_resolveBadgesEntity(child) {
if (!this.hass || !child) return null;
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const nameSlug = slug(child.name);
const candidates = [
`sensor.taskmate_${nameSlug}_badges`,
`sensor.taskmate_badges_${slug(this.config.child_id)}`,
];
for (const id of candidates) if (this.hass.states[id]) return this.hass.states[id];
if (nameSlug) {
for (const eid in this.hass.states) {
if (/^sensor\.taskmate_.*_badges$/.test(eid) && eid.includes(nameSlug)) return this.hass.states[eid];
}
}
return null;
}
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
_av(child, tone, size) {
const a = child.avatar || "";
const inner = a.startsWith("mdi:")
? html``
: a
? html``
: (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
return html`
`;
// Pre-reader mode replaces the chore list with picture tiles. It has to be
// handled here as well as in the classic path, or `pre_reader: true` is
// silently ignored under every designed style — including accessible,
// which is the one a child who needs picture tiles is most likely on.
// The tiles keep the designed shell (header, tokens) around them.
const body = this.config.pre_reader === true
? html`
`;
}
_renderSwappable(allChores, child, pointsIcon) {
// Rotation chores whose turn belongs to another child today, that this
// child is in the pool for — the child can ask a parent to swap them in.
if (this.config.show_swaps === false) return "";
// "Rotation" chores = single-assignee rotating modes. Mirror the backend
// (async_request_swap): everything except everyone/unassigned is swappable.
// We only surface ones currently assigned to another child today that this
// child is in the pool for.
const swappable = (allChores || []).filter(c =>
!["everyone", "unassigned"].includes(c.assignment_mode || "everyone") &&
c.enabled !== false &&
c.assignment_current_child_id &&
c.assignment_current_child_id !== child.id &&
(!Array.isArray(c.assigned_to) || c.assigned_to.length === 0 || c.assigned_to.includes(child.id))
);
if (swappable.length === 0) return "";
if (!this._swapRequested) this._swapRequested = new Set();
return html`
${this._t('child.swap_section_title') || 'Take over a chore'}
`;
}
async _handleRequestSwap(chore, child) {
const key = `swap_${chore.id}`;
if (this._loading[key]) return;
this._loading = { ...this._loading, [key]: true };
this.requestUpdate();
try {
await this.hass.callService('taskmate', 'request_swap', {
chore_id: chore.id,
requester_id: child.id,
});
if (!this._swapRequested) this._swapRequested = new Set();
this._swapRequested.add(chore.id);
} catch (error) {
console.error('Failed to request swap:', error);
} finally {
this._loading = { ...this._loading, [key]: false };
this.requestUpdate();
}
}
_filterAndSortChores(chores, child) {
const childId = String(child.id || "");
const childName = child.name;
const choreOrder = child.chore_order || [];
// Debug logging to diagnose assignment filtering issues
// Get today's day of week from sensor (set by backend) or compute client-side
const entity = this.hass?.states?.[this.config.entity];
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity?.attributes || {};
const todayDow = entity?.attributes?.today_day_of_week ||
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
const dueDaysMode = this.config.due_days_mode || 'hide';
const showDueDaysOnly = this.config.show_due_days_only !== false;
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
// First, filter chores for this child and time category
const filteredChores = chores.filter(chore => {
// Check if chore is disabled (one-shot chores that are completed or expired)
if (chore.enabled === false) return false;
const disabledFor = chore.disabled_for || [];
if (disabledFor.includes(childId)) return false;
// Check time category. The card's configured filter matches as before;
// on top of that, chores currently in their claim window (including the
// post-period grace) and any future-period chores are let through so
// they can render as locked previews or keep claimable during grace.
const isLockedPreview = this._isChorePreviewLocked(chore);
const inClaimWindow = this._isChoreInClaimWindow(chore);
chore._isLockedPreview = isLockedPreview;
const matchesCardFilter =
this.config.time_category === "all" ||
chore.time_category === this.config.time_category ||
chore.time_category === "anytime";
const matchesTime = matchesCardFilter || isLockedPreview || inClaimWindow;
// Check assignment
let assignedTo = chore.assigned_to;
if (!Array.isArray(assignedTo)) assignedTo = [];
const assignedToStrings = assignedTo.map(id => String(id));
const isAssignedToAll = assignedToStrings.length === 0;
let isAssignedToChild = isAssignedToAll || assignedToStrings.includes(childId);
// Dynamic assignment. Rotation modes (alternating / random / balanced):
// only the currently-active child sees the chore — the backend caches
// today's pick in assignment_current_child_id. first_come is competitive:
// the whole pool sees it (there is no single active child) until one
// member wins the race. Either way, once any eligible pool member has
// completed the chore today (e.g. a parent credited the off-rotation
// child), the chore is done for the whole pool and should disappear for
// everyone — including today's active child.
const assignmentMode = chore.assignment_mode || 'everyone';
if (assignmentMode !== 'everyone' && isAssignedToChild) {
const activeId = chore.assignment_current_child_id ? String(chore.assignment_current_child_id) : '';
if (assignmentMode !== 'first_come') {
isAssignedToChild = activeId !== '' && activeId === String(childId);
}
if (isAssignedToChild) {
const poolIds = assignedToStrings.length > 0
? assignedToStrings
: (attrs.children || []).map(c => String(c.id));
const poolSet = new Set(poolIds);
// first_come is a single-winner race regardless of any configured limit.
const dailyLimit = assignmentMode === 'first_come' ? 1 : (chore.daily_limit || 1);
const allTodayCompletions = attrs.todays_completions || [];
const poolCompletionsToday = allTodayCompletions.filter(
comp => comp.chore_id === chore.id && (poolSet.has(String(comp.child_id)) || comp.child_id === "__parent__") && !comp.bonus_subtask_id
).length;
if (poolCompletionsToday >= dailyLimit) {
// Bonus sub-tasks render inside the parent card; if the active
// child still has any pending, keep the chore visible so they
// remain reachable. Only applies to single-active-child rotation
// modes (mirrors the backend, which guards this on active_child_id).
const bonusSubtasks = activeId ? (chore.bonus_subtasks || []) : [];
const completedBonusIds = new Set(
allTodayCompletions
.filter(c => c.chore_id === chore.id && String(c.child_id) === activeId && c.bonus_subtask_id)
.map(c => c.bonus_subtask_id)
);
const hasPendingBonus = bonusSubtasks.some(b => b && b.id && !completedBonusIds.has(b.id));
if (!hasPendingBonus) {
isAssignedToChild = false;
}
}
}
}
// Check visibility_entity — if set, chore is only visible if entity matches visibility_state
// Supports exact match, numeric comparisons, and attributes
const visibilityEntity = chore.visibility_entity || '';
const visibilityState = chore.visibility_state || 'on';
const visibilityOperator = chore.visibility_operator || 'equals';
let visibilityOK = true;
if (visibilityEntity) {
const entityState = this.hass?.states?.[visibilityEntity];
if (entityState) {
const state = entityState.state;
let matched = false;
// Check numeric comparisons if operator is not "equals"
if (visibilityOperator !== 'equals') {
try {
const threshold = parseFloat(visibilityState);
const value = parseFloat(state);
if (!isNaN(value) && !isNaN(threshold)) {
if (visibilityOperator === 'gte') matched = value >= threshold;
else if (visibilityOperator === 'lte') matched = value <= threshold;
else if (visibilityOperator === 'gt') matched = value > threshold;
else if (visibilityOperator === 'lt') matched = value < threshold;
else if (visibilityOperator === 'not_equals') matched = value !== threshold;
visibilityOK = matched;
}
} catch (e) {
// Fall through to string matching
}
}
if (!matched) {
// Check state (case-insensitive matching)
const stateMatches = state.toLowerCase() === visibilityState.toLowerCase();
// For not_equals, we want the opposite of equality
if (visibilityOperator === 'not_equals') {
visibilityOK = !stateMatches;
} else {
// For equals and other operators, match is success
visibilityOK = stateMatches;
}
if (!visibilityOK && visibilityOperator !== 'not_equals') {
// Check attributes for a matching value (only for equals operator)
if (entityState.attributes) {
for (const attrValue of Object.values(entityState.attributes)) {
if (String(attrValue).toLowerCase() === visibilityState.toLowerCase()) {
visibilityOK = true;
break;
}
}
}
}
}
} else {
visibilityOK = true; // Entity doesn't exist, default to visible
}
}
chore._visibilityEntity = visibilityEntity;
chore._visibilityOK = visibilityOK;
// Check due_days — if chore has due_days set and today isn't one of them
const dueDays = chore.due_days || [];
const hasDueDays = dueDays.length > 0;
const isDueToday = !hasDueDays || dueDays.includes(todayDow);
// If due_days filtering is on and mode is "hide", exclude not-due chores
if (showDueDaysOnly && hasDueDays && !isDueToday && dueDaysMode === 'hide') {
return false;
}
// Store due status on chore object for rendering
chore._isDueToday = isDueToday;
chore._hasDueDays = hasDueDays;
// Mark whether this chore's time period has elapsed (grace-aware).
chore._isTimeElapsed = this._isTimePeriodElapsed(chore);
// If elapsed_time_mode is "hide", exclude elapsed-and-incomplete chores at filter stage.
// Completed chores are handled at render time so we can't check here — use a sentinel.
if (elapsedTimeMode === 'hide' && chore._isTimeElapsed) {
return false;
}
// Only return chore if visibility entity allows it
return matchesTime && isAssignedToChild && visibilityOK;
});
// Debug: Log the filtered results
// If no custom order is set, return filtered chores as-is
if (choreOrder.length === 0) {
return filteredChores;
}
// Sort by the child's custom chore order
// Chores in the order list appear first, in their specified order
// Chores not in the order list appear after, in their default order
return filteredChores.sort((a, b) => {
const indexA = choreOrder.indexOf(a.id);
const indexB = choreOrder.indexOf(b.id);
// If both are in the order list, sort by their position
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// If only one is in the order list, it comes first
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// If neither is in the order list, maintain original order
return 0;
});
}
_getTimeCategoryIcon(category) {
const period = this._getTimePeriods().find(p => p.id === category);
if (period) return period.icon;
const icons = {
morning: "mdi:weather-sunset-up",
afternoon: "mdi:weather-sunny",
evening: "mdi:weather-sunset-down",
night: "mdi:weather-night",
anytime: "mdi:clock-outline",
all: "mdi:clock-outline",
};
return icons[category] || icons.anytime;
}
_getTimeCategoryLabel(category) {
const period = this._getTimePeriods().find(p => p.id === category);
if (period && period.label) return period.label;
const keyMap = {
morning: 'common.morning',
afternoon: 'common.afternoon',
evening: 'common.evening',
night: 'common.night',
anytime: 'common.anytime',
all: 'common.all',
};
return keyMap[category] ? this._t(keyMap[category]) : category;
}
_getDynamicTitle() {
const category = this.config.time_category;
const period = this._getTimePeriods().find(p => p.id === category);
if (period && period.label) return period.label;
const keyMap = {
morning: 'child.morning_chores',
afternoon: 'child.afternoon_chores',
evening: 'child.evening_chores',
night: 'child.night_chores',
anytime: 'child.todays_chores',
all: 'child.todays_chores',
};
return keyMap[category] ? this._t(keyMap[category]) : this._t('child.todays_chores');
}
_getTimezone() {
// Get timezone from Home Assistant config, fallback to browser timezone
return this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Ordered list of user-defined periods: [{id, label, icon, start, end}]
// with start/end as decimal hours. Falls back to the legacy fixed four
// when the integration hasn't published time_periods yet.
_getTimePeriods() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) {
return attrs.time_periods
.filter(p => p && p.id)
.map(p => ({
id: p.id,
label: p.label || "",
icon: p.icon || "mdi:clock-outline",
start: this._parseHHMM(p.start, 0, 0),
end: this._parseHHMM(p.end, 23, 59),
}))
.sort((a, b) => a.start - b.start);
}
const tb = attrs.time_boundaries || {};
return [
{ id: "morning", label: "", icon: "mdi:weather-sunset-up", start: this._parseHHMM(tb.morning_start, 6, 0), end: this._parseHHMM(tb.morning_end, 12, 0) },
{ id: "afternoon", label: "", icon: "mdi:weather-sunny", start: this._parseHHMM(tb.afternoon_start, 12, 0), end: this._parseHHMM(tb.afternoon_end, 17, 0) },
{ id: "evening", label: "", icon: "mdi:weather-sunset-down", start: this._parseHHMM(tb.evening_start, 17, 0), end: this._parseHHMM(tb.evening_end, 21, 0) },
{ id: "night", label: "", icon: "mdi:weather-night", start: this._parseHHMM(tb.night_start, 21, 0), end: this._parseHHMM(tb.night_end, 23, 59) },
];
}
_getTimeBoundaries() {
const boundaries = {};
for (const p of this._getTimePeriods()) boundaries[p.id] = [p.start, p.end];
return boundaries;
}
_parseHHMM(str, defH, defM) {
if (!str) return defH + defM / 60;
const [h, m] = str.split(':').map(Number);
if (isNaN(h)) return defH + defM / 60;
return h + (m || 0) / 60;
}
_getCurrentTimePeriod() {
if (this.config.debug_time_period) return this.config.debug_time_period;
const tz = this._getTimezone();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
}).formatToParts(new Date());
let hour = parseInt(parts.find(p => p.type === 'hour')?.value, 10);
const minute = parseInt(parts.find(p => p.type === 'minute')?.value, 10) || 0;
if (isNaN(hour)) hour = 0;
if (hour === 24) hour = 0;
const now = hour + minute / 60;
const periods = this._getTimePeriods();
for (let i = periods.length - 1; i >= 0; i--) {
if (now >= periods[i].start) return periods[i].id;
}
// Before the first period starts (e.g. 02:00) we treat it as the first
// period of the day, matching the legacy pre-morning behaviour.
return periods[0]?.id || 'morning';
}
_getPeriodHours(period) {
const b = this._getTimeBoundaries();
const range = b[period];
if (!range) return null;
return [range[0], range[1]];
}
// Current {hour, minute} in HA timezone. Honours debug_time_period by
// pinning to the period's start so preview/grace logic remains deterministic.
_getCurrentHourMinute() {
if (this.config.debug_time_period) {
const hours = this._getPeriodHours(this.config.debug_time_period);
if (hours) return { hour: Math.floor(hours[0]), minute: Math.round((hours[0] % 1) * 60) };
}
const tz = this._getTimezone();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
}).formatToParts(new Date());
let hour = parseInt(parts.find(p => p.type === 'hour')?.value, 10);
const minute = parseInt(parts.find(p => p.type === 'minute')?.value, 10) || 0;
if (isNaN(hour)) hour = 0;
if (hour === 24) hour = 0;
return { hour, minute };
}
// True iff the chore's period is strictly later than the current period
// (any future period today, not just the immediately next one). Never true
// for "anytime"; no cross-midnight preview.
_isChorePreviewLocked(chore) {
const cat = chore?.time_category;
if (!cat || cat === 'anytime') return false;
const order = this._getTimePeriods().map(p => p.id);
const choreIndex = order.indexOf(cat);
const currentIndex = order.indexOf(this._getCurrentTimePeriod());
if (choreIndex < 0 || currentIndex < 0) return false;
return choreIndex > currentIndex;
}
// True iff the current HA-local time is within the chore's claim window,
// i.e. between period start and (period end + claim_allowance_minutes),
// capped at midnight for night-period chores.
_isChoreInClaimWindow(chore) {
const cat = chore?.time_category;
if (!cat) return false;
if (cat === 'anytime') return true;
const hours = this._getPeriodHours(cat);
if (!hours) return false;
const [startH, endH] = hours;
const allowance = Math.max(0, parseInt(chore.claim_allowance_minutes, 10) || 0);
const startMinutes = startH * 60;
const endMinutes = Math.min(24 * 60, endH * 60 + allowance);
const { hour, minute } = this._getCurrentHourMinute();
const nowMinutes = hour * 60 + minute;
return nowMinutes >= startMinutes && nowMinutes < endMinutes;
}
// Returns true when the chore's time_category period has passed without completion.
// "anytime" chores never elapse. A chore still inside its claim grace window is
// not considered elapsed. Already-completed chores are handled at the call site.
_isTimePeriodElapsed(choreOrCategory) {
const cat = typeof choreOrCategory === 'string'
? choreOrCategory
: choreOrCategory?.time_category;
if (!cat || cat === 'anytime') return false;
const order = {};
this._getTimePeriods().forEach((p, i) => { order[p.id] = i; });
const choreIndex = order[cat];
if (choreIndex === undefined) return false;
const currentIndex = order[this._getCurrentTimePeriod()];
if (currentIndex === undefined || currentIndex <= choreIndex) return false;
if (typeof choreOrCategory === 'object' && this._isChoreInClaimWindow(choreOrCategory)) {
return false;
}
return true;
}
_getDatePartsInTimezone(date) {
const timezone = this._getTimezone();
// Get year, month, day in the HA timezone
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// en-CA formats as YYYY-MM-DD
const dateStr = formatter.format(date);
const [year, month, day] = dateStr.split("-").map(Number);
return { year, month, day };
}
_isToday(date) {
const now = new Date();
const todayParts = this._getDatePartsInTimezone(now);
const dateParts = this._getDatePartsInTimezone(date);
return (
dateParts.year === todayParts.year &&
dateParts.month === todayParts.month &&
dateParts.day === todayParts.day
);
}
_filterCompletionsForToday(completions) {
// Filter completions to only include those completed today (in HA timezone)
return completions.filter(comp => {
if (!comp.completed_at) return false;
const completedDate = new Date(comp.completed_at);
return this._isToday(completedDate);
});
}
_getMidnightCountdown() {
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new Date();
// Get tomorrow midnight in HA timezone
const tomorrow = new Date(now.toLocaleDateString("en-CA", { timeZone: tz }) + "T00:00:00");
tomorrow.setDate(tomorrow.getDate() + 1);
const diffMs = tomorrow - now;
if (diffMs <= 0) return null;
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
const soon = totalMins <= 60; // less than 1 hour
let label;
if (hours > 0) {
label = this._t('child.chores_reset_hours_mins', { hours, mins });
} else if (mins > 0) {
label = this._t('child.chores_reset_mins', { mins });
} else {
label = this._t('child.chores_resetting_soon');
}
return { label, soon };
}
_renderEmptyState() {
return html`
${this._t('child.all_done')}
${this._t('child.no_chores_right_now')}
`;
}
/**
* Countdown badge for a reactive chore (#674) — "empty the washing machine
* within 30 minutes". Reads the deadline fresh on every render, so it ticks
* down with the coordinator's updates. Hidden once the chore is done.
*/
_renderDeadlineBadge(chore, isCompletedForToday) {
if (!chore.deadline_at || isCompletedForToday) return '';
const deadline = new Date(chore.deadline_at);
if (Number.isNaN(deadline.getTime())) return '';
const msLeft = deadline.getTime() - Date.now();
if (msLeft <= 0) return '';
const minsLeft = Math.ceil(msLeft / 60000);
const label = minsLeft >= 60
? this._t('child.deadline_hours', { hours: Math.floor(minsLeft / 60), mins: minsLeft % 60 })
: this._t('child.deadline_minutes', { mins: minsLeft });
// Under five minutes is the point at which it's worth shouting about.
const urgent = minsLeft <= 5;
const bonus = chore.speed_bonus_points || 0;
return html`
⏱ ${label}${bonus ? html` +${bonus}` : ''}
`;
}
/**
* Chore roulette (#677) — an opt-in nudge for the child who has stalled.
* Only renders when the parent enabled it in settings AND the card opts in,
* so an existing dashboard never sprouts a new button unasked.
*/
_renderRoulette(child, childChores, pointsIcon) {
if (this.config.show_roulette !== true) return '';
const roulette = child.roulette;
if (!roulette) return ''; // switched off in settings
const picked = roulette.chore_id
? childChores.find(c => String(c.id) === String(roulette.chore_id))
: null;
const spinsLeft = roulette.spins_left ?? 0;
const multiplier = roulette.multiplier || 2;
const spinning = this._spinning === child.id;
return html`