Skip to content

Commit 1a2d364

Browse files
authored
feat: chore and reward library (#1118)
1 parent ef4215c commit 1a2d364

22 files changed

Lines changed: 4785 additions & 26 deletions

‎custom_components/taskmate/coord_rewards.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from homeassistant.util import dt as dt_util
1010

1111
from .coord_rejections import clean_reject_reason
12-
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
12+
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim, clean_template_id
1313
from .timewindow import has_window, is_within_window
1414

1515
if TYPE_CHECKING:
@@ -100,8 +100,12 @@ async def async_add_reward(
100100
quantity: int | None = None,
101101
expires_at: str | None = None,
102102
refresh: bool = True,
103+
template_id: str = "",
103104
) -> Reward:
104-
"""Add a new reward. ``refresh=False`` defers the refresh to a batch."""
105+
"""Add a new reward. ``refresh=False`` defers the refresh to a batch.
106+
107+
``template_id`` names the 📚 library entry it came from, if any.
108+
"""
105109
reward = Reward(
106110
name=name,
107111
cost=cost,
@@ -112,6 +116,7 @@ async def async_add_reward(
112116
pool_enabled=pool_enabled, # Reward.__post_init__ forces this on for jackpots (#552)
113117
quantity=quantity,
114118
expires_at=expires_at,
119+
template_id=clean_template_id(template_id),
115120
)
116121
self.storage.add_reward(reward)
117122
await self.storage.async_save()

‎custom_components/taskmate/coord_setup.py‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
99
Everything is checked before anything is written, so a bad birthday or an
1010
unknown child leaves the family exactly as it was. The wizard only ever adds:
11-
a chore or reward whose name the family already has is skipped, a child whose
12-
name already exists is reused, and an existing child's birthday or age group is
13-
only filled in when it is still empty.
11+
a chore or reward the family already has (same name, or added from the same
12+
library entry) is skipped, a child whose name already exists is reused, and an
13+
existing child's birthday or age group is only filled in when it is still
14+
empty. The 📚 library adds through here too (``taskmate/library/add``).
1415
"""
1516

1617
from __future__ import annotations
@@ -20,6 +21,7 @@
2021

2122
from .const import AGE_GROUPS
2223
from .coord_birthdays import normalize_birthday
24+
from .models import clean_template_id
2325

2426
_LOGGER = logging.getLogger(__name__)
2527

@@ -37,6 +39,7 @@
3739
"recurrence",
3840
"recurrence_day",
3941
"assignment_mode",
42+
"template_id",
4043
)
4144

4245

@@ -126,33 +129,45 @@ def resolve(who: str) -> str:
126129
return who
127130
raise ValueError(f"Chore assigned to an unknown child {who!r}")
128131

129-
have_chores = {_fold(c.name) for c in self.storage.get_chores()}
132+
existing_chores = self.storage.get_chores()
133+
have_chores = {_fold(c.name) for c in existing_chores}
134+
have_chore_tpls = {c.template_id for c in existing_chores if c.template_id}
130135
chore_defs: list[dict] = []
131136
skipped_chores = 0
132137
for spec in chores:
133138
name = (spec.get("name") or "").strip()
134139
if not name:
135140
raise ValueError("A chore needs a name")
136141
assigned = [resolve(str(w)) for w in spec.get("assigned_to") or []]
137-
if _fold(name) in have_chores:
142+
tpl = clean_template_id(spec.get("template_id"))
143+
if _fold(name) in have_chores or (tpl and tpl in have_chore_tpls):
138144
skipped_chores += 1
139145
continue
146+
# Sent twice in one batch: the second is a duplicate too.
147+
have_chores.add(_fold(name))
148+
if tpl:
149+
have_chore_tpls.add(tpl)
140150
chore_def = {k: spec[k] for k in _CHORE_KEYS if k in spec}
141151
chore_def["name"] = name
142152
chore_def["assigned_to"] = assigned
143153
chore_defs.append(chore_def)
144154

145-
have_rewards = {_fold(r.name) for r in self.storage.get_rewards()}
155+
existing_rewards = self.storage.get_rewards()
156+
have_rewards = {_fold(r.name) for r in existing_rewards}
157+
have_reward_tpls = {r.template_id for r in existing_rewards if r.template_id}
146158
reward_specs: list[dict] = []
147159
skipped_rewards = 0
148160
for spec in rewards:
149161
name = (spec.get("name") or "").strip()
150162
if not name:
151163
raise ValueError("A reward needs a name")
152-
if _fold(name) in have_rewards:
164+
tpl = clean_template_id(spec.get("template_id"))
165+
if _fold(name) in have_rewards or (tpl and tpl in have_reward_tpls):
153166
skipped_rewards += 1
154167
continue
155168
have_rewards.add(_fold(name))
169+
if tpl:
170+
have_reward_tpls.add(tpl)
156171
reward_specs.append(spec)
157172

158173
# --- write, deferring every refresh to the end -----------------------
@@ -184,6 +199,7 @@ def resolve(who: str) -> str:
184199
description=spec.get("description", ""),
185200
icon=spec.get("icon") or "mdi:gift",
186201
refresh=False,
202+
template_id=spec.get("template_id", ""),
187203
)
188204

189205
await self.async_refresh()

‎custom_components/taskmate/coord_templates.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING
77

88
from .const import ASSIGNMENT_MODES, SCHEDULE_MODES, TIME_CATEGORIES
9-
from .models import Chore, generate_id, optional_float
9+
from .models import Chore, clean_template_id, generate_id, optional_float
1010
from .templates import BUILT_IN_IDS, BUILT_IN_TEMPLATES, TEMPLATE_CHORE_FIELDS
1111

1212
if TYPE_CHECKING:
@@ -110,6 +110,9 @@ async def async_apply_template(self, chores: list[dict], refresh: bool = True) -
110110
# packs don't, and keep the chore's default.
111111
if chore_def.get("icon"):
112112
chore.icon = chore_def["icon"]
113+
# Added from the 📚 library or the wizard: remembered so the
114+
# library shows it as already added.
115+
chore.template_id = clean_template_id(chore_def.get("template_id"))
113116
self.storage.add_chore(chore)
114117
created_ids.append(chore.id)
115118
await self.storage.async_save()

‎custom_components/taskmate/models.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import re
67
import uuid
78
from dataclasses import dataclass, field
89
from datetime import date, datetime, timezone
@@ -101,6 +102,22 @@ def format_datetime(dt: datetime | None) -> str | None:
101102
return utc_dt.isoformat().replace("+00:00", "Z")
102103

103104

105+
TEMPLATE_ID_MAX_LENGTH = 80
106+
_TEMPLATE_ID_RE = re.compile(r"[a-z0-9_.]+")
107+
108+
109+
def clean_template_id(raw: Any) -> str:
110+
"""A library template id (``make_bed``, ``pack.pet_care.2``) or "".
111+
112+
Anything else — a restored backup's junk, a too-long string — reads as
113+
"", which only means the library won't recognise the item as added.
114+
"""
115+
value = str(raw or "").strip()
116+
if len(value) > TEMPLATE_ID_MAX_LENGTH or not _TEMPLATE_ID_RE.fullmatch(value):
117+
return ""
118+
return value
119+
120+
104121
def normalize_tag_ids(raw: Any) -> list[str]:
105122
"""Clean a chore's linked HA tag ids (#923).
106123
@@ -491,6 +508,10 @@ class Chore:
491508
# one completion (0 = no cap).
492509
quantity_unit: str = ""
493510
quantity_max: int = 0
511+
# The library entry this chore was added from (a catalogue id such as
512+
# ``make_bed``, or ``pack.<pack>.<n>``), so the 📚 library can show it as
513+
# already added. "" for chores made by hand.
514+
template_id: str = ""
494515
id: str = field(default_factory=generate_id)
495516

496517
@classmethod
@@ -576,6 +597,7 @@ def from_dict(cls, data: dict[str, Any]) -> Chore:
576597
reference_image_url=str(data.get("reference_image_url", "") or ""),
577598
quantity_unit=str(data.get("quantity_unit", "") or "").strip()[:QUANTITY_UNIT_MAX_LENGTH],
578599
quantity_max=_clamp_int(data.get("quantity_max"), 0, MAX_QUANTITY),
600+
template_id=clean_template_id(data.get("template_id")),
579601
id=data.get("id") or generate_id(),
580602
)
581603

@@ -648,6 +670,7 @@ def to_dict(self) -> dict[str, Any]:
648670
"reference_image_url": self.reference_image_url,
649671
"quantity_unit": self.quantity_unit,
650672
"quantity_max": self.quantity_max,
673+
"template_id": self.template_id,
651674
"id": self.id,
652675
}
653676

