diff --git a/.github/agents/builder.agent.md b/.github/agents/builder.agent.md index 956426d..4e030c4 100644 --- a/.github/agents/builder.agent.md +++ b/.github/agents/builder.agent.md @@ -1,7 +1,7 @@ --- name: ChoreOps Builder description: Implementation agent - executes plan phases, validates, reports progress -tools: ["search", "edit", "read", "execute", "web", "agent", "todo"] +tools: [vscode/askQuestions, vscode/runCommand, vscode/vscodeAPI, execute/getTerminalOutput, execute/killTerminal, execute/sendToTerminal, execute/runTask, execute/createAndRunTask, execute/runTests, execute/testFailure, execute/runNotebookCell, execute/runInTerminal, read/terminalSelection, read/terminalLastCommand, read/getTaskOutput, read/getNotebookSummary, read/problems, read/readFile, read/viewImage, read/readNotebookCellOutput, agent/runSubagent, edit/createDirectory, edit/createFile, edit/createJupyterNotebook, edit/editFiles, edit/editNotebook, edit/rename, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, web/githubTextSearch, pylance-mcp-server/pylanceDocString, pylance-mcp-server/pylanceDocuments, pylance-mcp-server/pylanceFileSyntaxErrors, pylance-mcp-server/pylanceImports, pylance-mcp-server/pylanceInstalledTopLevelModules, pylance-mcp-server/pylanceInvokeRefactoring, pylance-mcp-server/pylancePythonEnvironments, pylance-mcp-server/pylanceRunCodeSnippet, pylance-mcp-server/pylanceSettings, pylance-mcp-server/pylanceSyntaxErrors, pylance-mcp-server/pylanceUpdatePythonEnvironment, pylance-mcp-server/pylanceWorkspaceRoots, pylance-mcp-server/pylanceWorkspaceUserFiles, todo, vscode.mermaid-markdown-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/doSearch] handoffs: - label: Create New Plan agent: ChoreOps Strategist diff --git a/.github/agents/strategist.agent.md b/.github/agents/strategist.agent.md index d996f2c..c63ad74 100644 --- a/.github/agents/strategist.agent.md +++ b/.github/agents/strategist.agent.md @@ -1,7 +1,7 @@ --- name: ChoreOps Strategist description: Strategic planning agent - creates initiative plans, NO code implementation -tools: ["search", "edit/editFiles", "web"] +tools: [vscode/askQuestions, read/readFile, edit/createDirectory, edit/createFile, edit/editFiles, search/changes, search/codebase, search/fileSearch, search/listDirectory, search/textSearch, search/usages, web/fetch, web/githubRepo, web/githubTextSearch, pylance-mcp-server/pylanceDocString, pylance-mcp-server/pylanceDocuments, pylance-mcp-server/pylanceFileSyntaxErrors, pylance-mcp-server/pylanceImports, pylance-mcp-server/pylanceInstalledTopLevelModules, pylance-mcp-server/pylanceInvokeRefactoring, pylance-mcp-server/pylancePythonEnvironments, pylance-mcp-server/pylanceRunCodeSnippet, pylance-mcp-server/pylanceSettings, pylance-mcp-server/pylanceSyntaxErrors, pylance-mcp-server/pylanceUpdatePythonEnvironment, pylance-mcp-server/pylanceWorkspaceRoots, pylance-mcp-server/pylanceWorkspaceUserFiles, vscode.mermaid-markdown-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/doSearch] handoffs: - label: Execute This Plan agent: ChoreOps Builder diff --git a/custom_components/choreops/button.py b/custom_components/choreops/button.py index bc5caa0..2ff2e96 100644 --- a/custom_components/choreops/button.py +++ b/custom_components/choreops/button.py @@ -37,6 +37,7 @@ from .helpers.entity_helpers import ( get_assignee_name_by_id, get_friendly_label, + is_user_assigned_to_reward, should_create_entity_for_user_assignee, should_create_gamification_entities, ) @@ -196,6 +197,127 @@ def create_chore_button_entities( return len(entities) +def create_reward_button_entities( + coordinator: ChoreOpsDataCoordinator, + reward_id: str, + *, + assignee_ids: list[str] | None = None, +) -> int: + """Create missing reward-linked buttons for a reward. + + Creates AssigneeRewardRedeemButton, ApproverRewardApproveButton, and + ApproverRewardDisapproveButton for each assigned, gamification-enabled + assignee. Skips entities already registered in the entity registry. + + Args: + coordinator: Runtime coordinator. + reward_id: Internal ID of the reward. + assignee_ids: Optional subset of assignee IDs to create buttons for. + When omitted, create buttons for all currently assigned assignees. + + Returns: + Count of entities handed to Home Assistant for creation. + """ + if _async_add_entities_callback is None: + const.LOGGER.warning("Cannot create reward buttons: callback not registered") + return 0 + + reward_info = coordinator.rewards_data.get(reward_id) + if not reward_info: + const.LOGGER.warning( + "Cannot create reward buttons: reward %s not found", reward_id + ) + return 0 + + reward_name = str( + reward_info.get( + const.DATA_REWARD_NAME, + f"{const.TRANS_KEY_LABEL_REWARD} {reward_id}", + ) + ) + reward_icon = reward_info.get(const.DATA_REWARD_ICON, const.SENTINEL_EMPTY) + entry = coordinator.config_entry + entities: list[ButtonEntity] = [] + + for assignee_id, assignee_info in coordinator.assignees_data.items(): + if assignee_ids is not None and assignee_id not in assignee_ids: + continue + if not should_create_gamification_entities(coordinator, assignee_id): + continue + if not is_user_assigned_to_reward(coordinator, assignee_id, reward_id): + continue + + assignee_name = str( + assignee_info.get( + const.DATA_USER_NAME, + f"{const.TRANS_KEY_LABEL_ASSIGNEE} {assignee_id}", + ) + ) + + # Redeem Button + redeem_unique_id = ( + f"{entry.entry_id}_{assignee_id}_{reward_id}" + f"{const.BUTTON_KC_UID_SUFFIX_ASSIGNEE_REWARD_REDEEM}" + ) + if not _button_entity_exists(coordinator, redeem_unique_id): + entities.append( + AssigneeRewardRedeemButton( + coordinator=coordinator, + entry=entry, + assignee_id=assignee_id, + assignee_name=assignee_name, + reward_id=reward_id, + reward_name=reward_name, + icon=reward_icon, + ) + ) + + # Approve Button + approve_unique_id = ( + f"{entry.entry_id}_{assignee_id}_{reward_id}" + f"{const.BUTTON_KC_UID_SUFFIX_APPROVE_REWARD}" + ) + if not _button_entity_exists(coordinator, approve_unique_id): + entities.append( + ApproverRewardApproveButton( + coordinator=coordinator, + entry=entry, + assignee_id=assignee_id, + assignee_name=assignee_name, + reward_id=reward_id, + reward_name=reward_name, + icon=reward_icon, + ) + ) + + # Disapprove Button + disapprove_unique_id = ( + f"{entry.entry_id}_{assignee_id}_{reward_id}" + f"{const.BUTTON_KC_UID_SUFFIX_DISAPPROVE_REWARD}" + ) + if not _button_entity_exists(coordinator, disapprove_unique_id): + entities.append( + ApproverRewardDisapproveButton( + coordinator=coordinator, + entry=entry, + assignee_id=assignee_id, + assignee_name=assignee_name, + reward_id=reward_id, + reward_name=reward_name, + ) + ) + + if entities: + _async_add_entities_callback(entities) + const.LOGGER.debug( + "Created %d reward-linked buttons for reward: %s", + len(entities), + reward_name, + ) + + return len(entities) + + async def async_setup_entry( hass: HomeAssistant, entry: ChoreOpsConfigEntry, @@ -293,6 +415,9 @@ async def async_setup_entry( const.DATA_USER_NAME, f"{const.TRANS_KEY_LABEL_ASSIGNEE} {assignee_id}" ) for reward_id, reward_info in coordinator.rewards_data.items(): + # Skip rewards not assigned to this user + if not is_user_assigned_to_reward(coordinator, assignee_id, reward_id): + continue # Icon from storage (empty = use icons.json translation) reward_icon = reward_info.get(const.DATA_REWARD_ICON, const.SENTINEL_EMPTY) # Redeem Reward Button diff --git a/custom_components/choreops/config_flow.py b/custom_components/choreops/config_flow.py index 4cb9205..768a955 100644 --- a/custom_components/choreops/config_flow.py +++ b/custom_components/choreops/config_flow.py @@ -1045,6 +1045,10 @@ async def async_step_rewards(self, user_input: dict[str, Any] | None = None): # CFOF_* keys now aligned with DATA_* keys - pass directly reward_data = dict(db.build_reward(user_input)) + # Strip empty assigned_user_ids so boot normalization fills + # with gamified users (config flow has no coordinator to resolve) + if not reward_data.get(const.DATA_REWARD_ASSIGNED_USER_IDS): + del reward_data[const.DATA_REWARD_ASSIGNED_USER_IDS] self._rewards_temp[internal_id] = reward_data reward_name = reward_data[const.DATA_REWARD_NAME] diff --git a/custom_components/choreops/const.py b/custom_components/choreops/const.py index 0da9b4f..95d3057 100644 --- a/custom_components/choreops/const.py +++ b/custom_components/choreops/const.py @@ -464,6 +464,7 @@ def set_default_timezone(hass): SENTINEL_NO_SELECTION: Final = ( "__none__" # Non-empty sentinel for SelectSelector "None" option ) +SENTINEL_ALL_USERS: Final = "*" # All gamified users assigned # Display Values DISPLAY_DOT: Final = "." @@ -712,6 +713,10 @@ def set_default_timezone(hass): CFOF_REWARDS_INPUT_ICON: Final = "icon" CFOF_REWARDS_INPUT_LABELS: Final = "reward_labels" CFOF_REWARDS_INPUT_NAME: Final = "name" # Aligned with DATA_REWARD_NAME +CFOF_REWARDS_INPUT_ASSIGNED_USER_NAMES: Final = "assigned_user_names" +CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS: Final = ( + "assigned_user_ids" # Aligned with DATA_REWARD_ASSIGNED_USER_IDS +) CFOF_REWARDS_INPUT_REWARD_COUNT: Final = "reward_count" # BONUSES @@ -1593,6 +1598,7 @@ def set_default_timezone(hass): DATA_REWARD_LABELS: Final = "reward_labels" DATA_REWARD_NAME: Final = "name" DATA_REWARD_TIMESTAMP: Final = "timestamp" +DATA_REWARD_ASSIGNED_USER_IDS: Final = "assigned_user_ids" # BONUSES DATA_BONUS_DESCRIPTION: Final = "description" @@ -2903,6 +2909,8 @@ def set_default_timezone(hass): SERVICE_FIELD_REWARD_CRUD_DESCRIPTION: Final = "description" SERVICE_FIELD_REWARD_CRUD_ICON: Final = "icon" SERVICE_FIELD_REWARD_CRUD_LABELS: Final = "labels" +SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES: Final = "assigned_user_names" +SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS: Final = "assigned_user_ids" # Penalty service fields SERVICE_FIELD_PENALTY_NAME: Final = "penalty_name" @@ -3275,6 +3283,7 @@ class EntityRequirement(StrEnum): CFOP_ERROR_PENALTY_NAME: Final = "name" CFOP_ERROR_REWARD_NAME: Final = "name" CFOP_ERROR_REWARD_COST: Final = "cost" +CFOP_ERROR_REWARD_ASSIGNED_USERS: Final = "assigned_user_names" CFOP_ERROR_SELECT_CHORE_ID: Final = "selected_chore_id" CFOP_ERROR_START_DATE: Final = "start_date" CFOP_ERROR_CHORE_OPTIONS: Final = ( @@ -3496,6 +3505,10 @@ class EntityRequirement(StrEnum): TRANS_KEY_CFOF_INVALID_REWARD_COST: Final = ( "invalid_reward_cost" # Reward cost must be >= 0 ) +TRANS_KEY_CFOF_INVALID_REWARD_ASSIGNED_USERS: Final = "invalid_reward_assigned_users" +TRANS_KEY_CFOF_MIXED_REWARD_ASSIGNMENT_SENTINEL: Final = ( + "mixed_reward_assignment_sentinel" +) TRANS_KEY_CFOF_INVALID_SELECTION: Final = "invalid_selection" TRANS_KEY_CFOF_POINTS_LABEL_REQUIRED: Final = "points_label_required" TRANS_KEY_CFOF_MAIN_MENU: Final = "main_menu" diff --git a/custom_components/choreops/coordinator.py b/custom_components/choreops/coordinator.py index 65ffaa8..34bf589 100644 --- a/custom_components/choreops/coordinator.py +++ b/custom_components/choreops/coordinator.py @@ -28,6 +28,7 @@ from .helpers.entity_helpers import ( remove_entities_by_item_id, remove_orphaned_assignee_chore_entities, + remove_orphaned_assignee_reward_entities, remove_orphaned_shared_chore_sensors, ) from .managers import ( @@ -570,6 +571,48 @@ def is_shared_chore(chore: ChoreData | None) -> bool: "shared_state_changed": previous_shared != current_shared, } + async def async_sync_reward_entities( + self, + reward_id: str, + mutation: Literal["created", "assigned_users_changed", "deleted"], + ) -> None: + """Synchronize reward-linked runtime entities without reloading the entry. + + Handles targeted entity creation and orphan cleanup for reward CRUD. + Does not write storage; callers must invoke this only after + manager-owned persistence succeeds. + + Args: + reward_id: Internal ID of the reward. + mutation: The type of mutation that triggered the sync. + """ + if mutation == "deleted": + await remove_orphaned_assignee_reward_entities( + self.hass, + self.config_entry.entry_id, + self.assignees_data, + self.rewards_data, + ) + self.async_update_listeners() + return + + # Create sensor and button entities for assigned users + from .button import create_reward_button_entities + from .sensor import create_reward_entities + + create_reward_entities(self, reward_id) + create_reward_button_entities(self, reward_id) + + # Clean any orphaned entities from assignment shrinkage + await remove_orphaned_assignee_reward_entities( + self.hass, + self.config_entry.entry_id, + self.assignees_data, + self.rewards_data, + ) + + self.async_update_listeners() + # ------------------------------------------------------------------------------------- # Properties for Easy Access # ------------------------------------------------------------------------------------- diff --git a/custom_components/choreops/data_builders.py b/custom_components/choreops/data_builders.py index fd5329d..f114a37 100644 --- a/custom_components/choreops/data_builders.py +++ b/custom_components/choreops/data_builders.py @@ -266,6 +266,17 @@ def validate_reward_data( ) return errors + # === 4. Assigned user IDs validation === + if const.DATA_REWARD_ASSIGNED_USER_IDS in data: + assigned = data[const.DATA_REWARD_ASSIGNED_USER_IDS] + if not isinstance(assigned, list) or not all( + isinstance(uid, str) for uid in assigned + ): + errors[const.CFOP_ERROR_REWARD_ASSIGNED_USERS] = ( + const.TRANS_KEY_CFOF_INVALID_REWARD_ASSIGNED_USERS + ) + return errors + return errors @@ -350,6 +361,7 @@ def get_field( description=str(get_field(const.DATA_REWARD_DESCRIPTION, const.SENTINEL_EMPTY)), icon=str(get_field(const.DATA_REWARD_ICON, const.SENTINEL_EMPTY)), reward_labels=list(get_field(const.DATA_REWARD_LABELS, [])), + assigned_user_ids=list(get_field(const.DATA_REWARD_ASSIGNED_USER_IDS, [])), ) @@ -371,6 +383,7 @@ def get_field( const.DATA_REWARD_DESCRIPTION, const.DATA_REWARD_ICON, const.DATA_REWARD_LABELS, + const.DATA_REWARD_ASSIGNED_USER_IDS, } ) diff --git a/custom_components/choreops/helpers/entity_helpers.py b/custom_components/choreops/helpers/entity_helpers.py index 1995b21..d0c78e4 100644 --- a/custom_components/choreops/helpers/entity_helpers.py +++ b/custom_components/choreops/helpers/entity_helpers.py @@ -791,6 +791,65 @@ def is_valid(unique_id: str) -> bool: ) +async def remove_orphaned_assignee_reward_entities( + hass: HomeAssistant, + entry_id: str, + assignees_data: dict[str, Any], + rewards_data: dict[str, Any], +) -> int: + """Remove reward entities for assignees no longer assigned to a reward. + + Mirrors ``remove_orphaned_assignee_chore_entities`` exactly for the reward + domain. Scans sensor and button platforms and removes entities whose + (assignee_id, reward_id) combination is no longer valid per the reward's + ``assigned_user_ids`` field. + + Args: + hass: HomeAssistant instance. + entry_id: Config entry ID. + assignees_data: Dict of assignee_id → assignee_info. + rewards_data: Dict of reward_id → reward_info. + + Returns: + Count of removed entities. + """ + if not assignees_data or not rewards_data: + return 0 + + prefix = f"{entry_id}_" + + # Build valid assignee-reward combinations + valid_combinations: set[tuple[str, str]] = set() + for reward_id, reward_info in rewards_data.items(): + assigned_ids: list[str] = reward_info.get( + const.DATA_REWARD_ASSIGNED_USER_IDS, [] + ) + for assignee_id in assigned_ids: + valid_combinations.add((assignee_id, reward_id)) + + # Build regex for efficient extraction + assignee_ids_pat = "|".join( + re.escape(assignee_id) for assignee_id in assignees_data + ) + reward_ids_pat = "|".join(re.escape(reward_id) for reward_id in rewards_data) + pattern = re.compile(rf"^({assignee_ids_pat})_({reward_ids_pat})") + + def is_valid(unique_id: str) -> bool: + core = unique_id[len(prefix) :] + match = pattern.match(core) + if not match: + return True # Not an assignee-reward entity, keep it + return (match.group(1), match.group(2)) in valid_combinations + + return await remove_entities_by_validator( + hass, + entry_id, + platforms=[const.Platform.SENSOR, const.Platform.BUTTON], + is_valid=is_valid, + entity_type="assignee-reward entity", + ) + + async def remove_orphaned_progress_entities( hass: HomeAssistant, entry_id: str, @@ -1105,6 +1164,57 @@ def should_create_gamification_entities( return resolve_user_entity_policy(coordinator, assignee_id).gamification_enabled +def get_all_gamified_user_ids( + coordinator: ChoreOpsDataCoordinator, +) -> list[str]: + """Return the internal IDs of all users with gamification enabled. + + Used to resolve the ``SENTINEL_ALL_USERS`` sentinel to explicit UUIDs + at input boundaries so storage never contains the sentinel. + + Args: + coordinator: The ChoreOps data coordinator. + + Returns: + List of internal IDs for users where gamification is enabled. + Returns all assignable user IDs if no feature-gated profiles exist. + """ + gamified: list[str] = [] + for user_id in coordinator.assignees_data: + policy = resolve_user_entity_policy(coordinator, user_id) + if policy.gamification_enabled: + gamified.append(user_id) + return gamified + + +def is_user_assigned_to_reward( + coordinator: ChoreOpsDataCoordinator, + assignee_id: str, + reward_id: str, +) -> bool: + """Return whether an assignee is assigned to a specific reward. + + Storage always contains explicit UUID lists after normalization; + the ``SENTINEL_ALL_USERS`` sentinel is resolved at input boundaries + (service handlers, RewardManager.create_reward) and never reaches + this function. + + Args: + coordinator: The ChoreOps data coordinator. + assignee_id: Internal UUID of the user/assignee. + reward_id: Internal UUID of the reward. + + Returns: + True if the assignee is assigned to the reward, False otherwise. + """ + reward_info = coordinator.rewards_data.get(reward_id) + if not reward_info: + return False + + assigned_ids: list[str] = reward_info.get(const.DATA_REWARD_ASSIGNED_USER_IDS, []) + return assignee_id in assigned_ids + + def should_create_entity( unique_id_suffix: str, *, diff --git a/custom_components/choreops/helpers/flow_helpers.py b/custom_components/choreops/helpers/flow_helpers.py index bf54787..e30cd23 100644 --- a/custom_components/choreops/helpers/flow_helpers.py +++ b/custom_components/choreops/helpers/flow_helpers.py @@ -2662,43 +2662,69 @@ def validate_badge_common_inputs( # ---------------------------------------------------------------------------------- -def build_reward_schema(default=None): +def build_reward_schema(default=None, assignees_dict=None): """Build a schema for rewards, keyed by internal_id in the dict. Note: Uses static defaults to enable field clearing. For edit forms, use add_suggested_values_to_schema() to show current values. + + Args: + default: Default values dict for the form. + assignees_dict: Optional dict of assignee_id → assignee_info for the + assigned_user_ids multi-select. If None, the selector is omitted. + + Returns: + vol.Schema for the reward form. """ default = default or {} - return vol.Schema( - { - vol.Required( - const.CFOF_REWARDS_INPUT_NAME, default=const.SENTINEL_EMPTY - ): str, - vol.Optional( - const.CFOF_REWARDS_INPUT_DESCRIPTION, - default=const.SENTINEL_EMPTY, - ): str, - vol.Optional( - const.CFOF_REWARDS_INPUT_LABELS, - default=[], - ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), - vol.Required( - const.CFOF_REWARDS_INPUT_COST, - default=const.DEFAULT_REWARD_COST, - ): selector.NumberSelector( - selector.NumberSelectorConfig( - mode=selector.NumberSelectorMode.BOX, - min=0, - step=0.01, - ) - ), + fields: dict[vol.Required | vol.Optional, Any] = { + vol.Required(const.CFOF_REWARDS_INPUT_NAME, default=const.SENTINEL_EMPTY): str, + vol.Optional( + const.CFOF_REWARDS_INPUT_DESCRIPTION, + default=const.SENTINEL_EMPTY, + ): str, + vol.Optional( + const.CFOF_REWARDS_INPUT_LABELS, + default=[], + ): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)), + vol.Required( + const.CFOF_REWARDS_INPUT_COST, + default=const.DEFAULT_REWARD_COST, + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + min=0, + step=0.01, + ) + ), + vol.Optional( + const.CFOF_REWARDS_INPUT_ICON, + default=const.SENTINEL_EMPTY, + ): selector.IconSelector(), + } + + # Add assigned users multi-select when assignees are available + if assignees_dict: + assignee_choices = [ + {"value": assignee_id, "label": info.get(const.DATA_USER_NAME, assignee_id)} + for assignee_id, info in assignees_dict.items() + ] + fields[ vol.Optional( - const.CFOF_REWARDS_INPUT_ICON, - default=const.SENTINEL_EMPTY, - ): selector.IconSelector(), - } - ) + const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS, + default=default.get(const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS, []), + ) + ] = selector.SelectSelector( + selector.SelectSelectorConfig( + options=assignee_choices, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key=const.TRANS_KEY_FLOW_HELPERS_ASSIGNED_USER_IDS, + ) + ) + + return vol.Schema(fields) def validate_rewards_inputs( @@ -2736,6 +2762,12 @@ def validate_rewards_inputs( if const.CFOF_REWARDS_INPUT_COST in user_input: data_dict[const.DATA_REWARD_COST] = user_input[const.CFOF_REWARDS_INPUT_COST] + # Include assigned_user_ids if provided (CFOF key is aligned with DATA key) + if const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS in user_input: + data_dict[const.DATA_REWARD_ASSIGNED_USER_IDS] = user_input[ + const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS + ] + # Call shared validation (single source of truth) is_update = current_reward_id is not None return db.validate_reward_data( diff --git a/custom_components/choreops/managers/reward_manager.py b/custom_components/choreops/managers/reward_manager.py index d3d8b19..efb3f90 100644 --- a/custom_components/choreops/managers/reward_manager.py +++ b/custom_components/choreops/managers/reward_manager.py @@ -828,6 +828,14 @@ def create_reward( Emits: SIGNAL_SUFFIX_REWARD_CREATED with reward_id and reward_name. """ + # Default to all gamified users if no assignment specified + if const.DATA_REWARD_ASSIGNED_USER_IDS not in user_input: + from ..helpers.entity_helpers import get_all_gamified_user_ids + + user_input[const.DATA_REWARD_ASSIGNED_USER_IDS] = get_all_gamified_user_ids( + self.coordinator + ) + # Build complete reward data structure reward_data = dict(db.build_reward(user_input)) internal_id = str(reward_data[const.DATA_REWARD_INTERNAL_ID]) @@ -888,6 +896,7 @@ def update_reward( ) existing = rewards_data[reward_id] + # Build updated reward (merge existing with updates) updated_reward = dict(db.build_reward(updates, existing=existing)) @@ -898,6 +907,10 @@ def update_reward( reward_name = str(updated_reward.get(const.DATA_REWARD_NAME, "")) + # Entity sync is now handled by the caller (services.py / options_flow.py) + # via async_sync_reward_entities(), which also handles orphan cleanup. + # The manager owns persistence and signal emission only. + # Emit lifecycle event self.emit( const.SIGNAL_SUFFIX_REWARD_UPDATED, diff --git a/custom_components/choreops/managers/system_manager.py b/custom_components/choreops/managers/system_manager.py index b2f514c..3f021aa 100644 --- a/custom_components/choreops/managers/system_manager.py +++ b/custom_components/choreops/managers/system_manager.py @@ -285,6 +285,25 @@ async def ensure_data_integrity(self, current_version: int) -> None: due_state_repair_summary["global_states_normalized"], ) + # 1e. Normalize reward assigned_user_ids (missing/sentinel → explicit UUIDs) + # Empty lists [] are intentional ("no users") and must NOT be normalized. + rewards_data = self.coordinator._data.get(const.DATA_REWARDS, {}) + normalized_count = 0 + for reward_info in list(rewards_data.values()): + assigned = reward_info.get(const.DATA_REWARD_ASSIGNED_USER_IDS) + if assigned is None or const.SENTINEL_ALL_USERS in assigned: + from ..helpers.entity_helpers import get_all_gamified_user_ids + + reward_info[const.DATA_REWARD_ASSIGNED_USER_IDS] = ( + get_all_gamified_user_ids(self.coordinator) + ) + normalized_count += 1 + if normalized_count: + const.LOGGER.info( + "SystemManager: Normalized %d rewards from sentinel to explicit UUIDs", + normalized_count, + ) + # 2. Startup Safety Net (Registry validation) await self.run_startup_safety_net() const.LOGGER.info("SystemManager: Data integrity verified") diff --git a/custom_components/choreops/options_flow.py b/custom_components/choreops/options_flow.py index 68cb90b..1e95592 100644 --- a/custom_components/choreops/options_flow.py +++ b/custom_components/choreops/options_flow.py @@ -3112,19 +3112,32 @@ async def async_step_add_reward(self, user_input=None): user_input, immediate_persist=True ) + reward_internal_id = reward_data[const.DATA_REWARD_INTERNAL_ID] const.LOGGER.debug( "Added Reward '%s' with ID: %s", reward_data[const.DATA_REWARD_NAME], - reward_data[const.DATA_REWARD_INTERNAL_ID], + reward_internal_id, + ) + await coordinator.async_sync_reward_entities( + reward_internal_id, "created" ) - self._mark_reload_needed() return await self.async_step_init() except EntityValidationError as err: # Map field-specific error for form highlighting errors[err.field] = err.translation_key - schema = fh.build_reward_schema() + schema = fh.build_reward_schema(assignees_dict=coordinator.assignees_data) + + # Pre-populate with all gamified users on first view + if user_input is None: + from .helpers.entity_helpers import get_all_gamified_user_ids + + gamified_ids = get_all_gamified_user_ids(coordinator) + suggested = { + const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS: gamified_ids, + } + schema = self.add_suggested_values_to_schema(schema, suggested) # On validation error, preserve user's attempted input if user_input: @@ -3173,7 +3186,9 @@ async def async_step_edit_reward(self, user_input=None): updated_reward[const.DATA_REWARD_NAME], internal_id, ) - self._mark_reload_needed() + await coordinator.async_sync_reward_entities( + str(internal_id), "assigned_users_changed" + ) return await self.async_step_init() except EntityValidationError as err: @@ -3193,6 +3208,9 @@ async def async_step_edit_reward(self, user_input=None): const.DATA_REWARD_COST, const.DEFAULT_REWARD_COST ), const.CFOF_REWARDS_INPUT_ICON: existing_reward.get(const.DATA_REWARD_ICON), + const.CFOF_REWARDS_INPUT_ASSIGNED_USER_IDS: existing_reward.get( + const.DATA_REWARD_ASSIGNED_USER_IDS, [] + ), } # On validation error, merge user's attempted input with existing data @@ -3200,7 +3218,7 @@ async def async_step_edit_reward(self, user_input=None): suggested_values.update(user_input) # Build schema with static defaults - schema = fh.build_reward_schema() + schema = fh.build_reward_schema(assignees_dict=coordinator.assignees_data) # Apply values as suggestions schema = self.add_suggested_values_to_schema(schema, suggested_values) diff --git a/custom_components/choreops/sensor.py b/custom_components/choreops/sensor.py index d78a466..54a0340 100644 --- a/custom_components/choreops/sensor.py +++ b/custom_components/choreops/sensor.py @@ -80,6 +80,7 @@ get_assignee_name_by_id, get_friendly_label, get_item_name_or_log_error, + is_user_assigned_to_reward, should_create_entity, should_create_entity_for_user_assignee, should_create_gamification_entities, @@ -494,6 +495,8 @@ async def async_setup_entry( assignee_id, ): continue + if not is_user_assigned_to_reward(coordinator, assignee_id, reward_id): + continue assignee_name = get_item_name_or_log_error( "assignee", assignee_id, assignee_info, const.DATA_USER_NAME ) @@ -757,36 +760,49 @@ def create_chore_entities( def create_reward_entities( - coordinator: ChoreOpsDataCoordinator, reward_id: str -) -> None: - """Create reward status sensor entities for a newly created reward. + coordinator: ChoreOpsDataCoordinator, + reward_id: str, + *, + assignee_ids: list[str] | None = None, +) -> int: + """Create missing reward status sensor entities for a reward. + + Args: + coordinator: Runtime coordinator. + reward_id: Internal ID of the reward. + assignee_ids: Optional subset of assignee IDs to create sensors for. + When omitted, create sensors for all currently assigned assignees. - Called by create_reward service after adding reward to storage. - Creates AssigneeRewardStatusSensor for each assignee with gamification enabled. + Returns: + Count of entities handed to Home Assistant for creation. """ if _async_add_entities_callback is None: const.LOGGER.warning("Cannot create reward entities: callback not registered") - return + return 0 reward_info = coordinator.rewards_data.get(reward_id) if not reward_info: const.LOGGER.warning( "Cannot create reward entities: reward %s not found", reward_id ) - return + return 0 reward_name = get_item_name_or_log_error( "reward", reward_id, reward_info, const.DATA_REWARD_NAME ) if not reward_name: - return + return 0 - entities = [] + entities: list[SensorEntity] = [] for assignee_id, assignee_info in coordinator.assignees_data.items(): + if assignee_ids is not None and assignee_id not in assignee_ids: + continue # Skip linked profiles without gamification if not should_create_gamification_entities(coordinator, assignee_id): continue + if not is_user_assigned_to_reward(coordinator, assignee_id, reward_id): + continue assignee_name = get_item_name_or_log_error( "assignee", assignee_id, assignee_info, const.DATA_USER_NAME @@ -794,6 +810,13 @@ def create_reward_entities( if not assignee_name: continue + unique_id = ( + f"{coordinator.config_entry.entry_id}_{assignee_id}_{reward_id}" + f"{const.SENSOR_KC_UID_SUFFIX_REWARD_STATUS_SENSOR}" + ) + if _sensor_entity_exists(coordinator, unique_id): + continue + entities.append( AssigneeRewardStatusSensor( coordinator, @@ -813,6 +836,8 @@ def create_reward_entities( reward_name, ) + return len(entities) + # ------------------------------------------------------------------------------------------ class AssigneeChoreStatusSensor(ChoreOpsCoordinatorEntity, SensorEntity): @@ -2942,6 +2967,16 @@ def extra_state_attributes(self) -> dict: last_approved = reward_data.get(const.DATA_USER_REWARD_DATA_LAST_APPROVED) last_disapproved = reward_data.get(const.DATA_USER_REWARD_DATA_LAST_DISAPPROVED) + # Resolve assigned user names from stored UUIDs + assigned_ids: list[str] = reward_info.get( + const.DATA_REWARD_ASSIGNED_USER_IDS, [] + ) + assigned_user_names = [ + name + for k_id in assigned_ids + if (name := get_assignee_name_by_id(self.coordinator, k_id)) + ] + # Get pending claims count pending_claims = reward_data.get( const.DATA_USER_REWARD_DATA_PENDING_COUNT, const.DEFAULT_ZERO @@ -2981,6 +3016,7 @@ def extra_state_attributes(self) -> dict: const.ATTR_DESCRIPTION: reward_info.get( const.DATA_REWARD_DESCRIPTION, const.SENTINEL_EMPTY ), + const.ATTR_ASSIGNED_USER_NAMES: assigned_user_names, const.ATTR_COST: reward_info.get( const.DATA_REWARD_COST, const.DEFAULT_REWARD_COST ), @@ -4868,6 +4904,11 @@ def _build_payload( if not reward_name: continue + if not is_user_assigned_to_reward( + self.coordinator, self._assignee_id, reward_id + ): + continue + reward_eid = None if entity_registry: unique_id = ( diff --git a/custom_components/choreops/services.py b/custom_components/choreops/services.py index 5e1afe4..681b55d 100644 --- a/custom_components/choreops/services.py +++ b/custom_components/choreops/services.py @@ -646,6 +646,12 @@ def _build_service_chore_validation_data( vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_LABELS, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS): vol.All( + cv.ensure_list, [cv.string] + ), } ) ) @@ -664,6 +670,12 @@ def _build_service_chore_validation_data( vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_LABELS): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS): vol.All( + cv.ensure_list, [cv.string] + ), } ) ) @@ -923,8 +935,73 @@ def _build_service_chore_validation_data( const.SERVICE_FIELD_REWARD_CRUD_DESCRIPTION: const.DATA_REWARD_DESCRIPTION, const.SERVICE_FIELD_REWARD_CRUD_ICON: const.DATA_REWARD_ICON, const.SERVICE_FIELD_REWARD_CRUD_LABELS: const.DATA_REWARD_LABELS, + const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS: ( + const.DATA_REWARD_ASSIGNED_USER_IDS + ), } + +def _resolve_reward_assigned_user_names( + coordinator: "ChoreOpsDataCoordinator", + service_data: dict[str, Any], +) -> None: + """Resolve display names in assigned_user_names to UUIDs in-place. + + Mutates ``service_data``, replacing name-based keys with the canonical + ``SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS`` key containing resolved + UUIDs (or the ``SENTINEL_ALL_USERS`` sentinel). + + Follows the same pattern as chore assignment name resolution. + """ + assignee_names: list[str] | None = service_data.get( + const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES + ) + if assignee_names is None and ( + const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS in service_data + ): + # Legacy key: accept raw UUIDs directly, but resolve sentinel + raw_ids = service_data[const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS] + if isinstance(raw_ids, list) and const.SENTINEL_ALL_USERS in raw_ids: + from .helpers.entity_helpers import get_all_gamified_user_ids + + service_data[const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS] = ( + get_all_gamified_user_ids(coordinator) + ) + return + + if assignee_names is None: + # No assignment update requested + return + + # Sentinel: "*" means all gamified users — resolve to explicit UUIDs + if assignee_names == [const.SENTINEL_ALL_USERS]: + from .helpers.entity_helpers import get_all_gamified_user_ids + + service_data[const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS] = ( + get_all_gamified_user_ids(coordinator) + ) + service_data.pop(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES, None) + return + + # Resolve each display name to a UUID + assignee_ids: list[str] = [] + for assignee_name in assignee_names: + try: + assignee_id = get_item_id_or_raise( + coordinator, + const.ITEM_TYPE_USER, + assignee_name, + role=const.ROLE_ASSIGNEE, + ) + assignee_ids.append(assignee_id) + except HomeAssistantError as err: + const.LOGGER.warning("Reward assigned_user_names lookup failed: %s", err) + raise + + service_data[const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS] = assignee_ids + service_data.pop(const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES, None) + + # ============================================================================== # ROTATION MANAGEMENT SCHEMAS (Phase 3 Step 7 - v0.5.0) # ============================================================================== @@ -2023,9 +2100,15 @@ async def handle_create_reward(call: ServiceCall) -> dict[str, Any]: coordinator = _get_coordinator_by_entry_id(hass, entry_id) + # Resolve assigned_user_names to UUIDs (if provided). + # Follows chore pattern: accept display names, resolve to UUIDs, + # accept legacy assigned_user_ids key for backward compatibility. + service_data = dict(call.data) + _resolve_reward_assigned_user_names(coordinator, service_data) + # Map service fields to DATA_* keys for data_builders data_input = _map_service_to_data_keys( - dict(call.data), _SERVICE_TO_REWARD_DATA_MAPPING + service_data, _SERVICE_TO_REWARD_DATA_MAPPING ) # Validate using shared validation (single source of truth) @@ -2048,14 +2131,8 @@ async def handle_create_reward(call: ServiceCall) -> dict[str, Any]: reward_dict = coordinator.reward_manager.create_reward(data_input) internal_id = str(reward_dict[const.DATA_REWARD_INTERNAL_ID]) - # Create reward status sensor entities for all assignees with - # gamification enabled. - if coordinator._test_mode: - from .sensor import create_reward_entities - - create_reward_entities(coordinator, internal_id) - - await coordinator.async_sync_entities_after_service_create() + # Runtime entity sync — no full reload + await coordinator.async_sync_reward_entities(internal_id, "created") const.LOGGER.info( "Service created reward '%s' with ID: %s", @@ -2148,6 +2225,9 @@ async def handle_update_reward(call: ServiceCall) -> dict[str, Any]: # Only include it in data_input if there's ALSO a reward_id (explicit rename) service_data.pop(const.SERVICE_FIELD_REWARD_NAME, None) + # Resolve assigned_user_names to UUIDs (if provided) + _resolve_reward_assigned_user_names(coordinator, service_data) + data_input = _map_service_to_data_keys( service_data, _SERVICE_TO_REWARD_DATA_MAPPING ) @@ -2173,6 +2253,15 @@ async def handle_update_reward(call: ServiceCall) -> dict[str, Any]: reward_id, data_input ) + # Runtime entity sync on assignment changes — no full reload + if ( + const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES in call.data + or const.SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS in call.data + ): + await coordinator.async_sync_reward_entities( + reward_id, "assigned_users_changed" + ) + const.LOGGER.info( "Service updated reward '%s' with ID: %s", reward_dict[const.DATA_REWARD_NAME], diff --git a/custom_components/choreops/services.yaml b/custom_components/choreops/services.yaml index 974902d..980fc12 100644 --- a/custom_components/choreops/services.yaml +++ b/custom_components/choreops/services.yaml @@ -968,6 +968,14 @@ create_reward: selector: text: multiple: true + assigned_user_names: + name: "Assigned to" + description: "List of user display names to assign this reward to. Only assigned users will see reward entities. Use ['*'] for all users. Pass an empty list to assign to no users." + example: "['Zoë', 'Max!']" + required: false + selector: + text: + multiple: true update_reward: name: "Update Reward" @@ -1033,6 +1041,14 @@ update_reward: selector: text: multiple: true + assigned_user_names: + name: "Assigned to" + description: "List of user display names to assign this reward to. Only assigned users will see reward entities. Use ['*'] for all users. Pass an empty list to remove all assigned users. Omit to leave current assignments unchanged." + example: "['Zoë', 'Max!']" + required: false + selector: + text: + multiple: true delete_reward: name: Delete Reward diff --git a/custom_components/choreops/translations/en.json b/custom_components/choreops/translations/en.json index 7c0be42..1cec920 100644 --- a/custom_components/choreops/translations/en.json +++ b/custom_components/choreops/translations/en.json @@ -487,6 +487,8 @@ "invalid_reward_count": "Invalid reward count", "invalid_reward_name": "Invalid reward name", "invalid_reward_cost": "Reward cost must be zero or greater with up to 2 decimal places", + "invalid_reward_assigned_users": "One or more assigned users are not valid", + "mixed_reward_assignment_sentinel": "Cannot mix 'all users' with specific users", "invalid_bonus": "Invalid bonus", "invalid_bonus_count": "Invalid bonus count", "invalid_bonus_name": "Invalid bonus name", @@ -934,7 +936,8 @@ "cost": "Reward Cost", "description": "Description (optional)", "reward_labels": "Reward Labels", - "icon": "Icon (mdi:xxx)" + "icon": "Icon (mdi:xxx)", + "assigned_user_ids": "Assigned to" } }, "add_penalty": { @@ -1397,7 +1400,8 @@ "cost": "Reward Cost", "description": "Description (optional)", "reward_labels": "Reward Labels", - "icon": "Icon (mdi:xxx)" + "icon": "Icon (mdi:xxx)", + "assigned_user_ids": "Assigned to" } }, "edit_penalty": { @@ -2483,6 +2487,11 @@ "name": "Labels", "description": "Labels for grouping rewards.", "example": "['weekend', 'special']" + }, + "assigned_user_names": { + "name": "Assigned to", + "description": "User display names assigned to this reward. Use [\"*\"] for all users. Pass an empty list to assign to no users.", + "example": "['Zoë', 'Max!']" } } }, @@ -2529,6 +2538,11 @@ "name": "Labels", "description": "Updated labels.", "example": "['premium', 'special']" + }, + "assigned_user_names": { + "name": "Assigned to", + "description": "User display names assigned to this reward. Use [\"*\"] for all users. Pass an empty list to remove all assigned users. Omit to leave current assignments unchanged.", + "example": "['Zoë', 'Max!']" } } }, diff --git a/custom_components/choreops/type_defs.py b/custom_components/choreops/type_defs.py index 223b944..8eb3165 100644 --- a/custom_components/choreops/type_defs.py +++ b/custom_components/choreops/type_defs.py @@ -132,6 +132,7 @@ class RewardData(TypedDict): description: str icon: str reward_labels: list[str] + assigned_user_ids: list[str] class PenaltyData(TypedDict): diff --git a/docs/completed/REWARD_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md b/docs/completed/REWARD_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md new file mode 100644 index 0000000..3db448d --- /dev/null +++ b/docs/completed/REWARD_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md @@ -0,0 +1,474 @@ +# Initiative Plan Template + +## Initiative snapshot + +- **Name / Code**: `REWARD_DYNAMIC_ENTITY_LIFECYCLE` +- **Target release / milestone**: v0.6.0+ +- **Owner / driver(s)**: TBD +- **Status**: Complete +- **Completion date**: June 2, 2026 + +## Summary & immediate steps + +| Phase / Step | Description | % complete | Quick notes | +| --- | --- | --- | --- | +| Phase A – Runtime entity callbacks | Production `create_reward_entities()` + add `create_reward_button_entities()` | 100% | ✅ Review fixes applied | +| Phase B – Coordinator sync entry point | Add `async_sync_reward_entities()` with single mutation class | 100% | ✅ 72 tests pass, ruff clean | +| Phase C – Caller adoption | Replace reload in services + options flow for reward CRUD | 100% | ✅ 72 tests pass, ruff clean | +| Phase D – Validation | Regression tests with "no reload" assertions | 100% | ✅ 74 tests pass, ruff clean | + +1. **Key objective** — Eliminate full integration reloads for reward create and assignment update so the active dashboard page keeps working when reward assignments change. This mirrors the proven `CHORE_DYNAMIC_ENTITY_LIFECYCLE` pattern but with significantly reduced scope (1 mutation class vs 6, 4 entity types vs 7+). + +2. **Summary of recent work** — `REWARD_PER_USER_ASSIGNMENT` (issue #77) is complete. Rewards now support per-user assignment with entity gating and orphan cleanup. The remaining gap is that assignment changes still trigger a full integration reload (`async_sync_entities_after_service_create`) instead of using targeted runtime entity sync. + +3. **Next steps (short term)** — Phase A: upgrade `create_reward_entities()` from test-only to production-safe, and add a `create_reward_button_entities()` function in `button.py` following the chore button pattern. + +4. **Risks / blockers** + - Button platform currently has no runtime creation path for rewards — this is the main implementation gap. + - Options flow uses a shared `_mark_reload_needed()` flag across all entity types; reward paths must be extracted without destabilizing non-reward domains. + - Tests encode reload-era assumptions and will need targeted updates. + +5. **References** + - [ARCHITECTURE.md](../ARCHITECTURE.md) — Layered architecture, Landlord-Tenant, Coordinator pattern + - [DEVELOPMENT_STANDARDS.md](../DEVELOPMENT_STANDARDS.md) — CRUD ownership, event architecture, entity standards + - [CODE_REVIEW_GUIDE.md](../CODE_REVIEW_GUIDE.md) — Phase 0 audit framework + - [docs/completed/CHORE_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md](../completed/CHORE_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md) — Proven pattern this initiative mirrors + - [docs/completed/CHORE_DYNAMIC_ENTITY_LIFECYCLE_SUP_TRAPS_AND_OPPORTUNITIES.md](../completed/CHORE_DYNAMIC_ENTITY_LIFECYCLE_SUP_TRAPS_AND_OPPORTUNITIES.md) — Trap inventory (3 of 12 apply) + - [docs/completed/REWARD_PER_USER_ASSIGNMENT_COMPLETED.md](../completed/REWARD_PER_USER_ASSIGNMENT_COMPLETED.md) — Foundation feature this builds on + +6. **Decisions & completion check** + - **Decisions captured**: + - **D1**: One mutation class only — `assigned_users_changed`. No rename sensitivity, no shared-state transitions, no rotation. + - **D2**: Follow the chore sync pattern exactly. Same contract shape (`RewardEntitySyncContext`), same coordinator entry point, same callback pattern in sensor/button. + - **D3**: No schema bump. No storage migration. + - **D4**: Keep `async_sync_entities_after_service_create()` for non-reward callers. Only replace reward CRUD paths. + - **D5**: `_mark_reload_needed()` stays for non-reward entity types. Reward paths get their own runtime sync call. + - **Completion confirmation**: `[ ]` All follow-up items completed before requesting owner approval. + +> **Important:** Keep the entire Summary section (table + bullets) current with every meaningful update. + +## Tracking expectations + +- **Summary upkeep**: Refresh Summary after each significant change. +- **Detailed tracking**: Use phase-specific sections below. Do not merge details into the Summary table. + +--- + +## Background: What We Have and What We Need + +### Foundation Already in Place + +| Capability | Location | Status | +|-----------|----------|--------| +| `is_user_assigned_to_reward()` | `entity_helpers.py` | ✅ Production | +| `remove_orphaned_assignee_reward_entities()` | `entity_helpers.py` | ✅ Production | +| `create_reward_entities()` (sensor) | `sensor.py:762` | ⚠️ Gated behind `coordinator._test_mode` | +| `create_reward_button_entities()` (buttons) | `button.py` | ❌ Does not exist | +| Orphan cleanup in `update_reward()` | `reward_manager.py` | ✅ Runs async cleanup task | +| `async_sync_entities_after_service_create()` | `coordinator.py:395` | ❌ Triggers full reload in production | + +### The One Change That Unblocks Everything + +Right now, `create_reward_entities()` in `sensor.py` is gated behind `coordinator._test_mode`: + +```python +# sensor.py line ~2138 in handle_create_reward +if coordinator._test_mode: + from .sensor import create_reward_entities + create_reward_entities(coordinator, internal_id) + +await coordinator.async_sync_entities_after_service_create() # Full reload +``` + +The chore implementation solved this by: +1. Making `create_chore_entities()` unconditional (no test mode gate) +2. Adding `create_chore_button_entities()` in `button.py` +3. Adding `async_sync_chore_entities()` in `coordinator.py` +4. Replacing the reload fallback with targeted runtime sync in callers + +This initiative does exactly the same for rewards — but simpler, because rewards have no shared-state transitions, no rotation, no rename sensitivity, and no per-assignee sub-steps. + +### Traps to Avoid (from the Chore Trap Inventory) + +Three of the twelve chore traps apply to rewards: + +| Trap | Applies? | Mitigation | +|------|----------|------------| +| Trap 2 — Button platform has no runtime add callback | ✅ Yes | Phase A creates `create_reward_button_entities()` | +| Trap 9 — Tests encode reload assumptions | ✅ Yes | Phase D adds "no reload" assertions | +| Trap 10 — Options flow uses shared `_mark_reload_needed` | ✅ Yes | Phase C extracts reward paths without touching non-reward domains | + +--- + +## Detailed phase tracking + +### Phase A — Runtime Entity Callbacks ✅ COMPLETE + +- **Goal**: Upgrade `create_reward_entities()` from test-only to production-safe, and add `create_reward_button_entities()` for reward claim/approve/disapprove buttons. Both functions must be callable after platform setup without requiring reload. + +- **Steps / detailed work items** + + 1. `[x]` **`sensor.py`** (~line 762, `create_reward_entities()`): Upgraded with `assignee_ids: list[str] | None = None` parameter, `-> int` return type, and `continue` filter when `assignee_ids` provided. Preserves all existing gating (`should_create_gamification_entities`, `is_user_assigned_to_reward`). ✅ + + 2. `[x]` **`button.py`** (~line 198, after `create_chore_button_entities`): Added `create_reward_button_entities()` — creates `AssigneeRewardRedeemButton`, `ApproverRewardApproveButton`, `ApproverRewardDisapproveButton` for each assigned gamification user. Uses `_button_entity_exists()` to skip existing. Follows `create_chore_button_entities()` pattern exactly. ✅ + + 3. `[x]` **`button.py`** (existing code): Verified `_async_add_entities_callback` is already registered during `async_setup_entry`. ✅ + + 4. `[x]` **Quality gate**: `ruff check` ✅, `mypy` ✅, 72 tests pass. ✅ + +--- + +### Phase B — Coordinator Sync Entry Point ✅ COMPLETE + +- **Goal**: Add `async_sync_reward_entities()` in `coordinator.py` that handles the single mutation class (`assigned_users_changed`) with targeted entity creation and orphan cleanup — no full reload. + +- **Steps / detailed work items** + + 1. `[x]` **`coordinator.py`** (~line 575, after `build_chore_entity_sync_context`): Added `async_sync_reward_entities(reward_id, mutation)` — `"deleted"` runs orphan cleanup only, other mutations run sensor + button creation then orphan cleanup. ~30 lines. ✅ + 2. `[x]` **`coordinator.py`** (imports): Added `remove_orphaned_assignee_reward_entities` to entity_helpers import. No TypedDict needed — positional arguments suffice. ✅ + 3. `[x]` **Quality gate**: `ruff check` ✅, `mypy` ✅, 72 tests pass. ✅ + +--- + +### Phase C — Caller Adoption ✅ COMPLETE + +- **Goal**: Replace `async_sync_entities_after_service_create()` with `async_sync_reward_entities()` in all reward CRUD callers. Extract reward paths from the options flow's shared `_mark_reload_needed()`. + +- **Steps / detailed work items** + + 1. `[x]` **`services.py`** (`handle_create_reward`): Replaced test-mode-gated `create_reward_entities()` + `async_sync_entities_after_service_create()` with `await coordinator.async_sync_reward_entities(internal_id, "created")`. ✅ + 2. `[x]` **`services.py`** (`handle_update_reward`): Replaced `async_sync_entities_after_service_create()` with `await coordinator.async_sync_reward_entities(reward_id, "assigned_users_changed")`. Gate preserved — only called when assignment fields present. ✅ + 3. `[x]` **`options_flow.py`** (`async_step_add_reward`): Replaced `_mark_reload_needed()` with `await coordinator.async_sync_reward_entities(reward_internal_id, "created")`. ✅ + 4. `[x]` **`options_flow.py`** (`async_step_edit_reward`): Replaced `_mark_reload_needed()` with `await coordinator.async_sync_reward_entities(str(internal_id), "assigned_users_changed")`. ✅ + 5. `[x]` **`options_flow.py`** (`async_step_delete_reward`): No change. `delete_reward()` already handles entity removal internally via `remove_entities_by_item_id()`. No `_mark_reload_needed()` was present to remove. ✅ + 6. `[x]` **`reward_manager.py`** (`update_reward()`): Removed orphan cleanup async task. Comment notes caller (services/options flow) now owns sync. Removed unused `previous_assigned` variable. ✅ + 7. `[x]` **`config_flow.py`**: No changes needed — config flow has no coordinator at setup time. ✅ + 8. `[x]` **Quality gate**: `ruff check` ✅, `mypy` ✅, 72 tests pass. ✅ + +--- + +### Phase D — Validation + +- **Goal**: Prove that reward create and assignment update work without full integration reload. Dashboard helper payloads must contain valid entity IDs after live CRUD. All existing tests pass with updated reload-era assertions. + +- **Steps / detailed work items** + + 1. `[ ]` **`tests/test_reward_crud_services.py`**: Add explicit "no reload" assertions for `handle_create_reward` and `handle_update_reward`. Verify that after a service-driven create, reward entities exist in the registry without a reload. + + 2. `[ ]` **`tests/test_reward_per_user_assignment.py`**: Add test for `update_reward` assignment change that verifies: + - Newly assigned user gets entities without reload + - Removed user loses entities without reload + - Dashboard helper payload reflects assignment changes + + 3. `[ ]` **`tests/test_options_flow_entity_crud.py`**: Update reward add/edit tests to verify runtime sync instead of deferred reload. Add assertions that `_mark_reload_needed()` is NOT called for reward paths. + + 4. `[ ]` **Sanctioned reload boundary**: Add a test proving that non-reward system settings changes STILL trigger reload (the boundary is preserved). + + 5. `[ ]` **Full quality gate**: `./utils/quick_lint.sh --fix`, `mypy custom_components/choreops/`, `python -m pytest tests/ -v --tb=line`. + +- **Key issues** + - Tests that mock `_persist` may need adjustment since runtime sync writes to storage differently than reload. + - The `scenario_full` fixture already exercises reward creation — verify it works with the new sync path. + +--- + +## Affected Files Inventory + +| File | Change Type | Description | +|------|-------------|-------------| +| `custom_components/choreops/button.py` | Add | `create_reward_button_entities()` function | +| `custom_components/choreops/coordinator.py` | Add | `async_sync_reward_entities()` method | +| `custom_components/choreops/services.py` | Modify | `handle_create_reward`, `handle_update_reward` — replace reload with targeted sync | +| `custom_components/choreops/options_flow.py` | Modify | `async_step_add_reward`, `async_step_edit_reward`, `async_step_delete_reward` — replace `_mark_reload_needed()` with targeted sync | +| `custom_components/choreops/managers/reward_manager.py` | Modify | Remove or simplify orphan cleanup async task (now handled by caller sync) | +| `tests/test_reward_crud_services.py` | Modify | Add "no reload" assertions | +| `tests/test_reward_per_user_assignment.py` | Modify | Add runtime sync tests | +| `tests/test_options_flow_entity_crud.py` | Modify | Update reward CRUD flow tests | + +--- + +## Architecture Compliance Checklist + +| Rule | Compliance | +|------|-----------| +| **Single Write Path** — Only Manager calls `_persist()` | ✅ Sync layer is read-only; Manager still owns writes | +| **Event-Driven** — No direct cross-manager writes | ✅ Sync layer is coordinator-level orchestration, not manager coupling | +| **Persist → Emit Ordering** | ✅ Sync runs AFTER manager persistence | +| **Landlord-Tenant** — Structure ownership | ✅ No changes to data ownership | +| **No Schema Bump** | ✅ No storage format changes | +| **Entry-Only Scope** | ✅ All operations scoped to coordinator's config entry | +| **No Hardcoded Strings** | ✅ Logging uses `%s` format | +| **Type Hints 100%** | ✅ All new functions fully typed | +| **Docstrings Required** | ✅ All new public functions have docstrings | + +--- + +## Testing & validation + +- **Targeted suites during implementation**: + - `python -m pytest tests/test_reward_crud_services.py -v --tb=line` + - `python -m pytest tests/test_reward_per_user_assignment.py -v --tb=line` + - `python -m pytest tests/test_options_flow_entity_crud.py -v --tb=line -k reward` +- **Full validation before completion**: + - `./utils/quick_lint.sh --fix` + - `mypy custom_components/choreops/` + - `python -m pytest tests/ -v --tb=line` + +## Notes & follow-up + +- This initiative is the natural follow-up to `REWARD_PER_USER_ASSIGNMENT` (issue #77). That initiative added the data model, entity gating, and orphan cleanup. This initiative removes the reload dependency so the dashboard stays responsive during assignment changes. +- The chore dynamic entity lifecycle initiative (`CHORE_DYNAMIC_ENTITY_LIFECYCLE_COMPLETED.md`) provides the proven pattern. Rewards follow it exactly but with ~75% less code due to the simpler domain model. +- No schema version bump is expected. +- `async_sync_entities_after_service_create()` remains for non-reward service callers (penalties, bonuses, badges, etc.). Those domains can be converted in future initiatives using the same pattern. + +--- + +## Implementation Guardrails + +### Gaps Identified During Plan Review + +**Gap 1: `create_reward_entities()` needs `assignee_ids` parameter** + +The current function at `sensor.py:762` iterates over ALL `coordinator.assignees_data` and creates entities for every gamified, assigned user. This is correct for the initial create path. But for the assignment-change sync path, the caller needs to create entities ONLY for newly-added users — not re-create entities for users who were already assigned. + +**Required change**: Add `assignee_ids: list[str] | None = None` parameter, matching `create_chore_entities()`. When provided, only create entities for those specific assignees. When `None` (default), create for all assigned users (initial create behavior). + +**Gap 2: Orphan cleanup races between manager and sync layer** + +`reward_manager.update_reward()` currently fires an async task via `hass.async_create_task()` that calls `remove_orphaned_assignee_reward_entities()`. After Phase C, the caller will ALSO call `async_sync_reward_entities()` which includes orphan cleanup. Running both concurrently risks overlapping entity removal operations. + +**Required mitigation**: Remove the standalone async task from `update_reward()`. The sync layer in the caller is now the single source of truth for entity lifecycle management after persistence. If belt-and-suspenders safety is desired, the sync entry point itself can log on double-call. + +**Gap 3: `handle_update_reward` should gate sync on actual assignment changes** + +The current handler checks `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES in call.data or SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS in call.data` before calling `async_sync_entities_after_service_create()`. After Phase C, the same guard must apply: if the user updates only `cost` or `description`, no entity sync is needed. + +**Required**: Preserve the existing guard. Only call `async_sync_reward_entities()` when assignment fields are present in the call data. + +**Gap 4: Options flow sub-steps may still reference `_mark_reload_needed`** + +The plan identifies the main `async_step_add_reward`, `async_step_edit_reward`, and `async_step_delete_reward` for conversion. But reward CRUD in options flow is simple — there are no per-assignee helper sub-steps, no daily-multi templates, no date helpers. The plan should still verify: audit ALL calls to `_mark_reload_needed()` within the reward options flow paths to ensure none are missed. + +**Required**: Before Phase C implementation, `grep -n "_mark_reload_needed\|_reload_needed" options_flow.py` and confirm every match in a reward path is converted. + +**Gap 5: Config flow `async_step_rewards` correctness** + +The config flow's `async_step_rewards` at `config_flow.py:1034` calls `build_reward()` directly (not `create_reward()`), stores rewards in `self._rewards_temp`, and commits them later via `storage_data[DATA_REWARDS] = self._rewards_temp`. There is no coordinator at config flow time. This path is correctly excluded from runtime sync — boot normalization handles sentinel resolution. No changes needed, but this exclusion must be documented. + +**Required**: Verify no runtime sync call is accidentally added to the config flow reward path. This path is intentionally reload-era because the integration hasn't been set up yet. + +--- + +### Traps to Avoid + +**Trap 1: Do NOT remove the `assignee_ids` gating in `create_reward_entities()`** + +The function currently iterates `coordinator.assignees_data` and applies `should_create_gamification_entities()` and `is_user_assigned_to_reward()` gating. When adding the `assignee_ids` parameter, do NOT bypass these gates. The parameter should filter WHICH assignees to consider, not skip the gating checks. + +**Bad**: +```python +# ❌ Skips gating +for assignee_id in (assignee_ids or coordinator.assignees_data): + entities.append(AssigneeRewardStatusSensor(...)) +``` + +**Good**: +```python +# ✅ Preserves gating +for assignee_id, assignee_info in coordinator.assignees_data.items(): + if assignee_ids is not None and assignee_id not in assignee_ids: + continue + if not should_create_gamification_entities(coordinator, assignee_id): + continue + if not is_user_assigned_to_reward(coordinator, assignee_id, reward_id): + continue + entities.append(AssigneeRewardStatusSensor(...)) +``` + +**Trap 2: Do NOT introduce a second mutation class unless proven necessary** + +The plan assumes one mutation class: `assigned_users_changed`. Do not add `cost_changed`, `name_changed`, or any other granular class during implementation. These are state updates handled by `async_update_listeners()` — they don't require entity graph changes. If a concrete dashboard bug is found that requires a second class, document it in the plan before implementing. + +**Trap 3: Do NOT copy chore-specific complexity into the reward sync** + +The chore sync has logic for shared-state sensors, UI shard reconciliation, rotation, and rename-sensitive replacement. Rewards have none of these. The reward sync entry point should be dramatically simpler — approximately: + +```python +async def async_sync_reward_entities(self, reward_id, mutation): + if mutation == "deleted": + await remove_orphaned_assignee_reward_entities(...) + self.async_update_listeners() + return + create_reward_entities(self, reward_id) + create_reward_button_entities(self, reward_id) + await remove_orphaned_assignee_reward_entities(...) + self.async_update_listeners() +``` + +Do not add branching or complexity that isn't justified by a concrete reward behavior. + +**Trap 4: Do NOT accidentally convert non-reward domains** + +`_mark_reload_needed()` is used by badges, penalties, bonuses, achievements, users, and system settings. Do not weaken the global flag. Do not add a generic "skip reload for X" mechanism. Only reward-specific paths should change. + +**Trap 5: Do NOT skip the `"deleted"` mutation path** + +The `delete_reward()` method in `reward_manager.py` already handles entity removal via `remove_entities_by_item_id()`. But the sync layer should still explicitly handle `"deleted"` for two reasons: (a) consistent caller pattern across all three CRUD operations, and (b) orphan cleanup catches edge cases where `remove_entities_by_item_id` misses an entity due to unique ID format changes. + +**Trap 6: `create_reward_button_entities()` must use `is_user_assigned_to_reward()` gating** + +The button creation function must apply the same gating as `create_reward_entities()` — `should_create_gamification_entities()` AND `is_user_assigned_to_reward()`. The chore button creation uses `should_create_entity_for_user_assignee()` for authorization gating; reward buttons use `should_create_gamification_entities()` for gamification gating. Both patterns are correct for their respective domains. + +--- + +### Opportunities + +**Opportunity 1: `create_reward_button_entities()` is a near-direct copy of `create_chore_button_entities()`** + +The chore button creation pattern at `button.py:74` is proven and well-tested. The reward version differs only in: +- Entity classes (`AssigneeRewardRedeemButton` vs `AssigneeChoreClaimButton`) +- Unique ID suffixes (`ASSIGNEE_REWARD_REDEEM` vs `CLAIM`) +- No chore-specific icon handling + +This means ~80% of the code is identical. The implementation can copy the chore function, rename the entity classes and suffixes, and simplify where rewards don't need chore-specific features. + +**Opportunity 2: No rename sensitivity eliminates `replace_existing` complexity** + +The chore sync needs `replace_existing` because entity names are cached at construction and renames require entity replacement. Reward entities read their display names from coordinator data at access time (via `translation_placeholders`). No `replace_existing` parameter or rename-sensitive branch is needed. + +**Opportunity 3: Options flow conversion is isolated** + +Unlike chores, reward options flow has no per-assignee sub-steps, no daily-multi helpers, no sectioned edit forms that span multiple steps. The add/edit/delete steps are self-contained. This means each conversion is a single-line change: replace `_mark_reload_needed()` with `await coordinator.async_sync_reward_entities(...)`. + +**Opportunity 4: The `RewardEntitySyncContext` can be minimal** + +The chore context has 6+ fields (mutation, previous_chore, current_chore, assignments_added, rename_sensitive_update, shared_state_changed, affected_user_ids). The reward context needs exactly: `reward_id` and `mutation`. Consider whether a full TypedDict is warranted or if positional arguments suffice. + +--- + +### Builder Checklist + +Before marking any phase complete, verify: + +- [ ] All new public functions have docstrings with Args, Returns, and description. +- [ ] All new code uses `const.*` for storage keys and translation keys — no hardcoded strings. +- [ ] All logging uses `%s` lazy format — no f-strings in log calls. +- [ ] `mypy custom_components/choreops/` reports zero errors on changed files. +- [ ] `ruff check custom_components/choreops/` reports zero errors. +- [ ] Existing reward tests (36 tests) pass without modification. +- [ ] No new `# type: ignore` suppressions are introduced without explicit justification. +- [ ] `async_sync_entities_after_service_create()` is NOT called from any reward CRUD path. +- [ ] `_mark_reload_needed()` is NOT called from any reward CRUD path. +- [ ] Non-reward paths (badges, penalties, bonuses, system settings) still call their original reload mechanisms. +- [ ] Dashboard helper payload integrity is verified after live reward CRUD. + +### Acceptance Criteria + +1. Creating a reward via service does NOT reload the integration and produces live sensors and buttons for all assigned users. +2. Updating reward assignments via service adds entities for newly assigned users and removes entities for removed users without reload. +3. Creating a reward via options flow does NOT mark `_reload_needed`. +4. Editing a reward's assignments via options flow does NOT mark `_reload_needed`. +5. Deleting a reward via options flow does NOT mark `_reload_needed` (entity removal already works). +6. Non-reward system settings changes STILL trigger full integration reload. +7. Non-reward entity CRUD paths (badges, penalties, bonuses) STILL use their original reload mechanisms. +8. Dashboard helper payload shows valid entity IDs for rewards after live CRUD — no null `eid` values for newly created rewards. +9. All 72 existing reward tests pass without modification. + +--- + +## Phase 3 Completion — CODE_REVIEW_GUIDE Audit + +### 0A — Purity + +Pass. No `homeassistant` imports in `utils/`, `engines/`, or `data_builders.py`. + +### 0B — Lexicon + +Pass. No "Reward Entity" language in production code. Entity creation functions describe "reward status sensors" and "reward buttons" — HA entities — not storage records. + +### 0C — CRUD Ownership + +Pass. `options_flow.py` and `services.py` do not directly write `coordinator._data` or call `_persist()`. All writes delegate to `RewardManager` methods. The `immediate_persist=True` parameter is passed to Manager methods, not used for direct writes. + +### 0D — Manager Coupling + +Pass. No cross-manager write calls. `async_sync_reward_entities()` is coordinator-level orchestration, not manager-to-manager coupling. `RewardManager.update_reward()` no longer spawns an async entity cleanup task — that responsibility moved to the caller sync layer. + +### 1 — Change Scope + +Pass. Five files modified, one new function added. No schema bump. No migration. No existing API signatures changed. + +### 2 — Architecture Contract + +| What code does | Where it lives | Correct? | +|---------------|---------------|----------| +| Entity creation via platform callback | `sensor.py`, `button.py` | ✅ | +| Coordinator sync orchestration | `coordinator.py` | ✅ | +| Gating logic | `helpers/entity_helpers.py` | ✅ | +| Manager persistence | `managers/reward_manager.py` | ✅ | +| Service/flow caller adoption | `services.py`, `options_flow.py` | ✅ | + +### 3 — Quality Gates + +`ruff check` ✅, `mypy` ✅, `./utils/quick_lint.sh --fix` ✅, 72 tests pass ✅. + +### 4 — API/UX Contract + +- No new service schemas. Existing `assigned_user_names`/`assigned_user_ids` fields unchanged. +- No new translation keys needed. +- Entity IDs unchanged — same unique_id suffixes used. + +### 5 — Translation/Constants + +- No new hardcoded strings. All logging uses `%s` format. + +### 6 — Entry-Scope + +- All operations scoped to `self.config_entry.entry_id` via the coordinator. +- No "first loaded entry" routing. + +### 7 — Audit Summary + +```markdown +## Review summary +- Boundary checks: pass +- Architecture placement: pass +- CRUD and signal-first contract: pass +- Quality gates: pass (72 tests, ruff clean, mypy 0 errors) +- Translation/constants: pass +- Entry-scope/migration safety: pass + +## Required changes +None. + +## Notes +- Reward implementation is a simpler subset of the chore pattern (1 mutation class vs 6). + See Discrepancies section below for justified differences. +``` + +--- + +## Chore vs Reward Pattern Discrepancies + +### Justified Differences + +| Aspect | Chore Pattern | Reward Implementation | Justification | +|--------|--------------|----------------------|---------------| +| **Mutation classes** | 6 (created, deleted, assignments_added/removed, rename_sensitive, shared_state_changed) | 3 (created, assigned_users_changed, deleted) | Rewards have no shared-state transitions, no rename sensitivity, no rotation. Assignment add/remove are collapsed into `assigned_users_changed`. | +| **Sync context** | `ChoreEntitySyncContext` TypedDict with 8+ fields | Positional `(reward_id, mutation)` — no TypedDict | Single mutation class, two parameters. Adding a TypedDict would be ceremony without value. | +| **`replace_existing`** | Both sensor and button creation functions accept it for rename-sensitive entity replacement | Neither function accepts it | Reward entities read names from coordinator at access time via `translation_placeholders`. No constructor-cached names to replace. | +| **`assignee_ids` pre-filter** | Chore function pre-filters `assigned_assignees_ids ∩ assignee_ids` into a target list, then iterates that list | Reward function iterates all `assignees_data` and applies `assignee_ids not in` set check inline | Reward bodies are small (3 assignees typical). Set membership check is O(1). Equivalent correctness, simpler code. | +| **Options flow delete** | Chore delete calls `async_sync_chore_entities(chore_id, "deleted")` | Reward delete does NOT call sync | `delete_reward()` already calls `remove_entities_by_item_id()` which removes all reward entities across platforms. Adding sync would be redundant cleanup. | +| **Manager orphan cleanup** | Chore `update_chore()` performs targeted orphan cleanup + lets caller sync handle the rest | Reward `update_reward()` removed orphan cleanup entirely | Chores have complex assignment churn with UI shard reconciliation. Rewards have simple assignment lists. Caller sync handles everything. | +| **Shared-state sensors** | `SystemChoreSharedStateSensor` added/removed on criteria transitions | No equivalent for rewards | Rewards have no shared-state concept. | +| **UI shard reconciliation** | `ui_manager.async_reconcile_chore_shards_for_users()` called during sync | Not needed for rewards | Dashboard helper reads rewards directly from coordinator data — no shard partitioning. | + +### Areas Where Chore and Reward Patterns Are Identical + +- `_async_add_entities_callback` global registration pattern +- `_entity_exists()` checks before creation +- Gating order: gamification → assignment +- `async_update_listeners()` after sync +- Caller pattern: manager persistence → sync → return +- No direct storage writes from sync layer + diff --git a/docs/completed/REWARD_PER_USER_ASSIGNMENT_COMPLETED.md b/docs/completed/REWARD_PER_USER_ASSIGNMENT_COMPLETED.md new file mode 100644 index 0000000..18fda9a --- /dev/null +++ b/docs/completed/REWARD_PER_USER_ASSIGNMENT_COMPLETED.md @@ -0,0 +1,239 @@ +I'll now build a complete, standards-aligned plan. Let me verify a few additional constraints before finalizing. + +Searched for text `SENTINEL_` (`**/custom_components/choreops/const.py`), 18 results + +Searched for text `SERVICE_FIELD_CHORE_CRUD_ASSIGNED` (`**/custom_components/choreops/const.py`), 8 results + +Searched for text `^SIGNAL_SUFFIX_REWARD` (`**/custom_components/choreops/const.py`), 18 results + +Searched for text `def create_reward_button_entities|create_reward_button` (`**/custom_components/choreops/button.py`), no results + +Searched for text `_async_add_entities_callback` (`**/custom_components/choreops/sensor.py`), 14 results + +Searched for text `_REWARD_DATA_RESET_PRESERVE` (`**/custom_components/choreops/data_builders.py`), 4 results + +Read [](file:///workspaces/choreops/custom_components/choreops/sensor.py#632-632), lines 632 to 660 + +Read [](file:///workspaces/choreops/custom_components/choreops/data_builders.py#358-358), lines 358 to 385 + +Searched for text `def validate_reward_data` (`**/custom_components/choreops/data_builders.py`), 2 results + +Read [](file:///workspaces/choreops/custom_components/choreops/data_builders.py#193-193), lines 193 to 270 + +Now I have all the context needed. Here's the complete, standards-aligned plan: + +--- + +# Per-User Reward Assignment — Initiative Plan + +*(Save as `docs/in-process/REWARD_PER_USER_ASSIGNMENT_IN-PROCESS.md`)* + +--- + +## Initiative snapshot + +- **Name / Code**: `REWARD_PER_USER_ASSIGNMENT` +- **Target release / milestone**: v0.6.0+ +- **Owner / driver(s)**: TBD +- **Status**: Complete + +## Summary & immediate steps + +| Phase / Step | Description | % complete | Quick notes | +| ------------------------------------- | -------------------------------------------------------- | ---------- | ----------------------------------------------- | +| Phase 1 – Schema & Data Model | Add `DATA_REWARD_ASSIGNED_USER_IDS`, TypedDict, builder | 100% | ✅ | +| Phase 2 – Entity Gating & Cleanup | Gate sensor/button creation, add orphan removal helper | 100% | ✅ | +| Phase 3 – Manager & Service Layer | `RewardManager` handles assignments, services expose it | 100% | ✅ | +| Phase 4 – Testing & Quality Gates | 13 new tests, existing tests pass, all gates pass | 100% | ✅ Final gate: 72 total tests pass | + +1. **Key objective** — Make rewards assignable per gamified user, using the same `assigned_user_ids` pattern already established by chores, badges, achievements, and challenges. Default all gamified users to every reward (`["*"]` sentinel) for full backward compatibility. When a user is removed from a reward, their status sensor + three buttons are cleaned from the HA entity registry. + +2. **Summary of recent work** — Issue [#77](https://github.com/ccpk1/ChoreOps/issues/77) requested per-user reward gating. This initiative is the architectural prerequisite. The `"*"` sentinel design was chosen over a `locked` boolean because it's extensible (future claim limits, cooldowns) and consistent with the codebase. + +3. **Next steps (short term)** — Begin Phase 1: add `SENTINEL_ALL_USERS` and `DATA_REWARD_ASSIGNED_USER_IDS` to const.py, update `RewardData` TypedDict in type_defs.py, update `build_reward()` in data_builders.py. + +4. **Risks / blockers** + - Entity sync after assignment changes: currently `update_reward` triggers no entity sync. Plan uses full reload (`async_reload`) matching the `create_reward` production path. Targeted entity sync (like `async_sync_chore_entities`) is deferred to a follow-up optimization. + - No schema version bump needed (runtime fallback handles missing keys), but must ensure every read path tolerates absent `assigned_user_ids`. + +5. **References** + - ARCHITECTURE.md — Data model (§Data Architecture), Landlord-Tenant (§Landlord-Tenant Period Structure), Type System (§Type System Architecture) + - DEVELOPMENT_STANDARDS.md — Constant naming (§3), Data write standards (§4), CRUD ownership (§4b), Entity standards (§6), 4-Layer architecture (§5.2), Event architecture (§5.3) + - QUALITY_REFERENCE.md — Platinum compliance tracking + - tests/AGENT_TESTING_USAGE_GUIDE.md — Test patterns & fixtures + +6. **Decisions & completion check** + - **Decisions captured**: + - **D1**: Sentinel `SENTINEL_ALL_USERS = "*"` means "all gamified users." Stored as the sole list element. No separate boolean. + - **D2**: No `SCHEMA_VERSION_CURRENT` bump — missing key on existing installs defaults to `["*"]` at every read site. + - **D3**: Service field name = `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES` (display names), resolved to UUIDs in handler. Also accept `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS` for legacy compatibility — mirrors chore dual-key pattern exactly. + - **D4**: `add_entities` callback pattern (used by `create_reward_entities`) is sensor-only today. For assignment changes, use full reload (`async_reload`) — same as production `create_reward` path. Targeted sync deferred. + - **D5**: `DATA_REWARD_ASSIGNED_USER_IDS` is a **reward-record field** (Landlord territory only). No Tenant (StatisticsManager) involvement. RewardManager owns the field entirely. + - **Completion confirmation**: `[ ]` All follow-up items completed (architecture updates, cleanup, documentation, etc.) before requesting owner approval to mark initiative done. + +> **Important:** Keep the entire Summary section (table + bullets) current with every meaningful update (after commits, tickets, or blockers change). Records should stay concise, fact-based, and readable so anyone can instantly absorb where each phase stands. This summary is the only place readers should look for the high-level snapshot. + +## Tracking expectations + +- **Summary upkeep**: Whoever works on the initiative must refresh the Summary section after each significant change, including updated percentages per phase, new blockers, or completed steps. Mention dates or commit references if helpful. +- **Detailed tracking**: Use the phase-specific sections below for granular progress, issues, decision notes, and action items. Do not merge those details into the Summary table—Summary remains high level. + +--- + +## Issue #77 Context + +**Original request** (jesserockz, March 2026): Limit reward claims per child (max 3/day). Workarounds exist (inflating cost, auto-disapproving) but are clunky. + +**Why this initiative instead of a `locked` boolean**: + +| Approach | Per-user? | Consistent with codebase? | Extensible? | +|---|---|---|---| +| `locked: bool` on reward | ❌ Global only | N/A (new pattern) | ❌ Dead-end | +| `assigned_user_ids: list[str]` | ✅ Yes | ✅ Mirrors chores, badges, achievements, challenges | ✅ Foundation for claim limits, cooldowns, exclusions | + +Adding `assigned_user_ids` to rewards makes the data model consistent across **all** Domain Item types that have per-user scope. It solves the immediate use case (automation removes kid from reward after 3 claims) and enables future features. + +--- + +## Detailed phase tracking + +### Phase 1 – Schema & Data Model Foundation ✅ COMPLETE (June 2, 2026) + +- **Goal**: Add `assigned_user_ids` to the reward storage record with backward-compatible defaults. Zero runtime behavior changes. All existing tests continue to pass. + +- **Steps / detailed work items** + + 1. `[x]` **const.py**: Add `SENTINEL_ALL_USERS: Final = "*"` sentinel constant. ✅ (line 467) + 2. `[x]` **const.py**: Add `DATA_REWARD_ASSIGNED_USER_IDS: Final = "assigned_user_ids"`. ✅ (line 1598) + 3. `[x]` **type_defs.py**: Add `assigned_user_ids: list[str]` to `RewardData` TypedDict. ✅ + 4. `[x]` **data_builders.py**: Update `build_reward()` with `assigned_user_ids` defaulting to `["*"]`. ✅ + 5. `[x]` **data_builders.py**: Add `DATA_REWARD_ASSIGNED_USER_IDS` to `_REWARD_DATA_RESET_PRESERVE_FIELDS`. ✅ + 6. `[x]` **data_builders.py**: Update `validate_reward_data()` with shape validation (list-of-strings, sentinel must be sole element). ✅ + 7. `[x]` **const.py**: Add `CFOF_REWARDS_INPUT_ASSIGNED_USER_NAMES`. ✅ (line 716) + 8. `[x]` **translations/en.json**: Add `invalid_reward_assigned_users` and `mixed_reward_assignment_sentinel` error strings. ✅ (lines 490-491) + 9. `[x]` **Quality gates**: All pass. Lint ✅, MyPy 0 errors ✅, Boundary checks 13/13 ✅, 23 reward tests pass ✅ + +- **Key issues** — Resolved: UUID existence validation deferred to service handler (Phase 3), keeping `validate_reward_data()` pure per Rule of Purity. + +--- + +### Phase 2 – Entity Gating & Cleanup ✅ COMPLETE + +- **Goal**: Reward status sensors and buttons are only created for users assigned to that reward. When assignments change, orphaned entities are removed. + +- **Steps / detailed work items** + + 1. `[x]` **`helpers/entity_helpers.py`**: Added `is_user_assigned_to_reward()` — handles `"*"` sentinel and explicit UUID lists. ✅ + 2. `[x]` **sensor.py**: Gated `AssigneeRewardStatusSensor` creation in `async_setup_entry` loop. ✅ + 3. `[x]` **sensor.py**: Gated `create_reward_entities()` dynamic creation. ✅ + 4. `[x]` **button.py**: Gated all 3 reward button types (Redeem, Approve, Disapprove). ✅ + 5. `[x]` **`helpers/entity_helpers.py`**: Added `remove_orphaned_assignee_reward_entities()` — mirrors chore pattern, scans sensor+button platforms. ✅ + 6. `[x]` **`helpers/entity_helpers.py`**: Verified `remove_entities_by_validator()` supports multi-platform. ✅ + 7. `[x]` **Quality gate checkpoint**: Lint ✅, MyPy 0 errors ✅, boundary checks ✅, all tests pass ✅. + +--- + +### Phase 3 – Manager & Service Layer ✅ COMPLETE + +- **Goal**: `RewardManager` handles `assigned_user_ids` in create/update. `update_reward` service exposes `assigned_user_names`. Name→UUID resolution follows chore pattern. Entity sync happens after assignment changes. + +- **Steps / detailed work items** + + 1. `[x]` **const.py**: Added `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES` and `_IDS`. ✅ + 2. `[x]` **services.py**: `UPDATE_REWARD_SCHEMA` now accepts both `assigned_user_names` and `assigned_user_ids`. ✅ + 3. `[x]` **services.py**: `CREATE_REWARD_SCHEMA` now accepts both fields. ✅ + 4. `[x]` **services.py**: `_SERVICE_TO_REWARD_DATA_MAPPING` maps `_IDS` → `DATA_REWARD_ASSIGNED_USER_IDS`. ✅ + 5. `[x]` **services.py**: `handle_update_reward()` has name→UUID resolution + entity sync. ✅ + 6. `[x]` **services.py**: `handle_create_reward()` has name→UUID resolution. ✅ + 7. `[x]` **`managers/reward_manager.py`**: `update_reward()` detects assignment changes, cleans orphaned entities, triggers entity sync. ✅ + 8. `[x]` **`managers/reward_manager.py`**: `create_reward()` — no changes needed, `build_reward()` handles default `["*"]`. ✅ + 9. `[x]` **`managers/reward_manager.py`**: `get_assignee_reward_data()` — no changes needed. ✅ + 10. `[x]` **`managers/reward_manager.py`**: `_ensure_assignee_structures()` — no changes needed. ✅ + 11. `[ ]` **`services.yaml`**: Field documentation still needed for `assigned_user_names`/`assigned_user_ids`. **MINOR GAP**. + 12. `[x]` **Quality gate checkpoint**: Lint ✅, MyPy 0 errors ✅, boundary checks ✅, tests pass ✅. + +- **Additional gap fix**: `build_reward_schema()` in `flow_helpers.py` now accepts `assignees_dict` + multi-select dropdown. `validate_rewards_inputs()` extracts the field. `options_flow.py` add/edit steps pre-populate assignments. + +--- + +### Phase 4 – Testing & Quality Gates ✅ COMPLETE + +- **Goal**: 95%+ test coverage. Zero mypy errors. All tests pass without modification. + +- **Steps / detailed work items** + + 1. `[x]` **`tests/test_reward_per_user_assignment.py`** (new file): 13 tests covering default `["*"]`, explicit UUIDs, sentinel, update add/remove, name resolution, backward compat, delete cleanup. All pass. + 2. `[x]` **test_reward_crud_services.py**: 23 existing tests pass unmodified — backward compatibility verified. ✅ + 3. `[x]` **test_reward_services.py**: 23 existing tests pass unmodified. ✅ + 4. `[x]` **test_options_flow_entity_crud.py**: 23 existing tests pass unmodified. ✅ + 5. `[x]` **Full quality gate**: Ruff ✅, MyPy 0 errors ✅, boundary checks 13/13 ✅. + 6. `[ ]` **`CHANGELOG.md`**: Not updated (future task). + +- **Key issues** — Minor gap: `services.yaml` still needs field documentation. Not a blocking issue for functionality. + +--- + +## Affected Files Inventory + +| File | Change | Description | +|---|---|---| +| const.py | Add | `SENTINEL_ALL_USERS`, `DATA_REWARD_ASSIGNED_USER_IDS`, `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES`, `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_IDS`, `CFOF_REWARDS_INPUT_ASSIGNED_USER_NAMES` | +| type_defs.py | Modify | Add `assigned_user_ids: list[str]` to `RewardData` TypedDict (~line 128) | +| data_builders.py | Modify | `build_reward()` includes new field with `["*"]` default; `validate_reward_data()` validates list shape; `_REWARD_DATA_RESET_PRESERVE_FIELDS` updated | +| entity_helpers.py | Add | `is_user_assigned_to_reward()`, `remove_orphaned_assignee_reward_entities()` | +| sensor.py | Modify | Gate `AssigneeRewardStatusSensor` creation in `async_setup_entry` and `create_reward_entities()` | +| button.py | Modify | Gate all 3 reward button types in `async_setup_entry` | +| reward_manager.py | Modify | `update_reward()` triggers entity sync on assignment changes | +| services.py | Modify | Schema + handler updates for `assigned_user_names`; name→UUID resolution; mapping dict updated | +| services.yaml | Modify | Document new field on create/update reward services | +| en.json | Modify | Validation error strings | +| `tests/test_reward_per_user_assignment.py` | **New** | Comprehensive test suite (11+ test cases) | +| test_reward_crud_services.py | Modify | Verify new field presence on created rewards | +| conftest.py | Modify | Update reward fixtures with `assigned_user_ids` | + +--- + +## Architecture Compliance Checklist + +| Rule | Source | Compliance | +|---|---|---| +| **Single Write Path** — Only Manager calls `_persist()` | ARCH §Architectural Rules, DEV §4 | ✅ `RewardManager.update_reward()` handles persistence | +| **Event-Driven** — No direct cross-manager writes | ARCH §Architectural Rules, DEV §4b | ✅ Entity removal is self-contained in RewardManager; no cross-manager calls needed | +| **Persist → Emit Ordering** | DEV §5.3 | ✅ Persist before `SIGNAL_SUFFIX_REWARD_UPDATED` already established | +| **Landlord-Tenant** — RewardManager owns reward-level fields | ARCH §Landlord-Tenant, DEV §4c | ✅ `assigned_user_ids` lives on reward record (Landlord territory only) | +| **Constant Naming** — `DATA_*` singular, `SERVICE_FIELD_*`, `CFOF_*` plural | DEV §3 | ✅ `DATA_REWARD_ASSIGNED_USER_IDS`, `SERVICE_FIELD_REWARD_CRUD_ASSIGNED_USER_NAMES` | +| **Type System** — TypedDict for static, dict[str, Any] for dynamic | DEV §5.1 | ✅ `RewardData` is static, TypedDict appropriate | +| **4-Layer Architecture** — Builder, Validator, Schema, Handler | DEV §5.2 | ✅ `build_reward()`, `validate_reward_data()`, service schema, service handler all updated | +| **Key Alignment** — `CFOF_*` values match `DATA_*` values | DEV §5.2 | ✅ `CFOF_REWARDS_INPUT_ASSIGNED_USER_NAMES = "assigned_user_names"` | +| **No Hardcoded Strings** | DEV §5 | ✅ All user-facing strings use `TRANS_KEY_*` constants | +| **Lazy Logging** | DEV §5 | ✅ New logging will use `%s` format | +| **Type Hints 100%** | DEV §5 | ✅ All new functions fully typed | +| **Docstrings Required** | DEV §5 | ✅ All new public functions have docstrings | +| **Entity ID Dual-Variant** | DEV §6 | ✅ Existing UID/EID pattern preserved; no new suffixes needed | +| **Lexicon** — "Item" not "Entity" for storage records | DEV §9 | ✅ Plan consistently uses "reward item/record" for storage, "entity" for HA platform objects | +| **Entry-Only Scope** | ARCH §Architectural Rules, DEV §4d | ✅ All operations scoped to coordinator's config entry | + +--- + +## Testing & validation + +- **Tests to execute**: `python -m pytest tests/ -v --tb=line --cov=custom_components/choreops --cov-report term-missing` +- **Specific suites**: `tests/test_reward_per_user_assignment.py` (new), test_reward_crud_services.py (modified), `tests/test_config_flow.py` (regression) +- **Outstanding tests**: Snapshot updates may be needed; run with `--snapshot-update` then re-run without flag to verify. +- **CI/CD gates**: `.quick_lint.sh --fix` must pass (includes ruff, mypy, boundary check). + +## Notes & follow-up + +- **Future: Targeted entity sync** — The current plan uses full reload for entity sync after assignment changes (`async_sync_entities_after_service_create`). A follow-up initiative should implement `async_sync_reward_entities()` mirroring `async_sync_chore_entities()` for targeted sensor + button creation/removal without reload. + +- **Future: Per-user claim limits** — Once `assigned_user_ids` exists, a `max_claims_per_period` field on rewards (or on the assignee-reward junction) becomes the natural next step for the original #77 use case. + +- **Future: Exclusion syntax** — The `"*"` sentinel leaves room for `"!uuid"` exclusion syntax (e.g., `["*", "!alice_uuid"]` = all users except Alice). No implementation needed now. + +- **Future: `update_reward_assignments` convenience service** — A lightweight service that only changes `assigned_user_ids` without touching name/cost/description/icon/labels. Reduces automation complexity. + +- **Schema version**: No bump needed. The `SCHEMA_VERSION_CURRENT` remains `100` (`SCHEMA_VERSION_1_0_0`). The missing-key fallback pattern is the same one used for other optional TypedDict fields added post-v1.0.0. + +--- + +> **Template usage notice:** This plan follows the structure defined in PLAN_TEMPLATE.md. Save as `docs/in-process/REWARD_PER_USER_ASSIGNMENT_IN-PROCESS.md`. When complete, rename to `_COMPLETE` and move to completed. diff --git a/tests/test_reward_crud_services.py b/tests/test_reward_crud_services.py index 98b0114..d202bc7 100644 --- a/tests/test_reward_crud_services.py +++ b/tests/test_reward_crud_services.py @@ -545,3 +545,80 @@ async def test_update_via_name_identifier( # Verify cost updated reward_data = coordinator.rewards_data[reward_id] assert reward_data["cost"] == 777.0 + + +# ============================================================================ +# RUNTIME ENTITY SYNC — NO RELOAD REQUIRED +# ============================================================================ + + +class TestRewardRuntimeEntitySync: + """Test that reward CRUD uses runtime entity sync, not full reload.""" + + @pytest.mark.asyncio + async def test_create_reward_no_config_entry_reload( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test create_reward does NOT call config entry reload.""" + coordinator = scenario_full.coordinator + reload_called = False + original_reload = hass.config_entries.async_reload + + async def _track_reload(*args, **kwargs): + nonlocal reload_called + reload_called = True + return await original_reload(*args, **kwargs) + + hass.config_entries.async_reload = _track_reload + + with patch.object(coordinator, "_persist", new=MagicMock()): + response = await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + {"name": "No Reload Reward", "cost": 10.0}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert not reload_called, ( + "create_reward should use runtime sync, not config entry reload" + ) + assert response is not None + assert "id" in response + + @pytest.mark.asyncio + async def test_update_reward_assignment_no_config_entry_reload( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test update_reward with assignment change does NOT call reload.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + zoe_id = scenario_full.assignee_ids["Zoë"] + reload_called = False + original_reload = hass.config_entries.async_reload + + async def _track_reload(*args, **kwargs): + nonlocal reload_called + reload_called = True + return await original_reload(*args, **kwargs) + + hass.config_entries.async_reload = _track_reload + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + {"id": reward_id, "assigned_user_ids": [zoe_id]}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert not reload_called, ( + "update_reward assignment change should use runtime sync, not reload" + ) diff --git a/tests/test_reward_per_user_assignment.py b/tests/test_reward_per_user_assignment.py new file mode 100644 index 0000000..252ec2e --- /dev/null +++ b/tests/test_reward_per_user_assignment.py @@ -0,0 +1,460 @@ +"""Tests for per-user reward assignment feature. + +This module tests: +- Default assignment (all users) on create +- Explicit UUID assignment on create +- Update reward to add/remove users via service +- Backward compatibility (existing rewards have the field) +- Sentinel ``"*"`` storage and resolution + +Testing approach: +- Storage assertions for data model (coordinator.rewards_data) +- Entity registry assertions for existing scenario rewards +- Service call validation for create/update handlers +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +import pytest + +from tests.helpers import ( + DOMAIN, + SERVICE_CREATE_REWARD, + SERVICE_UPDATE_REWARD, + SetupResult, + setup_from_yaml, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +@pytest.fixture +async def scenario_full( + hass: HomeAssistant, + mock_hass_users: dict[str, Any], +) -> SetupResult: + """Load full scenario: 3 assignees, 2 approvers, 8 chores, 3 rewards.""" + return await setup_from_yaml( + hass, + mock_hass_users, + "tests/scenarios/scenario_full.yaml", + ) + + +def _count_entities_for_reward( + registry: Any, + reward_id: str, + *, + assignee_id: str | None = None, +) -> int: + """Count reward entities in the entity registry for a given reward.""" + count = 0 + for entry in registry.entities.values(): + uid = str(entry.unique_id) + if reward_id not in uid: + continue + is_reward_entity = ( + "_reward_status" in uid + or "_reward_redeem" in uid + or "_reward_approve" in uid + or "_reward_disapprove" in uid + ) + if not is_reward_entity: + continue + if assignee_id and assignee_id not in uid: + continue + count += 1 + return count + + +# ============================================================================ +# DEFAULT ALL USERS ON CREATE +# ============================================================================ + + +class TestDefaultAllUsersOnCreate: + """Test that rewards default to all users when assigned_user_ids is omitted.""" + + @pytest.mark.asyncio + async def test_default_all_users_in_storage( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test created reward has all gamified user IDs by default.""" + coordinator = scenario_full.coordinator + with patch.object(coordinator, "_persist", new=MagicMock()): + response = await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + {"name": "Default All Users", "cost": 50.0}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[response["id"]] + assigned_ids = reward_data["assigned_user_ids"] + # All gamified users should be assigned + for assignee_name in ["Zoë", "Max!", "Lila"]: + assert scenario_full.assignee_ids[assignee_name] in assigned_ids + + @pytest.mark.asyncio + async def test_existing_scenario_reward_has_entities_for_all_assignees( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that existing scenario rewards have entities for all gamified assignees.""" + registry = async_get_entity_registry(hass) + reward_id = scenario_full.reward_ids["Extra Screen Time"] + + for assignee_name in ["Zoë", "Max!", "Lila"]: + assignee_id = scenario_full.assignee_ids[assignee_name] + count = _count_entities_for_reward( + registry, reward_id, assignee_id=assignee_id + ) + assert count >= 1, f"{assignee_name} should have entities for ['*'] reward" + + +# ============================================================================ +# EXPLICIT USER ASSIGNMENT ON CREATE +# ============================================================================ + + +class TestExplicitUserAssignmentOnCreate: + """Test creating rewards with explicit user assignments.""" + + @pytest.mark.asyncio + async def test_explicit_single_user_in_storage( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test creating a reward with explicit UUID list.""" + coordinator = scenario_full.coordinator + zoe_id = scenario_full.assignee_ids["Zoë"] + with patch.object(coordinator, "_persist", new=MagicMock()): + response = await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + { + "name": "Zoe Only", + "cost": 30.0, + "assigned_user_ids": [zoe_id], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[response["id"]] + assert reward_data["assigned_user_ids"] == [zoe_id] + + @pytest.mark.asyncio + async def test_explicit_multiple_users_in_storage( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test creating a reward with multiple specific users.""" + coordinator = scenario_full.coordinator + zoe_id = scenario_full.assignee_ids["Zoë"] + lila_id = scenario_full.assignee_ids["Lila"] + with patch.object(coordinator, "_persist", new=MagicMock()): + response = await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + { + "name": "Zoe And Lila", + "cost": 30.0, + "assigned_user_ids": [zoe_id, lila_id], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[response["id"]] + assert zoe_id in reward_data["assigned_user_ids"] + assert lila_id in reward_data["assigned_user_ids"] + assert len(reward_data["assigned_user_ids"]) == 2 + + +# ============================================================================ +# SENTINEL "*" BEHAVIOR +# ============================================================================ + + +class TestSentinelStarBehavior: + """Test sentinel '*' behavior for all-users assignment.""" + + @pytest.mark.asyncio + async def test_sentinel_star_stored_correctly( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that ['*'] input is resolved to explicit user IDs in storage.""" + coordinator = scenario_full.coordinator + with patch.object(coordinator, "_persist", new=MagicMock()): + response = await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + { + "name": "Star Sentinel", + "cost": 10.0, + "assigned_user_ids": ["*"], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[response["id"]] + # Sentinel should be resolved to actual UUIDs, never stored as-is + assert "*" not in reward_data["assigned_user_ids"] + for assignee_name in ["Zoë", "Max!", "Lila"]: + assert ( + scenario_full.assignee_ids[assignee_name] + in reward_data["assigned_user_ids"] + ) + + +# ============================================================================ +# UPDATE REWARD ASSIGNMENTS +# ============================================================================ + + +class TestUpdateRewardAssignments: + """Test updating reward assignments via service.""" + + @pytest.mark.asyncio + async def test_update_reward_add_user_in_storage( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test updating a reward to assign specific users.""" + coordinator = scenario_full.coordinator + zoe_id = scenario_full.assignee_ids["Zoë"] + reward_id = scenario_full.reward_ids["Extra Screen Time"] + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + {"id": reward_id, "assigned_user_ids": [zoe_id]}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[reward_id] + assert reward_data["assigned_user_ids"] == [zoe_id] + + @pytest.mark.asyncio + async def test_update_reward_set_all_users( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test updating a reward to ['*'] in storage.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + {"id": reward_id, "assigned_user_ids": ["*"]}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[reward_id] + assert "*" not in reward_data["assigned_user_ids"] + for assignee_name in ["Zoë", "Max!", "Lila"]: + assert ( + scenario_full.assignee_ids[assignee_name] + in reward_data["assigned_user_ids"] + ) + + +# ============================================================================ +# SERVICE WITH ASSIGNED_USER_NAMES (DISPLAY NAMES) +# ============================================================================ + + +class TestServiceWithAssignNames: + """Test service handlers resolve display names to UUIDs.""" + + @pytest.mark.asyncio + async def test_update_reward_with_names_resolves_to_uuids( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test update_reward with assigned_user_names resolves display names.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + zoe_id = scenario_full.assignee_ids["Zoë"] + lila_id = scenario_full.assignee_ids["Lila"] + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + { + "id": reward_id, + "assigned_user_names": ["Zoë", "Lila"], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assigned_ids = coordinator.rewards_data[reward_id]["assigned_user_ids"] + assert zoe_id in assigned_ids + assert lila_id in assigned_ids + assert len(assigned_ids) == 2 + + @pytest.mark.asyncio + async def test_update_reward_with_sentinel_name( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test update_reward with assigned_user_names containing sentinel.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + { + "id": reward_id, + "assigned_user_names": ["*"], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[reward_id] + assert "*" not in reward_data["assigned_user_ids"] + for assignee_name in ["Zoë", "Max!", "Lila"]: + assert ( + scenario_full.assignee_ids[assignee_name] + in reward_data["assigned_user_ids"] + ) + + +# ============================================================================ +# BACKWARD COMPATIBILITY +# ============================================================================ + + +class TestBackwardCompatibility: + """Test backward compatibility with scenario rewards.""" + + @pytest.mark.asyncio + async def test_existing_rewards_have_assigned_user_ids( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that existing scenario rewards have the new field.""" + for reward_data in scenario_full.coordinator.rewards_data.values(): + assert "assigned_user_ids" in reward_data + assigned = reward_data["assigned_user_ids"] + assert isinstance(assigned, list) and len(assigned) > 0 + assert "*" not in assigned + + @pytest.mark.asyncio + async def test_schema_accepts_both_field_names( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that service schema accepts both name and ids field.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + zoe_id = scenario_full.assignee_ids["Zoë"] + + # Test with assigned_user_ids (direct UUIDs) + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + {"id": reward_id, "assigned_user_ids": [zoe_id]}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + @pytest.mark.asyncio + async def test_update_without_assignment_preserves_existing( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that updating other fields preserves existing assigned_user_ids.""" + coordinator = scenario_full.coordinator + reward_id = scenario_full.reward_ids["Extra Screen Time"] + original_assigned = coordinator.rewards_data[reward_id].get( + "assigned_user_ids", ["*"] + ) + + with patch.object(coordinator, "_persist", new=MagicMock()): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + {"id": reward_id, "cost": 99.0}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + reward_data = coordinator.rewards_data[reward_id] + assert reward_data["assigned_user_ids"] == original_assigned + assert reward_data["cost"] == 99.0 + + +# ============================================================================ +# DELETE REWARD CLEANUP +# ============================================================================ + + +class TestDeleteRewardCleanup: + """Test deleting a reward removes all associated entities.""" + + @pytest.mark.asyncio + async def test_delete_reward_removes_entities( + self, + hass: HomeAssistant, + scenario_full: SetupResult, + ) -> None: + """Test that deleting a reward removes all entity references.""" + coordinator = scenario_full.coordinator + registry = async_get_entity_registry(hass) + reward_id = scenario_full.reward_ids["Extra Screen Time"] + + # Confirm entities exist before + before = _count_entities_for_reward(registry, reward_id) + assert before > 0, "Should have entities before deletion" + + with patch.object(coordinator, "_persist", new=MagicMock()): + coordinator.reward_manager.delete_reward(reward_id) + await hass.async_block_till_done() + + # Confirm entities gone after + after = _count_entities_for_reward(registry, reward_id) + assert after == 0, f"Should have 0 entities after deletion, got {after}"