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) 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, + ) + ), } ), ) 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" 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.""" 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( 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, + } 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.""" 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: 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.""" 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" + } + } } } } 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" + } + } } } } diff --git a/custom_components/choremander/www/choremander-activity-card.js b/custom_components/choremander/www/choremander-activity-card.js new file mode 100644 index 0000000..fb9284b --- /dev/null +++ b/custom_components/choremander/www/choremander-activity-card.js @@ -0,0 +1,538 @@ +/** + * Choremander Activity Feed Card + * Scrollable timeline of recent events — completions, approvals, points, rewards. + * + * 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 ChoremanderActivityCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + }; + } + + static get styles() { + return css` + :host { + display: block; + --act-purple: #9b59b6; + --act-green: #2ecc71; + --act-orange: #e67e22; + --act-blue: #3498db; + --act-red: #e74c3c; + --act-gold: #f1c40f; + } + + ha-card { overflow: hidden; } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, var(--act-blue) 0%, #2980b9 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; } + .event-count { + background: rgba(255,255,255,0.2); + padding: 3px 10px; + border-radius: 12px; + font-size: 0.85rem; + } + + .feed-container { + max-height: 480px; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 0; + } + + /* Date group header */ + .date-group { margin-bottom: 4px; } + + .date-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 8px 4px 4px; + } + + /* Activity item */ + .activity-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 4px; + border-bottom: 1px solid var(--divider-color, #f0f0f0); + position: relative; + } + + .activity-item:last-child { border-bottom: none; } + + .activity-icon { + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + } + + .activity-icon ha-icon { --mdc-icon-size: 18px; color: white; } + + .activity-icon.chore { background: var(--act-blue); } + .activity-icon.approved { background: var(--act-green); } + .activity-icon.rejected { background: var(--act-red); } + .activity-icon.points_added { background: var(--act-green); } + .activity-icon.points_removed { background: var(--act-red); } + .activity-icon.reward { background: var(--act-purple); } + .activity-icon.reward_claimed { background: var(--act-orange); } + .activity-icon.reward_approved { background: var(--act-purple); } + .activity-icon.pending { background: var(--act-orange); } + + .activity-reason { + font-size: 0.78rem; + color: var(--secondary-text-color); + font-style: italic; + margin-top: 2px; + } + + .activity-body { flex: 1; min-width: 0; } + + .activity-title { + font-size: 0.9rem; + font-weight: 500; + color: var(--primary-text-color); + line-height: 1.3; + } + + .activity-title strong { color: var(--act-purple); } + + .activity-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 3px; + flex-wrap: wrap; + } + + .activity-time { + font-size: 0.75rem; + color: var(--secondary-text-color); + } + + .activity-points { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.75rem; + font-weight: 600; + color: var(--act-orange); + } + + .activity-points ha-icon { --mdc-icon-size: 12px; color: var(--act-gold); } + + .activity-status { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.72rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + } + + .activity-status.pending { + background: rgba(230,126,34,0.15); + color: var(--act-orange); + } + + .activity-status.approved { + background: rgba(46,204,113,0.15); + color: var(--act-green); + } + + .activity-status.rejected { + background: rgba(231,76,60,0.15); + color: var(--act-red); + } + + /* Empty / error */ + .empty-state, .error-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); } + .empty-state ha-icon, .error-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; } + `; + } + + setConfig(config) { + if (!config.entity) throw new Error("Please define an entity"); + this.config = { + title: "Activity", + max_items: 30, + child_id: null, + ...config, + }; + } + + getCardSize() { return 4; } + static getConfigElement() { return document.createElement("choremander-activity-card-editor"); } + static getStubConfig() { + return { entity: "sensor.choremander_overview", title: "Activity" }; + } + + 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 pointsIcon = entity.attributes.points_icon || "mdi:star"; + const children = entity.attributes.children || []; + const chores = entity.attributes.chores || []; + + // Build lookup maps + const childNames = {}; + children.forEach(ch => { childNames[ch.id] = ch.name; }); + const chorePointsMap = {}; + chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; }); + const choreNamesMap = {}; + chores.forEach(ch => { choreNamesMap[ch.id] = ch.name; }); + + // Use recent_completions (last 50 all-time) if available, fall back to today only + let completions = [...(entity.attributes.recent_completions || entity.attributes.todays_completions || [])]; + + // Deduplicate by completion_id + const seen = new Set(); + completions = completions.filter(comp => { + if (seen.has(comp.completion_id)) return false; + seen.add(comp.completion_id); + return true; + }); + + // Merge in manual points transactions + const transactions = (entity.attributes.recent_transactions || []).map(t => ({ + ...t, + // Normalise to a single timestamp field for sorting + completed_at: t.created_at, + })); + + let allEvents = [...completions, ...transactions]; + + // Filter by child if configured + if (this.config.child_id) { + allEvents = allEvents.filter(e => e.child_id === this.config.child_id); + } + + // Sort by timestamp descending + allEvents.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at)); + + // Limit + const maxItems = this.config.max_items || 30; + const events = allEvents.slice(0, maxItems); + + if (events.length === 0) { + return html` + +
+
+ + ${this.config.title} +
+
+
+ +
No activity yet
+
Completed chores will appear here
+
+
+ `; + } + + // Group by day + const groups = this._groupByDay(events); + + return html` + +
+
+ + ${this.config.title} +
+ ${events.length} events +
+
+ ${groups.map(([dayLabel, items]) => html` +
+
${dayLabel}
+ ${items.map(item => this._renderItem(item, childNames, pointsIcon, chorePointsMap))} +
+ `)} +
+
+ `; + } + + _renderItem(item, childNames, pointsIcon, chorePointsMap) { + const childName = childNames[item.child_id] || item.child_name || "Unknown"; + const type = item.type || "chore"; + const time = this._formatTime(new Date(item.completed_at)); + + // Points transactions + if (type === "points_added" || type === "points_removed") { + const isAdd = type === "points_added"; + const pts = Math.abs(item.points || 0); + return html` +
+
+ +
+
+
+ ${childName} + ${isAdd ? ' received' : ' lost'} + ${pts} + ${item.reason ? html` — ${item.reason}` : ' points manually'} +
+
+ ${time} + + + ${isAdd ? '+' : '-'}${pts} + +
+
+
+ `; + } + + // Reward claim events + if (type === "reward_claimed" || type === "reward_approved") { + const pts = Math.abs(item.points || 0); + const isPending = type === "reward_claimed" && !item.approved; + return html` +
+
+ +
+
+
+ ${childName} + ${isPending ? ' claimed' : ' redeemed'} + ${item.reward_name || 'a reward'} +
+
+ ${time} + ${pts ? html` + + + -${pts} + + ` : ''} + + ${isPending ? 'awaiting approval' : 'approved'} + +
+
+
+ `; + } + + // Chore completions and rewards + const status = item.approved ? "approved" : item.rejected ? "rejected" : "pending"; + const iconMap = { + chore: { icon: "mdi:checkbox-marked-circle", cls: status === "approved" ? "approved" : status === "rejected" ? "rejected" : "chore" }, + reward: { icon: "mdi:gift", cls: "reward" }, + }; + const { icon, cls } = iconMap[type] || iconMap.chore; + + const choreName = item.chore_name || (chorePointsMap && item.chore_id ? '' : 'a chore'); + const titleMap = { + chore: html`${childName} completed ${choreName || 'a chore'}`, + reward: html`${childName} claimed ${item.reward_name || 'a reward'}`, + }; + + const pts = item.points !== undefined ? item.points : (chorePointsMap?.[item.chore_id] || 0); + + return html` +
+
+ +
+
+
${titleMap[type] || titleMap.chore}
+
+ ${time} + ${pts ? html` + + + +${pts} + + ` : ''} + ${type === 'chore' ? html` + ${status} + ` : ''} +
+
+
+ `; + } + + _groupByDay(items) { + const groups = new Map(); + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const now = new Date(); + + items.forEach(item => { + const date = new Date(item.completed_at); + const key = date.toLocaleDateString("en-CA", { timeZone: tz }); + const nowKey = now.toLocaleDateString("en-CA", { timeZone: tz }); + const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); + const yKey = yesterday.toLocaleDateString("en-CA", { timeZone: tz }); + + let label; + if (key === nowKey) label = "Today"; + else if (key === yKey) label = "Yesterday"; + else label = date.toLocaleDateString(undefined, { timeZone: tz, month: "short", day: "numeric", weekday: "short" }); + + if (!groups.has(label)) groups.set(label, []); + groups.get(label).push(item); + }); + + return [...groups.entries()]; + } + + _formatTime(date) { + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } +} + +// Card Editor +class ChoremanderActivityCardEditor 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` + + +
+ + + 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-child-card.js b/custom_components/choremander/www/choremander-child-card.js index a64e2df..c783803 100644 --- a/custom_components/choremander/www/choremander-child-card.js +++ b/custom_components/choremander/www/choremander-child-card.js @@ -69,7 +69,6 @@ class ChoremanderChildCard extends LitElement { _playSound(soundName) { // Don't play if sound is "none" or not specified if (!soundName || soundName === 'none') { - console.debug('[Choremander] Sound disabled for this chore'); return; } @@ -132,11 +131,9 @@ class ChoremanderChildCard extends LitElement { this._playAudioFile(`fart${randomFartNum}.mp3`); break; default: - console.warn(`[Choremander] Unknown sound: ${soundName}, playing coin`); this._playCoinSound(ctx, now); } } catch (e) { - console.warn('[Choremander] Error playing sound:', e); } } @@ -391,10 +388,8 @@ class ChoremanderChildCard extends LitElement { const audio = new Audio(`/local/choremander/${filename}`); audio.volume = 1.0; audio.play().catch(e => { - console.warn('[Choremander] Error playing audio file:', e); }); } catch (e) { - console.warn('[Choremander] Error creating audio element:', e); } } @@ -412,54 +407,44 @@ class ChoremanderChildCard extends LitElement { --fun-cyan: #1abc9c; } - ha-card { - overflow: hidden; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 24px; - padding: 0; - } + ha-card { overflow: hidden; } - /* Header with child avatar and points */ + /* ── Header — matches overview/weekly/streak card style ── */ .card-header { display: flex; align-items: center; justify-content: space-between; - padding: 20px 24px; - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(10px); + padding: 14px 18px; + background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%); + color: white; gap: 12px; min-width: 0; } - .child-info { + .header-left { display: flex; align-items: center; - gap: 16px; + gap: 12px; min-width: 0; - flex-shrink: 1; + flex: 1; } .avatar-container { - position: relative; - width: 70px; - height: 70px; + width: 48px; + height: 48px; + min-width: 48px; border-radius: 50%; - background: white; + background: rgba(255,255,255,0.15); + border: 2px solid rgba(255,255,255,0.3); display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); - animation: float 3s ease-in-out infinite; - } - - @keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-5px); } + flex-shrink: 0; } .avatar-container ha-icon { - --mdc-icon-size: 50px; - color: var(--fun-purple); + --mdc-icon-size: 30px; + color: white; } .child-name-container { @@ -469,27 +454,25 @@ class ChoremanderChildCard extends LitElement { } .child-name { - font-size: clamp(1.2rem, 5vw, 2rem); - font-weight: bold; + font-size: 1.15rem; + font-weight: 700; color: white; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - /* Points display - BIG and FUN! */ + /* Points pill — right side of header */ .points-display { display: flex; flex-direction: column; align-items: center; - background: white; - padding: 12px 16px; - border-radius: 20px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); - min-width: 0; - max-width: 45%; - flex-shrink: 1; + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.25); + padding: 8px 14px; + border-radius: 14px; + flex-shrink: 0; + min-width: 72px; } .stars-row { @@ -510,39 +493,31 @@ class ChoremanderChildCard extends LitElement { } .stars-value { - font-size: clamp(1rem, 4vw, 1.8rem); - font-weight: bold; + font-size: 1.4rem; + font-weight: 800; line-height: 1; display: flex; align-items: center; - gap: 2px; + gap: 3px; white-space: nowrap; + color: white; } - .stars-value.my-stars { - color: var(--fun-purple); - } + .stars-value.my-stars { color: white; } .stars-value.waiting-stars { - color: var(--fun-orange); - font-size: clamp(0.85rem, 3.5vw, 1.4rem); - opacity: 0.9; + color: rgba(255,255,255,0.75); + font-size: 1rem; } - .stars-value ha-icon { - --mdc-icon-size: clamp(16px, 4vw, 24px); - flex-shrink: 0; - } + .stars-value ha-icon { --mdc-icon-size: 18px; flex-shrink: 0; } .stars-value.my-stars ha-icon { color: var(--fun-yellow); animation: spin-star 4s linear infinite; } - .stars-value.waiting-stars ha-icon { - color: var(--fun-orange); - animation: pulse-star 2s ease-in-out infinite; - } + .stars-value.waiting-stars ha-icon { color: var(--fun-orange); } @keyframes spin-star { 0% { transform: rotate(0deg) scale(1); } @@ -552,32 +527,23 @@ class ChoremanderChildCard extends LitElement { 100% { transform: rotate(360deg) scale(1); } } - @keyframes pulse-star { - 0%, 100% { transform: scale(1); opacity: 0.7; } - 50% { transform: scale(1.1); opacity: 1; } - } - .stars-label { - font-size: clamp(0.55rem, 2vw, 0.7rem); + font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; + color: rgba(255,255,255,0.7); } - .stars-label.my-stars { - color: var(--fun-purple); - } - - .stars-label.waiting-stars { - color: var(--fun-orange); - } + .stars-label.my-stars { color: rgba(255,255,255,0.7); } + .stars-label.waiting-stars { color: rgba(255,255,255,0.5); } .stars-divider { - width: 2px; - height: 35px; - background: linear-gradient(to bottom, transparent, #ddd, transparent); - margin: 0 4px; + width: 1px; + height: 28px; + background: rgba(255,255,255,0.25); + margin: 0 6px; flex-shrink: 0; } @@ -587,28 +553,28 @@ class ChoremanderChildCard extends LitElement { display: flex; flex-direction: column; gap: 20px; - background: rgba(255, 255, 255, 0.95); + background: var(--card-background-color, #fff); min-height: 200px; } .section-title { - font-size: 1.8rem; - font-weight: bold; + font-size: 1.4rem; + font-weight: 700; color: var(--fun-purple); display: flex; align-items: center; - gap: 12px; + gap: 10px; margin-bottom: 12px; - padding: 8px 0; + padding: 4px 0; } - .section-title ha-icon { - --mdc-icon-size: 36px; - } + .section-title ha-icon { --mdc-icon-size: 28px; } + + .section-title-text { flex: 1; } /* Individual chore card - optimized for tablet touch, ENTIRE ROW IS CLICKABLE */ .chore-card { - background: white; + background: var(--card-background-color, #fff); border-radius: 24px; padding: 20px 24px; display: flex; @@ -632,22 +598,22 @@ class ChoremanderChildCard extends LitElement { .chore-card:nth-child(odd) { border-color: var(--fun-blue); - background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + background: color-mix(in srgb, var(--fun-blue) 10%, var(--card-background-color, #fff)); } .chore-card:nth-child(even) { border-color: var(--fun-pink); - background: linear-gradient(135deg, #fce4ec 0%, #f8bbd9 100%); + background: color-mix(in srgb, var(--fun-pink) 10%, var(--card-background-color, #fff)); } .chore-card:nth-child(3n) { border-color: var(--fun-green); - background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + background: color-mix(in srgb, var(--fun-green) 10%, var(--card-background-color, #fff)); } .chore-card:nth-child(4n) { border-color: var(--fun-orange); - background: linear-gradient(135deg, #fff3e0 0%, #ffcc80 100%); + background: color-mix(in srgb, var(--fun-orange) 10%, var(--card-background-color, #fff)); } /* Touch/hover feedback - works for both touch and mouse */ @@ -738,8 +704,8 @@ class ChoremanderChildCard extends LitElement { height: 44px; min-width: 44px; border-radius: 12px; - border: 3px solid #bdc3c7; - background: white; + border: 3px solid var(--divider-color, #bdc3c7); + background: var(--card-background-color, #fff); display: flex; align-items: center; justify-content: center; @@ -782,7 +748,7 @@ class ChoremanderChildCard extends LitElement { .chore-name { font-size: 1.5rem; font-weight: bold; - color: #333; + color: var(--primary-text-color); line-height: 1.2; } @@ -805,6 +771,13 @@ class ChoremanderChildCard extends LitElement { to { transform: rotate(360deg); } } + /* Chore not due today — dimmed/greyed */ + .chore-card.not-due-today { + opacity: 0.45; + filter: grayscale(0.6); + pointer-events: none; + } + /* Chore card in completed state - faded green styling */ .chore-card.completed { opacity: 0.75; @@ -821,12 +794,39 @@ class ChoremanderChildCard extends LitElement { } .chore-card.completed .chore-name { - color: #2d5a3d; + color: var(--primary-text-color); + opacity: 0.7; } .chore-card.completed .chore-points { - color: #2d5a3d; - opacity: 0.8; + color: var(--primary-text-color); + opacity: 0.6; + } + + /* Reset countdown — inline beside section title */ + .reset-countdown { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + color: var(--secondary-text-color); + background: var(--secondary-background-color, #f5f5f5); + border-radius: 20px; + padding: 2px 8px 2px 6px; + white-space: nowrap; + flex-shrink: 0; + } + + .reset-countdown ha-icon { + --mdc-icon-size: 12px; + flex-shrink: 0; + } + + .reset-countdown.soon { + color: var(--fun-orange); + background: rgba(230,126,34,0.12); + font-weight: 700; } /* Empty state */ @@ -860,7 +860,7 @@ class ChoremanderChildCard extends LitElement { .empty-state .submessage { font-size: 1.1rem; - color: #666; + color: var(--secondary-text-color); } /* Celebration overlay */ @@ -884,7 +884,7 @@ class ChoremanderChildCard extends LitElement { } .celebration-content { - background: white; + background: var(--card-background-color, #fff); border-radius: 30px; padding: 40px 50px; text-align: center; @@ -924,7 +924,7 @@ class ChoremanderChildCard extends LitElement { .celebration-message { font-size: 1.3rem; - color: #666; + color: var(--secondary-text-color); margin-bottom: 16px; } @@ -1119,8 +1119,11 @@ class ChoremanderChildCard extends LitElement { this.config = { time_category: "anytime", debug: false, - default_sound: "coin", // Default sound to use if chore doesn't specify one - undo_sound: "undo", // Sound to play when undoing a completion + default_sound: "coin", + undo_sound: "undo", + due_days_mode: "hide", // "hide" = hide chores not due today, "dim" = show greyed out + show_countdown: true, // Show midnight reset countdown below section title + show_due_days_only: true, // Whether to apply due_days filtering at all ...config, }; } @@ -1178,11 +1181,6 @@ class ChoremanderChildCard extends LitElement { const allChores = entity.attributes.chores || []; // Log raw data for debugging assignment issues - console.debug( - `[Choremander] Rendering card for child "${child.name}" (${child.id}), time_category="${this.config.time_category}"`, - `\n Total chores in entity: ${allChores.length}`, - `\n Children in entity:`, children.map(c => ({id: c.id, name: c.name})) - ); // DEBUG: Create debug info object for visible debugging const debugInfo = { @@ -1197,14 +1195,10 @@ class ChoremanderChildCard extends LitElement { isArray: Array.isArray(c.assigned_to) })) }; - console.log('[Choremander DEBUG]', JSON.stringify(debugInfo, null, 2)); const childChores = this._filterAndSortChores(allChores, child); // Log the filtering result - console.debug( - `[Choremander] After filtering: showing ${childChores.length} of ${allChores.length} chores for child "${child.name}" (${child.id})` - ); // Store debug info for rendering this._debugInfo = { @@ -1215,12 +1209,8 @@ class ChoremanderChildCard extends LitElement { const pointsIcon = entity.attributes.points_icon || "mdi:star"; const pointsName = entity.attributes.points_name || "Stars"; - // Get child entity for avatar - const childEntityId = Object.keys(this.hass.states).find( - eid => this.hass.states[eid].attributes?.child_id === this.config.child_id - ); - const childEntity = childEntityId ? this.hass.states[childEntityId] : null; - const avatar = childEntity?.attributes?.avatar || "mdi:account-circle"; + // Avatar now in children array directly + const avatar = child.avatar || "mdi:account-circle"; // Get pending points for this child const pendingPoints = child.pending_points || 0; @@ -1233,17 +1223,12 @@ class ChoremanderChildCard extends LitElement { // Debug logging to help troubleshoot daily limit issues if (allCompletions.length > 0 || todaysCompletions.length > 0) { - console.debug( - `[Choremander] Child "${child.name}" (${child.id}): ` + - `allCompletions = ${allCompletions.length}, todaysCompletions = ${todaysCompletions.length}`, - { allCompletions, todaysCompletions } - ); } return html`
-
+
@@ -1258,7 +1243,7 @@ class ChoremanderChildCard extends LitElement { ${child.points}
-
My ${pointsName}
+
${pointsName}
${pendingPoints > 0 ? html`
@@ -1267,7 +1252,7 @@ class ChoremanderChildCard extends LitElement { +${pendingPoints}
-
Waiting
+
Pending
` : ''} @@ -1280,7 +1265,16 @@ class ChoremanderChildCard extends LitElement { : html`
- ${this._getDynamicTitle()} + ${this._getDynamicTitle()} + ${this.config.show_countdown !== false ? (() => { + const countdown = this._getMidnightCountdown(); + return countdown ? html` +
+ + ${countdown.label} +
+ ` : ''; + })() : ''}
${childChores.map((chore, index) => this._renderChoreCard(chore, child, pointsIcon, todaysCompletions, index))} `} @@ -1314,10 +1308,14 @@ class ChoremanderChildCard extends LitElement { const choreOrder = child.chore_order || []; // Debug logging to diagnose assignment filtering issues - console.debug( - `[Choremander] Filtering chores for child: id="${childId}" (type: ${typeof childId}), name="${childName}", config.child_id="${this.config.child_id}"`, - `\n All chores:`, chores.map(c => ({name: c.name, assigned_to: c.assigned_to, assigned_to_type: typeof c.assigned_to})) - ); + + // Get today's day of week from sensor (set by backend) or compute client-side + const entity = this.hass?.states?.[this.config.entity]; + const todayDow = entity?.attributes?.today_day_of_week || + new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase(); + + const dueDaysMode = this.config.due_days_mode || 'hide'; + const showDueDaysOnly = this.config.show_due_days_only !== false; // First, filter chores for this child and time category const filteredChores = chores.filter(chore => { @@ -1327,40 +1325,31 @@ class ChoremanderChildCard extends LitElement { chore.time_category === this.config.time_category || chore.time_category === "anytime"; - // Check if chore is assigned to this child - // If assigned_to is empty or not set, show to ALL children - // If assigned_to has specific child IDs, only show to those children - // Ensure assigned_to is always an array + // Check assignment let assignedTo = chore.assigned_to; - if (!Array.isArray(assignedTo)) { - assignedTo = []; - } - - // Convert all assigned_to values to strings for consistent comparison + if (!Array.isArray(assignedTo)) assignedTo = []; const assignedToStrings = assignedTo.map(id => String(id)); - - // STRICT: Only check child ID, not name - // assigned_to should ONLY contain child IDs, never names const isAssignedToAll = assignedToStrings.length === 0; const isAssignedToChild = isAssignedToAll || assignedToStrings.includes(childId); - // Debug logging for each chore with assignments (always log to help debug) - console.debug( - `[Choremander] Chore "${chore.name}": ` + - `assigned_to=${JSON.stringify(assignedTo)} (isArray: ${Array.isArray(chore.assigned_to)}), ` + - `childId="${childId}", isAssignedToAll=${isAssignedToAll}, ` + - `isAssignedToChild=${isAssignedToChild}, matchesTime=${matchesTime}, ` + - `SHOWING=${matchesTime && isAssignedToChild}` - ); + // Check due_days — if chore has due_days set and today isn't one of them + const dueDays = chore.due_days || []; + const hasDueDays = dueDays.length > 0; + const isDueToday = !hasDueDays || dueDays.includes(todayDow); + + // If due_days filtering is on and mode is "hide", exclude not-due chores + if (showDueDaysOnly && hasDueDays && !isDueToday && dueDaysMode === 'hide') { + return false; + } + + // Store due status on chore object for rendering + chore._isDueToday = isDueToday; + chore._hasDueDays = hasDueDays; return matchesTime && isAssignedToChild; }); // Debug: Log the filtered results - console.debug( - `[Choremander] FINAL filtered chores for "${childName}" (${childId}): ${filteredChores.length} of ${chores.length}`, - filteredChores.map(c => c.name) - ); // If no custom order is set, return filtered chores as-is if (choreOrder.length === 0) { @@ -1466,6 +1455,33 @@ class ChoremanderChildCard extends LitElement { }); } + _getMidnightCountdown() { + const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const now = new Date(); + // Get tomorrow midnight in HA timezone + const tomorrow = new Date(now.toLocaleDateString("en-CA", { timeZone: tz }) + "T00:00:00"); + tomorrow.setDate(tomorrow.getDate() + 1); + // Convert back to UTC ms + const tomorrowUTC = new Date(tomorrow.toLocaleString("en-US", { timeZone: tz })); + const diffMs = tomorrow - now; + if (diffMs <= 0) return null; + + const totalMins = Math.floor(diffMs / 60000); + const hours = Math.floor(totalMins / 60); + const mins = totalMins % 60; + + const soon = totalMins <= 60; // less than 1 hour + let label; + if (hours > 0) { + label = `Chores reset in ${hours}h ${mins}m`; + } else if (mins > 0) { + label = `Chores reset in ${mins}m`; + } else { + label = "Chores resetting soon!"; + } + return { label, soon }; + } + _renderEmptyState() { return html`
@@ -1523,13 +1539,6 @@ class ChoremanderChildCard extends LitElement { // Debug logging to help troubleshoot daily limit issues if (childCompletionsToday.length > 0 || isCompletedForToday || hasOptimisticCompletion) { - console.debug( - `[Choremander] Chore "${chore.name}" (${chore.id}): ` + - `completions today = ${completionsToday}, daily limit = ${dailyLimit}, ` + - `completed = ${isCompletedForToday}, optimistic = ${!!hasOptimisticCompletion}, ` + - `completions:`, - childCompletionsToday - ); } // Check if the most recent completion is pending approval @@ -1540,8 +1549,10 @@ class ChoremanderChildCard extends LitElement { const colorClass = `color-${choreIndex % 8}`; // Click handler for the entire row + const notDueToday = chore._hasDueDays && !chore._isDueToday && this.config.due_days_mode === 'dim'; const handleRowClick = () => { if (isLoading) return; + if (notDueToday) return; // Dim mode — not interactive if (isCompletedForToday) { this._handleUndo(chore, child, childCompletionsToday); } else { @@ -1551,9 +1562,9 @@ class ChoremanderChildCard extends LitElement { return html`
@@ -1628,7 +1639,6 @@ class ChoremanderChildCard extends LitElement { // Check if already loading for this chore (prevent double-clicks during loading) if (this._loading[chore.id]) { - console.debug(`[Choremander] Chore "${chore.name}" is already loading, ignoring click`); return; } @@ -1650,10 +1660,6 @@ class ChoremanderChildCard extends LitElement { // Guard: If daily limit already reached, don't allow another completion if (totalCompletions >= dailyLimit) { - console.debug( - `[Choremander] Daily limit already reached for chore "${chore.name}": ` + - `${actualCompletionsToday} actual + ${existingOptimisticCount} optimistic >= ${dailyLimit} limit` - ); this.requestUpdate(); // Force re-render to show completed state return; } @@ -1742,7 +1748,6 @@ class ChoremanderChildCard extends LitElement { async _handleUndo(chore, child, childCompletionsToday) { // Check if already loading for this chore (prevent double-clicks during loading) if (this._loading[chore.id]) { - console.debug(`[Choremander] Chore "${chore.name}" is already loading, ignoring undo click`); return; } @@ -1758,12 +1763,9 @@ class ChoremanderChildCard extends LitElement { // Check for completion_id (from sensor) or id (fallback) const completionId = completionToUndo?.completion_id || completionToUndo?.id; if (!completionToUndo || !completionId) { - console.warn(`[Choremander] No completion found to undo for chore "${chore.name}"`, sortedCompletions); return; } - console.debug(`[Choremander] Undoing completion "${completionId}" for chore "${chore.name}"`); - this._loading = { ...this._loading, [chore.id]: true }; this.requestUpdate(); @@ -1773,8 +1775,6 @@ class ChoremanderChildCard extends LitElement { completion_id: completionId, }); - console.debug(`[Choremander] Successfully undid completion for chore "${chore.name}"`); - // Play undo sound (sad/descending tone) const undoSoundToPlay = this.config.undo_sound || 'undo'; this._playSound(undoSoundToPlay); @@ -1838,33 +1838,91 @@ class ChoremanderChildCardEditor extends LitElement { static get styles() { return css` - .form-group { - margin-bottom: 16px; - } + :host { display: block; padding: 4px 0; } + + ha-textfield { width: 100%; margin-bottom: 8px; } - .form-group label { + .field-row { margin-bottom: 16px; } + + .field-label { display: block; - margin-bottom: 4px; + font-size: 12px; font-weight: 500; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 6px; + padding: 0 4px; } - .form-group input, - .form-group select { + .field-select { + display: block; width: 100%; - padding: 8px; - border: 1px solid var(--divider-color); + padding: 10px 12px; + border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; - background: var(--card-background-color); + background: var(--card-background-color, #fff); color: var(--primary-text-color); - font-size: 1em; + font-size: 14px; + font-family: var(--mdc-typography-body1-font-family, Roboto, sans-serif); box-sizing: border-box; + cursor: pointer; + appearance: auto; + transition: border-color 0.15s; + } + + .field-select:focus { + outline: none; + border-color: var(--primary-color, #3498db); + border-width: 2px; } - .form-group small { + .field-helper { display: block; - margin-top: 4px; + font-size: 11px; color: var(--secondary-text-color); - font-size: 0.85em; + margin-top: 5px; + padding: 0 4px; + line-height: 1.4; + } + + .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: 4px; + transition: background 0.15s; + } + + .check-row:hover { background: var(--secondary-background-color, #f5f5f5); } + + .check-row input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; + accent-color: var(--primary-color, #3498db); + margin: 0; + } + + .check-label { + font-size: 14px; + color: var(--primary-text-color); + font-family: var(--mdc-typography-body1-font-family, Roboto, sans-serif); + flex: 1; + line-height: 1.3; + } + + .section-divider { + height: 1px; + background: var(--divider-color, #e0e0e0); + margin: 16px 0; } `; } @@ -1883,69 +1941,68 @@ class ChoremanderChildCardEditor extends LitElement { const children = overviewEntity?.attributes?.children || []; return html` -
- - - 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
+ +
+ + + + `; } 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-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); } 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;" +);