Skip to content

Commit 6353e54

Browse files
authored
feat: chores at set times of day (#1119)
1 parent 1a2d364 commit 6353e54

40 files changed

Lines changed: 3106 additions & 51 deletions

‎custom_components/taskmate/__init__.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
)
136136
from .coordinator import TaskMateCoordinator
137137
from .frontend import async_register_cards, async_register_frontend
138-
from .models import REJECT_REASON_MAX, Badge, BadgeCriterion
138+
from .models import REJECT_REASON_MAX, Badge, BadgeCriterion, validate_chore_times
139139
from .panel import async_register_panel
140140
from .websocket import async_register_websocket_commands
141141

@@ -589,6 +589,7 @@ async def handle_complete_chore(call: ServiceCall) -> None:
589589
note=call.data.get("note", ""),
590590
suggested_points=call.data.get("suggested_points", 0),
591591
quantity=call.data.get("quantity", 0),
592+
time_id=call.data.get("time_id", ""),
592593
)
593594
except ValueError as err:
594595
# Only genuinely bad input (unknown chore/child) raises now — expected
@@ -1189,7 +1190,13 @@ async def handle_add_chore(call: ServiceCall) -> None:
11891190
deadline_at = ""
11901191
if expires_in:
11911192
deadline_at = (dt_util.now() + timedelta(minutes=expires_in)).isoformat()
1192-
await coordinator.async_add_chore(
1193+
# Set times are checked before the chore exists, so a bad set refuses
1194+
# the whole call rather than leaving an ordinary chore behind.
1195+
try:
1196+
times = validate_chore_times(call.data.get("times"), team_size=call.data.get("team_size", 0))
1197+
except ValueError as err:
1198+
raise ServiceValidationError(str(err)) from err
1199+
chore = await coordinator.async_add_chore(
11931200
name=call.data[ATTR_CHORE_NAME],
11941201
description=call.data.get(ATTR_CHORE_DESCRIPTION, ""),
11951202
points=call.data.get(ATTR_CHORE_POINTS, 10),
@@ -1204,6 +1211,10 @@ async def handle_add_chore(call: ServiceCall) -> None:
12041211
team_points_mode=call.data.get("team_points_mode", "each"),
12051212
team_bonus=call.data.get("team_bonus", 0),
12061213
)
1214+
if times:
1215+
chore.times = times
1216+
chore.times_bonus = call.data.get("times_bonus", 0)
1217+
await coordinator.async_update_chore(chore)
12071218

12081219
async def handle_add_badge(call: ServiceCall) -> None:
12091220
"""Handle the add_badge service call (custom only)."""
@@ -1336,6 +1347,8 @@ async def handle_rebuild_badges(call: ServiceCall) -> None:
13361347
),
13371348
# Quantity chores: how many units the child did.
13381349
vol.Optional("quantity"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10000)),
1350+
# Which set time this ticks off; blank = the one open now.
1351+
vol.Optional("time_id"): cv.string,
13391352
}
13401353
),
13411354
)
@@ -1871,6 +1884,10 @@ async def handle_rebuild_badges(call: ServiceCall) -> None:
18711884
vol.Optional("team_size", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=TEAM_SIZE_MAX)),
18721885
vol.Optional("team_points_mode", default="each"): vol.In(TEAM_POINTS_MODES),
18731886
vol.Optional("team_bonus", default=0): cv.positive_int,
1887+
# Set times (several times a day): a list of
1888+
# {name, start "HH:MM", end "HH:MM", points}.
1889+
vol.Optional("times"): vol.All(cv.ensure_list, [dict]),
1890+
vol.Optional("times_bonus", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000)),
18741891
}
18751892
),
18761893
)

‎custom_components/taskmate/coord_chores.py‎

Lines changed: 140 additions & 9 deletions
Large diffs are not rendered by default.

‎custom_components/taskmate/coord_mandatory.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
1818
NOTIF_TYPE_MANDATORY_REMINDER,
1919
)
20+
from .coord_set_times import set_time_label
2021
from .models import MandatoryMiss, parse_datetime
2122