@@ -741,6 +764,10 @@ class Reward:
741764
# Streak freeze (#925): approving a claim hands the child one streak-freeze
742765
# token instead of anything physical. Always a fixed cost like any reward.
743766
streak_freeze: bool = False
767+
# The library entry this reward was added from (a catalogue id such as
768+
# ``sleepover``); "" for rewards made by hand. Only used to show "already
769+
# added".
770+
template_id: str = ""
744771
id: str = field(default_factory=generate_id)
745772

746773
def __post_init__(self) -> None:
@@ -783,6 +810,7 @@ def from_dict(cls, data: dict[str, Any]) -> Reward:
783810
available_from=str(data.get("available_from", "") or ""),
784811
available_until=str(data.get("available_until", "") or ""),
785812
streak_freeze=bool(data.get("streak_freeze", False)),
813+
template_id=clean_template_id(data.get("template_id")),
786814
id=data.get("id") or generate_id(),
787815
)
788816

@@ -809,6 +837,7 @@ def to_dict(self) -> dict[str, Any]:
809837
"available_from": self.available_from,
810838
"available_until": self.available_until,
811839
"streak_freeze": self.streak_freeze,
840+
"template_id": self.template_id,
812841
"id": self.id,
813842
}
814843

0 commit comments

Comments
 (0)