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
111 changes: 111 additions & 0 deletions custom_components/taskmate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,32 @@
ATTR_CHILD_ID,
ATTR_CHORE_ID,
ATTR_CHORE_ORDER,
ATTR_PENALTY_ASSIGNED_TO,
ATTR_PENALTY_DESCRIPTION,
ATTR_PENALTY_ICON,
ATTR_PENALTY_ID,
ATTR_PENALTY_NAME,
ATTR_PENALTY_POINTS,
ATTR_POINTS,
ATTR_REASON,
ATTR_REWARD_ID,
ATTR_SOUND,
DOMAIN,
EVENT_PREVIEW_SOUND,
SERVICE_ADD_PENALTY,
SERVICE_ADD_POINTS,
SERVICE_APPLY_PENALTY,
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_REWARD,
SERVICE_CLAIM_REWARD,
SERVICE_REJECT_REWARD,
SERVICE_COMPLETE_CHORE,
SERVICE_PREVIEW_SOUND,
SERVICE_REJECT_CHORE,
SERVICE_REMOVE_PENALTY,
SERVICE_REMOVE_POINTS,
SERVICE_SET_CHORE_ORDER,
SERVICE_UPDATE_PENALTY,
)
from .coordinator import TaskMateCoordinator
from .frontend import async_register_cards, async_register_frontend
Expand Down Expand Up @@ -199,6 +209,58 @@ async def handle_set_chore_order(call: ServiceCall) -> None:
chore_order = call.data[ATTR_CHORE_ORDER]
await coordinator.async_set_chore_order(child_id, chore_order)

async def handle_add_penalty(call: ServiceCall) -> None:
"""Handle the add_penalty service call."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_add_penalty(
name=call.data[ATTR_PENALTY_NAME],
points=call.data[ATTR_PENALTY_POINTS],
description=call.data.get(ATTR_PENALTY_DESCRIPTION, ""),
icon=call.data.get(ATTR_PENALTY_ICON, "mdi:alert-circle-outline"),
assigned_to=call.data.get(ATTR_PENALTY_ASSIGNED_TO, []),
)

async def handle_update_penalty(call: ServiceCall) -> None:
"""Handle the update_penalty service call."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
from .models import Penalty
penalty_id = call.data[ATTR_PENALTY_ID]
existing = coordinator.storage.get_penalty(penalty_id)
if not existing:
_LOGGER.error("Penalty %s not found", penalty_id)
return
existing.name = call.data.get(ATTR_PENALTY_NAME, existing.name)
existing.points = call.data.get(ATTR_PENALTY_POINTS, existing.points)
existing.description = call.data.get(ATTR_PENALTY_DESCRIPTION, existing.description)
existing.icon = call.data.get(ATTR_PENALTY_ICON, existing.icon)
existing.assigned_to = call.data.get(ATTR_PENALTY_ASSIGNED_TO, existing.assigned_to)
await coordinator.async_update_penalty(existing)

async def handle_remove_penalty(call: ServiceCall) -> None:
"""Handle the remove_penalty service call."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_remove_penalty(call.data[ATTR_PENALTY_ID])

async def handle_apply_penalty(call: ServiceCall) -> None:
"""Handle the apply_penalty service call."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_apply_penalty(
penalty_id=call.data[ATTR_PENALTY_ID],
child_id=call.data[ATTR_CHILD_ID],
)

# Register all services
hass.services.async_register(
DOMAIN,
Expand Down Expand Up @@ -318,6 +380,51 @@ async def handle_set_chore_order(call: ServiceCall) -> None:
)


hass.services.async_register(
DOMAIN,
SERVICE_ADD_PENALTY,
handle_add_penalty,
schema=vol.Schema({
vol.Required(ATTR_PENALTY_NAME): cv.string,
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
}),
)

hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_PENALTY,
handle_update_penalty,
schema=vol.Schema({
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Optional(ATTR_PENALTY_NAME): cv.string,
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
vol.Optional(ATTR_PENALTY_ICON): cv.string,
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
}),
)

hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_PENALTY,
handle_remove_penalty,
schema=vol.Schema({vol.Required(ATTR_PENALTY_ID): cv.string}),
)

hass.services.async_register(
DOMAIN,
SERVICE_APPLY_PENALTY,
handle_apply_penalty,
schema=vol.Schema({
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}),
)


def _async_unregister_services(hass: HomeAssistant) -> None:
"""Unregister TaskMate services."""
services = [
Expand All @@ -331,6 +438,10 @@ def _async_unregister_services(hass: HomeAssistant) -> None:
SERVICE_REMOVE_POINTS,
SERVICE_SET_CHORE_ORDER,
SERVICE_PREVIEW_SOUND,
SERVICE_ADD_PENALTY,
SERVICE_UPDATE_PENALTY,
SERVICE_REMOVE_PENALTY,
SERVICE_APPLY_PENALTY,
]
for service in services:
hass.services.async_remove(DOMAIN, service)
2 changes: 1 addition & 1 deletion custom_components/taskmate/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ async def async_step_edit_chore(
if user_input is not None:
action = user_input.get("action", "save")
if action == "delete":
await self.coordinator.async_delete_chore(chore_id)
await self.coordinator.async_remove_chore(chore_id)
return await self.async_step_manage_chores()

name = user_input.get("name", "").strip()
Expand Down
10 changes: 10 additions & 0 deletions custom_components/taskmate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@
SERVICE_RESET_DAILY: Final = "reset_daily"
SERVICE_SET_CHORE_ORDER: Final = "set_chore_order"
SERVICE_PREVIEW_SOUND: Final = "preview_sound"
SERVICE_ADD_PENALTY: Final = "add_penalty"
SERVICE_UPDATE_PENALTY: Final = "update_penalty"
SERVICE_REMOVE_PENALTY: Final = "remove_penalty"
SERVICE_APPLY_PENALTY: Final = "apply_penalty"

# Events
EVENT_PREVIEW_SOUND: Final = "taskmate_preview_sound"
Expand All @@ -212,6 +216,12 @@
ATTR_REASON: Final = "reason"
ATTR_CHORE_ORDER: Final = "chore_order"
ATTR_SOUND: Final = "sound"
ATTR_PENALTY_ID: Final = "penalty_id"
ATTR_PENALTY_NAME: Final = "name"
ATTR_PENALTY_POINTS: Final = "points"
ATTR_PENALTY_DESCRIPTION: Final = "description"
ATTR_PENALTY_ICON: Final = "icon"
ATTR_PENALTY_ASSIGNED_TO: Final = "assigned_to"

# States
STATE_PENDING: Final = "pending"
Expand Down
46 changes: 46 additions & 0 deletions custom_components/taskmate/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ async def _async_update_data(self) -> dict[str, Any]:
"points_name": self.storage.get_points_name(),
"points_icon": self.storage.get_points_icon(),
"settings": self.storage._data.get("settings", {}),
"penalties": self.storage.get_penalties(),
}