2223
# How often the escalation ladder is re-evaluated (FEAT-6).
@@ -100,6 +101,8 @@ async def async_recheck_mandatory_miss(self, chore_id: str, child_id: str, day:
100101
chore = self.get_chore(chore_id)
101102
if not chore or not getattr(chore, "mandatory", False):
102103
return 0
104+
if isinstance(getattr(chore, "times", None), list) and chore.times:
105+
return await self.async_detect_set_time_misses(day=day)
103106
period_id = self._effective_period_for(chore, child_id, day)
104107
if not self._period_has_ended(period_id, day):
105108
return 0
@@ -114,6 +117,9 @@ async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
114117
continue
115118
if not getattr(chore, "enabled", True):
116119
continue
120+
# Set times have a miss per time (async_detect_set_time_misses).
121+
if isinstance(getattr(chore, "times", None), list) and chore.times:
122+
continue
117123
if not self._is_chore_scheduled_for_date(chore, day):
118124
continue
119125
for child_id in self._mandatory_owers(chore, day):
@@ -183,7 +189,7 @@ async def async_apply_mandatory_penalty(self, miss_id: str) -> None:
183189
if miss is None:
184190
return
185191
chore = next((c for c in self.storage.get_chores() if c.id == miss.chore_id), None)
186-
name = getattr(chore, "name", "chore")
192+
name = set_time_label(chore, getattr(miss, "time_id", "")) if chore else "chore"
187193
if miss.penalty_points > 0:
188194
await self.async_remove_points(
189195
miss.child_id,
@@ -275,7 +281,10 @@ async def async_escalate_mandatory_misses(self, now: datetime | None = None) ->
275281
for miss in self.storage.get_mandatory_misses():
276282
if miss.due_date != today_iso:
277283
continue # only escalate same-day misses (skips midnight backfill)
278-
if self._child_completed_today(miss.chore_id, miss.child_id, now.date()):
284+
if getattr(miss, "time_id", ""):
285+
if self.set_time_miss_done(miss, now.date()):
286+
continue
287+
elif self._child_completed_today(miss.chore_id, miss.child_id, now.date()):
279288
continue
280289
created = parse_datetime(miss.created_at)
281290
if created is None:
@@ -298,7 +307,7 @@ async def async_escalate_mandatory_misses(self, now: datetime | None = None) ->
298307
ctx = {
299308
"child_id": miss.child_id,
300309
"child_name": getattr(child, "name", ""),
301-
"chore_name": getattr(chore, "name", "chore"),
310+
"chore_name": set_time_label(chore, getattr(miss, "time_id", "")) if chore else "chore",
302311
}
303312
for stage in range(miss.escalation_stage + 1, target + 1):
304313
if stage in (1, 2):
@@ -357,7 +366,13 @@ def arm_mandatory_schedules(self) -> None:
357366

358367
@callback
359368
def _escalation_tick(self, now: datetime) -> None:
360-
self.hass.async_create_task(self.async_escalate_mandatory_misses(now))
369+
# Set times close all through the day, so their misses are checked
370+
# on the same tick rather than at fixed period ends.
371+
self.hass.async_create_task(self._async_set_time_then_escalate(now))
372+
373+
async def _async_set_time_then_escalate(self, now: datetime) -> None:
374+
await self.async_detect_set_time_misses(now)
375+
await self.async_escalate_mandatory_misses(now)
361376

362377
def _make_mandatory_period_cb(self, period_id: str):
363378
@callback
@@ -389,13 +404,16 @@ async def async_catchup_mandatory_misses(self) -> None:
389404
for hour, minute, period_id in self._mandatory_period_end_times():
390405
if dt_time(hour, minute) <= cur:
391406
await self.async_detect_mandatory_misses(period_id, today)
407+
await self.async_detect_set_time_misses(now)
392408

393409
async def async_detect_anytime_mandatory_misses(self) -> None:
394410
"""Midnight: raise misses for incomplete mandatory 'anytime' chores for the
395411
day that just ended, then prune the postpone override map (new day starts clean)."""
396412
self._ensure_mandatory_state()
397413
yesterday = (dt_util.now() - timedelta(days=1)).date()
398414
await self.async_detect_mandatory_misses("anytime", yesterday)
415+
# A set time that closed in the last minutes of yesterday.
416+
await self.async_detect_set_time_misses(day=yesterday)
399417
self.mandatory_postpone = {}
400418

401419
# ---- maintenance -------------------------------------------------------
@@ -421,7 +439,8 @@ def mandatory_misses_state(self) -> list[dict]:
421439
out = []
422440
for m in self.storage.get_mandatory_misses():
423441
d = m.to_dict()
424-
d["chore_name"] = getattr(chores.get(m.chore_id), "name", "")
442+
chore = chores.get(m.chore_id)
443+
d["chore_name"] = set_time_label(chore, getattr(m, "time_id", "")) if chore else ""
425444
d["child_name"] = getattr(children.get(m.child_id), "name", "")
426445
out.append(d)
427446
return out

0 commit comments

Comments
 (0)