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
95 changes: 95 additions & 0 deletions custom_components/taskmate/chore_sort.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Family-wide chore order on the cards (#1231).

Every card that lists a child's chores (child, kiosk, routine, sticker chart)
and the watch's "next chore" follow the ``chore_order`` the sensors publish for
that child. This module decides what that list is:

* ``manual`` (the default) is each child's own order from the reorder card or
the panel, exactly as before. Chores missing from it follow in the order
they were added.
* ``time``, ``name`` and ``added`` sort automatically, so a chore added later
lands in its place without anyone reordering it. The child's manual order is
kept untouched underneath, ready for when the setting goes back to manual.

The same sort backs the panel's "sort everyone once" action, which writes the
result into every child's manual order and leaves the setting on manual.
"""

from __future__ import annotations

from typing import Any

CHORE_SORT_MODES: tuple[str, ...] = ("manual", "time", "name", "added")
CHORE_SORT_PRESETS: tuple[str, ...] = ("time", "name", "added")
CHORE_SORT_SETTING = "chore_sort"


def chore_sort_mode(storage: Any) -> str:
"""The stored mode, or ``manual`` when unset or unknown."""
value = storage.get_setting(CHORE_SORT_SETTING, "manual")
return value if value in CHORE_SORT_MODES else "manual"


def _minutes(hhmm: Any) -> int | None:
try:
h, m = (int(x) for x in str(hhmm).split(":"))
except (TypeError, ValueError):
return None
return h * 60 + m


def _time_key(chore: Any, period_starts: dict[str, int]) -> int:
"""When in the day a chore comes up, in minutes; anytime sorts first."""
starts = [
m for t in (getattr(chore, "times", None) or []) if (m := _minutes(getattr(t, "start", None))) is not None
]
if starts:
return min(starts)
category = getattr(chore, "time_category", "anytime") or "anytime"
if category == "anytime":
return -1
# A period that no longer exists sorts after every real one.
return period_starts.get(category, 24 * 60)


def sorted_chore_ids(
mode: str,
chores: list,
manual_order: list[str] | None = None,
periods: list[dict] | None = None,
) -> list[str]:
"""Chore ids in ``mode`` order. ``chores`` must be in the order they were added.

Ties keep the child's manual order, then the order the chores were added,
so sorting by time of day still respects a hand-made order within a period.
"""
manual_rank: dict[str, int] = {}
for index, chore_id in enumerate(manual_order or []):
manual_rank.setdefault(chore_id, index)
unranked = len(manual_rank)
added = {c.id: i for i, c in enumerate(chores)}

if mode == "added":
return [c.id for c in chores]
if mode == "name":
return [c.id for c in sorted(chores, key=lambda c: ((getattr(c, "name", "") or "").casefold(), added[c.id]))]
if mode == "time":
starts = {p["id"]: m for p in (periods or []) if (m := _minutes(p.get("start"))) is not None}
return [
c.id
for c in sorted(chores, key=lambda c: (_time_key(c, starts), manual_rank.get(c.id, unranked), added[c.id]))
]
# manual: the stored list as-is (the cards append the rest in added order).
return list(manual_order or [])


def effective_chore_order(coordinator: Any, child: Any, chores: list | None = None) -> list[str]:
"""The ``chore_order`` the cards should follow for ``child``."""
manual = list(getattr(child, "chore_order", None) or [])
mode = chore_sort_mode(coordinator.storage)
if mode == "manual":
return manual
if chores is None:
chores = coordinator.storage.get_chores()
periods = coordinator.get_time_periods() if mode == "time" else None
return sorted_chore_ids(mode, chores, manual, periods)
80 changes: 80 additions & 0 deletions custom_components/taskmate/coord_chores.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from homeassistant.util import dt as dt_util

from . import images, notify_strings, photos
from .chore_sort import CHORE_SORT_PRESETS, sorted_chore_ids
from .chore_undo import affected_completions, child_can_undo, undo_window_seconds
from .const import (
CHORE_NOTE_MAX_LENGTH,
Expand Down Expand Up @@ -449,6 +450,61 @@ async def async_bulk_chore_action(
await self.async_refresh()
return count

# The settings "Edit selected" can change on several chores at once (#1174).
BULK_EDIT_FIELDS: tuple[str, ...] = (
"points",
"assigned_to",
"requires_approval",
"time_category",
"schedule_mode",
"due_days",
"recurrence",
"recurrence_day",
)

async def async_bulk_edit_chores(self, chore_ids: list[str], changes: dict) -> dict:
"""Apply the same settings to several chores at once (#1174).

Only the fields in ``changes`` are written; everything else on each
chore stays as it was. Each chore goes through ``async_update_chore``
like a one-at-a-time edit, so the same rules apply; a chore that
refuses the change (say, a team chore given a rotation it can't have)
is left untouched and named in ``skipped``. One save and one refresh
for the batch (#794).
"""
unknown = set(changes) - set(self.BULK_EDIT_FIELDS)
if unknown:
raise ValueError(f"Can't bulk edit {', '.join(sorted(unknown))}")
if not changes:
return {"count": 0, "skipped": []}
if "assigned_to" in changes:
known = {c.id for c in self.storage.get_children()}
missing = [cid for cid in changes["assigned_to"] if cid not in known]
if missing:
raise ValueError(f"Unknown child {missing[0]}")
if "time_category" in changes:
periods = {"anytime", *(p["id"] for p in self.get_time_periods())}
if changes["time_category"] not in periods:
raise ValueError(f"Unknown time period {changes['time_category']}")
count = 0
skipped: list[str] = []
for cid in dict.fromkeys(chore_ids or []):
chore = self.storage.get_chore(cid)
if not chore:
continue
for field, value in changes.items():
setattr(chore, field, list(value) if isinstance(value, list) else value)
try:
await self.async_update_chore(chore, refresh=False)
except ValueError:
skipped.append(chore.name)
continue
count += 1
if count:
await self.storage.async_save()
await self.async_refresh()
return {"count": count, "skipped": skipped}

async def async_approve_chores_bulk(
self, completion_ids: list[str] | None = None, rating: int | None = None
) -> int:
Expand Down Expand Up @@ -2993,6 +3049,30 @@ async def async_set_chore_order(self, child_id: str, chore_order: list[str]) ->
await self.storage.async_save()
await self.async_refresh()

async def async_sort_chore_orders(self, sort_by: str, child_ids: list[str] | None = None) -> int:
"""Sort every child's manual chore order once by a preset (#1231).

Writes the sorted list into each child's own ``chore_order`` (all of
them, or just ``child_ids``) and saves once, so the reorder card can
fine-tune it afterwards. Returns how many children were updated.
"""
if sort_by not in CHORE_SORT_PRESETS:
raise ValueError(f"Unknown sort {sort_by!r}")
chores = self.storage.get_chores()
periods = self.get_time_periods() if sort_by == "time" else None
wanted = set(child_ids) if child_ids else None
updated = 0
for child in self.storage.get_children():
if wanted is not None and child.id not in wanted:
continue
child.chore_order = sorted_chore_ids(sort_by, chores, child.chore_order, periods)
self.storage.update_child(child)
updated += 1
if updated:
await self.storage.async_save()
await self.async_refresh()
return updated

async def async_set_global_chore_order(self, chore_order: list[str]) -> None:
"""Set the global chore display order."""
self.storage.set_chore_display_order(chore_order)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/taskmate/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "7.7.1"
"version": "7.8.0"
}
10 changes: 8 additions & 2 deletions custom_components/taskmate/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from homeassistant.util import dt as dt_util

from . import images
from .chore_sort import chore_sort_mode, effective_chore_order
from .chore_undo import child_undo_metadata, undo_window_seconds
from .const import BOUNTY_RECENT_HOURS, DOMAIN, WISH_MAX_OPEN_PER_CHILD
from .coord_rewards import reward_is_time_locked
Expand Down Expand Up @@ -263,7 +264,9 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
# Allocations were deducted from child.points already, so spendable
# only needs to account for pending-claim commitments.
"spendable_balance": max(0, c.points - committed_amount),
"chore_order": c.chore_order,
# The order the cards follow: the family-wide sort, or this
# child's own manual order (#1231).
"chore_order": effective_chore_order(coordinator, c, common["chores"]),
"current_streak": getattr(c, "current_streak", 0) or 0,
"best_streak": getattr(c, "best_streak", 0) or 0,
"season_points": int(season.get(c.id, 0)),
Expand Down Expand Up @@ -1451,6 +1454,9 @@ def _build_attributes(self) -> dict:
# How chores with set times show on the child and kiosk cards:
# one row per time, or one row with a pill per time.
"set_times_layout": "pills" if settings.get("set_times_layout") == "pills" else "rows",
# Family-wide chore order (#1231): "manual" leaves each child's own
# order in charge; the reorder card and panel say when it is not.
"chore_sort": chore_sort_mode(self.coordinator.storage),
# Seasonal themes: the season the cards dress for today, only while one is.
**({"season": today_season} if (today_season := active_season(settings, dt_util.now().date())) else {}),
# Season event packs: today's advent door (the per-child doors are
Expand Down Expand Up @@ -2006,7 +2012,7 @@ def _included(c):
{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category}
for c in assigned_chores
],
"chore_order": child.chore_order,
"chore_order": effective_chore_order(self.coordinator, child, chores),
}


Expand Down
7 changes: 5 additions & 2 deletions custom_components/taskmate/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from homeassistant.util import dt as dt_util

from .chore_sort import effective_chore_order

# Complication text has to stay short; long chore names are clipped.
NEXT_CHORE_NAME_MAX = 40

Expand Down Expand Up @@ -52,7 +54,8 @@ def chore_needs_input(coordinator, chore) -> bool:
def next_chore_for_child(coordinator, child_id: str):
"""The chore ``complete_next_chore`` would complete for this child, or None.

"Next" follows the child card: the child's custom ``chore_order`` first,
"Next" follows the child card: the published ``chore_order`` first (the
family-wide sort, or the child's own order — #1231),
then the remaining chores in their default order. Only chores the child
still owes today (``get_due_chores_for_child``) that a single tap can
finish are considered.
Expand All @@ -65,7 +68,7 @@ def next_chore_for_child(coordinator, child_id: str):
return None
# indexOf semantics, as on the card: a duplicated id keeps its first slot.
rank: dict[str, int] = {}
for index, chore_id in enumerate(getattr(child, "chore_order", None) or []):
for index, chore_id in enumerate(effective_chore_order(coordinator, child)):
rank.setdefault(chore_id, index)
unranked = len(rank)
# sorted() is stable, so unranked chores keep their default order.
Expand Down
Loading
Loading