Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/agents/builder.agent.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/agents/strategist.agent.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
125 changes: 125 additions & 0 deletions custom_components/choreops/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions custom_components/choreops/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions custom_components/choreops/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions custom_components/choreops/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
# -------------------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions custom_components/choreops/data_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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, [])),
)


Expand All @@ -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,
}
)

Expand Down
Loading
Loading