# Child operations
Expand Down Expand Up @@ -680,6 +681,51 @@ async def async_reject_reward(self, claim_id: str) -> None:
await self.storage.async_save()
await self.async_refresh()

# Penalty operations
async def async_add_penalty(
self,
name: str,
points: int,
description: str = "",
icon: str = "mdi:alert-circle-outline",
assigned_to: list | None = None,
):
"""Create a new penalty definition."""
from .models import Penalty
penalty = Penalty(
name=name,
points=points,
description=description,
icon=icon,
assigned_to=assigned_to or [],
)
self.storage.add_penalty(penalty)
await self.storage.async_save()
await self.async_refresh()
return penalty

async def async_update_penalty(self, penalty) -> None:
"""Update an existing penalty definition."""
self.storage.update_penalty(penalty)
await self.storage.async_save()
await self.async_refresh()

async def async_remove_penalty(self, penalty_id: str) -> None:
"""Delete a penalty definition."""
self.storage.remove_penalty(penalty_id)
await self.storage.async_save()
await self.async_refresh()

async def async_apply_penalty(self, penalty_id: str, child_id: str) -> None:
"""Apply a penalty — deducts the penalty's points from the child."""
penalty = self.storage.get_penalty(penalty_id)
if not penalty:
raise ValueError(f"Penalty {penalty_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")

# Points operations
async def async_add_points(self, child_id: str, points: int, reason: str = "") -> None:
"""Add points to a child (bonus)."""
Expand Down
1 change: 1 addition & 0 deletions custom_components/taskmate/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"taskmate-reward-progress-card.js",
"taskmate-leaderboard-card.js",
"taskmate-parent-dashboard-card.js",
"taskmate-penalties-card.js",
]

# JS modules to load globally (for config flow sound preview)
Expand Down
35 changes: 35 additions & 0 deletions custom_components/taskmate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,41 @@ def to_dict(self) -> dict[str, Any]:
}


@dataclass
class Penalty:
"""Represents a penalty that deducts points from a child."""

name: str
points: int # Points to deduct (always positive; deduction is applied on use)
description: str = ""
icon: str = "mdi:alert-circle-outline"
assigned_to: list = None # Child IDs who can receive this penalty (empty = all)
id: str = field(default_factory=generate_id)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Penalty":
"""Create a Penalty from a dictionary."""
return cls(
id=data.get("id", generate_id()),
name=data.get("name", ""),
points=data.get("points", 0),
description=data.get("description", ""),
icon=data.get("icon", "mdi:alert-circle-outline"),
assigned_to=data.get("assigned_to", []),
)

def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"name": self.name,
"points": self.points,
"description": self.description,
"icon": self.icon,
"assigned_to": self.assigned_to or [],
}


@dataclass
class PointsTransaction:
"""Represents a manual points adjustment (add or remove)."""
Expand Down
8 changes: 8 additions & 0 deletions custom_components/taskmate/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ def extra_state_attributes(self) -> dict:
rewards,
child_lookup,
),
"penalties": [{
"id": p.id,
"name": p.name,
"points": p.points,
"description": p.description,
"icon": p.icon,
"assigned_to": p.assigned_to or [],
} for p in data.get("penalties", [])],
}

@property
Expand Down
33 changes: 33 additions & 0 deletions custom_components/taskmate/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,39 @@ def remove_reward_claim(self, claim_id: str) -> None:
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
]

# Penalties management
def get_penalties(self) -> list:
"""Get all penalties."""
from .models import Penalty
return [Penalty.from_dict(p) for p in self._data.get("penalties", [])]

def get_penalty(self, penalty_id: str):
"""Get a penalty by ID."""
from .models import Penalty
for p in self._data.get("penalties", []):
if p.get("id") == penalty_id:
return Penalty.from_dict(p)
return None

def add_penalty(self, penalty) -> None:
"""Add a new penalty."""
self._data.setdefault("penalties", []).append(penalty.to_dict())

def update_penalty(self, penalty) -> None:
"""Update an existing penalty."""
penalties = self._data.get("penalties", [])
for i, p in enumerate(penalties):
if p.get("id") == penalty.id:
penalties[i] = penalty.to_dict()
return
penalties.append(penalty.to_dict())

def remove_penalty(self, penalty_id: str) -> None:
"""Remove a penalty."""
self._data["penalties"] = [
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
]

# Points transactions management
def get_points_transactions(self) -> list[PointsTransaction]:
"""Get all points transactions."""
Expand Down
Loading
Loading