From e75287528a205dffbf41cb8de1de40b9d612dd6d Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:05:51 +0000 Subject: [PATCH 01/15] Refactor version retrieval to async method Prevent other integration resources from being removed. --- custom_components/choremander/frontend.py | 56 +++++++++++------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/custom_components/choremander/frontend.py b/custom_components/choremander/frontend.py index 1b297ac..84f98fc 100644 --- a/custom_components/choremander/frontend.py +++ b/custom_components/choremander/frontend.py @@ -1,6 +1,7 @@ """Frontend registration for Choremander custom cards.""" from __future__ import annotations +import json import logging from pathlib import Path from typing import Final @@ -34,16 +35,15 @@ FRONTEND_REGISTERED: Final = "frontend_registered" -def _get_version() -> str: - """Get version from manifest.json for cache busting.""" - import json - +async def _async_get_version(hass: HomeAssistant) -> str: + """Get version from manifest.json for cache busting (async-safe).""" manifest_path = Path(__file__).parent / "manifest.json" try: - with open(manifest_path) as f: - manifest = json.load(f) - return manifest.get("version", "1.0.0") - except (FileNotFoundError, json.JSONDecodeError): + content = await hass.async_add_executor_job( + manifest_path.read_text, "utf-8" + ) + return json.loads(content).get("version", "1.0.0") + except Exception: # noqa: BLE001 return "1.0.0" @@ -68,7 +68,7 @@ async def async_register_frontend(hass: HomeAssistant) -> None: _LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path) # Register global JS modules (loaded on all pages, including config flow) - version = _get_version() + version = await _async_get_version(hass) for module in GLOBAL_MODULES: module_url = f"{URL_BASE}/{module}?v={version}" add_extra_js_url(hass, module_url) @@ -79,8 +79,12 @@ async def async_register_frontend(hass: HomeAssistant) -> None: async def async_register_cards(hass: HomeAssistant) -> None: - """Register card resources with Lovelace automatically.""" - version = _get_version() + """Register card resources with Lovelace automatically. + + Only adds missing Choremander resources. Never modifies or removes + existing resources, including those from other integrations. + """ + version = await _async_get_version(hass) lovelace_data = hass.data.get("lovelace") if lovelace_data is None: @@ -99,40 +103,36 @@ async def async_register_cards(hass: HomeAssistant) -> None: _LOGGER.info(" type: module") return - # Storage mode - add resources automatically + # Storage mode - only ADD missing resources, never update or remove existing ones try: resources = lovelace_data.resources if resources is None: _LOGGER.debug("Lovelace resources collection not available") return - # Get existing resource URLs (strip query params for comparison) - existing_urls = set() + # Build set of existing Choremander resource base URLs only + # (strip query params for comparison) + existing_choremander_urls = set() for item in resources.async_items(): url = item.get("url", "") - existing_urls.add(url.split("?")[0]) + base_url = url.split("?")[0] + # Only track our own resources to avoid touching others + if base_url.startswith(URL_BASE + "/"): + existing_choremander_urls.add(base_url) - # Register each card + # Only add cards that are completely missing — never update existing entries + # This prevents any risk of corrupting or removing other resources for card in CARDS: card_url = f"{URL_BASE}/{card}" versioned_url = f"{card_url}?v={version}" - if card_url in existing_urls: - # Update version if needed - for item in resources.async_items(): - if item.get("url", "").split("?")[0] == card_url: - if item.get("url") != versioned_url: - await resources.async_update_item( - item["id"], - {"url": versioned_url}, - ) - _LOGGER.debug("Updated card version: %s", versioned_url) - break - else: + if card_url not in existing_choremander_urls: await resources.async_create_item( {"url": versioned_url, "res_type": "module"} ) _LOGGER.info("Registered Lovelace resource: %s", versioned_url) + else: + _LOGGER.debug("Resource already registered: %s", card_url) except Exception as err: # noqa: BLE001 _LOGGER.warning( From c8e8032de3d895fd519930a9483df95a5cfda7d1 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:27:55 +0000 Subject: [PATCH 02/15] Add reward approval flow, safe resource registration, new services, and coordinator improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Safe Lovelace resource registration (prevents resource wipe on restart) Previously async_register_cards ran on every HA restart and called resources.async_create_item against the Lovelace storage collection. Under certain timing conditions during startup this could corrupt or wipe all Lovelace resources including those from other integrations. Changed to a one-time registration pattern: On first install, resources are registered and resources_registered: True is saved to the config entry data On every subsequent restart, registration is skipped entirely — HA never touches Lovelace resources again Detected via entry.data.get("resources_registered", False) 2. Coordinator shutdown on unload async_unload_entry now calls coordinator.async_shutdown() before unloading platforms. This cleans up the midnight streak check listener and daily history prune listener registered in coordinator.async_initialize(), preventing listener leaks when the integration is reloaded. 3. New service: reject_reward Added handle_reject_reward service handler and registered SERVICE_REJECT_REWARD. This completes the reward approval flow — parents can now reject a pending reward claim without deducting points (since points are no longer deducted at claim time, only at approval). Added to _async_unregister_services to ensure clean unload. 4. New service: preview_sound Added handle_preview_sound service handler and registered SERVICE_PREVIEW_SOUND. Fires a choremander_preview_sound HA bus event that the config-sounds JS module listens for, allowing parents to preview chore completion sounds from Developer Tools → Actions. Added ATTR_SOUND and EVENT_PREVIEW_SOUND to imports from const. 5. Updated imports Added to imports from .const: ATTR_SOUND — for the preview_sound service EVENT_PREVIEW_SOUND — for the preview_sound service SERVICE_REJECT_REWARD — for the new reject_reward service SERVICE_PREVIEW_SOUND — for the new preview_sound service Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/__init__.py | 60 ++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/custom_components/choremander/__init__.py b/custom_components/choremander/__init__.py index 6fd9c29..f6c4b6e 100644 --- a/custom_components/choremander/__init__.py +++ b/custom_components/choremander/__init__.py @@ -17,12 +17,16 @@ ATTR_POINTS, ATTR_REASON, ATTR_REWARD_ID, + ATTR_SOUND, DOMAIN, + EVENT_PREVIEW_SOUND, SERVICE_ADD_POINTS, SERVICE_APPROVE_CHORE, SERVICE_APPROVE_REWARD, SERVICE_CLAIM_REWARD, + SERVICE_REJECT_REWARD, SERVICE_COMPLETE_CHORE, + SERVICE_PREVIEW_SOUND, SERVICE_REJECT_CHORE, SERVICE_REMOVE_POINTS, SERVICE_SET_CHORE_ORDER, @@ -54,9 +58,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = coordinator - # Register frontend static paths and Lovelace resources (only once) + # Register frontend static paths await async_register_frontend(hass) - await async_register_cards(hass) + + # Register Lovelace resources only on first install, not on every restart + # A new entry has no prior data stored — use that to detect first install + is_first_install = not entry.data.get("resources_registered", False) + await async_register_cards(hass, first_install=is_first_install) + + # Mark resources as registered so future restarts skip auto-registration + if is_first_install: + new_data = dict(entry.data) + new_data["resources_registered"] = True + hass.config_entries.async_update_entry(entry, data=new_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -70,6 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + coordinator = hass.data[DOMAIN].get(entry.entry_id) + if coordinator: + await coordinator.async_shutdown() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) @@ -123,6 +141,15 @@ async def handle_reject_chore(call: ServiceCall) -> None: completion_id = call.data["completion_id"] await coordinator.async_reject_chore(completion_id) + async def handle_reject_reward(call: ServiceCall) -> None: + """Handle the reject_reward service call.""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No Choremander coordinator available") + return + claim_id = call.data["claim_id"] + await coordinator.async_reject_reward(claim_id) + async def handle_claim_reward(call: ServiceCall) -> None: """Handle the claim_reward service call.""" coordinator = _get_coordinator(hass) @@ -164,6 +191,11 @@ async def handle_remove_points(call: ServiceCall) -> None: reason = call.data.get(ATTR_REASON, "") await coordinator.async_remove_points(child_id, points, reason) + async def handle_preview_sound(call: ServiceCall) -> None: + """Handle the preview_sound service call — fires a browser event for the config-sounds card.""" + sound = call.data.get(ATTR_SOUND, "coin") + hass.bus.async_fire(EVENT_PREVIEW_SOUND, {"sound": sound}) + async def handle_set_chore_order(call: ServiceCall) -> None: """Handle the set_chore_order service call.""" coordinator = _get_coordinator(hass) @@ -221,6 +253,13 @@ async def handle_set_chore_order(call: ServiceCall) -> None: ), ) + hass.services.async_register( + DOMAIN, + SERVICE_REJECT_REWARD, + handle_reject_reward, + schema=vol.Schema({ vol.Required("claim_id"): cv.string }), + ) + hass.services.async_register( DOMAIN, SERVICE_APPROVE_REWARD, @@ -258,6 +297,21 @@ async def handle_set_chore_order(call: ServiceCall) -> None: ), ) + hass.services.async_register( + DOMAIN, + SERVICE_PREVIEW_SOUND, + handle_preview_sound, + schema=vol.Schema( + { + vol.Required(ATTR_SOUND): vol.In([ + "none", "coin", "levelup", "fanfare", "chime", "powerup", "undo", + "fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7", + "fart8", "fart9", "fart10", "fart_random", + ]), + } + ), + ) + hass.services.async_register( DOMAIN, SERVICE_SET_CHORE_ORDER, @@ -279,9 +333,11 @@ def _async_unregister_services(hass: HomeAssistant) -> None: SERVICE_REJECT_CHORE, SERVICE_CLAIM_REWARD, SERVICE_APPROVE_REWARD, + SERVICE_REJECT_REWARD, SERVICE_ADD_POINTS, SERVICE_REMOVE_POINTS, SERVICE_SET_CHORE_ORDER, + SERVICE_PREVIEW_SOUND, ] for service in services: hass.services.async_remove(DOMAIN, service) From 3a97c00ce2499577067f2fdab65ea76e275082b5 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:30:42 +0000 Subject: [PATCH 03/15] Enhance coordinator with scheduled tasks and points tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Scheduled midnight streak check Added async_track_time_change listener firing at 00:00:05 daily. Checks each child's last_completion_date against yesterday — if they missed a day, behaviour depends on streak_reset_mode setting: reset sets current_streak to 0, pause sets streak_paused = True preserving the streak value until the next completion. Listener stored in _unsub_midnight and cleaned up in async_shutdown. 2. Scheduled daily history pruning Added second async_track_time_change listener at 00:01:00 daily calling async_prune_history. Reads history_days setting (default 90) from storage. Keeps all pending (unapproved) completions regardless of age. Stored in _unsub_prune, cleaned up in async_shutdown. 3. async_shutdown method New method cancels both scheduled listeners. Called from __init__.py async_unload_entry to prevent listener leaks on reload. 4. Streak pause mode in _award_points _award_points now reads streak_reset_mode and streak_paused flag. In pause mode, when a child resumes completing chores after a gap their streak continues from where it was rather than resetting to 1. Removed the inline from datetime import — moved date to top-level imports. 5. Reward approval flow overhaul async_claim_reward — removed immediate point deduction. Now only creates a RewardClaim with approved=False. Points are reserved but not deducted, mirroring the chore approval pattern async_approve_reward — now deducts points on approval. Validates the child still has sufficient points at approval time. Raises ValueError if not async_reject_reward — simplified. Since points were never deducted, rejection just deletes the claim record. Removed the refund logic entirely 6. async_prune_history method New public method accepting a days parameter. Filters completions older than the cutoff, keeping all pending completions regardless of age. Logs how many were pruned. 7. async_set_setting method New method for storing arbitrary key/value settings (e.g. streak_reset_mode, history_days) via storage.set_setting. 8. Settings exposed in coordinator data Added "settings": self.storage._data.get("settings", {}) to _async_update_data so the sensor can read settings like streak_reset_mode and expose them as sensor attributes. 9. CALC_COSTS warnings → debug Four _LOGGER.warning("CALC_COSTS...") calls changed to _LOGGER.debug. These fired every 30 seconds and polluted the HA logs with non-error information. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/coordinator.py | 236 ++++++++++++++++--- 1 file changed, 206 insertions(+), 30 deletions(-) diff --git a/custom_components/choremander/coordinator.py b/custom_components/choremander/coordinator.py index 544f3c2..fb7654e 100644 --- a/custom_components/choremander/coordinator.py +++ b/custom_components/choremander/coordinator.py @@ -1,16 +1,17 @@ """Data coordinator for Choremander integration.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import logging from typing import Any -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import DOMAIN -from .models import Child, Chore, ChoreCompletion, Reward, RewardClaim +from .models import Child, Chore, ChoreCompletion, Reward, RewardClaim, PointsTransaction from .storage import ChoremanderStorage _LOGGER = logging.getLogger(__name__) @@ -29,11 +30,94 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None: ) self.storage = ChoremanderStorage(hass, entry_id) self.entry_id = entry_id + self._unsub_midnight: callable | None = None + self._unsub_prune: callable | None = None async def async_initialize(self) -> None: """Initialize the coordinator.""" await self.storage.async_load() await self.async_refresh() + # Schedule midnight streak check at 00:00:05 + self._unsub_midnight = async_track_time_change( + self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5 + ) + # Schedule daily history pruning at 00:01:00 + self._unsub_prune = async_track_time_change( + self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0 + ) + + async def async_shutdown(self) -> None: + """Shutdown the coordinator and clean up listeners.""" + if self._unsub_midnight: + self._unsub_midnight() + self._unsub_midnight = None + if self._unsub_prune: + self._unsub_prune() + self._unsub_prune = None + + @callback + def _async_midnight_streak_check(self, now: datetime) -> None: + """Scheduled callback at midnight to check and reset streaks if needed.""" + self.hass.async_create_task(self._async_check_streaks()) + + @callback + def _async_scheduled_prune(self, now: datetime) -> None: + """Scheduled callback to prune old completion history.""" + days = int(self.storage.get_setting("history_days", "90")) + self.hass.async_create_task(self.async_prune_history(days)) + + async def _async_check_streaks(self) -> None: + """Check all children's streaks and reset/pause if they missed yesterday. + + Behaviour depends on streak_reset_mode setting: + - "reset" (default): streak goes back to 0 on missed day + - "pause": streak is preserved but not incremented until they complete again + """ + today = dt_util.now().date() + yesterday = today - timedelta(days=1) + yesterday_str = yesterday.isoformat() + today_str = today.isoformat() + + streak_mode = self.storage.get_setting("streak_reset_mode", "reset") + + children = self.storage.get_children() + changed = False + + for child in children: + last_date_str = getattr(child, "last_completion_date", None) + if last_date_str is None: + continue # No completions yet, nothing to do + + # If last completion was today or yesterday, streak is fine + if last_date_str in (yesterday_str, today_str): + continue + + # They missed a day + if (child.current_streak or 0) > 0: + if streak_mode == "pause": + # Preserve the streak value but mark it as paused + # We do this by leaving current_streak as-is — _award_points + # will NOT increment it (gap detected) but won't reset either + # We need a flag so _award_points knows to resume not reset + child.streak_paused = True + _LOGGER.info( + "Streak paused for %s (last completion: %s, mode=pause)", + child.name, last_date_str + ) + else: + # Default: reset to 0 + child.current_streak = 0 + child.streak_paused = False + _LOGGER.info( + "Streak reset for %s (last completion: %s, mode=reset)", + child.name, last_date_str + ) + self.storage.update_child(child) + changed = True + + if changed: + await self.storage.async_save() + await self.async_refresh() async def _async_update_data(self) -> dict[str, Any]: """Fetch data from storage.""" @@ -45,8 +129,10 @@ async def _async_update_data(self) -> dict[str, Any]: "pending_completions": self.storage.get_pending_completions(), "reward_claims": self.storage.get_reward_claims(), "pending_reward_claims": self.storage.get_pending_reward_claims(), + "points_transactions": self.storage.get_points_transactions(), "points_name": self.storage.get_points_name(), "points_icon": self.storage.get_points_icon(), + "settings": self.storage._data.get("settings", {}), } # Child operations @@ -475,7 +561,7 @@ async def async_reject_chore(self, completion_id: str) -> None: # Reward claim operations async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim: - """Child claims a reward.""" + """Child claims a reward — creates a pending claim awaiting parent approval.""" reward = self.get_reward(reward_id) if not reward: raise ValueError(f"Reward {reward_id} not found") @@ -491,26 +577,41 @@ async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim if child.points < effective_cost: raise ValueError(f"Not enough points. Need {effective_cost}, have {child.points}") + # Points are NOT deducted here — they are deducted on parent approval + # This mirrors the chore approval flow claim = RewardClaim( reward_id=reward_id, child_id=child_id, claimed_at=dt_util.now(), ) - # Deduct points immediately using the effective cost - child.points -= effective_cost - self.storage.update_child(child) - self.storage.add_reward_claim(claim) await self.storage.async_save() await self.async_refresh() return claim async def async_approve_reward(self, claim_id: str) -> None: - """Approve a reward claim.""" + """Approve a reward claim and deduct points from the child.""" claims = self.storage.get_reward_claims() for claim in claims: if claim.id == claim_id: + reward = self.get_reward(claim.reward_id) + child = self.get_child(claim.child_id) + if not reward or not child: + raise ValueError(f"Reward or child not found for claim {claim_id}") + + # Deduct points now that parent has approved + costs = self.calculate_dynamic_reward_costs(reward) + effective_cost = costs.get(claim.child_id, reward.cost) + + if child.points < effective_cost: + raise ValueError( + f"Not enough points to approve. Need {effective_cost}, have {child.points}" + ) + + child.points -= effective_cost + self.storage.update_child(child) + claim.approved = True claim.approved_at = dt_util.now() self.storage.update_reward_claim(claim) @@ -519,25 +620,13 @@ async def async_approve_reward(self, claim_id: str) -> None: return async def async_reject_reward(self, claim_id: str) -> None: - """Reject a reward claim and refund points.""" - claims = self.storage.get_reward_claims() - for claim in claims: - if claim.id == claim_id: - reward = self.get_reward(claim.reward_id) - child = self.get_child(claim.child_id) - if reward and child: - # Refund points using the effective cost for this child - costs = self.calculate_dynamic_reward_costs(reward) - effective_cost = costs.get(claim.child_id, reward.cost) - child.points += effective_cost - self.storage.update_child(child) - self.storage._data["reward_claims"] = [ - c for c in self.storage._data.get("reward_claims", []) - if c.get("id") != claim_id - ] - await self.storage.async_save() - await self.async_refresh() - return + """Reject a reward claim — no refund needed as points were never deducted.""" + self.storage._data["reward_claims"] = [ + c for c in self.storage._data.get("reward_claims", []) + if c.get("id") != claim_id + ] + await self.storage.async_save() + await self.async_refresh() # Points operations async def async_add_points(self, child_id: str, points: int, reason: str = "") -> None: @@ -545,7 +634,17 @@ async def async_add_points(self, child_id: str, points: int, reason: str = "") - child = self.get_child(child_id) if not child: raise ValueError(f"Child {child_id} not found") - await self._award_points(child, points) + child.points += points + child.total_points_earned += points + self.storage.update_child(child) + # Log the manual transaction + transaction = PointsTransaction( + child_id=child_id, + points=points, + reason=reason, + created_at=dt_util.now(), + ) + self.storage.add_points_transaction(transaction) await self.storage.async_save() await self.async_refresh() @@ -554,19 +653,90 @@ async def async_remove_points(self, child_id: str, points: int, reason: str = "" child = self.get_child(child_id) if not child: raise ValueError(f"Child {child_id} not found") + actual_deducted = min(points, child.points) # Can't go below 0 child.points = max(0, child.points - points) self.storage.update_child(child) + # Log the manual transaction (negative points) + transaction = PointsTransaction( + child_id=child_id, + points=-actual_deducted, + reason=reason, + created_at=dt_util.now(), + ) + self.storage.add_points_transaction(transaction) await self.storage.async_save() await self.async_refresh() async def _award_points(self, child: Child, points: int) -> None: - """Award points to a child.""" + """Award points to a child and update streak.""" child.points += points child.total_points_earned += points child.total_chores_completed += 1 + # Update streak tracking + today_str = dt_util.now().date().isoformat() + last_date_str = getattr(child, 'last_completion_date', None) + + streak_mode = self.storage.get_setting("streak_reset_mode", "reset") + streak_paused = getattr(child, "streak_paused", False) + + if last_date_str is None: + # First ever completion — start streak at 1 + child.current_streak = 1 + child.streak_paused = False + elif last_date_str == today_str: + # Already completed something today — streak unchanged + pass + else: + try: + last_date = date.fromisoformat(last_date_str) + yesterday = dt_util.now().date() - timedelta(days=1) + if last_date == yesterday: + # Consecutive day — extend streak + child.current_streak = (child.current_streak or 0) + 1 + child.streak_paused = False + elif streak_mode == "pause" or streak_paused: + # Pause mode — resume streak from where it was (don't increment) + child.streak_paused = False + _LOGGER.debug("Streak resumed for %s at %d", child.name, child.current_streak) + else: + # Reset mode — gap means start over + child.current_streak = 1 + child.streak_paused = False + except (ValueError, TypeError): + child.current_streak = 1 + child.streak_paused = False + + # Update last completion date + child.last_completion_date = today_str + + # Update best streak + if child.current_streak > (child.best_streak or 0): + child.best_streak = child.current_streak + self.storage.update_child(child) + async def async_prune_history(self, days: int = 90) -> None: + """Prune completion history older than specified days.""" + cutoff = dt_util.now() - timedelta(days=days) + all_completions = self.storage.get_completions() + before = len(all_completions) + + # Keep completions newer than cutoff OR unapproved (pending) + to_keep = [ + c for c in all_completions + if c.completed_at >= cutoff or not c.approved + ] + + if len(to_keep) < before: + self.storage._data["completions"] = [c.to_dict() for c in to_keep] + await self.storage.async_save() + await self.async_refresh() + _LOGGER.info( + "Pruned %d completions older than %d days", + before - len(to_keep), days + ) + # Child chore order operations async def async_set_chore_order(self, child_id: str, chore_order: list[str]) -> None: """Set the chore order for a child.""" @@ -579,6 +749,12 @@ async def async_set_chore_order(self, child_id: str, chore_order: list[str]) -> await self.storage.async_save() await self.async_refresh() + async def async_set_setting(self, key: str, value: str) -> None: + """Update a generic setting.""" + self.storage.set_setting(key, value) + await self.storage.async_save() + await self.async_refresh() + # Settings async def async_set_points_settings(self, name: str, icon: str) -> None: """Update points settings.""" From d74b09817e2fd2aab60ba6e15961b889f753bfb1 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:31:45 +0000 Subject: [PATCH 04/15] Add last_completion_date and streak_paused fields 1. Child.last_completion_date New str | None field storing ISO date string of the child's most recent chore completion. Used by the coordinator's midnight streak check to determine if a day was missed. Included in from_dict and to_dict. 2. Child.streak_paused New bool field (default False). Set to True by the midnight streak check when streak_reset_mode is pause and the child missed a day. Cleared to False when the child next completes a chore. Included in from_dict and to_dict. 3. PointsTransaction dataclass New model representing a manual points adjustment (add or remove). Fields: child_id, points (positive = added, negative = removed), reason, created_at, id. Includes from_dict and to_dict. Used to build the activity feed history of manual point changes. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/models.py | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/custom_components/choremander/models.py b/custom_components/choremander/models.py index 913e09b..d82551b 100644 --- a/custom_components/choremander/models.py +++ b/custom_components/choremander/models.py @@ -64,6 +64,8 @@ class Child: best_streak: int = 0 pending_rewards: list[str] = field(default_factory=list) chore_order: list[str] = field(default_factory=list) # Custom chore ordering for this child + last_completion_date: str | None = None # ISO date string of last chore completion (for streak tracking) + streak_paused: bool = False # True if streak is paused due to missed day (pause mode) id: str = field(default_factory=generate_id) @classmethod @@ -79,6 +81,8 @@ def from_dict(cls, data: dict[str, Any]) -> Child: best_streak=data.get("best_streak", 0), pending_rewards=data.get("pending_rewards", []), chore_order=data.get("chore_order", []), + last_completion_date=data.get("last_completion_date", None), + streak_paused=data.get("streak_paused", False), id=data.get("id", generate_id()), ) @@ -94,6 +98,8 @@ def to_dict(self) -> dict[str, Any]: "best_streak": self.best_streak, "pending_rewards": self.pending_rewards, "chore_order": self.chore_order, + "last_completion_date": self.last_completion_date, + "streak_paused": self.streak_paused, "id": self.id, } @@ -275,3 +281,36 @@ def to_dict(self) -> dict[str, Any]: "approved_at": format_datetime(self.approved_at), "id": self.id, } + + +@dataclass +class PointsTransaction: + """Represents a manual points adjustment (add or remove).""" + + child_id: str + points: int # positive = added, negative = removed + reason: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + id: str = field(default_factory=generate_id) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PointsTransaction: + """Create a PointsTransaction from a dictionary.""" + created_at = parse_datetime(data.get("created_at")) + return cls( + child_id=data.get("child_id", ""), + points=data.get("points", 0), + reason=data.get("reason", ""), + created_at=created_at or datetime.now(timezone.utc), + id=data.get("id", generate_id()), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return { + "child_id": self.child_id, + "points": self.points, + "reason": self.reason, + "created_at": format_datetime(self.created_at), + "id": self.id, + } From c20a648d120f18f3041c9cf385a7a853a6353d0e Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:32:16 +0000 Subject: [PATCH 05/15] Add points transaction management to storage 1. points_transactions in default data Added "points_transactions": [] to the default storage structure created for new installs. 2. get_points_transactions / add_points_transaction New CRUD methods for PointsTransaction records. add_points_transaction caps the list at 200 entries to prevent unbounded storage growth. 3. get_setting / set_setting New generic key/value settings store backed by a settings dict in storage. Used for streak_reset_mode and history_days. Avoids adding new top-level storage fields for every new configuration option. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/storage.py | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/custom_components/choremander/storage.py b/custom_components/choremander/storage.py index 8130658..8b648cd 100644 --- a/custom_components/choremander/storage.py +++ b/custom_components/choremander/storage.py @@ -8,7 +8,7 @@ from homeassistant.helpers.storage import Store from .const import DOMAIN -from .models import Child, Chore, ChoreCompletion, Reward, RewardClaim +from .models import Child, Chore, ChoreCompletion, Reward, RewardClaim, PointsTransaction _LOGGER = logging.getLogger(__name__) @@ -36,6 +36,7 @@ async def async_load(self) -> dict[str, Any]: "rewards": [], "completions": [], "reward_claims": [], + "points_transactions": [], "points_name": "Stars", "points_icon": "mdi:star", } @@ -271,6 +272,32 @@ def update_reward_claim(self, claim: RewardClaim) -> None: claims[i] = claim.to_dict() return + # Points transactions management + def get_points_transactions(self) -> list[PointsTransaction]: + """Get all points transactions.""" + return [PointsTransaction.from_dict(t) for t in self._data.get("points_transactions", [])] + + def add_points_transaction(self, transaction: PointsTransaction) -> None: + """Add a points transaction record.""" + if "points_transactions" not in self._data: + self._data["points_transactions"] = [] + self._data["points_transactions"].append(transaction.to_dict()) + + # Keep only the last 200 transactions to avoid unbounded storage growth + if len(self._data["points_transactions"]) > 200: + self._data["points_transactions"] = self._data["points_transactions"][-200:] + + # Generic settings + def get_setting(self, key: str, default: str = "") -> str: + """Get a generic setting value.""" + return self._data.get("settings", {}).get(key, default) + + def set_setting(self, key: str, value: str) -> None: + """Set a generic setting value.""" + if "settings" not in self._data: + self._data["settings"] = {} + self._data["settings"][key] = value + # Settings def get_points_name(self) -> str: """Get the points currency name.""" From 084a3048e92822c2d3a3258a50d8bc3cb812894a Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:34:34 +0000 Subject: [PATCH 06/15] Enrich completion data and track committed points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. due_days and requires_approval in chores list Both fields now included in the chores array exposed in sensor attributes, allowing frontend cards to filter chores by scheduled days and show approval requirements. 2. today_day_of_week attribute New top-level attribute (e.g. "wednesday") set from dt_util.now() in the HA timezone. Used by the child card for due_days filtering without requiring client-side day calculation. 3. streak_reset_mode attribute New top-level attribute exposing the current global streak reset setting so frontend cards can read it without needing a separate API call. 4. Extended children array Each child now includes: current_streak, best_streak, total_points_earned, total_chores_completed, avatar, last_completion_date, streak_paused, committed_points. committed_points is the total points reserved by pending reward claims — used by the rewards card to prevent claiming when points are already reserved. 5. pending_reward_claims attribute New attribute exposing the full list of pending (unapproved) reward claims, enriched with child_name, child_avatar, reward_name, reward_icon, and calculated cost per child. Used by the parent dashboard Claims tab. 6. recent_completions increased to 200 Was capped at 50, now 200 to support longer activity history in the graph and activity cards. 7. recent_transactions refactored Previously only showed manual point adjustments. Now merged with reward claim events via _build_recent_transactions helper. Reward claims appear as reward_claimed (pending) or reward_approved events with points shown as negative (spent). Capped at 50 combined events. 8. _build_recent_transactions helper New method building the unified activity feed from both points_transactions and reward_claims. Sorts all events newest-first and caps at 50. 9. _build_pending_reward_claims helper New method building the enriched pending reward claims list. Handles cost calculation per child using calculate_dynamic_reward_costs, falling back to static cost on error. 10. Duplicate override_point_value key removed Fixed a duplicate dict key in the rewards list that could cause silent data loss. 11. committed_points_by_child calculation New calculation alongside pending_points_by_child that totals the points reserved by pending reward claims per child. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/sensor.py | 144 +++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/custom_components/choremander/sensor.py b/custom_components/choremander/sensor.py index 1c3c490..616122a 100644 --- a/custom_components/choremander/sensor.py +++ b/custom_components/choremander/sensor.py @@ -129,6 +129,11 @@ def extra_state_attributes(self) -> dict: now = dt_util.now() today = now.date() todays_completions = [] + # Build chore lookup for enriching completions + chore_lookup = {c.id: c for c in chores} + # Build child lookup for enriching completions + child_lookup = {c.id: c for c in children} + for comp in all_completions: # Convert completion time to HA timezone for proper date comparison comp_dt = comp.completed_at @@ -136,16 +141,20 @@ def extra_state_attributes(self) -> dict: # If timezone-aware, convert to HA timezone comp_dt = dt_util.as_local(comp_dt) comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else comp_dt + matched_chore = chore_lookup.get(comp.chore_id) if comp_date == today: todays_completions.append({ "completion_id": comp.id, "chore_id": comp.chore_id, "child_id": comp.child_id, + "child_name": child_lookup.get(comp.child_id, None) and child_lookup[comp.child_id].name or "", + "chore_name": matched_chore.name if matched_chore else "", + "points": matched_chore.points if matched_chore else 0, "approved": comp.approved, "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), }) - # Calculate pending points per child + # Calculate pending points per child (chores awaiting approval = points to be earned) pending_points_by_child = {} for comp in pending_completions: child_id = comp.child_id @@ -153,6 +162,19 @@ def extra_state_attributes(self) -> dict: if chore: pending_points_by_child[child_id] = pending_points_by_child.get(child_id, 0) + chore.points + # Calculate committed points per child (reward claims awaiting approval = points reserved) + committed_points_by_child = {} + pending_reward_claim_objs = data.get("pending_reward_claims", []) + for rc in pending_reward_claim_objs: + reward = next((r for r in rewards if r.id == rc.reward_id), None) + if reward: + try: + costs = self.coordinator.calculate_dynamic_reward_costs(reward) + cost = costs.get(rc.child_id, reward.cost) + except Exception: + cost = reward.cost + committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + cost + # Build chores list with explicit assigned_to handling chores_list = [] for c in chores: @@ -167,6 +189,8 @@ def extra_state_attributes(self) -> dict: "assigned_to": assigned_to, "completion_sound": getattr(c, 'completion_sound', 'coin'), "completion_percentage_per_month": getattr(c, 'completion_percentage_per_month', 100), + "due_days": getattr(c, 'due_days', []) or [], + "requires_approval": getattr(c, 'requires_approval', True), }) # Build rewards list with per-child dynamic cost calculation @@ -212,7 +236,12 @@ def extra_state_attributes(self) -> dict: "child_daily_points": child_daily_points, # Dict of child_id -> daily expected points (for weighted jackpot meter) }) + # Day of week for due_days filtering in frontend (lowercase, e.g. "monday") + today_dow = dt_util.now().strftime("%A").lower() + return { + "today_day_of_week": today_dow, + "streak_reset_mode": data.get("settings", {}).get("streak_reset_mode", "reset"), "total_children": len(children), "total_chores": len(chores), "total_rewards": len(rewards), @@ -220,12 +249,45 @@ def extra_state_attributes(self) -> dict: "total_chores_completed": total_chores_completed, "points_name": data.get("points_name", "Stars"), "points_icon": data.get("points_icon", "mdi:star"), - "children": [{"id": c.id, "name": c.name, "points": c.points, "pending_points": pending_points_by_child.get(c.id, 0), "chore_order": c.chore_order} for c in children], + "children": [{ + "id": c.id, + "name": c.name, + "points": c.points, + "pending_points": pending_points_by_child.get(c.id, 0), + "committed_points": committed_points_by_child.get(c.id, 0), + "chore_order": c.chore_order, + "current_streak": getattr(c, 'current_streak', 0) or 0, + "best_streak": getattr(c, 'best_streak', 0) or 0, + "total_points_earned": getattr(c, 'total_points_earned', 0) or 0, + "total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0, + "avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', + "last_completion_date": getattr(c, 'last_completion_date', None), + "streak_paused": getattr(c, 'streak_paused', False), + } for c in children], "chores": chores_list, "rewards": rewards_list, "todays_completions": todays_completions, "total_completions_all_time": len(all_completions), "total_pending_completions": len(pending_completions), + "pending_reward_claims": self._build_pending_reward_claims( + data.get("pending_reward_claims", []), rewards, child_lookup + ), + "recent_completions": [{ + "completion_id": comp.id, + "chore_id": comp.chore_id, + "child_id": comp.child_id, + "child_name": child_lookup.get(comp.child_id, None) and child_lookup[comp.child_id].name or "", + "chore_name": chore_lookup.get(comp.chore_id, None) and chore_lookup[comp.chore_id].name or "", + "points": chore_lookup.get(comp.chore_id, None) and chore_lookup[comp.chore_id].points or 0, + "approved": comp.approved, + "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), + } for comp in sorted(all_completions, key=lambda c: c.completed_at, reverse=True)[:200]], + "recent_transactions": self._build_recent_transactions( + data.get("points_transactions", []), + data.get("reward_claims", []), + rewards, + child_lookup, + ), } @property @@ -234,6 +296,84 @@ def icon(self) -> str: return "mdi:clipboard-check-multiple" + def _build_recent_transactions(self, points_transactions, all_reward_claims, rewards, child_lookup): + """Build unified activity feed of manual point adjustments and reward claims.""" + reward_lookup = {r.id: r for r in rewards} + events = [] + + # Manual points transactions + for t in points_transactions: + child = child_lookup.get(t.child_id) + if not child: + continue + events.append({ + "transaction_id": t.id, + "type": "points_added" if t.points > 0 else "points_removed", + "child_id": t.child_id, + "child_name": child.name, + "points": t.points, + "reason": t.reason or "", + "created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at), + }) + + # Reward claims — show both pending and approved + for rc in all_reward_claims: + child = child_lookup.get(rc.child_id) + reward = reward_lookup.get(rc.reward_id) + if not child or not reward: + continue + try: + costs = self.coordinator.calculate_dynamic_reward_costs(reward) + cost = costs.get(rc.child_id, reward.cost) + except Exception: + cost = reward.cost + event_type = "reward_approved" if rc.approved else "reward_claimed" + timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at + events.append({ + "transaction_id": rc.id, + "type": event_type, + "child_id": rc.child_id, + "child_name": child.name, + "reward_id": rc.reward_id, + "reward_name": reward.name, + "reward_icon": reward.icon or "mdi:gift", + "points": -cost, # negative — points spent + "approved": rc.approved, + "created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp), + }) + + # Sort all events newest first, cap at 50 + events.sort(key=lambda e: e["created_at"], reverse=True) + return events[:50] + + def _build_pending_reward_claims(self, pending_claims, rewards, child_lookup): + """Build enriched pending reward claims list.""" + reward_lookup = {r.id: r for r in rewards} + result = [] + for rc in pending_claims: + reward = reward_lookup.get(rc.reward_id) + child = child_lookup.get(rc.child_id) + if not reward or not child: + continue + try: + costs = self.coordinator.calculate_dynamic_reward_costs(reward) + cost = costs.get(rc.child_id, reward.cost) + except Exception: + cost = reward.cost + result.append({ + "claim_id": rc.id, + "reward_id": rc.reward_id, + "child_id": rc.child_id, + "child_name": child.name, + "child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', + "reward_name": reward.name, + "reward_icon": reward.icon or 'mdi:gift', + "cost": cost, + "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at), + }) + return result + + class ChildPointsSensor(ChoremandorBaseSensor): """Sensor for a child's points.""" From 72c0c2482432c7619e1b781f99e02530ae180248 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:35:11 +0000 Subject: [PATCH 07/15] Add SERVICE_REJECT_REWARD constant 1. SERVICE_REJECT_REWARD New constant "reject_reward" for the reward rejection service. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/choremander/const.py b/custom_components/choremander/const.py index 2a08db7..8f26c66 100644 --- a/custom_components/choremander/const.py +++ b/custom_components/choremander/const.py @@ -169,6 +169,7 @@ SERVICE_REJECT_CHORE: Final = "reject_chore" SERVICE_CLAIM_REWARD: Final = "claim_reward" SERVICE_APPROVE_REWARD: Final = "approve_reward" +SERVICE_REJECT_REWARD: Final = "reject_reward" SERVICE_ADD_POINTS: Final = "add_points" SERVICE_REMOVE_POINTS: Final = "remove_points" SERVICE_RESET_DAILY: Final = "reset_daily" From f1ac7be63eb56248976ebde80e11b9dde4e8a371 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:36:01 +0000 Subject: [PATCH 08/15] Add 'Reject Reward' service to services.yaml 1. reject_reward service New service definition documenting the claim_id field for rejecting a pending reward claim. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/services.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/custom_components/choremander/services.yaml b/custom_components/choremander/services.yaml index cbd5108..706ccbd 100644 --- a/custom_components/choremander/services.yaml +++ b/custom_components/choremander/services.yaml @@ -116,3 +116,14 @@ remove_points: required: false selector: text: + +reject_reward: + name: Reject Reward + description: Reject a reward claim (no points deducted as approval was pending) + fields: + claim_id: + name: Claim ID + description: The ID of the reward claim to reject + required: true + selector: + text: From e0550ccac3ba0aa95ca3fa0781add33cc0eed3ff Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:36:47 +0000 Subject: [PATCH 09/15] Update strings.json with new settings options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Settings step — streak_reset_mode New field label and description for the streak reset mode selector in the integration settings. 2. Settings step — history_days New field label and description for the history retention days number input. 3. reject_reward service New service entry with name, description, and claim_id field documentation. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/strings.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/custom_components/choremander/strings.json b/custom_components/choremander/strings.json index e883baf..04c713b 100644 --- a/custom_components/choremander/strings.json +++ b/custom_components/choremander/strings.json @@ -171,7 +171,13 @@ "description": "Configure your Choremander settings", "data": { "points_name": "Points Currency Name", - "points_icon": "Points Icon" + "points_icon": "Points Icon", + "streak_reset_mode": "Streak Reset Mode", + "history_days": "History Days to Keep" + }, + "data_description": { + "streak_reset_mode": "What happens to a streak when a day is missed", + "history_days": "How many days of chore completion history to retain (30-365)" } } }, @@ -273,6 +279,16 @@ "description": "Optional reason for the penalty" } } + }, + "reject_reward": { + "name": "Reject Reward", + "description": "Reject a reward claim", + "fields": { + "claim_id": { + "name": "Claim ID", + "description": "The ID of the reward claim to reject" + } + } } } } From 78451e36b353d8f5f0bdd84c9e982b6ce7881b08 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:37:21 +0000 Subject: [PATCH 10/15] Add new fields for streak reset and history days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Settings step — streak_reset_mode New field label and description for the streak reset mode selector in the integration settings. 2. Settings step — history_days New field label and description for the history retention days number input. 3. reject_reward service New service entry with name, description, and claim_id field documentation. Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- .../choremander/translations/en.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/custom_components/choremander/translations/en.json b/custom_components/choremander/translations/en.json index e883baf..225d100 100644 --- a/custom_components/choremander/translations/en.json +++ b/custom_components/choremander/translations/en.json @@ -171,7 +171,13 @@ "description": "Configure your Choremander settings", "data": { "points_name": "Points Currency Name", - "points_icon": "Points Icon" + "points_icon": "Points Icon", + "streak_reset_mode": "Streak Reset Mode", + "history_days": "History Days to Keep" + }, + "data_description": { + "streak_reset_mode": "What happens to a streak when a day is missed", + "history_days": "How many days of chore completion history to retain (30\u2013365)" } } }, @@ -273,6 +279,16 @@ "description": "Optional reason for the penalty" } } + }, + "reject_reward": { + "name": "Reject Reward", + "description": "Reject a reward claim", + "fields": { + "claim_id": { + "name": "Claim ID", + "description": "The ID of the reward claim to reject" + } + } } } } From 8db565f5a64610bfb93858668b75d57082e88593 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:37:52 +0000 Subject: [PATCH 11/15] Add settings for streak reset mode and history days Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- custom_components/choremander/config_flow.py | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/custom_components/choremander/config_flow.py b/custom_components/choremander/config_flow.py index 68d8360..0897917 100644 --- a/custom_components/choremander/config_flow.py +++ b/custom_components/choremander/config_flow.py @@ -714,8 +714,22 @@ async def async_step_settings( name=user_input.get("points_name", DEFAULT_POINTS_NAME), icon=user_input.get("points_icon", DEFAULT_POINTS_ICON), ) + await self.coordinator.async_set_setting( + "streak_reset_mode", + user_input.get("streak_reset_mode", "reset"), + ) + await self.coordinator.async_set_setting( + "history_days", + str(int(float(user_input.get("history_days", 90)))), + ) return await self.async_step_init() + current_streak_mode = self.coordinator.storage.get_setting("streak_reset_mode", "reset") + try: + current_history_days = float(self.coordinator.storage.get_setting("history_days", "90")) + except (ValueError, TypeError): + current_history_days = 90.0 + return self.async_show_form( step_id="settings", data_schema=vol.Schema( @@ -728,6 +742,29 @@ async def async_step_settings( "points_icon", default=self.coordinator.storage.get_points_icon(), ): selector.IconSelector(), + vol.Required( + "streak_reset_mode", + default=current_streak_mode, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value="reset", label="Reset — streak goes to 0 on missed day"), + selector.SelectOptionDict(value="pause", label="Pause — streak preserved until next completion"), + ], + mode=selector.SelectSelectorMode.LIST, + ) + ), + vol.Required( + "history_days", + default=current_history_days, + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=30, + max=365, + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), } ), ) From a06532194323eaa62a166c45bef5f964bb2f6423 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:39:29 +0000 Subject: [PATCH 12/15] Refactor and improve styling in choremander-child-card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header restyled to match overview/weekly/streak card pattern — dark navy gradient, compact avatar circle, points pill Dark mode — all hardcoded white/grey colours replaced with CSS variables (--card-background-color, --primary-text-color, --divider-color, color-mix() for chore card tints) Daily reset countdown — shows time to midnight beside "Today's Chores" heading, turns orange under 1 hour, configurable via editor toggle due_days filtering — chores with due_days set can be hidden, dimmed, or shown normally based on editor setting. Reads today_day_of_week from sensor Streak reset mode removed from card editor — moved to global integration settings Editor restyled — uses ha-textfield for entity, native - The Choremander overview sensor entity - - -
- - - ${children.map( - (child) => html` - - ` - )} + ${children.map(child => html` + + `)} - Which child is this card for? + Which child is this card for?
-
- - + + + + + + - Which time of day to show chores for (also sets the card title) + Which time of day to show chores for — also sets the card title
-
- - Display debug information at the bottom of the card +
+ + + Applies to chores with due_days set when today isn't scheduled
+ +
+ + + + `; } From ee9b49dfb1e867323a03d3e87af7396e5bc31a0a Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:40:33 +0000 Subject: [PATCH 13/15] Implement loading state for claiming rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claim button added — appears on each reward row when child_id is configured in the editor, calls claim_reward service Pending approval state — after claiming, reward shows orange "Awaiting parent approval" label and claim button is hidden canAfford uses available_points — deducts committed_points (points reserved by pending claims) from child.points before checking affordability, preventing double-claiming _loading state — prevents double-taps on claim button Signed-off-by: tempus2016 <40920327+tempus2016@users.noreply.github.com> --- .../www/choremander-rewards-card.js | 107 +++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/custom_components/choremander/www/choremander-rewards-card.js b/custom_components/choremander/www/choremander-rewards-card.js index 52d6ed6..bfc27c5 100644 --- a/custom_components/choremander/www/choremander-rewards-card.js +++ b/custom_components/choremander/www/choremander-rewards-card.js @@ -20,9 +20,15 @@ class ChoremanderRewardsCard extends LitElement { return { hass: { type: Object }, config: { type: Object }, + _loading: { type: Object }, }; } + constructor() { + super(); + this._loading = {}; + } + static get styles() { return css` :host { @@ -453,6 +459,50 @@ class ChoremanderRewardsCard extends LitElement { color: white; } + /* Pending approval state */ + .reward-row.pending-approval { + opacity: 0.6; + border-left: 3px solid #e67e22; + } + + .pending-label { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(230,126,34,0.12); + color: #e67e22; + border-radius: 8px; + padding: 3px 8px; + font-size: 0.75rem; + font-weight: 600; + margin-top: 4px; + } + + .pending-label ha-icon { --mdc-icon-size: 12px; } + + /* Claim button */ + .claim-btn { + width: 42px; height: 42px; + border-radius: 50%; border: none; cursor: pointer; + background: linear-gradient(135deg, #9b59b6, #8e44ad); + color: white; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 3px 10px rgba(155,89,182,0.35); + transition: transform 0.15s, box-shadow 0.15s; + flex-shrink: 0; + } + + .claim-btn:hover { transform: scale(1.08); box-shadow: 0 4px 14px rgba(155,89,182,0.45); } + .claim-btn:active { transform: scale(0.96); } + .claim-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + .claim-btn ha-icon { --mdc-icon-size: 22px; } + + .claim-btn.cant-afford { + background: linear-gradient(135deg, #bdc3c7, #95a5a6); + box-shadow: none; + cursor: not-allowed; + } + /* Empty state */ .empty-state { display: flex; @@ -728,8 +778,25 @@ class ChoremanderRewardsCard extends LitElement { const percentage = Math.min((currentStars / displayCost) * 100, 100); + // Check if this reward has a pending claim + const entity = this.hass?.states?.[this.config?.entity]; + const pendingClaims = entity?.attributes?.pending_reward_claims || []; + const childId = this.config?.child_id; + const hasPendingClaim = pendingClaims.some(c => + c.reward_id === reward.id && (!childId || c.child_id === childId) + ); + + // Can the current child afford it? Account for points committed to other pending claims + const relevantChild = childId + ? children.find(c => c.id === childId) + : relevantChildren[0]; + const committedPoints = relevantChild?.committed_points || 0; + const availablePoints = (relevantChild?.points || 0) - committedPoints; + const canAfford = relevantChild && availablePoints >= displayCost; + const isLoading = this._loading[reward.id]; + return html` -
+
${displayCost} @@ -747,6 +814,13 @@ class ChoremanderRewardsCard extends LitElement { ? this._renderJackpotProgress(reward, childContributions, currentStars, pointsIcon, displayCost) : this._renderRegularProgress(currentStars, displayCost, percentage, pointsIcon)} + ${hasPendingClaim ? html` +
+ + Awaiting parent approval +
+ ` : ''} + ${showChildBadges && !isJackpot ? html`
@@ -760,13 +834,40 @@ class ChoremanderRewardsCard extends LitElement { ` : ""}
-
+
+ ${!hasPendingClaim && childId ? html` + + ` : ''}
`; } + async _handleClaim(reward, child) { + if (!child || !reward) return; + this._loading = { ...this._loading, [reward.id]: true }; + this.requestUpdate(); + try { + await this.hass.callService("choremander", "claim_reward", { + reward_id: reward.id, + child_id: child.id, + }); + } catch (e) { + console.error("Failed to claim reward:", e); + } finally { + this._loading = { ...this._loading, [reward.id]: false }; + this.requestUpdate(); + } + } + _renderRegularProgress(currentStars, cost, percentage, pointsIcon) { return html`
@@ -1017,7 +1118,7 @@ class ChoremanderRewardsCardEditor extends LitElement { } _childIdChanged(e) { - const value = e.target.value; + const value = e.detail?.value ?? e.target?.value; this._updateConfig("child_id", value || null); } From be38cf6dfdb4dd36a1fcf66cc76b472c69b31605 Mon Sep 17 00:00:00 2001 From: tempus2016 <40920327+tempus2016@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:42:16 +0000 Subject: [PATCH 14/15] Update choremander-points-card.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor updated — ha-combo-box replaced with native + + ${children.map(c => html``)} + + Only show activity for this child +
+ + `; + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (value === null || value === "" || value === undefined) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("choremander-activity-card", ChoremanderActivityCard); +customElements.define("choremander-activity-card-editor", ChoremanderActivityCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-activity-card", + name: "Choremander Activity Feed", + description: "Timeline of recent chore completions and reward claims", + preview: true, +}); + +console.info( + "%c CHOREMANDER-ACTIVITY-CARD %c v1.0.0 ", + "background: #3498db; color: white; font-weight: bold; border-radius: 4px 0 0 4px;", + "background: #2ecc71; color: white; font-weight: bold; border-radius: 0 4px 4px 0;" +); diff --git a/custom_components/choremander/www/choremander-graph-card.js b/custom_components/choremander/www/choremander-graph-card.js new file mode 100644 index 0000000..f4d2301 --- /dev/null +++ b/custom_components/choremander/www/choremander-graph-card.js @@ -0,0 +1,729 @@ +/** + * Choremander Points Graph Card + * Line graph tracking points earned per day or cumulative total. + * Configurable time range, per-child or combined view, toggle between modes. + * + * Version: 1.0.0 + * Last Updated: 2026-03-18 + */ + +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; + +// Child colors — matches the streak/jackpot palette +const CHILD_COLORS = [ + { line: "#9b59b6", fill: "rgba(155,89,182,0.15)" }, + { line: "#3498db", fill: "rgba(52,152,219,0.15)" }, + { line: "#2ecc71", fill: "rgba(46,204,113,0.15)" }, + { line: "#e67e22", fill: "rgba(230,126,34,0.15)" }, + { line: "#e74c3c", fill: "rgba(231,76,60,0.15)" }, + { line: "#1abc9c", fill: "rgba(26,188,156,0.15)" }, +]; + +class ChoremanderGraphCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + _mode: { type: String }, // "daily" | "cumulative" + }; + } + + constructor() { + super(); + this._mode = "daily"; + } + + static get styles() { + return css` + :host { + display: block; + --gr-purple: #9b59b6; + --gr-purple-light: #a569bd; + --gr-gold: #f1c40f; + --gr-green: #2ecc71; + --gr-blue: #3498db; + } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%); + color: white; + gap: 12px; + } + + .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; } + .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } + .header-title { font-size: 1.15rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + .mode-toggle { + display: flex; + background: rgba(255,255,255,0.12); + border-radius: 20px; + padding: 3px; + gap: 2px; + flex-shrink: 0; + } + + .mode-btn { + background: none; + border: none; + color: rgba(255,255,255,0.6); + padding: 4px 10px; + border-radius: 16px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + } + + .mode-btn.active { + background: rgba(255,255,255,0.2); + color: white; + } + + .mode-btn:hover:not(.active) { + color: rgba(255,255,255,0.85); + } + + .card-content { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + + /* Legend */ + .legend { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + padding: 0 4px; + } + + .legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--secondary-text-color); + font-weight: 500; + } + + .legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + } + + /* SVG chart container */ + .chart-wrap { + position: relative; + width: 100%; + min-height: 180px; + } + + /* Tooltip */ + .tooltip { + position: absolute; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 8px; + padding: 8px 12px; + font-size: 0.8rem; + color: var(--primary-text-color); + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + pointer-events: none; + z-index: 10; + white-space: nowrap; + display: none; + } + + .tooltip.visible { display: block; } + + .tooltip-date { + font-weight: 700; + margin-bottom: 4px; + color: var(--secondary-text-color); + font-size: 0.75rem; + } + + .tooltip-row { + display: flex; + align-items: center; + gap: 6px; + margin: 2px 0; + } + + .tooltip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + /* Empty / error */ + .error-state, .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--secondary-text-color); text-align: center; + } + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + .empty-state .message { font-size: 1rem; color: var(--primary-text-color); } + .empty-state .submessage { font-size: 0.85rem; margin-top: 4px; } + + @media (max-width: 480px) { + .card-header { padding: 12px 14px; } + .header-title { font-size: 1rem; } + .mode-btn { padding: 3px 8px; font-size: 0.7rem; } + .card-content { padding: 12px; } + } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Points Graph", + child_id: null, + days: 14, + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("choremander-graph-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Points Graph", days: 14 }; + } + + render() { + try { + return this._render(); + } catch(e) { + console.error("[ChoremanderGraph] Render error:", e); + return html`
Graph error: ${e.message}
`; + } + } + + _render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) { + return html`
Entity not found: ${this.config.entity}
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html`
Choremander is unavailable
`; + } + + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + let children = entity.attributes.children || []; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Points"; + const days = Math.max(3, Math.min(90, this.config.days || 14)); + + // Filter to specific child if configured + if (this.config.child_id) { + children = children.filter(c => c.id === this.config.child_id); + } + + // Get all completions (approved only for points) + const allCompletions = [ + ...(entity.attributes.recent_completions || entity.attributes.todays_completions || []), + ].filter(c => c.approved); + + // Get manual transactions + const allTransactions = entity.attributes.recent_transactions || []; + + // Build chore points map + const chorePointsMap = {}; + (entity.attributes.chores || []).forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; }); + + // Build date range + const dateRange = this._buildDateRange(days, tz); + + // Build per-child data series + const series = children.map((child, idx) => { + const color = CHILD_COLORS[idx % CHILD_COLORS.length]; + const dailyPoints = this._buildDailyPoints( + child.id, dateRange, allCompletions, allTransactions, chorePointsMap, tz + ); + const cumulativePoints = this._buildCumulative(dailyPoints); + return { child, color, dailyPoints, cumulativePoints }; + }); + + if (series.length === 0) { + return html`
No children found
`; + } + + const dataKey = this._mode === "daily" ? "dailyPoints" : "cumulativePoints"; + const hasData = series.some(s => s[dataKey].some(v => v > 0)); + + return html` + +
+
+ + ${this.config.title} +
+
+ + +
+
+ +
+ ${series.length > 1 ? html` +
+ ${series.map(s => html` +
+
+ ${s.child.name} +
+ `)} +
+ ` : ''} + + ${hasData + ? this._renderChart(series, dateRange, dataKey, pointsName) + : html` +
+ +
No data yet
+
Complete and approve chores to see the graph
+
+ `} +
+
+
+ `; + } + + get _tooltipId() { + if (!this.__tid) this.__tid = Math.random().toString(36).slice(2, 8); + return this.__tid; + } + + _renderChart(series, dateRange, dataKey, pointsName) { + // Store for canvas drawing after render + this._chartData = { series, dateRange, dataKey, pointsName }; + + return html` +
+ + +
+ `; + } + + updated() { + this._drawCanvas(); + } + + _drawCanvas() { + if (!this._chartData) return; + const canvas = this.shadowRoot?.querySelector(`#chart-canvas-${this._tooltipId}`); + if (!canvas) return; + + const { series, dateRange, dataKey } = this._chartData; + const DPR = window.devicePixelRatio || 1; + const W = canvas.offsetWidth || 300; + const H = 180; + + canvas.width = W * DPR; + canvas.height = H * DPR; + + const ctx = canvas.getContext('2d'); + ctx.scale(DPR, DPR); + + const PAD = { top: 16, right: 16, bottom: 28, left: 36 }; + const innerW = W - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + const n = dateRange.length; + + const allValues = series.flatMap(s => s[dataKey]); + const maxVal = Math.max(1, ...allValues); + const niceMax = this._niceMax(maxVal); + const yTicks = this._yTicks(niceMax); + + const xPos = (i) => PAD.left + (i / Math.max(n - 1, 1)) * innerW; + const yPos = (v) => PAD.top + innerH - (v / niceMax) * innerH; + + // Store for tooltip use + this._xPos = xPos; + this._PAD = PAD; + this._innerH = innerH; + this._canvasW = W; + + // Get computed colours from CSS + const style = getComputedStyle(this); + const gridColor = style.getPropertyValue('--divider-color').trim() || '#e0e0e0'; + const textColor = style.getPropertyValue('--secondary-text-color').trim() || '#888'; + + ctx.clearRect(0, 0, W, H); + + // Y grid lines and labels + ctx.font = '10px sans-serif'; + ctx.textBaseline = 'middle'; + for (const tick of yTicks) { + const y = yPos(tick); + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(PAD.left, y); + ctx.lineTo(W - PAD.right, y); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = textColor; + ctx.textAlign = 'right'; + ctx.fillText(tick, PAD.left - 4, y); + } + + // Zero line (solid) + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(PAD.left, yPos(0)); + ctx.lineTo(W - PAD.right, yPos(0)); + ctx.stroke(); + + // Area fills + for (const s of series) { + const data = s[dataKey]; + ctx.beginPath(); + ctx.moveTo(xPos(0), yPos(data[0])); + for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(data[i])); + ctx.lineTo(xPos(n - 1), yPos(0)); + ctx.lineTo(xPos(0), yPos(0)); + ctx.closePath(); + ctx.fillStyle = s.color.fill; + ctx.fill(); + } + + // Lines + for (const s of series) { + const data = s[dataKey]; + ctx.strokeStyle = s.color.line; + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(xPos(0), yPos(data[0])); + for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(data[i])); + ctx.stroke(); + } + + // Dots + for (const s of series) { + const data = s[dataKey]; + for (let i = 0; i < n; i++) { + if (data[i] > 0) { + ctx.beginPath(); + ctx.arc(xPos(i), yPos(data[i]), 3.5, 0, Math.PI * 2); + ctx.fillStyle = s.color.line; + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + } + + // X axis labels + const labelEvery = n <= 7 ? 1 : n <= 14 ? 2 : n <= 31 ? 4 : 7; + ctx.fillStyle = textColor; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + ctx.font = '10px sans-serif'; + dateRange.forEach((d, i) => { + if (i % labelEvery === 0 || i === n - 1) { + ctx.fillText(this._shortDate(d), xPos(i), H - 4); + } + }); + } + + _onChartInteract(e, isTouch) { + if (!this._chartData || !this._xPos) return; + const rect = e.currentTarget.getBoundingClientRect(); + const clientX = isTouch ? e.touches[0]?.clientX : e.clientX; + if (clientX === undefined) return; + if (isTouch) e.preventDefault(); + const xPx = clientX - rect.left; + + const { series, dateRange, dataKey, pointsName } = this._chartData; + const n = dateRange.length; + const xPos = this._xPos; + + // Scale pixel position to canvas coordinate space + const scaledX = (xPx / rect.width) * this._canvasW; + + let nearest = 0; + let minDist = Infinity; + for (let i = 0; i < n; i++) { + const dist = Math.abs(xPos(i) - scaledX); + if (dist < minDist) { minDist = dist; nearest = i; } + } + + const date = dateRange[nearest]; + const tooltip = this.shadowRoot?.querySelector(`#graph-tooltip-${this._tooltipId}`); + const hoverLine = this.shadowRoot?.querySelector(`#hover-line-${this._tooltipId}`); + + if (hoverLine) { + const lineX = (xPos(nearest) / this._canvasW) * rect.width; + hoverLine.style.left = `${lineX}px`; + hoverLine.style.display = 'block'; + } + + if (!tooltip) return; + const dateLabel = this._formatTooltipDate(date); + let html_content = `
${dateLabel}
`; + series.forEach(s => { + const val = s[dataKey][nearest] || 0; + html_content += `
${series.length > 1 ? s.child.name + ': ' : ''}${val} ${pointsName}
`; + }); + tooltip.innerHTML = html_content; + tooltip.classList.add('visible'); + + const tipW = 150; + let left = (xPos(nearest) / this._canvasW) * rect.width - tipW / 2; + left = Math.max(4, Math.min(left, rect.width - tipW - 4)); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${this._PAD.top}px`; + } + + _hideTooltip() { + const tooltip = this.shadowRoot?.querySelector(`#graph-tooltip-${this._tooltipId}`); + const hoverLine = this.shadowRoot?.querySelector(`#hover-line-${this._tooltipId}`); + if (tooltip) tooltip.classList.remove('visible'); + if (hoverLine) hoverLine.style.display = 'none'; + } + + // ── Data helpers ───────────────────────────────────────────── + + _buildDateRange(days, tz) { + const range = []; + const today = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + range.push(d.toLocaleDateString("en-CA", { timeZone: tz })); + } + return range; + } + + _buildDailyPoints(childId, dateRange, completions, transactions, chorePointsMap, tz) { + const byDay = {}; + dateRange.forEach(d => { byDay[d] = 0; }); + + // Approved chore completions + completions + .filter(c => c.child_id === childId) + .forEach(c => { + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + if (day in byDay) { + byDay[day] += c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0); + } + }); + + // Manual transactions (positive only for points earned; include removes too if desired) + transactions + .filter(t => t.child_id === childId) + .forEach(t => { + const day = new Date(t.created_at).toLocaleDateString("en-CA", { timeZone: tz }); + if (day in byDay) { + byDay[day] += t.points || 0; // negative for removals + } + }); + + return dateRange.map(d => Math.max(0, byDay[d])); + } + + _buildCumulative(dailyPoints) { + let running = 0; + return dailyPoints.map(v => { running += v; return running; }); + } + + _niceMax(val) { + if (val <= 10) return 10; + if (val <= 20) return 20; + if (val <= 50) return 50; + const magnitude = Math.pow(10, Math.floor(Math.log10(val))); + return Math.ceil(val / magnitude) * magnitude; + } + + _yTicks(max) { + const count = 4; + const step = max / count; + return Array.from({ length: count + 1 }, (_, i) => Math.round(i * step)); + } + + _shortDate(dateStr) { + const d = new Date(dateStr + "T12:00:00"); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + + _formatTooltipDate(dateStr) { + const d = new Date(dateStr + "T12:00:00"); + const today = new Date().toLocaleDateString("en-CA"); + const yesterday = new Date(Date.now() - 86400000).toLocaleDateString("en-CA"); + if (dateStr === today) return "Today"; + if (dateStr === yesterday) return "Yesterday"; + return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); + } +} + +// ── Card Editor ────────────────────────────────────────────── +class ChoremanderGraphCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + ha-textfield { width: 100%; margin-bottom: 16px; } + .form-row { margin-bottom: 16px; } + .form-label { + display: block; + font-size: 0.85rem; + font-weight: 500; + color: var(--primary-text-color); + margin-bottom: 6px; + padding: 0 2px; + } + .form-select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + font-size: 1rem; + box-sizing: border-box; + cursor: pointer; + appearance: auto; + } + .form-select:focus { + outline: none; + border-color: var(--primary-color); + } + .form-helper { + display: block; + font-size: 0.78rem; + color: var(--secondary-text-color); + margin-top: 4px; + padding: 0 2px; + } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + const entity = this.config.entity ? this.hass.states[this.config.entity] : null; + const children = entity?.attributes?.children || []; + + return html` + + + + + + +
+ + + Show one child's line or all children together +
+ `; + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (value === null || value === "" || value === undefined) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("choremander-graph-card", ChoremanderGraphCard); +customElements.define("choremander-graph-card-editor", ChoremanderGraphCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-graph-card", + name: "Choremander Points Graph", + description: "Line graph tracking daily or cumulative points over time", + preview: true, +}); + +console.info( + "%c CHOREMANDER-GRAPH-CARD %c v1.0.0 ", + "background: #2c3e50; color: white; font-weight: bold; border-radius: 4px 0 0 4px;", + "background: #9b59b6; color: white; font-weight: bold; border-radius: 0 4px 4px 0;" +); \ No newline at end of file diff --git a/custom_components/choremander/www/choremander-leaderboard-card.js b/custom_components/choremander/www/choremander-leaderboard-card.js new file mode 100644 index 0000000..7e77ed1 --- /dev/null +++ b/custom_components/choremander/www/choremander-leaderboard-card.js @@ -0,0 +1,533 @@ +/** + * Choremander Leaderboard Card + * Competitive multi-child ranking showing points, streaks, and weekly activity. + * Adapts gracefully for single-child households (shows personal bests instead). + * + * Version: 1.0.0 + */ + +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; + +const RANK_COLOURS = ["#f1c40f", "#bdc3c7", "#cd7f32", "#9b59b6", "#3498db"]; +const RANK_LABELS = ["🥇", "🥈", "🥉", "4th", "5th"]; + +class ChoremanderLeaderboardCard extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + ha-card { overflow: hidden; } + + .card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%); + color: white; gap: 12px; + } + + .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; } + .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } + .header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + .period-badge { + background: rgba(255,255,255,0.15); + border-radius: 10px; + padding: 3px 10px; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; + } + + .card-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; } + + /* Rank row */ + .rank-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 14px; + transition: box-shadow 0.2s; + position: relative; + overflow: hidden; + } + + .rank-row:hover { box-shadow: 0 3px 12px rgba(0,0,0,0.08); } + + .rank-row.first { + border-color: #f1c40f; + background: linear-gradient(135deg, rgba(241,196,15,0.06) 0%, var(--card-background-color, #fff) 100%); + } + + .rank-row.second { + border-color: #bdc3c7; + background: linear-gradient(135deg, rgba(189,195,199,0.06) 0%, var(--card-background-color, #fff) 100%); + } + + .rank-row.third { + border-color: #cd7f32; + background: linear-gradient(135deg, rgba(205,127,50,0.06) 0%, var(--card-background-color, #fff) 100%); + } + + .rank-badge { + font-size: 1.6rem; + line-height: 1; + flex-shrink: 0; + width: 36px; + text-align: center; + } + + .rank-number { + font-size: 1rem; + font-weight: 700; + color: var(--secondary-text-color); + text-align: center; + width: 36px; + flex-shrink: 0; + } + + .child-avatar { + width: 44px; height: 44px; min-width: 44px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + + .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; } + + .rank-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } + + .rank-name { + font-size: 1rem; font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .rank-stats { + display: flex; align-items: center; flex-wrap: wrap; + gap: 8px; font-size: 0.78rem; color: var(--secondary-text-color); + } + + .stat-chip { + display: flex; align-items: center; gap: 3px; + font-size: 0.75rem; color: var(--secondary-text-color); + } + + .stat-chip ha-icon { --mdc-icon-size: 13px; } + + .rank-score { + text-align: right; flex-shrink: 0; + display: flex; flex-direction: column; align-items: flex-end; gap: 2px; + } + + .score-value { + font-size: 1.4rem; font-weight: 800; + line-height: 1; + } + + .score-label { + font-size: 0.65rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: var(--secondary-text-color); + } + + /* Tie indicator */ + .tie-line { + height: 1px; + background: linear-gradient(90deg, transparent, var(--divider-color, #e0e0e0), transparent); + margin: -4px 0; + position: relative; + } + + .tie-line::after { + content: 'TIE'; + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + background: var(--secondary-background-color, #f5f5f5); + padding: 0 6px; + font-size: 0.65rem; font-weight: 700; + color: var(--secondary-text-color); + letter-spacing: 0.1em; + } + + /* Solo mode (1 child) */ + .solo-header { + font-size: 0.78rem; + font-weight: 600; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0 4px; + margin-bottom: 4px; + } + + .personal-best-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + background: var(--secondary-background-color, #f5f5f5); + border-radius: 10px; + } + + .pb-icon { --mdc-icon-size: 20px; } + .pb-label { flex: 1; font-size: 0.88rem; color: var(--primary-text-color); } + .pb-value { font-size: 0.95rem; font-weight: 700; color: var(--primary-text-color); } + + /* Footer */ + .card-footer { + padding: 10px 18px; + background: var(--secondary-background-color, #f5f5f5); + border-top: 1px solid var(--divider-color, #e0e0e0); + display: flex; justify-content: center; + font-size: 0.78rem; color: var(--secondary-text-color); + } + + /* Error / empty */ + .error-state, .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--secondary-text-color); text-align: center; + } + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + + @media (max-width: 480px) { + .card-content { padding: 10px; gap: 8px; } + .rank-row { padding: 10px 12px; gap: 10px; } + .rank-badge { font-size: 1.3rem; width: 28px; } + .child-avatar { width: 38px; height: 38px; min-width: 38px; } + .child-avatar ha-icon { --mdc-icon-size: 22px; } + .rank-name { font-size: 0.95rem; } + .score-value { font-size: 1.2rem; } + } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Leaderboard", + sort_by: "points", // "points" | "streak" | "weekly" + show_streak: true, + show_weekly: true, + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("choremander-leaderboard-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Leaderboard" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) return html`
Entity not found: ${this.config.entity}
`; + if (entity.state === "unavailable" || entity.state === "unknown") return html`
Choremander unavailable
`; + + const children = [...(entity.attributes.children || [])]; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Points"; + + if (children.length === 0) return html`
No children found
`; + + // Build weekly points from recent_completions + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const weeklyPoints = this._buildWeeklyPoints(entity, tz); + + // Sort children + const sortBy = this.config.sort_by || "points"; + const sorted = [...children].sort((a, b) => { + if (sortBy === "streak") return (b.current_streak || 0) - (a.current_streak || 0); + if (sortBy === "weekly") return (weeklyPoints[b.id] || 0) - (weeklyPoints[a.id] || 0); + return (b.points || 0) - (a.points || 0); + }); + + const sortLabels = { points: "All-time Points", streak: "Current Streak", weekly: "This Week" }; + const periodLabel = sortLabels[sortBy] || sortLabels.points; + + // Solo mode + if (children.length === 1) { + return this._renderSolo(sorted[0], weeklyPoints, pointsIcon, pointsName, periodLabel); + } + + return html` + +
+
+ + ${this.config.title} +
+ ${periodLabel} +
+
+ ${sorted.map((child, idx) => { + const prevChild = idx > 0 ? sorted[idx - 1] : null; + const isTie = prevChild && this._getScore(child, prevChild, sortBy, weeklyPoints); + return html` + ${isTie ? html`
` : ''} + ${this._renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName)} + `; + })} +
+ +
+ `; + } + + _getScore(a, b, sortBy, weeklyPoints) { + if (sortBy === "streak") return (a.current_streak || 0) === (b.current_streak || 0); + if (sortBy === "weekly") return (weeklyPoints[a.id] || 0) === (weeklyPoints[b.id] || 0); + return (a.points || 0) === (b.points || 0); + } + + _renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName) { + const rankClass = idx === 0 ? "first" : idx === 1 ? "second" : idx === 2 ? "third" : ""; + const avatarColour = RANK_COLOURS[idx % RANK_COLOURS.length]; + const rankEmoji = idx < 3 ? RANK_LABELS[idx] : null; + const rankNum = idx + 1; + + let scoreValue, scoreLabel; + if (sortBy === "streak") { + scoreValue = child.current_streak || 0; + scoreLabel = "day streak"; + } else if (sortBy === "weekly") { + scoreValue = weeklyPoints[child.id] || 0; + scoreLabel = "this week"; + } else { + scoreValue = child.points || 0; + scoreLabel = pointsName; + } + + return html` +
+ ${rankEmoji + ? html`
${rankEmoji}
` + : html`
${rankNum}
`} + +
+ +
+ +
+
${child.name}
+
+ ${this.config.show_streak !== false && sortBy !== "streak" ? html` + + + ${child.current_streak || 0}d streak + + ` : ''} + ${this.config.show_weekly !== false && sortBy !== "weekly" ? html` + + + ${weeklyPoints[child.id] || 0} this week + + ` : ''} + ${sortBy !== "points" ? html` + + + ${child.points || 0} total + + ` : ''} +
+
+ +
+
${scoreValue}
+
${scoreLabel}
+
+
+ `; + } + + _renderSolo(child, weeklyPoints, pointsIcon, pointsName, periodLabel) { + const entity = this.hass.states[this.config.entity]; + const totalChores = child.total_chores_completed || 0; + const bestStreak = child.best_streak || 0; + const weekly = weeklyPoints[child.id] || 0; + + return html` + +
+
+ + ${this.config.title} +
+
+
+
+
🥇
+
+ +
+
+
${child.name}
+
+ + + ${child.current_streak || 0}d streak + +
+
+
+
${child.points || 0}
+
${pointsName}
+
+
+ +
Personal Bests
+
+ + Best streak + ${bestStreak} days +
+
+ + Total chores completed + ${totalChores} +
+
+ + Points this week + ${weekly} +
+
+ + Total points earned + ${child.total_points_earned || child.points || 0} +
+
+
+ `; + } + + _buildWeeklyPoints(entity, tz) { + const result = {}; + const completions = entity.attributes.recent_completions || entity.attributes.todays_completions || []; + const choreMap = {}; + (entity.attributes.chores || []).forEach(ch => { choreMap[ch.id] = ch.points || 0; }); + + const today = new Date(); + const weekDays = new Set(); + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + weekDays.add(d.toLocaleDateString("en-CA", { timeZone: tz })); + } + + completions + .filter(c => c.approved) + .forEach(c => { + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + if (weekDays.has(day)) { + result[c.child_id] = (result[c.child_id] || 0) + + (c.points !== undefined ? c.points : (choreMap[c.chore_id] || 0)); + } + }); + + return result; + } +} + +class ChoremanderLeaderboardCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; padding: 4px 0; } + ha-textfield { width: 100%; margin-bottom: 8px; } + .field-row { margin-bottom: 16px; } + .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; } + .field-select { display: block; width: 100%; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; box-sizing: border-box; cursor: pointer; appearance: auto; } + .field-select:focus { outline: none; border-color: var(--primary-color); border-width: 2px; } + .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; } + .check-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); cursor: pointer; user-select: none; margin-bottom: 8px; } + .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; accent-color: var(--primary-color); margin: 0; } + .check-label { font-size: 14px; color: var(--primary-text-color); flex: 1; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + return html` + + + + +
+ + + What to rank children by +
+ + + + + `; + } + + _update(key, value) { + const cfg = { ...this.config, [key]: value }; + this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true })); + } +} + +customElements.define("choremander-leaderboard-card", ChoremanderLeaderboardCard); +customElements.define("choremander-leaderboard-card-editor", ChoremanderLeaderboardCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-leaderboard-card", + name: "Choremander Leaderboard", + description: "Multi-child competitive ranking by points, streak, or weekly activity", + preview: true, +}); + +console.info("%c CHOREMANDER-LEADERBOARD-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#f1c40f;color:#333;font-weight:bold;border-radius:0 4px 4px 0;"); diff --git a/custom_components/choremander/www/choremander-overview-card.js b/custom_components/choremander/www/choremander-overview-card.js new file mode 100644 index 0000000..16e9e51 --- /dev/null +++ b/custom_components/choremander/www/choremander-overview-card.js @@ -0,0 +1,504 @@ +/** + * Choremander Overview Card + * At-a-glance parent dashboard showing all children's points, + * today's chore completion progress, and pending approvals. + * + * Version: 1.0.0 + * Last Updated: 2026-03-18 + */ + +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 ChoremanderOverviewCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + }; + } + + static get styles() { + return css` + :host { + display: block; + --ov-purple: #9b59b6; + --ov-purple-light: #a569bd; + --ov-gold: #f1c40f; + --ov-green: #2ecc71; + --ov-orange: #e67e22; + --ov-red: #e74c3c; + --ov-blue: #3498db; + } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, var(--ov-purple) 0%, var(--ov-purple-light) 100%); + color: white; + } + + .header-content { display: flex; align-items: center; gap: 10px; } + .header-icon { --mdc-icon-size: 28px; opacity: 0.9; } + .header-title { font-size: 1.2rem; font-weight: 600; } + + .pending-badge { + background: var(--ov-red); + color: white; + border-radius: 12px; + padding: 3px 10px; + font-size: 0.85rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; + animation: badge-pulse 2s ease-in-out infinite; + } + + @keyframes badge-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); } + 50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); } + } + + .pending-badge ha-icon { --mdc-icon-size: 14px; } + + .card-content { + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Child tile */ + .child-tile { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 14px; + transition: box-shadow 0.2s ease; + } + + .child-tile:hover { + box-shadow: 0 3px 10px rgba(0,0,0,0.08); + } + + .child-avatar { + width: 46px; + height: 46px; + border-radius: 50%; + background: linear-gradient(135deg, var(--ov-purple) 0%, var(--ov-purple-light) 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .child-avatar ha-icon { --mdc-icon-size: 28px; color: white; } + + .child-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } + + .child-name-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .child-name { + font-weight: 600; + font-size: 1.05rem; + color: var(--primary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .points-pill { + display: flex; + align-items: center; + gap: 4px; + background: rgba(241,196,15,0.15); + color: var(--ov-orange); + border-radius: 10px; + padding: 3px 8px; + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; + } + + .points-pill ha-icon { --mdc-icon-size: 14px; color: var(--ov-gold); } + + .pending-points-pill { + display: flex; + align-items: center; + gap: 3px; + background: rgba(230,126,34,0.12); + color: var(--ov-orange); + border-radius: 10px; + padding: 2px 7px; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; + opacity: 0.85; + } + + .pending-points-pill ha-icon { --mdc-icon-size: 12px; } + + /* Chore progress bar */ + .progress-row { + display: flex; + align-items: center; + gap: 8px; + } + + .progress-bar-bg { + flex: 1; + height: 8px; + background: var(--divider-color, #e0e0e0); + border-radius: 4px; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.4s ease; + } + + .progress-bar-fill.complete { + background: linear-gradient(90deg, var(--ov-green), #27ae60); + } + + .progress-bar-fill.partial { + background: linear-gradient(90deg, var(--ov-blue), #2980b9); + } + + .progress-bar-fill.none { + background: var(--divider-color, #e0e0e0); + width: 0 !important; + } + + .progress-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--secondary-text-color); + white-space: nowrap; + min-width: 36px; + text-align: right; + } + + .progress-label.complete { color: var(--ov-green); } + + /* Approval item in tile */ + .approvals-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(231,76,60,0.12); + color: var(--ov-red); + border-radius: 10px; + padding: 2px 8px; + font-size: 0.78rem; + font-weight: 600; + } + + .approvals-chip ha-icon { --mdc-icon-size: 13px; } + + /* Footer summary row */ + .summary-footer { + display: flex; + align-items: center; + justify-content: space-around; + padding: 10px 16px; + background: var(--secondary-background-color, #f5f5f5); + border-top: 1px solid var(--divider-color, #e0e0e0); + gap: 8px; + } + + .summary-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + } + + .summary-stat-value { + font-size: 1.3rem; + font-weight: 700; + color: var(--primary-text-color); + } + + .summary-stat-label { + font-size: 0.7rem; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .summary-divider { + width: 1px; + height: 32px; + background: var(--divider-color, #e0e0e0); + } + + /* States */ + .error-state, .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--secondary-text-color); + text-align: center; + } + + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Choremander", + approvals_entity: null, + ...config, + }; + } + + getCardSize() { return 3; } + static getConfigElement() { return document.createElement("choremander-overview-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Choremander" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) { + return html`
Entity not found: ${this.config.entity}
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html`
Choremander is unavailable
`; + } + + const children = entity.attributes.children || []; + const chores = entity.attributes.chores || []; + const completions = [...(entity.attributes.todays_completions || [])]; + const chorePointsMap = {}; + chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; }); + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Stars"; + + // Pending approvals — from approvals entity if configured, else from completions + let pendingApprovals = 0; + if (this.config.approvals_entity) { + const appEntity = this.hass.states[this.config.approvals_entity]; + pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0; + } else { + pendingApprovals = completions.filter(c => !c.approved).length; + } + + // Total points across all children + const totalPoints = children.reduce((sum, c) => sum + (c.points || 0), 0); + // Only count approved completions + const totalCompletedToday = completions.filter(c => c.approved).length; + + if (children.length === 0) { + return html`
No children found
`; + } + + return html` + +
+
+ + ${this.config.title} +
+ ${pendingApprovals > 0 ? html` +
+ + ${pendingApprovals} pending +
+ ` : ''} +
+ +
+ ${children.map(child => this._renderChildTile(child, chores, completions, pointsIcon, pointsName))} +
+ + +
+ `; + } + + _renderChildTile(child, chores, completions, pointsIcon, pointsName) { + // Avatar now included directly in children array from the overview sensor + const avatar = child.avatar || "mdi:account-circle"; + + // Chores assigned to this child + const childChores = chores.filter(c => { + const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : []; + return at.length === 0 || at.includes(String(child.id)); + }); + + // All completions today for this child + const childCompletions = completions.filter(c => c.child_id === child.id); + // Only approved completions count toward progress + const childApprovedCompletions = childCompletions.filter(c => c.approved); + const completedCount = childApprovedCompletions.length; + const totalChores = childChores.length; + const percentage = totalChores > 0 ? Math.min((completedCount / totalChores) * 100, 100) : 0; + const isComplete = totalChores > 0 && completedCount >= totalChores; + + // Pending approvals for this child + const childPending = childCompletions.filter(c => !c.approved).length; + + return html` +
+
+ +
+
+
+ ${child.name} +
+ ${child.pending_points > 0 ? html` + + +${child.pending_points} + + ` : ''} + + + ${child.points} + + ${childPending > 0 ? html` + + ${childPending} + + ` : ''} +
+
+ ${totalChores > 0 ? html` +
+
+
+
+ + ${completedCount}/${totalChores} + +
+ ` : html` +
No chores today
+ `} +
+
+ `; + } +} + +// Card Editor +class ChoremanderOverviewCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + ha-textfield { width: 100%; margin-bottom: 16px; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + return html` + + + + `; + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (!value) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("choremander-overview-card", ChoremanderOverviewCard); +customElements.define("choremander-overview-card-editor", ChoremanderOverviewCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-overview-card", + name: "Choremander Overview", + description: "At-a-glance parent dashboard for all children", + preview: true, +}); + +console.info( + "%c CHOREMANDER-OVERVIEW-CARD %c v1.0.0 ", + "background: #9b59b6; color: white; font-weight: bold; border-radius: 4px 0 0 4px;", + "background: #2ecc71; color: white; font-weight: bold; border-radius: 0 4px 4px 0;" +); \ No newline at end of file diff --git a/custom_components/choremander/www/choremander-parent-dashboard-card.js b/custom_components/choremander/www/choremander-parent-dashboard-card.js new file mode 100644 index 0000000..d9059eb --- /dev/null +++ b/custom_components/choremander/www/choremander-parent-dashboard-card.js @@ -0,0 +1,732 @@ +/** + * Choremander Parent Dashboard Card + * Unified parent view: all children's today progress, pending approvals + * with inline approve/reject, pending reward claims, and quick point adjustments. + * + * Version: 1.0.0 + */ + +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 ChoremanderParentDashboardCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + _loading: { type: Object }, + _activeSection: { type: String }, + }; + } + + constructor() { + super(); + this._loading = {}; + this._activeSection = "overview"; + } + + static get styles() { + return css` + :host { display: block; } + ha-card { overflow: hidden; } + + /* ── Header ── */ + .card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%); + color: white; gap: 12px; + } + + .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } + .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } + .header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + .pending-badge { + background: #e74c3c; color: white; + border-radius: 12px; padding: 3px 10px; + font-size: 0.82rem; font-weight: 700; + display: flex; align-items: center; gap: 4px; + flex-shrink: 0; + animation: badge-pulse 2s ease-in-out infinite; + } + + .pending-badge ha-icon { --mdc-icon-size: 14px; } + + @keyframes badge-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); } + 50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); } + } + + /* ── Tab nav ── */ + .tab-nav { + display: flex; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + background: var(--secondary-background-color, #f5f5f5); + } + + .tab-btn { + flex: 1; padding: 10px 8px; + background: none; border: none; + font-size: 0.78rem; font-weight: 600; + color: var(--secondary-text-color); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + display: flex; align-items: center; justify-content: center; gap: 5px; + position: relative; + } + + .tab-btn ha-icon { --mdc-icon-size: 16px; } + + .tab-btn.active { + color: var(--primary-color, #3498db); + border-bottom-color: var(--primary-color, #3498db); + } + + .tab-badge { + background: #e74c3c; color: white; + border-radius: 8px; padding: 1px 5px; + font-size: 0.65rem; font-weight: 700; + line-height: 1.4; + } + + /* ── Content ── */ + .tab-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; } + + /* ── Child overview tiles ── */ + .child-tile { + display: flex; align-items: center; gap: 12px; + padding: 12px 14px; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 12px; + } + + .child-avatar { + width: 42px; height: 42px; min-width: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + + .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; } + + .child-tile-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } + + .child-tile-header { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + } + + .child-tile-name { + font-size: 0.95rem; font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .points-pill { + display: flex; align-items: center; gap: 3px; + background: rgba(241,196,15,0.15); + color: #e67e22; border-radius: 10px; + padding: 2px 8px; font-size: 0.8rem; font-weight: 700; + flex-shrink: 0; + } + + .points-pill ha-icon { --mdc-icon-size: 13px; color: #f1c40f; } + + .progress-row { display: flex; align-items: center; gap: 8px; } + + .progress-bar { + flex: 1; height: 7px; + background: var(--divider-color, #e0e0e0); + border-radius: 4px; overflow: hidden; + } + + .progress-fill { + height: 100%; border-radius: 4px; + transition: width 0.4s ease; + } + + .progress-fill.complete { background: linear-gradient(90deg, #27ae60, #2ecc71); } + .progress-fill.partial { background: linear-gradient(90deg, #3498db, #2980b9); } + .progress-fill.none { width: 0 !important; } + + .progress-label { + font-size: 0.75rem; font-weight: 600; + color: var(--secondary-text-color); + white-space: nowrap; min-width: 32px; text-align: right; + } + + /* ── Approval items ── */ + .approval-item { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 12px; + transition: opacity 0.2s; + } + + .approval-item.loading { opacity: 0.5; pointer-events: none; } + + .approval-child-avatar { + width: 38px; height: 38px; min-width: 38px; + border-radius: 50%; + background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); + display: flex; align-items: center; justify-content: center; + } + + .approval-child-avatar ha-icon { --mdc-icon-size: 22px; color: white; } + + .approval-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } + + .approval-chore { + font-size: 0.9rem; font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .approval-meta { + font-size: 0.75rem; color: var(--secondary-text-color); + display: flex; align-items: center; gap: 6px; + } + + .approval-points { + display: flex; align-items: center; gap: 2px; + font-weight: 600; color: #e67e22; + } + + .approval-points ha-icon { --mdc-icon-size: 12px; color: #f1c40f; } + + .approval-actions { display: flex; gap: 6px; flex-shrink: 0; } + + .btn-approve, .btn-reject { + width: 34px; height: 34px; + border-radius: 50%; border: none; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: transform 0.1s, box-shadow 0.1s; + flex-shrink: 0; + } + + .btn-approve { + background: linear-gradient(135deg, #27ae60, #2ecc71); + color: white; box-shadow: 0 2px 8px rgba(46,204,113,0.3); + } + + .btn-reject { + background: linear-gradient(135deg, #c0392b, #e74c3c); + color: white; box-shadow: 0 2px 8px rgba(231,76,60,0.3); + } + + .btn-approve:hover { transform: scale(1.1); } + .btn-reject:hover { transform: scale(1.1); } + .btn-approve ha-icon, .btn-reject ha-icon { --mdc-icon-size: 18px; } + + /* ── Reward claim items ── */ + .claim-item { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; + background: var(--card-background-color, #fff); + border: 1px solid rgba(155,89,182,0.3); + border-radius: 12px; + background: rgba(155,89,182,0.04); + transition: opacity 0.2s; + } + + .claim-item.loading { opacity: 0.5; pointer-events: none; } + + .claim-icon-wrap { + width: 38px; height: 38px; min-width: 38px; + border-radius: 50%; + background: linear-gradient(135deg, #9b59b6, #8e44ad); + display: flex; align-items: center; justify-content: center; + } + + .claim-icon-wrap ha-icon { --mdc-icon-size: 22px; color: white; } + + .claim-info { flex: 1; min-width: 0; } + + .claim-reward-name { + font-size: 0.9rem; font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .claim-meta { + font-size: 0.75rem; color: var(--secondary-text-color); margin-top: 2px; + } + + /* ── Quick points ── */ + .quick-points-row { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 12px; + } + + .qp-avatar { + width: 38px; height: 38px; min-width: 38px; + border-radius: 50%; + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); + display: flex; align-items: center; justify-content: center; + } + + .qp-avatar ha-icon { --mdc-icon-size: 22px; color: white; } + + .qp-name { + flex: 1; font-size: 0.9rem; font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .qp-points { + font-size: 1rem; font-weight: 700; + color: #9b59b6; white-space: nowrap; + display: flex; align-items: center; gap: 3px; + } + + .qp-points ha-icon { --mdc-icon-size: 14px; color: #f1c40f; } + + .qp-actions { display: flex; gap: 6px; } + + .btn-add, .btn-remove { + width: 32px; height: 32px; + border-radius: 50%; border: none; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: transform 0.1s; + flex-shrink: 0; + } + + .btn-add { + background: linear-gradient(135deg, #27ae60, #2ecc71); + color: white; box-shadow: 0 2px 6px rgba(46,204,113,0.3); + } + + .btn-remove { + background: linear-gradient(135deg, #c0392b, #e74c3c); + color: white; box-shadow: 0 2px 6px rgba(231,76,60,0.3); + } + + .btn-add:hover, .btn-remove:hover { transform: scale(1.1); } + .btn-add ha-icon, .btn-remove ha-icon { --mdc-icon-size: 16px; } + + /* ── Empty state ── */ + .empty-section { + display: flex; flex-direction: column; align-items: center; + padding: 24px 16px; text-align: center; gap: 8px; + color: var(--secondary-text-color); + } + + .empty-section ha-icon { --mdc-icon-size: 40px; opacity: 0.35; } + .empty-section span { font-size: 0.9rem; } + + .error-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--error-color, #f44336); text-align: center; + } + + .error-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + + @media (max-width: 480px) { + .card-header { padding: 12px 14px; } + .tab-btn { font-size: 0.72rem; padding: 8px 6px; } + .tab-content { padding: 10px; gap: 8px; } + .approval-item, .claim-item, .quick-points-row, .child-tile { padding: 10px 12px; } + } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Parent Dashboard", + quick_points_amount: 5, + show_claims: true, + ...config, + }; + } + + getCardSize() { return 6; } + static getConfigElement() { return document.createElement("choremander-parent-dashboard-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Parent Dashboard" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) return html`
Entity not found: ${this.config.entity}
`; + if (entity.state === "unavailable" || entity.state === "unknown") return html`
Choremander unavailable
`; + + const children = entity.attributes.children || []; + const chores = entity.attributes.chores || []; + const completions = entity.attributes.todays_completions || []; + const pendingCompletions = completions.filter(c => !c.approved); + const pendingRewardClaims = entity.attributes.pending_reward_claims || []; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Points"; + const totalPending = pendingCompletions.length + pendingRewardClaims.length; + + const tabs = [ + { id: "overview", label: "Overview", icon: "mdi:view-dashboard" }, + { id: "approvals", label: "Approvals", icon: "mdi:check-circle", count: pendingCompletions.length }, + { id: "points", label: "Points", icon: "mdi:star-plus" }, + ]; + + if (this.config.show_claims) { + tabs.splice(2, 0, { id: "claims", label: "Claims", icon: "mdi:gift", count: pendingRewardClaims.length }); + } + + return html` + +
+
+ + ${this.config.title} +
+ ${totalPending > 0 ? html` +
+ + ${totalPending} +
+ ` : ''} +
+ +
+ ${tabs.map(tab => html` + + `)} +
+ +
+ ${this._activeSection === "overview" ? this._renderOverview(children, chores, completions, pointsIcon, pointsName) : ''} + ${this._activeSection === "approvals" ? this._renderApprovals(pendingCompletions, children, chores, pointsIcon) : ''} + ${this._activeSection === "claims" ? this._renderClaims(pendingRewardClaims, pointsIcon) : ''} + ${this._activeSection === "points" ? this._renderPoints(children, pointsIcon, pointsName) : ''} +
+
+ `; + } + + _renderOverview(children, chores, completions, pointsIcon, pointsName) { + if (!children.length) return html`
No children found
`; + + return html` + ${children.map(child => { + const childChores = chores.filter(c => { + const at = c.assigned_to || []; + return at.length === 0 || at.includes(child.id); + }); + const approved = completions.filter(c => c.child_id === child.id && c.approved).length; + const total = childChores.length; + const pct = total > 0 ? Math.min(100, (approved / total) * 100) : 0; + const isComplete = total > 0 && approved >= total; + const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none"; + + return html` +
+
+ +
+
+
+ ${child.name} + + + ${child.points} + +
+
+
+
+
+ ${approved}/${total} +
+
+
+ `; + })} + `; + } + + _renderApprovals(pending, children, chores, pointsIcon) { + if (!pending.length) return html` +
+ + All caught up! No pending approvals. +
+ `; + + const childMap = {}; + children.forEach(c => { childMap[c.id] = c; }); + const choreMap = {}; + chores.forEach(c => { choreMap[c.id] = c; }); + + return html` + ${pending.map(comp => { + const child = childMap[comp.child_id]; + const chore = choreMap[comp.chore_id]; + const isLoading = this._loading[comp.completion_id]; + const time = new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + + return html` +
+
+ +
+
+
${comp.chore_name || chore?.name || 'Unknown chore'}
+
+ ${child?.name || 'Unknown'} + + ${time} + + + +${comp.points || chore?.points || 0} + +
+
+
+ + +
+
+ `; + })} + `; + } + + _renderClaims(claims, pointsIcon) { + if (!claims.length) return html` +
+ + No pending reward claims. +
+ `; + + return html` + ${claims.map(claim => { + const isLoading = this._loading[claim.claim_id]; + const time = new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return html` +
+
+ +
+
+
${claim.reward_name}
+
+ ${claim.child_name} • ${time} • + + + ${claim.cost} + +
+
+
+ + +
+
+ `; + })} + `; + } + + _renderPoints(children, pointsIcon, pointsName) { + const amount = this.config.quick_points_amount || 5; + + return html` + ${children.map(child => html` +
+
+ +
+ ${child.name} + + + ${child.points} + +
+ + +
+
+ `)} + `; + } + + async _handleApprove(completionId) { + this._loading = { ...this._loading, [completionId]: true }; + this.requestUpdate(); + try { + await this.hass.callService("choremander", "approve_chore", { completion_id: completionId }); + } catch (e) { + console.error("Failed to approve chore:", e); + } finally { + this._loading = { ...this._loading, [completionId]: false }; + this.requestUpdate(); + } + } + + async _handleReject(completionId) { + this._loading = { ...this._loading, [completionId]: true }; + this.requestUpdate(); + try { + await this.hass.callService("choremander", "reject_chore", { completion_id: completionId }); + } catch (e) { + console.error("Failed to reject chore:", e); + } finally { + this._loading = { ...this._loading, [completionId]: false }; + this.requestUpdate(); + } + } + + async _handleApproveReward(claimId) { + this._loading = { ...this._loading, [claimId]: true }; + this.requestUpdate(); + try { + await this.hass.callService("choremander", "approve_reward", { claim_id: claimId }); + } catch (e) { + console.error("Failed to approve reward:", e); + } finally { + this._loading = { ...this._loading, [claimId]: false }; + this.requestUpdate(); + } + } + + async _handleRejectReward(claimId) { + this._loading = { ...this._loading, [claimId]: true }; + this.requestUpdate(); + try { + await this.hass.callService("choremander", "reject_reward", { claim_id: claimId }); + } catch (e) { + console.error("Failed to reject reward:", e); + } finally { + this._loading = { ...this._loading, [claimId]: false }; + this.requestUpdate(); + } + } + + async _handlePoints(childId, delta) { + const key = `${childId}_${delta}`; + this._loading = { ...this._loading, [key]: true }; + this.requestUpdate(); + try { + const service = delta > 0 ? "add_points" : "remove_points"; + await this.hass.callService("choremander", service, { + child_id: childId, + points: Math.abs(delta), + }); + } catch (e) { + console.error("Failed to adjust points:", e); + } finally { + this._loading = { ...this._loading, [key]: false }; + this.requestUpdate(); + } + } +} + +class ChoremanderParentDashboardCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; padding: 4px 0; } + ha-textfield { width: 100%; margin-bottom: 8px; } + .field-row { margin-bottom: 16px; } + .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; } + .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; } + .check-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); cursor: pointer; user-select: none; margin-bottom: 8px; } + .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; accent-color: var(--primary-color); margin: 0; } + .check-label { font-size: 14px; color: var(--primary-text-color); flex: 1; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + return html` + + + + + + + + `; + } + + _update(key, value) { + const cfg = { ...this.config, [key]: value }; + this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true })); + } +} + +customElements.define("choremander-parent-dashboard-card", ChoremanderParentDashboardCard); +customElements.define("choremander-parent-dashboard-card-editor", ChoremanderParentDashboardCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-parent-dashboard-card", + name: "Choremander Parent Dashboard", + description: "Unified parent view with approvals, child progress, and quick point controls", + preview: true, +}); + +console.info("%c CHOREMANDER-PARENT-DASHBOARD-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#e74c3c;color:white;font-weight:bold;border-radius:0 4px 4px 0;"); \ No newline at end of file diff --git a/custom_components/choremander/www/choremander-reward-progress-card.js b/custom_components/choremander/www/choremander-reward-progress-card.js new file mode 100644 index 0000000..c696742 --- /dev/null +++ b/custom_components/choremander/www/choremander-reward-progress-card.js @@ -0,0 +1,632 @@ +/** + * Choremander Reward Progress Card + * Full-screen motivational display showing a single reward's progress. + * Designed for wall tablets as a persistent motivation display. + * + * Version: 1.0.0 + */ + +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 ChoremanderRewardProgressCard extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%); + color: white; + gap: 12px; + } + + .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; } + .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; } + .header-title { + font-size: 1.1rem; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + .card-content { padding: 20px 18px; } + + /* Reward hero section */ + .reward-hero { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 12px 0 20px; + gap: 10px; + } + + .reward-icon-wrap { + width: 90px; + height: 90px; + border-radius: 50%; + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 6px 24px rgba(155,89,182,0.35); + animation: hero-float 3s ease-in-out infinite; + } + + @keyframes hero-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } + } + + .reward-icon-wrap ha-icon { --mdc-icon-size: 52px; color: white; } + + .reward-name { + font-size: 1.6rem; + font-weight: 700; + color: var(--primary-text-color); + line-height: 1.2; + } + + .reward-description { + font-size: 0.9rem; + color: var(--secondary-text-color); + max-width: 280px; + } + + /* Children progress blocks */ + .children-section { + display: flex; + flex-direction: column; + gap: 16px; + } + + .child-progress-block { + background: var(--secondary-background-color, #f8f8f8); + border-radius: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .child-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .child-progress-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .child-avatar { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); + display: flex; + align-items: center; + justify-content: center; + } + + .child-avatar ha-icon { --mdc-icon-size: 24px; color: white; } + + .child-progress-name { + font-size: 1rem; + font-weight: 600; + color: var(--primary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .child-points-label { + font-size: 0.8rem; + color: var(--secondary-text-color); + } + + .child-progress-cost { + text-align: right; + flex-shrink: 0; + } + + .cost-label { + font-size: 0.75rem; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .cost-value { + font-size: 1.2rem; + font-weight: 700; + color: #9b59b6; + display: flex; + align-items: center; + gap: 3px; + justify-content: flex-end; + } + + .cost-value ha-icon { --mdc-icon-size: 16px; color: #f1c40f; } + + /* Big animated progress bar */ + .big-progress-wrap { + display: flex; + flex-direction: column; + gap: 6px; + } + + .big-progress-bar { + height: 22px; + background: var(--divider-color, #e0e0e0); + border-radius: 11px; + overflow: hidden; + position: relative; + } + + .big-progress-fill { + height: 100%; + border-radius: 11px; + transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); + position: relative; + overflow: hidden; + } + + .big-progress-fill::after { + content: ''; + position: absolute; + top: 0; left: -100%; + width: 60%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 2s infinite; + } + + @keyframes shimmer { + 0% { left: -100%; } + 100% { left: 200%; } + } + + .big-progress-fill.affordable { + background: linear-gradient(90deg, #27ae60, #2ecc71); + } + + .big-progress-fill.close { + background: linear-gradient(90deg, #e67e22, #f39c12); + } + + .big-progress-fill.far { + background: linear-gradient(90deg, #9b59b6, #a569bd); + } + + .big-progress-fill.complete { + background: linear-gradient(90deg, #27ae60, #1abc9c); + } + + .progress-stat-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; + } + + .progress-have { + font-weight: 600; + color: var(--primary-text-color); + } + + .progress-need { + color: var(--secondary-text-color); + } + + .progress-pct { + font-weight: 700; + font-size: 0.95rem; + } + + .progress-pct.affordable { color: #27ae60; } + .progress-pct.close { color: #e67e22; } + .progress-pct.far { color: #9b59b6; } + + /* Can afford badge */ + .can-afford-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background: rgba(46,204,113,0.15); + color: #27ae60; + border-radius: 20px; + padding: 5px 12px; + font-size: 0.85rem; + font-weight: 700; + animation: pulse-green 2s ease-in-out infinite; + align-self: center; + } + + .can-afford-badge ha-icon { --mdc-icon-size: 16px; } + + @keyframes pulse-green { + 0%, 100% { box-shadow: 0 0 0 0 rgba(46,204,113,0.3); } + 50% { box-shadow: 0 0 0 6px rgba(46,204,113,0); } + } + + /* Jackpot section */ + .jackpot-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background: linear-gradient(135deg, #f39c12, #f1c40f); + color: white; + border-radius: 20px; + padding: 4px 12px; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + align-self: flex-start; + } + + .jackpot-badge ha-icon { --mdc-icon-size: 14px; } + + .jackpot-pool { + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(241,196,15,0.08); + border: 1px solid rgba(241,196,15,0.25); + border-radius: 12px; + padding: 12px; + } + + .jackpot-pool-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .jackpot-contributors { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .jackpot-contributor { + display: flex; + align-items: center; + gap: 6px; + background: var(--card-background-color, #fff); + border-radius: 20px; + padding: 4px 10px 4px 6px; + font-size: 0.82rem; + font-weight: 600; + color: var(--primary-text-color); + } + + .jackpot-contributor .mini-avatar { + width: 22px; height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, #f39c12, #f1c40f); + display: flex; align-items: center; justify-content: center; + } + + .jackpot-contributor .mini-avatar ha-icon { --mdc-icon-size: 13px; color: white; } + + /* Error / empty */ + .error-state, .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--secondary-text-color); text-align: center; + } + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + + /* Responsive */ + @media (max-width: 480px) { + .card-content { padding: 14px 12px; } + .reward-icon-wrap { width: 70px; height: 70px; } + .reward-icon-wrap ha-icon { --mdc-icon-size: 40px; } + .reward-name { font-size: 1.3rem; } + .big-progress-bar { height: 18px; border-radius: 9px; } + .child-progress-block { padding: 12px; } + } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Reward Goal", + reward_id: null, + child_id: null, + ...config, + }; + } + + getCardSize() { return 5; } + static getConfigElement() { return document.createElement("choremander-reward-progress-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Reward Goal" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) return html`
Entity not found: ${this.config.entity}
`; + if (entity.state === "unavailable" || entity.state === "unknown") return html`
Choremander unavailable
`; + + const rewards = entity.attributes.rewards || []; + const children = entity.attributes.children || []; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Points"; + + // Pick reward + let reward = this.config.reward_id + ? rewards.find(r => r.id === this.config.reward_id) + : rewards[0]; + + if (!reward) return html`
No rewards found
`; + + // Which children to show + let showChildren = children; + if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id); + if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id)); + if (!showChildren.length) showChildren = children; + + const isJackpot = reward.is_jackpot; + + return html` + +
+
+ + ${this.config.title} +
+
+
+
+
+ +
+
${reward.name}
+ ${reward.description ? html`
${reward.description}
` : ''} + ${isJackpot ? html` +
+ + Jackpot Reward +
+ ` : ''} +
+ + ${isJackpot + ? this._renderJackpot(reward, showChildren, pointsIcon, pointsName) + : html` +
+ ${showChildren.map(child => this._renderChildProgress(child, reward, pointsIcon, pointsName))} +
+ `} +
+
+ `; + } + + _renderChildProgress(child, reward, pointsIcon, pointsName) { + const cost = reward.calculated_costs?.[child.id] ?? reward.cost; + const have = child.points || 0; + const pct = Math.min(100, Math.round((have / cost) * 100)); + const canAfford = have >= cost; + const close = pct >= 70; + const cls = canAfford ? "complete" : close ? "close" : "far"; + const pctCls = canAfford ? "affordable" : close ? "close" : "far"; + + return html` +
+
+
+
+ +
+
+
${child.name}
+
${have} ${pointsName}
+
+
+
+
Goal
+
+ + ${cost} +
+
+
+ +
+
+
+
+
+ ${have} / ${cost} ${pointsName} + ${canAfford + ? html`🎉 Ready to claim!` + : html`${cost - have} more needed`} + ${pct}% +
+
+ + ${canAfford ? html` +
+ + Ready to claim! +
+ ` : ''} +
+ `; + } + + _renderJackpot(reward, children, pointsIcon, pointsName) { + const cost = reward.calculated_costs + ? Object.values(reward.calculated_costs)[0] ?? reward.cost + : reward.cost; + + const totalHave = children.reduce((s, c) => s + (c.points || 0), 0); + const pct = Math.min(100, Math.round((totalHave / cost) * 100)); + const canAfford = totalHave >= cost; + const close = pct >= 70; + const cls = canAfford ? "complete" : close ? "close" : "far"; + const pctCls = canAfford ? "affordable" : close ? "close" : "far"; + + return html` +
+
+
+
Combined Points Pool
+
+ ${children.map(child => html` +
+
+ +
+ ${child.name}: ${child.points} +
+ `)} +
+
+ +
+
+
Total Pool
+
${totalHave} ${pointsName} combined
+
+
+
Goal
+
+ + ${cost} +
+
+
+ +
+
+
+
+
+ ${totalHave} / ${cost} ${pointsName} + ${canAfford + ? html`🎉 Ready!` + : html`${cost - totalHave} more needed`} + ${pct}% +
+
+ + ${canAfford ? html` +
+ + Ready to claim! +
+ ` : ''} +
+
+ `; + } +} + +class ChoremanderRewardProgressCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; padding: 4px 0; } + ha-textfield { width: 100%; margin-bottom: 8px; } + .field-row { margin-bottom: 16px; } + .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; } + .field-select { display: block; width: 100%; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; box-sizing: border-box; cursor: pointer; appearance: auto; } + .field-select:focus { outline: none; border-color: var(--primary-color); border-width: 2px; } + .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + const entity = this.config.entity ? this.hass.states[this.config.entity] : null; + const rewards = entity?.attributes?.rewards || []; + const children = entity?.attributes?.children || []; + + return html` + + + + +
+ + + Which reward to show progress for +
+ +
+ + + Show only this child's progress +
+ `; + } + + _update(key, value) { + const cfg = { ...this.config, [key]: value }; + if (!value) delete cfg[key]; + this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true })); + } +} + +customElements.define("choremander-reward-progress-card", ChoremanderRewardProgressCard); +customElements.define("choremander-reward-progress-card-editor", ChoremanderRewardProgressCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-reward-progress-card", + name: "Choremander Reward Progress", + description: "Full-screen motivational reward progress display", + preview: true, +}); + +console.info("%c CHOREMANDER-REWARD-PROGRESS-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#9b59b6;color:white;font-weight:bold;border-radius:0 4px 4px 0;"); diff --git a/custom_components/choremander/www/choremander-streak-card.js b/custom_components/choremander/www/choremander-streak-card.js new file mode 100644 index 0000000..0eaed2f --- /dev/null +++ b/custom_components/choremander/www/choremander-streak-card.js @@ -0,0 +1,488 @@ +/** + * Choremander Streak & Achievement Card + * Shows each child's consecutive day streak and milestone badges. + * Streaks are calculated client-side from completion data. + * + * Version: 1.0.0 + * Last Updated: 2026-03-18 + */ + +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 ChoremanderStreakCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + }; + } + + static get styles() { + return css` + :host { + display: block; + --str-purple: #9b59b6; + --str-gold: #f1c40f; + --str-orange: #e67e22; + --str-green: #2ecc71; + --str-red: #e74c3c; + --str-fire: #ff6b35; + } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, var(--str-orange) 0%, var(--str-fire) 100%); + color: white; + } + + .header-content { display: flex; align-items: center; gap: 10px; } + .header-icon { --mdc-icon-size: 28px; opacity: 0.9; } + .header-title { font-size: 1.2rem; font-weight: 600; } + + .card-content { + padding: 14px; + display: flex; + flex-direction: column; + gap: 14px; + } + + /* Child streak tile */ + .streak-tile { + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 14px; + overflow: hidden; + } + + .streak-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px 10px; + } + + .child-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, var(--str-purple) 0%, #a569bd 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; } + + .streak-info { flex: 1; } + .child-name { + font-weight: 600; + font-size: 1rem; + color: var(--primary-text-color); + } + + .streak-count-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + } + + .streak-number { + font-size: 1.5rem; + font-weight: 800; + line-height: 1; + } + + .streak-number.hot { color: var(--str-fire); } + .streak-number.warm { color: var(--str-orange); } + .streak-number.cold { color: var(--secondary-text-color); } + + .streak-label { + font-size: 0.8rem; + color: var(--secondary-text-color); + font-weight: 500; + } + + .streak-emoji { font-size: 1.4rem; } + + /* Streak bar - visual days */ + .streak-days { + display: flex; + gap: 4px; + padding: 0 16px 14px; + flex-wrap: wrap; + } + + .day-dot { + width: 10px; + height: 10px; + border-radius: 50%; + transition: transform 0.2s ease; + } + + .day-dot.active { background: var(--str-fire); } + .day-dot.today-active { background: var(--str-green); transform: scale(1.3); } + .day-dot.inactive { background: var(--divider-color, #e0e0e0); } + + /* Achievements */ + .achievements-section { + padding: 0 16px 14px; + border-top: 1px solid var(--divider-color, #f0f0f0); + padding-top: 10px; + } + + .achievements-label { + font-size: 0.72rem; + font-weight: 700; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + } + + .badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 8px 10px; + border-radius: 10px; + background: var(--secondary-background-color, #f5f5f5); + min-width: 56px; + transition: transform 0.15s ease; + } + + .badge:hover { transform: scale(1.05); } + + .badge.earned { + background: linear-gradient(135deg, rgba(241,196,15,0.2), rgba(230,126,34,0.2)); + border: 1px solid rgba(241,196,15,0.4); + } + + .badge.locked { opacity: 0.35; filter: grayscale(1); } + + .badge-emoji { font-size: 1.5rem; } + .badge-name { + font-size: 0.65rem; + font-weight: 600; + color: var(--secondary-text-color); + text-align: center; + line-height: 1.2; + } + + .badge.earned .badge-name { color: var(--str-orange); } + + /* Empty / error */ + .error-state, .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--secondary-text-color); text-align: center; + } + + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Streaks & Achievements", + child_id: null, + streak_days_shown: 14, + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("choremander-streak-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Streaks & Achievements" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) { + return html`
Entity not found: ${this.config.entity}
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html`
Choremander is unavailable
`; + } + + let children = entity.attributes.children || []; + // Use recent_completions (all-time history, last 50) for streak/achievement calculation + const completions = [...(entity.attributes.recent_completions || entity.attributes.todays_completions || [])]; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const chores = entity.attributes.chores || []; + + if (this.config.child_id) { + children = children.filter(c => c.id === this.config.child_id); + } + + if (children.length === 0) { + return html`
No children found
`; + } + + return html` + +
+
+ + ${this.config.title} +
+
+
+ ${children.map(child => this._renderStreakTile(child, completions, chores, pointsIcon, entity))} +
+
+ `; + } + + _renderStreakTile(child, completions, chores, pointsIcon, entity_ref) { + const childCompletions = completions.filter(c => c.child_id === child.id); + // Use backend-calculated streak if available, fall back to client calculation + const streak = child.current_streak !== undefined + ? child.current_streak + : this._calculateStreak(childCompletions, chores, child.id); + const daysShown = this.config.streak_days_shown || 14; + const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown); + const achievements = this._getAchievements(child, childCompletions, streak, entity_ref); + + // Avatar now included directly in children array from the overview sensor + const avatar = child.avatar || "mdi:account-circle"; + + const streakClass = streak >= 7 ? "hot" : streak >= 3 ? "warm" : "cold"; + const streakEmoji = streak >= 14 ? "🔥🔥" : streak >= 7 ? "🔥" : streak >= 3 ? "⚡" : streak >= 1 ? "✨" : "💤"; + + return html` +
+
+
+
+
${child.name}
+
+ ${streak} + day streak + ${streakEmoji} +
+
+
+ +
+ ${dayDots.map(dot => html` +
+ `)} +
+ + ${achievements.length > 0 ? html` +
+
Achievements
+
+ ${achievements.map(a => html` +
+ ${a.emoji} + ${a.name} +
+ `)} +
+
+ ` : ''} +
+ `; + } + + _calculateStreak(completions, chores, childId) { + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Build set of unique days with at least one completion + const daysWithCompletion = new Set(); + completions.forEach(c => { + if (!c.completed_at) return; + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + daysWithCompletion.add(day); + }); + + // Walk backwards from today counting consecutive days + let streak = 0; + const today = new Date(); + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = d.toLocaleDateString("en-CA", { timeZone: tz }); + if (daysWithCompletion.has(key)) { + streak++; + } else { + break; + } + } + return streak; + } + + _buildDayDots(completions, chores, childId, days) { + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const daysWithCompletion = new Set(); + completions.forEach(c => { + if (!c.completed_at) return; + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + daysWithCompletion.add(day); + }); + + const dots = []; + const today = new Date(); + const todayKey = today.toLocaleDateString("en-CA", { timeZone: tz }); + + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = d.toLocaleDateString("en-CA", { timeZone: tz }); + const active = daysWithCompletion.has(key); + const isToday = key === todayKey; + dots.push({ + cssClass: active ? (isToday ? "today-active" : "active") : "inactive", + label: d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }), + }); + } + return dots; + } + + _getAchievements(child, completions, streak, entity_ref) { + // Prefer backend-tracked totals over client-visible completions + const totalCompletions = child.total_chores_completed !== undefined + ? child.total_chores_completed + : (entity_ref?.attributes?.total_completions_all_time || completions.filter(comp => comp.child_id === child.id).length); + const totalPoints = (child.total_points_earned !== undefined ? child.total_points_earned : child.points) || 0; + const bestStreak = child.best_streak || streak || 0; + + const milestones = [ + { id: "first", name: "First!", emoji: "🌟", description: "Complete your first chore", earned: totalCompletions >= 1 }, + { id: "ten", name: "10 Done", emoji: "🏅", description: "Complete 10 chores", earned: totalCompletions >= 10 }, + { id: "fifty", name: "50 Done", emoji: "🥈", description: "Complete 50 chores", earned: totalCompletions >= 50 }, + { id: "hundred", name: "100 Done", emoji: "🥇", description: "Complete 100 chores", earned: totalCompletions >= 100 }, + { id: "streak3", name: "3 Days", emoji: "⚡", description: "3 day streak", earned: bestStreak >= 3 }, + { id: "streak7", name: "Week!", emoji: "🔥", description: "7 day streak", earned: bestStreak >= 7 }, + { id: "streak14", name: "2 Weeks", emoji: "🔥🔥", description: "14 day streak", earned: bestStreak >= 14 }, + { id: "streak30", name: "Month!", emoji: "💎", description: "30 day streak", earned: bestStreak >= 30 }, + { id: "points50", name: "50 ⭐", emoji: "🎯", description: "Earn 50 points total", earned: totalPoints >= 50 }, + { id: "points100", name: "100 ⭐", emoji: "💰", description: "Earn 100 points total", earned: totalPoints >= 100 }, + ]; + + // Show earned ones + next locked milestone + const earned = milestones.filter(m => m.earned); + const nextLocked = milestones.find(m => !m.earned); + return nextLocked ? [...earned, nextLocked] : earned; + } +} + +// Card Editor +class ChoremanderStreakCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + ha-textfield { width: 100%; margin-bottom: 16px; } + .form-row { margin-bottom: 16px; } + .form-label { + display: block; font-size: 0.85rem; font-weight: 500; + color: var(--primary-text-color); margin-bottom: 6px; padding: 0 2px; + } + .form-select { + width: 100%; padding: 10px 12px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + font-size: 1rem; box-sizing: border-box; cursor: pointer; appearance: auto; + } + .form-select:focus { outline: none; border-color: var(--primary-color); } + .form-helper { display: block; font-size: 0.78rem; color: var(--secondary-text-color); margin-top: 4px; padding: 0 2px; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + const entity = this.config.entity ? this.hass.states[this.config.entity] : null; + const children = entity?.attributes?.children || []; + + return html` + + +
+ + + Show streak for a specific child only +
+ + `; + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (value === null || value === "" || value === undefined) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("choremander-streak-card", ChoremanderStreakCard); +customElements.define("choremander-streak-card-editor", ChoremanderStreakCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-streak-card", + name: "Choremander Streaks & Achievements", + description: "Consecutive day streaks and milestone badges for each child", + preview: true, +}); + +console.info( + "%c CHOREMANDER-STREAK-CARD %c v1.0.0 ", + "background: #e67e22; color: white; font-weight: bold; border-radius: 4px 0 0 4px;", + "background: #f1c40f; color: #333; font-weight: bold; border-radius: 0 4px 4px 0;" +); diff --git a/custom_components/choremander/www/choremander-weekly-card.js b/custom_components/choremander/www/choremander-weekly-card.js new file mode 100644 index 0000000..76a7844 --- /dev/null +++ b/custom_components/choremander/www/choremander-weekly-card.js @@ -0,0 +1,547 @@ +/** + * Choremander Weekly Summary Card + * Current week at a glance: days completed, points per day as a bar chart, + * rewards claimed this week, and a per-child breakdown. + * + * Version: 1.0.0 + * Last Updated: 2026-03-18 + */ + +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 ChoremanderWeeklyCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + }; + } + + static get styles() { + return css` + :host { + display: block; + --wk-purple: #9b59b6; + --wk-green: #2ecc71; + --wk-orange: #e67e22; + --wk-blue: #3498db; + --wk-gold: #f1c40f; + --wk-red: #e74c3c; + } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, var(--wk-green) 0%, #27ae60 100%); + color: white; + } + + .header-content { display: flex; align-items: center; gap: 10px; } + .header-icon { --mdc-icon-size: 28px; opacity: 0.9; } + .header-title { font-size: 1.2rem; font-weight: 600; } + .week-label { + background: rgba(255,255,255,0.2); + padding: 3px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + } + + .card-content { + padding: 14px; + display: flex; + flex-direction: column; + gap: 16px; + } + + /* Summary stats row */ + .stats-row { + display: flex; + gap: 10px; + } + + .stat-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + background: var(--secondary-background-color, #f5f5f5); + border-radius: 12px; + } + + .stat-value { + font-size: 1.6rem; + font-weight: 800; + color: var(--primary-text-color); + line-height: 1; + } + + .stat-value.green { color: var(--wk-green); } + .stat-value.orange { color: var(--wk-orange); } + .stat-value.purple { color: var(--wk-purple); } + + .stat-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.4px; + text-align: center; + } + + /* Daily bar chart */ + .chart-section { } + + .section-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; + } + + .bar-chart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 80px; + } + + .bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + height: 100%; + justify-content: flex-end; + } + + .bar-value { + font-size: 0.68rem; + font-weight: 600; + color: var(--secondary-text-color); + min-height: 14px; + } + + .bar-fill { + width: 100%; + border-radius: 4px 4px 0 0; + min-height: 3px; + transition: height 0.4s ease; + } + + .bar-fill.today { + background: linear-gradient(180deg, var(--wk-green) 0%, #27ae60 100%); + } + + .bar-fill.past { + background: linear-gradient(180deg, var(--wk-blue) 0%, #2980b9 100%); + } + + .bar-fill.future { + background: var(--divider-color, #e8e8e8); + } + + .bar-fill.zero { + background: var(--divider-color, #e8e8e8); + min-height: 3px !important; + height: 3px !important; + } + + .bar-day { + font-size: 0.7rem; + font-weight: 600; + color: var(--secondary-text-color); + } + + .bar-day.today { + color: var(--wk-green); + font-weight: 800; + } + + /* Per-child breakdown */ + .children-section { } + + .child-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--divider-color, #f0f0f0); + } + + .child-row:last-child { border-bottom: none; } + + .child-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: linear-gradient(135deg, var(--wk-purple), #a569bd); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .child-avatar ha-icon { --mdc-icon-size: 20px; color: white; } + + .child-info { flex: 1; min-width: 0; } + + .child-name { + font-weight: 600; + font-size: 0.9rem; + color: var(--primary-text-color); + } + + .child-week-stats { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; + font-size: 0.78rem; + color: var(--secondary-text-color); + flex-wrap: wrap; + } + + .child-week-stats span { display: flex; align-items: center; gap: 3px; } + .child-week-stats ha-icon { --mdc-icon-size: 13px; } + + .week-progress-bar { + flex: 1; + height: 6px; + background: var(--divider-color, #e0e0e0); + border-radius: 3px; + overflow: hidden; + min-width: 40px; + } + + .week-progress-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, var(--wk-green), #27ae60); + } + + /* Error / empty */ + .error-state, .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 40px 20px; + color: var(--secondary-text-color); text-align: center; + } + + .error-state { color: var(--error-color, #f44336); } + .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "This Week", + child_id: null, + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("choremander-weekly-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "This Week" }; + } + + render() { + if (!this.hass || !this.config) return html``; + + const entity = this.hass.states[this.config.entity]; + if (!entity) { + return html`
Entity not found: ${this.config.entity}
`; + } + if (entity.state === "unavailable" || entity.state === "unknown") { + return html`
Choremander is unavailable
`; + } + + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + let children = entity.attributes.children || []; + const chores = entity.attributes.chores || []; + const pointsIcon = entity.attributes.points_icon || "mdi:star"; + const pointsName = entity.attributes.points_name || "Stars"; + + // Use recent_completions (last 50 all-time) for full week view + let allCompletions = [...(entity.attributes.recent_completions || entity.attributes.todays_completions || [])]; + const seen = new Set(); + allCompletions = allCompletions.filter(comp => { + if (seen.has(comp.completion_id)) return false; + seen.add(comp.completion_id); return true; + }); + + if (this.config.child_id) { + children = children.filter(c => c.id === this.config.child_id); + allCompletions = allCompletions.filter(c => c.child_id === this.config.child_id); + } + + // Build week dates (Mon–Sun or Sun–Sat based on HA locale) + const weekDays = this._getWeekDays(tz); + const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz }); + + // Group completions by day + const completionsByDay = {}; + allCompletions.forEach(c => { + if (!c.completed_at) return; + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + if (!completionsByDay[day]) completionsByDay[day] = []; + completionsByDay[day].push(c); + }); + + // Only show completions within this week + const weekCompletions = allCompletions.filter(c => { + if (!c.completed_at) return false; + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + return weekDays.some(d => d.key === day); + }); + + // Build chore points lookup — completions don't carry points directly + const chorePointsMap = {}; + chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; }); + + // Only count approved completions for all stats + const approvedWeekCompletions = weekCompletions.filter(c => c.approved); + + const weekPoints = approvedWeekCompletions + .reduce((sum, c) => sum + (c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0)), 0); + const weekChores = approvedWeekCompletions.length; + const daysActive = new Set(approvedWeekCompletions.map(c => + new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }) + )).size; + + // Bar chart also uses approved completions only + const approvedCompletionsByDay = {}; + approvedWeekCompletions.forEach(c => { + if (!c.completed_at) return; + const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }); + if (!approvedCompletionsByDay[day]) approvedCompletionsByDay[day] = []; + approvedCompletionsByDay[day].push(c); + }); + + // Max completions in a day for chart scale + const maxPerDay = Math.max(1, ...weekDays.map(d => (approvedCompletionsByDay[d.key] || []).length)); + const weekLabel = this._getWeekLabel(weekDays, tz); + + return html` + +
+
+ + ${this.config.title} +
+ ${weekLabel} +
+ +
+ +
+
+ ${weekChores} + Chores +
+
+ ${weekPoints} + ${pointsName} +
+
+ ${daysActive}/7 + Days Active +
+
+ + +
+ +
+ ${weekDays.map(day => { + const count = (approvedCompletionsByDay[day.key] || []).length; + const isToday = day.key === todayKey; + const isFuture = day.key > todayKey; + const heightPct = isFuture ? 0 : Math.round((count / maxPerDay) * 60); + const barClass = isFuture ? "future" : isToday ? "today" : count === 0 ? "zero" : "past"; + return html` +
+ ${!isFuture && count > 0 ? count : ''} +
+ ${day.short} +
+ `; + })} +
+
+ + + ${children.length > 0 ? html` +
+ + ${children.map(child => { + // Only count approved completions for all per-child stats + const childApprovedCompletions = approvedWeekCompletions.filter(c => c.child_id === child.id); + const childPoints = childApprovedCompletions + .reduce((s, comp) => s + (comp.points !== undefined ? comp.points : (chorePointsMap[comp.chore_id] || 0)), 0); + const childChoreCount = childApprovedCompletions.length; + const childDaysActive = new Set(childApprovedCompletions.map(c => + new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz }) + )).size; + + // Avatar now included directly in children array from the overview sensor + const avatar = child.avatar || "mdi:account-circle"; + + const pct = Math.min((childDaysActive / 7) * 100, 100); + + return html` +
+
+
+
${child.name}
+
+ ${childChoreCount} chores + ${childPoints} ${pointsName} + ${childDaysActive}/7 days +
+
+
+
+
+
+ `; + })} +
+ ` : ''} +
+
+ `; + } + + _getWeekDays(tz) { + const today = new Date(); + const todayDay = today.getDay(); // 0=Sun + // Start week on Monday + const mondayOffset = (todayDay === 0 ? -6 : 1 - todayDay); + const monday = new Date(today); + monday.setDate(today.getDate() + mondayOffset); + + const days = []; + const shortNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + days.push({ + key: d.toLocaleDateString("en-CA", { timeZone: tz }), + short: shortNames[i], + date: d, + }); + } + return days; + } + + _getWeekLabel(weekDays, tz) { + const first = weekDays[0].date; + const last = weekDays[6].date; + const fmt = { month: "short", day: "numeric" }; + return `${first.toLocaleDateString(undefined, fmt)} – ${last.toLocaleDateString(undefined, fmt)}`; + } +} + +// Card Editor +class ChoremanderWeeklyCardEditor extends LitElement { + static get properties() { + return { hass: { type: Object }, config: { type: Object } }; + } + + static get styles() { + return css` + :host { display: block; } + ha-textfield { width: 100%; margin-bottom: 16px; } + .form-row { margin-bottom: 16px; } + .form-label { + display: block; font-size: 0.85rem; font-weight: 500; + color: var(--primary-text-color); margin-bottom: 6px; padding: 0 2px; + } + .form-select { + width: 100%; padding: 10px 12px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + font-size: 1rem; box-sizing: border-box; cursor: pointer; appearance: auto; + } + .form-select:focus { outline: none; border-color: var(--primary-color); } + .form-helper { display: block; font-size: 0.78rem; color: var(--secondary-text-color); margin-top: 4px; padding: 0 2px; } + `; + } + + setConfig(config) { this.config = config; } + + render() { + if (!this.hass || !this.config) return html``; + const entity = this.config.entity ? this.hass.states[this.config.entity] : null; + const children = entity?.attributes?.children || []; + + return html` + + +
+ + + Show weekly summary for a specific child only +
+ `; + } + + _updateConfig(key, value) { + const newConfig = { ...this.config, [key]: value }; + if (value === null || value === "" || value === undefined) delete newConfig[key]; + this.dispatchEvent(new CustomEvent("config-changed", { + detail: { config: newConfig }, bubbles: true, composed: true, + })); + } +} + +customElements.define("choremander-weekly-card", ChoremanderWeeklyCard); +customElements.define("choremander-weekly-card-editor", ChoremanderWeeklyCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "choremander-weekly-card", + name: "Choremander Weekly Summary", + description: "Week at a glance — chores, points, and daily bar chart", + preview: true, +}); + +console.info( + "%c CHOREMANDER-WEEKLY-CARD %c v1.0.0 ", + "background: #2ecc71; color: white; font-weight: bold; border-radius: 4px 0 0 4px;", + "background: #27ae60; color: white; font-weight: bold; border-radius: 0 4px 4px 0;" +);