From 177a2567c0f37d82422b5ba6e2d8b4299f742668 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 18:56:01 +0000 Subject: [PATCH 1/2] Add penalty system for deducting points from children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parents can define named penalties (e.g. "Not going to bed = 10 points") and apply them to a child with a single tap. Backend: - New Penalty dataclass (name, points, description, icon, assigned_to) - Full CRUD in storage: add/get/update/remove_penalty - Coordinator: async_add_penalty, async_update_penalty, async_remove_penalty, async_apply_penalty (delegates to async_remove_points with a labelled reason) - 4 new HA services: taskmate.add_penalty, taskmate.update_penalty, taskmate.remove_penalty, taskmate.apply_penalty - Penalties exposed in sensor.taskmate_overview attributes Frontend: - New taskmate-penalties-card.js: - Child tabs to select who to penalise - Penalty tiles showing icon, name, and point cost - Tap Apply → instant deduction + flash animation + toast - Edit mode to add/edit/delete penalty definitions https://claude.ai/code/session_01CkPmKWe5siThpfZ6GDpNQF --- custom_components/taskmate/__init__.py | 111 +++ custom_components/taskmate/const.py | 10 + custom_components/taskmate/coordinator.py | 46 ++ custom_components/taskmate/frontend.py | 1 + custom_components/taskmate/models.py | 35 + custom_components/taskmate/sensor.py | 8 + custom_components/taskmate/storage.py | 33 + .../taskmate/www/taskmate-penalties-card.js | 677 ++++++++++++++++++ 8 files changed, 921 insertions(+) create mode 100644 custom_components/taskmate/www/taskmate-penalties-card.js diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index c9583ab..78e4f87 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -14,13 +14,21 @@ ATTR_CHILD_ID, ATTR_CHORE_ID, ATTR_CHORE_ORDER, + ATTR_PENALTY_ASSIGNED_TO, + ATTR_PENALTY_DESCRIPTION, + ATTR_PENALTY_ICON, + ATTR_PENALTY_ID, + ATTR_PENALTY_NAME, + ATTR_PENALTY_POINTS, ATTR_POINTS, ATTR_REASON, ATTR_REWARD_ID, ATTR_SOUND, DOMAIN, EVENT_PREVIEW_SOUND, + SERVICE_ADD_PENALTY, SERVICE_ADD_POINTS, + SERVICE_APPLY_PENALTY, SERVICE_APPROVE_CHORE, SERVICE_APPROVE_REWARD, SERVICE_CLAIM_REWARD, @@ -28,8 +36,10 @@ SERVICE_COMPLETE_CHORE, SERVICE_PREVIEW_SOUND, SERVICE_REJECT_CHORE, + SERVICE_REMOVE_PENALTY, SERVICE_REMOVE_POINTS, SERVICE_SET_CHORE_ORDER, + SERVICE_UPDATE_PENALTY, ) from .coordinator import TaskMateCoordinator from .frontend import async_register_cards, async_register_frontend @@ -199,6 +209,58 @@ async def handle_set_chore_order(call: ServiceCall) -> None: chore_order = call.data[ATTR_CHORE_ORDER] await coordinator.async_set_chore_order(child_id, chore_order) + async def handle_add_penalty(call: ServiceCall) -> None: + """Handle the add_penalty service call.""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + await coordinator.async_add_penalty( + name=call.data[ATTR_PENALTY_NAME], + points=call.data[ATTR_PENALTY_POINTS], + description=call.data.get(ATTR_PENALTY_DESCRIPTION, ""), + icon=call.data.get(ATTR_PENALTY_ICON, "mdi:alert-circle-outline"), + assigned_to=call.data.get(ATTR_PENALTY_ASSIGNED_TO, []), + ) + + async def handle_update_penalty(call: ServiceCall) -> None: + """Handle the update_penalty service call.""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + from .models import Penalty + penalty_id = call.data[ATTR_PENALTY_ID] + existing = coordinator.storage.get_penalty(penalty_id) + if not existing: + _LOGGER.error("Penalty %s not found", penalty_id) + return + existing.name = call.data.get(ATTR_PENALTY_NAME, existing.name) + existing.points = call.data.get(ATTR_PENALTY_POINTS, existing.points) + existing.description = call.data.get(ATTR_PENALTY_DESCRIPTION, existing.description) + existing.icon = call.data.get(ATTR_PENALTY_ICON, existing.icon) + existing.assigned_to = call.data.get(ATTR_PENALTY_ASSIGNED_TO, existing.assigned_to) + await coordinator.async_update_penalty(existing) + + async def handle_remove_penalty(call: ServiceCall) -> None: + """Handle the remove_penalty service call.""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + await coordinator.async_remove_penalty(call.data[ATTR_PENALTY_ID]) + + async def handle_apply_penalty(call: ServiceCall) -> None: + """Handle the apply_penalty service call.""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + await coordinator.async_apply_penalty( + penalty_id=call.data[ATTR_PENALTY_ID], + child_id=call.data[ATTR_CHILD_ID], + ) + # Register all services hass.services.async_register( DOMAIN, @@ -318,6 +380,51 @@ async def handle_set_chore_order(call: ServiceCall) -> None: ) + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PENALTY, + handle_add_penalty, + schema=vol.Schema({ + vol.Required(ATTR_PENALTY_NAME): cv.string, + vol.Required(ATTR_PENALTY_POINTS): cv.positive_int, + vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string, + vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string, + vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), + }), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_PENALTY, + handle_update_penalty, + schema=vol.Schema({ + vol.Required(ATTR_PENALTY_ID): cv.string, + vol.Optional(ATTR_PENALTY_NAME): cv.string, + vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int, + vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string, + vol.Optional(ATTR_PENALTY_ICON): cv.string, + vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), + }), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_PENALTY, + handle_remove_penalty, + schema=vol.Schema({vol.Required(ATTR_PENALTY_ID): cv.string}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_APPLY_PENALTY, + handle_apply_penalty, + schema=vol.Schema({ + vol.Required(ATTR_PENALTY_ID): cv.string, + vol.Required(ATTR_CHILD_ID): cv.string, + }), + ) + + def _async_unregister_services(hass: HomeAssistant) -> None: """Unregister TaskMate services.""" services = [ @@ -331,6 +438,10 @@ def _async_unregister_services(hass: HomeAssistant) -> None: SERVICE_REMOVE_POINTS, SERVICE_SET_CHORE_ORDER, SERVICE_PREVIEW_SOUND, + SERVICE_ADD_PENALTY, + SERVICE_UPDATE_PENALTY, + SERVICE_REMOVE_PENALTY, + SERVICE_APPLY_PENALTY, ] for service in services: hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 7356aa0..ab525e0 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -200,6 +200,10 @@ SERVICE_RESET_DAILY: Final = "reset_daily" SERVICE_SET_CHORE_ORDER: Final = "set_chore_order" SERVICE_PREVIEW_SOUND: Final = "preview_sound" +SERVICE_ADD_PENALTY: Final = "add_penalty" +SERVICE_UPDATE_PENALTY: Final = "update_penalty" +SERVICE_REMOVE_PENALTY: Final = "remove_penalty" +SERVICE_APPLY_PENALTY: Final = "apply_penalty" # Events EVENT_PREVIEW_SOUND: Final = "taskmate_preview_sound" @@ -212,6 +216,12 @@ ATTR_REASON: Final = "reason" ATTR_CHORE_ORDER: Final = "chore_order" ATTR_SOUND: Final = "sound" +ATTR_PENALTY_ID: Final = "penalty_id" +ATTR_PENALTY_NAME: Final = "name" +ATTR_PENALTY_POINTS: Final = "points" +ATTR_PENALTY_DESCRIPTION: Final = "description" +ATTR_PENALTY_ICON: Final = "icon" +ATTR_PENALTY_ASSIGNED_TO: Final = "assigned_to" # States STATE_PENDING: Final = "pending" diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index 9a89e26..99a2e21 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -205,6 +205,7 @@ async def _async_update_data(self) -> dict[str, Any]: "points_name": self.storage.get_points_name(), "points_icon": self.storage.get_points_icon(), "settings": self.storage._data.get("settings", {}), + "penalties": self.storage.get_penalties(), } # Child operations @@ -680,6 +681,51 @@ async def async_reject_reward(self, claim_id: str) -> None: await self.storage.async_save() await self.async_refresh() + # Penalty operations + async def async_add_penalty( + self, + name: str, + points: int, + description: str = "", + icon: str = "mdi:alert-circle-outline", + assigned_to: list | None = None, + ): + """Create a new penalty definition.""" + from .models import Penalty + penalty = Penalty( + name=name, + points=points, + description=description, + icon=icon, + assigned_to=assigned_to or [], + ) + self.storage.add_penalty(penalty) + await self.storage.async_save() + await self.async_refresh() + return penalty + + async def async_update_penalty(self, penalty) -> None: + """Update an existing penalty definition.""" + self.storage.update_penalty(penalty) + await self.storage.async_save() + await self.async_refresh() + + async def async_remove_penalty(self, penalty_id: str) -> None: + """Delete a penalty definition.""" + self.storage.remove_penalty(penalty_id) + await self.storage.async_save() + await self.async_refresh() + + async def async_apply_penalty(self, penalty_id: str, child_id: str) -> None: + """Apply a penalty — deducts the penalty's points from the child.""" + penalty = self.storage.get_penalty(penalty_id) + if not penalty: + raise ValueError(f"Penalty {penalty_id} not found") + child = self.get_child(child_id) + if not child: + raise ValueError(f"Child {child_id} not found") + await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}") + # Points operations async def async_add_points(self, child_id: str, points: int, reason: str = "") -> None: """Add points to a child (bonus).""" diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index 865a83c..c10c76c 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -32,6 +32,7 @@ "taskmate-reward-progress-card.js", "taskmate-leaderboard-card.js", "taskmate-parent-dashboard-card.js", + "taskmate-penalties-card.js", ] # JS modules to load globally (for config flow sound preview) diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index c159c64..8cb4882 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -300,6 +300,41 @@ def to_dict(self) -> dict[str, Any]: } +@dataclass +class Penalty: + """Represents a penalty that deducts points from a child.""" + + name: str + points: int # Points to deduct (always positive; deduction is applied on use) + description: str = "" + icon: str = "mdi:alert-circle-outline" + assigned_to: list = None # Child IDs who can receive this penalty (empty = all) + id: str = field(default_factory=generate_id) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Penalty": + """Create a Penalty from a dictionary.""" + return cls( + id=data.get("id", generate_id()), + name=data.get("name", ""), + points=data.get("points", 0), + description=data.get("description", ""), + icon=data.get("icon", "mdi:alert-circle-outline"), + assigned_to=data.get("assigned_to", []), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "id": self.id, + "name": self.name, + "points": self.points, + "description": self.description, + "icon": self.icon, + "assigned_to": self.assigned_to or [], + } + + @dataclass class PointsTransaction: """Represents a manual points adjustment (add or remove).""" diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index a8c552e..9aa082b 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -300,6 +300,14 @@ def extra_state_attributes(self) -> dict: rewards, child_lookup, ), + "penalties": [{ + "id": p.id, + "name": p.name, + "points": p.points, + "description": p.description, + "icon": p.icon, + "assigned_to": p.assigned_to or [], + } for p in data.get("penalties", [])], } @property diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index ab31031..5f2b1a9 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -283,6 +283,39 @@ def remove_reward_claim(self, claim_id: str) -> None: c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id ] + # Penalties management + def get_penalties(self) -> list: + """Get all penalties.""" + from .models import Penalty + return [Penalty.from_dict(p) for p in self._data.get("penalties", [])] + + def get_penalty(self, penalty_id: str): + """Get a penalty by ID.""" + from .models import Penalty + for p in self._data.get("penalties", []): + if p.get("id") == penalty_id: + return Penalty.from_dict(p) + return None + + def add_penalty(self, penalty) -> None: + """Add a new penalty.""" + self._data.setdefault("penalties", []).append(penalty.to_dict()) + + def update_penalty(self, penalty) -> None: + """Update an existing penalty.""" + penalties = self._data.get("penalties", []) + for i, p in enumerate(penalties): + if p.get("id") == penalty.id: + penalties[i] = penalty.to_dict() + return + penalties.append(penalty.to_dict()) + + def remove_penalty(self, penalty_id: str) -> None: + """Remove a penalty.""" + self._data["penalties"] = [ + p for p in self._data.get("penalties", []) if p.get("id") != penalty_id + ] + # Points transactions management def get_points_transactions(self) -> list[PointsTransaction]: """Get all points transactions.""" diff --git a/custom_components/taskmate/www/taskmate-penalties-card.js b/custom_components/taskmate/www/taskmate-penalties-card.js new file mode 100644 index 0000000..b4fdfbe --- /dev/null +++ b/custom_components/taskmate/www/taskmate-penalties-card.js @@ -0,0 +1,677 @@ +/** + * TaskMate Penalties Card + * Apply point-deduction penalties to children (e.g. "Not going to bed"). + * Parents can manage penalty definitions and tap to apply them instantly. + * + * Version: 0.0.1 + */ + +const LitElement = customElements.get("hui-masonry-view") + ? Object.getPrototypeOf(customElements.get("hui-masonry-view")) + : Object.getPrototypeOf(customElements.get("hui-view")); + +const html = LitElement.prototype.html; +const css = LitElement.prototype.css; + +class TaskMatePenaltiesCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + _selectedChildId: { type: String }, + _editMode: { type: Boolean }, + _loading: { type: Object }, + _editingPenalty: { type: Object }, // penalty being edited (null = none) + _showNewForm: { type: Boolean }, + _toast: { type: String }, + _newForm: { type: Object }, + }; + } + + constructor() { + super(); + this._selectedChildId = null; + this._editMode = false; + this._loading = {}; + this._editingPenalty = null; + this._showNewForm = false; + this._toast = null; + this._newForm = { name: "", points: "", description: "", icon: "mdi:alert-circle-outline" }; + } + + setConfig(config) { + this.config = config; + } + + static getConfigElement() { + return document.createElement("taskmate-penalties-card-editor"); + } + + static getStubConfig() { + return { entity: "sensor.taskmate_overview" }; + } + + static get styles() { + return css` + :host { + display: block; + --penalty-red: #e74c3c; + --penalty-red-dark: #c0392b; + --penalty-red-light: rgba(231, 76, 60, 0.12); + --text-primary: var(--primary-text-color, #212121); + --text-secondary: var(--secondary-text-color, #757575); + --card-bg: var(--card-background-color, #fff); + --divider: var(--divider-color, #e0e0e0); + } + + ha-card { overflow: hidden; } + + /* ── Header ── */ + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--taskmate-header-bg, var(--penalty-red)); + color: white; + } + .header-left { display: flex; align-items: center; gap: 12px; } + .header-icon { --mdc-icon-size: 32px; opacity: 0.95; } + .header-title { font-size: 1.3rem; font-weight: 600; } + .penalty-count { + background: rgba(255,255,255,0.2); + padding: 4px 12px; + border-radius: 16px; + font-size: 0.9rem; + font-weight: 500; + } + .header-actions { display: flex; gap: 8px; } + .icon-btn { + background: rgba(255,255,255,0.18); + border: none; + color: white; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s; + --mdc-icon-size: 20px; + } + .icon-btn:hover { background: rgba(255,255,255,0.32); } + .icon-btn.active { background: rgba(255,255,255,0.35); } + + /* ── Child tabs ── */ + .child-tabs { + display: flex; + gap: 6px; + padding: 10px 16px 0; + overflow-x: auto; + scrollbar-width: none; + } + .child-tabs::-webkit-scrollbar { display: none; } + .child-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + background: var(--divider); + border: 2px solid transparent; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + color: var(--text-secondary); + transition: all 0.15s; + } + .child-tab ha-icon { --mdc-icon-size: 18px; } + .child-tab.selected { + background: var(--penalty-red-light); + border-color: var(--penalty-red); + color: var(--penalty-red); + } + + /* ── Card body ── */ + .card-content { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + } + + /* ── Penalty tile ── */ + .penalty-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + background: var(--card-bg); + border: 1px solid var(--divider); + border-radius: 12px; + transition: box-shadow 0.2s, transform 0.15s; + } + .penalty-row:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.09); transform: translateY(-1px); } + + /* Flash animation when penalty is applied */ + @keyframes flash-red { + 0% { background: var(--penalty-red-light); } + 40% { background: rgba(231,76,60,0.25); } + 100% { background: var(--card-bg); } + } + .penalty-row.flashing { animation: flash-red 0.6s ease forwards; } + + /* Points badge */ + .points-badge { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 64px; + padding: 10px 8px; + background: linear-gradient(135deg, var(--penalty-red) 0%, var(--penalty-red-dark) 100%); + border-radius: 10px; + flex-shrink: 0; + box-shadow: 0 2px 6px rgba(231,76,60,0.3); + } + .points-badge ha-icon { --mdc-icon-size: 20px; color: white; margin-bottom: 2px; } + .points-value { font-size: 1.3rem; font-weight: 700; color: white; line-height: 1; } + .points-label { font-size: 0.62rem; font-weight: 600; color: rgba(255,255,255,0.88); text-transform: uppercase; letter-spacing: 0.4px; margin-top: 2px; } + + /* Penalty info */ + .penalty-info { flex: 1; min-width: 0; } + .penalty-name { font-size: 1.05rem; font-weight: 600; color: var(--text-primary); } + .penalty-description { font-size: 0.85rem; color: var(--text-secondary); margin-top: 2px; } + + /* Apply button */ + .apply-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + background: var(--penalty-red); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; + flex-shrink: 0; + --mdc-icon-size: 16px; + } + .apply-btn:hover { background: var(--penalty-red-dark); } + .apply-btn:active { transform: scale(0.97); } + .apply-btn:disabled { opacity: 0.55; cursor: default; } + + /* Edit mode actions */ + .edit-actions { display: flex; gap: 6px; flex-shrink: 0; } + .edit-btn { + background: none; + border: 1px solid var(--divider); + border-radius: 8px; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + --mdc-icon-size: 18px; + transition: all 0.15s; + } + .edit-btn:hover { background: var(--divider); color: var(--text-primary); } + .edit-btn.delete:hover { background: var(--penalty-red-light); color: var(--penalty-red); border-color: var(--penalty-red); } + + /* Inline edit form */ + .edit-form { + background: var(--ha-card-background, #f5f5f5); + border: 1px solid var(--divider); + border-radius: 12px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: -4px; + } + .form-row { display: flex; gap: 10px; } + .form-row.full { flex-direction: column; } + .form-field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } + .form-field label { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.4px; } + .form-field input { + padding: 8px 10px; + border: 1px solid var(--divider); + border-radius: 8px; + font-size: 0.95rem; + background: var(--card-bg); + color: var(--text-primary); + width: 100%; + box-sizing: border-box; + } + .form-field input:focus { outline: 2px solid var(--penalty-red); border-color: transparent; } + .form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; } + .btn-save { + padding: 8px 18px; + background: var(--penalty-red); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + } + .btn-save:hover { background: var(--penalty-red-dark); } + .btn-cancel { + padding: 8px 14px; + background: none; + color: var(--text-secondary); + border: 1px solid var(--divider); + border-radius: 8px; + font-size: 0.9rem; + cursor: pointer; + } + .btn-cancel:hover { background: var(--divider); } + + /* Add new button */ + .add-penalty-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + border: 2px dashed var(--divider); + border-radius: 12px; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.15s; + --mdc-icon-size: 20px; + } + .add-penalty-btn:hover { border-color: var(--penalty-red); color: var(--penalty-red); background: var(--penalty-red-light); } + + /* Empty state */ + .empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-secondary); + } + .empty-state ha-icon { --mdc-icon-size: 48px; opacity: 0.35; display: block; margin: 0 auto 12px; } + .empty-state .empty-title { font-size: 1rem; font-weight: 600; margin-bottom: 4px; } + .empty-state .empty-sub { font-size: 0.85rem; } + + /* Toast */ + .toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(0); + background: #333; + color: white; + padding: 10px 20px; + border-radius: 24px; + font-size: 0.92rem; + font-weight: 500; + z-index: 9999; + pointer-events: none; + animation: toast-in 0.25s ease, toast-out 0.3s ease 2s forwards; + white-space: nowrap; + } + @keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(12px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } + } + @keyframes toast-out { + to { opacity: 0; transform: translateX(-50%) translateY(8px); } + } + `; + } + + _getState() { + const entityId = this.config?.entity || "sensor.taskmate_overview"; + return this.hass?.states[entityId]; + } + + _getAttrs() { + return this._getState()?.attributes || {}; + } + + _getChildren() { + return this._getAttrs().children || []; + } + + _getPenalties() { + return this._getAttrs().penalties || []; + } + + _getSelectedChild() { + const children = this._getChildren(); + if (!children.length) return null; + if (this._selectedChildId) return children.find(c => c.id === this._selectedChildId) || children[0]; + return children[0]; + } + + _getPointsName() { + return this._getAttrs().points_name || "Stars"; + } + + _getVisiblePenalties() { + const child = this._getSelectedChild(); + if (!child) return this._getPenalties(); + return this._getPenalties().filter(p => + !p.assigned_to?.length || p.assigned_to.includes(child.id) + ); + } + + _selectChild(id) { + this._selectedChildId = id; + this._editingPenalty = null; + this._showNewForm = false; + } + + async _applyPenalty(penalty) { + const child = this._getSelectedChild(); + if (!child) return; + const key = penalty.id; + if (this._loading[key]) return; + this._loading = { ...this._loading, [key]: true }; + + try { + await this.hass.callService("taskmate", "apply_penalty", { + penalty_id: penalty.id, + child_id: child.id, + }); + this._showToast(`-${penalty.points} ${this._getPointsName()} from ${child.name}`); + // Flash the row + const row = this.shadowRoot.querySelector(`[data-penalty-id="${penalty.id}"]`); + if (row) { + row.classList.add("flashing"); + setTimeout(() => row.classList.remove("flashing"), 700); + } + } catch (e) { + this._showToast("Failed to apply penalty"); + } finally { + this._loading = { ...this._loading, [key]: false }; + } + } + + _showToast(msg) { + this._toast = null; + // Force re-render with new toast + setTimeout(() => { + this._toast = msg; + setTimeout(() => { this._toast = null; }, 2700); + }, 10); + } + + _startEdit(penalty) { + this._editingPenalty = { ...penalty }; + this._showNewForm = false; + } + + _cancelEdit() { + this._editingPenalty = null; + } + + async _saveEdit() { + if (!this._editingPenalty?.name || !this._editingPenalty?.points) return; + try { + await this.hass.callService("taskmate", "update_penalty", { + penalty_id: this._editingPenalty.id, + name: this._editingPenalty.name, + points: parseInt(this._editingPenalty.points, 10), + description: this._editingPenalty.description || "", + icon: this._editingPenalty.icon || "mdi:alert-circle-outline", + }); + this._editingPenalty = null; + } catch (e) { + this._showToast("Failed to save changes"); + } + } + + async _deletePenalty(id) { + try { + await this.hass.callService("taskmate", "remove_penalty", { penalty_id: id }); + } catch (e) { + this._showToast("Failed to delete penalty"); + } + } + + _openNewForm() { + this._showNewForm = true; + this._editingPenalty = null; + this._newForm = { name: "", points: "", description: "", icon: "mdi:alert-circle-outline" }; + } + + async _saveNew() { + if (!this._newForm.name || !this._newForm.points) return; + try { + await this.hass.callService("taskmate", "add_penalty", { + name: this._newForm.name, + points: parseInt(this._newForm.points, 10), + description: this._newForm.description || "", + icon: this._newForm.icon || "mdi:alert-circle-outline", + }); + this._showNewForm = false; + } catch (e) { + this._showToast("Failed to add penalty"); + } + } + + _renderChildTabs() { + const children = this._getChildren(); + if (children.length <= 1) return html``; + const selected = this._getSelectedChild(); + return html` +
+ ${children.map(c => html` +
this._selectChild(c.id)}> + + ${c.name} +
+ `)} +
+ `; + } + + _renderPenaltyRow(p) { + const isEditing = this._editingPenalty?.id === p.id; + const isLoading = this._loading[p.id]; + const child = this._getSelectedChild(); + const pointsName = this._getPointsName(); + + return html` +
+
+ +
${p.points}
+
${pointsName}
+
+ +
+
${p.name}
+ ${p.description ? html`
${p.description}
` : ""} +
+ + ${this._editMode ? html` +
+ + +
+ ` : html` + + `} +
+ ${isEditing ? this._renderEditForm() : ""} + `; + } + + _renderEditForm() { + const p = this._editingPenalty; + return html` +
+
+
+ + this._editingPenalty = { ...p, name: e.target.value }} /> +
+
+ + this._editingPenalty = { ...p, points: e.target.value }} /> +
+
+
+
+ + this._editingPenalty = { ...p, icon: e.target.value }} /> +
+
+
+
+ + this._editingPenalty = { ...p, description: e.target.value }} /> +
+
+
+ + +
+
+ `; + } + + _renderNewForm() { + const f = this._newForm; + return html` +
+
+
+ + this._newForm = { ...f, name: e.target.value }} /> +
+
+ + this._newForm = { ...f, points: e.target.value }} /> +
+
+
+
+ + this._newForm = { ...f, icon: e.target.value }} /> +
+
+
+
+ + this._newForm = { ...f, description: e.target.value }} /> +
+
+
+ + +
+
+ `; + } + + render() { + if (!this.hass || !this.config) return html``; + + const penalties = this._getPenalties(); + const visible = this._getVisiblePenalties(); + const child = this._getSelectedChild(); + + return html` + +
+
+ + ${this.config.title || "Penalties"} +
+
+ ${penalties.length ? html` + ${penalties.length} + ` : ""} + +
+
+ + ${this._renderChildTabs()} + +
+ ${visible.length === 0 && !this._showNewForm ? html` +
+ +
No penalties yet
+
Tap the pencil icon to add penalties
+
+ ` : ""} + + ${visible.map(p => this._renderPenaltyRow(p))} + + ${this._editMode ? html` + ${this._showNewForm + ? this._renderNewForm() + : html` + + `} + ` : ""} + + ${child && !this._editMode ? html` +
+ Applying to ${child.name} + — current balance: ${child.points} ${this._getPointsName()} +
+ ` : ""} +
+
+ + ${this._toast ? html`
${this._toast}
` : ""} + `; + } +} + +customElements.define("taskmate-penalties-card", TaskMatePenaltiesCard); +window.customCards = window.customCards || []; +window.customCards.push({ + type: "taskmate-penalties-card", + name: "TaskMate Penalties", + description: "Apply point-deduction penalties to children", + preview: false, +}); From 575886d150363f73a1cd8121c6bd713a1cf32dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 18:57:37 +0000 Subject: [PATCH 2/2] Fix AttributeError when deleting a chore from config flow config_flow.py called async_delete_chore() which doesn't exist; the correct method name is async_remove_chore(). https://claude.ai/code/session_01CkPmKWe5siThpfZ6GDpNQF --- custom_components/taskmate/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/taskmate/config_flow.py b/custom_components/taskmate/config_flow.py index 39a3a76..0210f93 100644 --- a/custom_components/taskmate/config_flow.py +++ b/custom_components/taskmate/config_flow.py @@ -552,7 +552,7 @@ async def async_step_edit_chore( if user_input is not None: action = user_input.get("action", "save") if action == "delete": - await self.coordinator.async_delete_chore(chore_id) + await self.coordinator.async_remove_chore(chore_id) return await self.async_step_manage_chores() name = user_input.get("name", "").strip()