147 files
This commit is contained in:
@@ -125,6 +125,8 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
self._child_id = child.id
|
||||
self._attr_unique_id = f"{entry.entry_id}_{child.id}_calendar"
|
||||
self._attr_name = f"TaskMate {child.name}"
|
||||
self._events_cache: list[CalendarEvent] | None = None
|
||||
self._events_key: tuple | None = None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -140,17 +142,40 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""The current or next upcoming event (HA shows this as the entity state)."""
|
||||
"""The current or next upcoming event (HA shows this as the entity state).
|
||||
|
||||
Home Assistant reads this twice per state write (once for the state,
|
||||
once for the state attributes), and writes on every coordinator
|
||||
refresh — so the horizon projection is memoized against the same key
|
||||
the sensors use: the data snapshot plus the external-entity version
|
||||
that covers availability/visibility flips (#823). Only the cheap
|
||||
"which one is next" filter re-runs.
|
||||
"""
|
||||
child = self._child
|
||||
if not child:
|
||||
return None
|
||||
now = dt_util.now()
|
||||
today = now.date()
|
||||
events = self._build_events(child, today, today + timedelta(days=_NEXT_EVENT_HORIZON_DAYS))
|
||||
events = self._cached_horizon(child, today)
|
||||
upcoming = [e for e in events if _as_local_dt(e.end) > now]
|
||||
upcoming.sort(key=lambda e: _as_local_dt(e.start))
|
||||
return upcoming[0] if upcoming else None
|
||||
|
||||
def _cached_horizon(self, child: Child, today: date) -> list[CalendarEvent]:
|
||||
"""The next-up horizon for ``today``, rebuilt only when inputs change."""
|
||||
key = (
|
||||
today,
|
||||
id(self.coordinator.data),
|
||||
getattr(self.coordinator, "external_state_version", 0),
|
||||
)
|
||||
cached = getattr(self, "_events_cache", None)
|
||||
if cached is not None and getattr(self, "_events_key", None) == key:
|
||||
return cached
|
||||
events = self._build_events(child, today, today + timedelta(days=_NEXT_EVENT_HORIZON_DAYS))
|
||||
self._events_cache = events
|
||||
self._events_key = key
|
||||
return events
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
@@ -161,6 +186,14 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
return self._build_events(child, start_date.date(), end_date.date())
|
||||
|
||||
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
|
||||
with self.coordinator.availability_build_scope():
|
||||
return self._build_events_locked(child, start_day, end_day)
|
||||
|
||||
def _build_events_locked(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
|
||||
"""Project the range. Must run inside ``availability_build_scope`` so the
|
||||
rotation pool and balanced-mode grouping resolve from the scope cache
|
||||
instead of rebuilding every stored chore/child per (chore, day) (#823).
|
||||
"""
|
||||
coord = self.coordinator
|
||||
events: list[CalendarEvent] = []
|
||||
|
||||
@@ -182,7 +215,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
events.append(_away_event(run_start, end_day + timedelta(days=1), run_label))
|
||||
|
||||
# ---- chore occurrences (skipped on away days) ----
|
||||
chores = coord.storage.get_chores()
|
||||
chores = coord._cached_chores()
|
||||
tz = dt_util.DEFAULT_TIME_ZONE
|
||||
day = start_day
|
||||
while day <= end_day:
|
||||
|
||||
@@ -280,7 +280,7 @@ class AssignmentsMixin:
|
||||
2. unavailability_entity — neutral when missing; _AVAILABLE_STATES means BUSY.
|
||||
Final = step1 AND NOT step2.
|
||||
"""
|
||||
child = self.storage.get_child(child_id)
|
||||
child = self._cached_child(child_id)
|
||||
if not child:
|
||||
return True
|
||||
|
||||
@@ -327,10 +327,11 @@ class AssignmentsMixin:
|
||||
Prefers the chore's `assigned_to` list. When empty, falls back to every
|
||||
stored child so "Everyone"-style chores can still alternate/randomize.
|
||||
"""
|
||||
pool = [cid for cid in (chore.assigned_to or []) if self.storage.get_child(cid)]
|
||||
lookup = self._cached_child_lookup()
|
||||
pool = [cid for cid in (chore.assigned_to or []) if cid in lookup]
|
||||
if pool:
|
||||
return pool
|
||||
return [child.id for child in self.storage.get_children()]
|
||||
return list(lookup)
|
||||
|
||||
def _compute_active_children(self, chore: Chore, today: date | None = None) -> list[str]:
|
||||
"""Return the child IDs the chore is active for today.
|
||||
@@ -347,16 +348,18 @@ class AssignmentsMixin:
|
||||
parent has skipped. Stale skip state (skip_date != today) is ignored at
|
||||
read time and cleared during the midnight refresh.
|
||||
"""
|
||||
# PERF-1: the active set depends only on the chore for "today" (the
|
||||
# availability matrix calls this per chore × child). Memoize per chore
|
||||
# within an availability build scope. Skip when an explicit `today` is
|
||||
# passed (callers that probe other days must not read today's cache).
|
||||
# PERF-1/#823: the active set depends only on (chore, day) — the
|
||||
# availability matrix calls this per chore × child and the calendar
|
||||
# projection per chore × day. Memoize on that pair within a build
|
||||
# scope; `today=None` ("today") keys separately from an explicit date
|
||||
# so a probe of another day can never be served today's answer.
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
if cache is not None and today is None:
|
||||
cached = cache["active"].get(chore.id)
|
||||
if cache is not None:
|
||||
key = (chore.id, today)
|
||||
cached = cache["active"].get(key)
|
||||
if cached is None:
|
||||
cached = self._compute_active_children_uncached(chore, None)
|
||||
cache["active"][chore.id] = cached
|
||||
cached = self._compute_active_children_uncached(chore, today)
|
||||
cache["active"][key] = cached
|
||||
return cached
|
||||
return self._compute_active_children_uncached(chore, today)
|
||||
|
||||
@@ -448,7 +451,7 @@ class AssignmentsMixin:
|
||||
pool_key = tuple(sorted(pool))
|
||||
group_ids = sorted(
|
||||
c.id
|
||||
for c in self.storage.get_chores()
|
||||
for c in self._cached_chores()
|
||||
if getattr(c, "assignment_mode", "everyone") == "balanced"
|
||||
and tuple(sorted(self._chore_assignment_pool(c))) == pool_key
|
||||
)
|
||||
@@ -613,9 +616,7 @@ class AssignmentsMixin:
|
||||
active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
|
||||
completions_today = 0
|
||||
completed_bonus_ids_today: set[str] = set()
|
||||
for comp in self._cached_completions():
|
||||
if comp.chore_id != chore.id:
|
||||
continue
|
||||
for comp in self._cached_completions_for_chore(chore.id):
|
||||
comp_dt = comp.completed_at
|
||||
try:
|
||||
if hasattr(comp_dt, "astimezone"):
|
||||
|
||||
@@ -1330,7 +1330,7 @@ class ChoresMixin:
|
||||
# Vacation / pause mode: while away, all chores are hidden. Covers the
|
||||
# global static range, the family vacation calendar, and this child's
|
||||
# own availability sensor (per-child vacation).
|
||||
if self._is_child_on_vacation(self.storage.get_child(child_id)):
|
||||
if self._is_child_on_vacation(self._cached_child(child_id)):
|
||||
return False
|
||||
|
||||
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
||||
|
||||
@@ -183,18 +183,26 @@ class TaskMateCoordinator(
|
||||
"""Memoize chore-only computations for one availability build (PERF-1).
|
||||
|
||||
The availability matrix calls ``is_chore_available_for_child`` for every
|
||||
chore × child. Several inner computations depend only on the chore (the
|
||||
chore × child, and the calendar projection does the same for every
|
||||
chore × day. Several inner computations depend only on the chore (the
|
||||
rotation active-set, the rotation-done check) or are re-fetched from
|
||||
storage repeatedly (the completions list, which ``get_completions``
|
||||
rebuilds from dicts on each call). Inside this scope those are computed
|
||||
once and cached. The build is fully synchronous (no awaits), so storage
|
||||
cannot mutate while the cache is live. Re-entrant scopes reuse the cache.
|
||||
storage repeatedly — every ``storage.get_*`` call rebuilds its whole
|
||||
list of dataclasses from raw dicts, so a per-chore ``get_chores()``
|
||||
(balanced-mode grouping) or a per-pool-member ``get_child()``
|
||||
(``_chore_assignment_pool``) turns a projection into hundreds of
|
||||
thousands of object constructions (#823). Inside this scope those are
|
||||
computed once and cached. The build is fully synchronous (no awaits),
|
||||
so storage cannot mutate while the cache is live. Re-entrant scopes
|
||||
reuse the cache.
|
||||
"""
|
||||
if getattr(self, "_avail_cache", None) is not None:
|
||||
yield # already inside a scope — reuse the existing cache
|
||||
return
|
||||
self._avail_cache = {
|
||||
"completions": self.storage.get_completions(),
|
||||
"completions_by_chore": None, # built lazily — only rotation modes need it
|
||||
"chores": None,
|
||||
"child_lookup": None,
|
||||
"active": {},
|
||||
"rotation_done": {},
|
||||
}
|
||||
@@ -210,6 +218,48 @@ class TaskMateCoordinator(
|
||||
return cache["completions"]
|
||||
return self.storage.get_completions()
|
||||
|
||||
def _cached_completions_for_chore(self, chore_id: str) -> list:
|
||||
"""Completions for one chore, via a shared index when in scope.
|
||||
|
||||
Without the index each rotation chore walks the entire completions
|
||||
list, making the availability matrix O(chores × completions).
|
||||
"""
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
if cache is None:
|
||||
return [c for c in self.storage.get_completions() if c.chore_id == chore_id]
|
||||
index = cache["completions_by_chore"]
|
||||
if index is None:
|
||||
index = {}
|
||||
for comp in cache["completions"]:
|
||||
index.setdefault(comp.chore_id, []).append(comp)
|
||||
cache["completions_by_chore"] = index
|
||||
return index.get(chore_id, [])
|
||||
|
||||
def _cached_chores(self) -> list:
|
||||
"""Chores list, rebuilt from storage once per scope."""
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
if cache is None:
|
||||
return self.storage.get_chores()
|
||||
if cache["chores"] is None:
|
||||
cache["chores"] = self.storage.get_chores()
|
||||
return cache["chores"]
|
||||
|
||||
def _cached_child_lookup(self) -> dict:
|
||||
"""``{child_id: Child}``, rebuilt from storage once per scope."""
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
if cache is None:
|
||||
return {c.id: c for c in self.storage.get_children()}
|
||||
if cache["child_lookup"] is None:
|
||||
cache["child_lookup"] = {c.id: c for c in self.storage.get_children()}
|
||||
return cache["child_lookup"]
|
||||
|
||||
def _cached_child(self, child_id: str):
|
||||
"""A single child, served from the scope lookup when in scope."""
|
||||
cache = getattr(self, "_avail_cache", None)
|
||||
if cache is None:
|
||||
return self.storage.get_child(child_id)
|
||||
return self._cached_child_lookup().get(child_id)
|
||||
|
||||
def _is_child_on_vacation(self, child, on: date | None = None) -> bool:
|
||||
"""True when ``child`` should be treated as away (streak frozen, chores
|
||||
hidden) on ``on`` (default today). Three stacking sources, OR'd:
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.3.0"
|
||||
"version": "5.4.1"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class TimedSession:
|
||||
segments=list(data.get("segments", [])),
|
||||
total_seconds_today=data.get("total_seconds_today", 0),
|
||||
session_date=data.get("session_date", ""),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -136,7 +136,7 @@ class BonusSubTask:
|
||||
name=data.get("name", ""),
|
||||
points=data.get("points", 5),
|
||||
description=data.get("description", ""),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -212,7 +212,7 @@ class Child:
|
||||
quiet_hours_start=data.get("quiet_hours_start", ""),
|
||||
quiet_hours_end=data.get("quiet_hours_end", ""),
|
||||
level=int(data.get("level", 1) or 1),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -422,7 +422,7 @@ class Chore:
|
||||
timed_rate_points=data.get("timed_rate_points", 10),
|
||||
timed_rate_minutes=max(1, int(data.get("timed_rate_minutes", 5) or 5)),
|
||||
timed_max_daily_minutes=max(0, int(data.get("timed_max_daily_minutes", 0) or 0)),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -542,7 +542,7 @@ class Reward:
|
||||
unlock_minutes=int(data.get("unlock_minutes", 0) or 0),
|
||||
restock_period=data.get("restock_period", "weekly"),
|
||||
restock_last=data.get("restock_last", ""),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -598,7 +598,7 @@ class Quest:
|
||||
assigned_to=list(data.get("assigned_to", [])),
|
||||
repeatable=bool(data.get("repeatable", False)),
|
||||
active=bool(data.get("active", True)),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -648,7 +648,7 @@ class Challenge:
|
||||
bonus_points=int(data.get("bonus_points", 15) or 0),
|
||||
assigned_to=list(data.get("assigned_to", [])),
|
||||
active=bool(data.get("active", True)),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -697,7 +697,7 @@ class ChoreCompletion:
|
||||
bonus_subtask_id=data.get("bonus_subtask_id", ""),
|
||||
timed_duration_seconds=data.get("timed_duration_seconds", 0),
|
||||
photo_url=data.get("photo_url", ""),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -747,7 +747,7 @@ class MandatoryMiss:
|
||||
postpone_count=max(0, int(data.get("postpone_count", 0) or 0)),
|
||||
escalation_stage=max(0, int(data.get("escalation_stage", 0) or 0)),
|
||||
created_at=data.get("created_at", "") or dt_util_now_iso(),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -788,7 +788,7 @@ class RewardClaim:
|
||||
claimed_at=claimed_at or datetime.now(timezone.utc),
|
||||
approved=data.get("approved", False),
|
||||
approved_at=approved_at,
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -819,7 +819,7 @@ class PoolAllocation:
|
||||
child_id=data.get("child_id", ""),
|
||||
reward_id=data.get("reward_id", ""),
|
||||
allocated_points=data.get("allocated_points", 0),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -847,7 +847,7 @@ class Penalty:
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Penalty":
|
||||
"""Create a Penalty from a dictionary."""
|
||||
return cls(
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
name=data.get("name", ""),
|
||||
# Always positive — a negative value would *grant* points on use
|
||||
points=max(0, int(data.get("points", 0) or 0)),
|
||||
@@ -883,7 +883,7 @@ class Bonus:
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Bonus":
|
||||
"""Create a Bonus from a dictionary."""
|
||||
return cls(
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
name=data.get("name", ""),
|
||||
# Always positive — a negative value would *deduct* points on use
|
||||
points=max(0, int(data.get("points", 0) or 0)),
|
||||
@@ -946,7 +946,7 @@ class PointsTransaction:
|
||||
points=data.get("points", 0),
|
||||
reason=data.get("reason", ""),
|
||||
created_at=created_at or datetime.now(timezone.utc),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
link_id=data.get("link_id", ""),
|
||||
)
|
||||
|
||||
@@ -984,7 +984,7 @@ class Badge:
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Badge":
|
||||
created_at = parse_datetime(data.get("created_at"))
|
||||
return cls(
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
name=data.get("name", ""),
|
||||
description=data.get("description", ""),
|
||||
icon=data.get("icon", "mdi:trophy"),
|
||||
@@ -1033,7 +1033,7 @@ class AwardedBadge:
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AwardedBadge":
|
||||
earned_at = parse_datetime(data.get("earned_at"))
|
||||
return cls(
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
child_id=data.get("child_id", ""),
|
||||
badge_id=data.get("badge_id", ""),
|
||||
earned_at=earned_at or datetime.now(timezone.utc),
|
||||
@@ -1078,7 +1078,7 @@ class TaskGroup:
|
||||
name=data.get("name", ""),
|
||||
policy=data.get("policy", "sticky"),
|
||||
chore_ids=list(data.get("chore_ids", [])),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -1122,7 +1122,7 @@ class ScheduledChange:
|
||||
applied=bool(data.get("applied", False)),
|
||||
applied_at=data.get("applied_at", ""),
|
||||
created_at=data.get("created_at", "") or dt_util_now_iso(),
|
||||
id=data.get("id", generate_id()),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
|
||||
@@ -936,12 +936,11 @@ class TaskMateChoreAvailabilitySensor(_CachedAttrsSensor):
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
common = _compute_common(self.coordinator)
|
||||
total = 0
|
||||
availability = _build_chore_availability(self.coordinator, common)
|
||||
for per_child in availability.values():
|
||||
total += sum(1 for v in per_child.values() if v)
|
||||
return total
|
||||
# Read the matrix back off the attribute cache rather than rebuilding
|
||||
# it: HA writes the state and the attributes together, so building it
|
||||
# here too doubled the cost of every write (#823).
|
||||
availability = self.extra_state_attributes.get("chore_availability", {})
|
||||
return sum(sum(1 for v in per_child.values() if v) for per_child in availability.values())
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
|
||||
@@ -524,13 +524,20 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
|
||||
def _image_url_or_blank(value):
|
||||
"""Accept a blank string (clears the picture) or one of our image URLs.
|
||||
|
||||
Returns the *canonical* URL: the panel is handed a signed one for its
|
||||
<img> and posts it back verbatim, so the signature is stripped here rather
|
||||
than persisted (#827).
|
||||
|
||||
A bare predicate can't be used as a voluptuous validator: voluptuous treats
|
||||
a callable as a coercer, so returning False would be a *value*, not a
|
||||
rejection. This raises instead.
|
||||
"""
|
||||
text = str(value or "")
|
||||
if not text or images.is_taskmate_image_url(text):
|
||||
if not text:
|
||||
return text
|
||||
canonical = images.normalize_taskmate_image_url(text)
|
||||
if canonical:
|
||||
return canonical
|
||||
raise vol.Invalid("image_url must be a TaskMate image URL")
|
||||
|
||||
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "Aufgaben werden bald zurückgesetzt!",
|
||||
"child.click_to_complete": "Klicken Sie hier, um den Vorgang abzuschließen",
|
||||
"child.click_to_undo": "Klicken Sie zum Rückgängigmachen",
|
||||
"child.claimed_by_another": "Bereits erledigt von {name}",
|
||||
"child.claimed_by_another_generic": "Heute bereits beansprucht",
|
||||
"child.difficulty_easy": "Leicht",
|
||||
"child.difficulty_medium": "Mittel",
|
||||
"child.difficulty_hard": "Schwer",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Anzeigen – normal zeigen (weiterhin nicht antippbar)",
|
||||
"child.editor.dependency_helper": "Was angezeigt wird, wenn eine Aufgabe von einer anderen abhängt, die heute noch nicht genehmigt ist",
|
||||
"child.blocked_by_dependency": "Zuerst {chores} erledigen",
|
||||
"child.parent": "Eltern",
|
||||
"child.editor.due_days_dim": "Gedimmt – ausgegraut anzeigen, nicht interaktiv",
|
||||
"child.editor.due_days_helper": "Gilt für Aufgaben, bei denen due_days festgelegt ist, wenn der heutige Tag nicht geplant ist",
|
||||
"child.editor.due_days_hide": "Ausblenden – nicht anzeigen",
|
||||
"child.editor.due_days_show": "Anzeigen – trotzdem normal anzeigen",
|
||||
"child.editor.first_come_dim": "Gedimmt – für alle außer dem Kind, das die Aufgabe erledigt hat, ausgegraut anzeigen",
|
||||
"child.editor.first_come_helper": "Was für den Rest der Gruppe angezeigt wird, nachdem eine Wer-zuerst-kommt-Aufgabe von einem Kind beansprucht wurde",
|
||||
"child.editor.first_come_hide": "Ausblenden – nach Beanspruchung entfernen (Standard, entspricht dem bisherigen Verhalten)",
|
||||
"child.editor.first_come_show": "Anzeigen – für alle vollständig sichtbar lassen (weiterhin nicht antippbar, sobald beansprucht)",
|
||||
"child.editor.first_come_when_completed": "Wer-zuerst-kommt-Aufgaben – nach Beanspruchung",
|
||||
"child.editor.elapsed_dim": "Gedimmt – ausgegraut anzeigen, nicht interaktiv",
|
||||
"child.editor.elapsed_helper": "Was ist zu zeigen, wenn eine Morgen-/Nachmittags-/Abend-/Nachtaufgabe nicht erledigt wurde und die Zeit abgelaufen ist?",
|
||||
"child.editor.elapsed_hide": "Ausblenden – nicht anzeigen",
|
||||
|
||||
@@ -210,6 +210,8 @@
|
||||
"child.chores_resetting_soon": "Chores resetting soon!",
|
||||
"child.click_to_complete": "Click to complete",
|
||||
"child.click_to_undo": "Click to undo",
|
||||
"child.claimed_by_another": "Already done by {name}",
|
||||
"child.claimed_by_another_generic": "Already claimed today",
|
||||
"child.difficulty_easy": "Easy",
|
||||
"child.difficulty_medium": "Medium",
|
||||
"child.difficulty_hard": "Hard",
|
||||
@@ -238,10 +240,16 @@
|
||||
"child.editor.dependency_show": "Show — show normally (still not tappable)",
|
||||
"child.editor.dependency_helper": "What to show when a chore depends on another that isn't approved yet today",
|
||||
"child.blocked_by_dependency": "Do {chores} first",
|
||||
"child.parent": "Parent",
|
||||
"child.editor.due_days_dim": "Dim — show greyed out, non-interactive",
|
||||
"child.editor.due_days_helper": "Applies to chores with due_days set when today isn't scheduled",
|
||||
"child.editor.due_days_hide": "Hide — don't show",
|
||||
"child.editor.due_days_show": "Show — show normally regardless",
|
||||
"child.editor.first_come_dim": "Dim — show greyed out for everyone except the child who completed it",
|
||||
"child.editor.first_come_helper": "What to show to the rest of the pool once a first-come chore has been claimed by one child",
|
||||
"child.editor.first_come_hide": "Hide — remove once claimed (default, matches prior behavior)",
|
||||
"child.editor.first_come_show": "Show — keep fully visible for everyone (still not tappable once claimed)",
|
||||
"child.editor.first_come_when_completed": "First-Come Chores — When Claimed",
|
||||
"child.editor.elapsed_dim": "Dim — show greyed out, non-interactive",
|
||||
"child.editor.elapsed_helper": "What to show when a morning/afternoon/evening/night chore wasn't completed and that time has passed",
|
||||
"child.editor.elapsed_hide": "Hide — don't show",
|
||||
|
||||
@@ -210,6 +210,8 @@
|
||||
"child.chores_resetting_soon": "Chores resetting soon!",
|
||||
"child.click_to_complete": "Click to complete",
|
||||
"child.click_to_undo": "Click to undo",
|
||||
"child.claimed_by_another": "Already done by {name}",
|
||||
"child.claimed_by_another_generic": "Already claimed today",
|
||||
"child.difficulty_easy": "Easy",
|
||||
"child.difficulty_medium": "Medium",
|
||||
"child.difficulty_hard": "Hard",
|
||||
@@ -238,10 +240,16 @@
|
||||
"child.editor.dependency_show": "Show — show normally (still not tappable)",
|
||||
"child.editor.dependency_helper": "What to show when a chore depends on another that isn't approved yet today",
|
||||
"child.blocked_by_dependency": "Do {chores} first",
|
||||
"child.parent": "Parent",
|
||||
"child.editor.due_days_dim": "Dim — show greyed out, non-interactive",
|
||||
"child.editor.due_days_helper": "Applies to chores with due_days set when today isn't scheduled",
|
||||
"child.editor.due_days_hide": "Hide — don't show",
|
||||
"child.editor.due_days_show": "Show — show normally regardless",
|
||||
"child.editor.first_come_dim": "Dim — show greyed out for everyone except the child who completed it",
|
||||
"child.editor.first_come_helper": "What to show to the rest of the pool once a first-come chore has been claimed by one child",
|
||||
"child.editor.first_come_hide": "Hide — remove once claimed (default, matches prior behavior)",
|
||||
"child.editor.first_come_show": "Show — keep fully visible for everyone (still not tappable once claimed)",
|
||||
"child.editor.first_come_when_completed": "First-Come Chores — When Claimed",
|
||||
"child.editor.elapsed_dim": "Dim — show greyed out, non-interactive",
|
||||
"child.editor.elapsed_helper": "What to show when a morning/afternoon/evening/night chore wasn't completed and that time has passed",
|
||||
"child.editor.elapsed_hide": "Hide — don't show",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "Réinitialisation des tâches bientôt !",
|
||||
"child.click_to_complete": "Cliquez pour terminer",
|
||||
"child.click_to_undo": "Cliquez pour annuler",
|
||||
"child.claimed_by_another": "Déjà fait par {name}",
|
||||
"child.claimed_by_another_generic": "Déjà prise aujourd'hui",
|
||||
"child.difficulty_easy": "Facile",
|
||||
"child.difficulty_medium": "Moyen",
|
||||
"child.difficulty_hard": "Difficile",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Afficher — affichage normal (toujours non cliquable)",
|
||||
"child.editor.dependency_helper": "Ce qu'il faut afficher quand une tâche dépend d'une autre non encore approuvée aujourd'hui",
|
||||
"child.blocked_by_dependency": "Fais d'abord {chores}",
|
||||
"child.parent": "Parent",
|
||||
"child.editor.due_days_dim": "Estomper — afficher en gris, non interactif",
|
||||
"child.editor.due_days_helper": "S'applique aux tâches avec des jours de passage définis quand aujourd'hui n'est pas programmé",
|
||||
"child.editor.due_days_hide": "Masquer — ne pas afficher",
|
||||
"child.editor.due_days_show": "Afficher — afficher normalement malgré tout",
|
||||
"child.editor.first_come_dim": "Estomper — afficher en gris pour tout le monde sauf l'enfant qui a terminé la tâche",
|
||||
"child.editor.first_come_helper": "Ce qu'il faut montrer au reste du groupe une fois qu'une tâche de type premier arrivé a été prise par un enfant",
|
||||
"child.editor.first_come_hide": "Masquer — retirer une fois prise (par défaut, comportement identique à avant)",
|
||||
"child.editor.first_come_show": "Afficher — rester entièrement visible pour tous (toujours non cliquable une fois prise)",
|
||||
"child.editor.first_come_when_completed": "Tâches premier arrivé — une fois prises",
|
||||
"child.editor.elapsed_dim": "Estomper — afficher en gris, non interactif",
|
||||
"child.editor.elapsed_helper": "Que montrer lorsqu'une tâche du matin/après-midi/soir/nuit n'a pas été accomplie et que le moment est passé",
|
||||
"child.editor.elapsed_hide": "Masquer — ne pas afficher",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "Oppgavene nullstilles snart!",
|
||||
"child.click_to_complete": "Klikk for å fullføre",
|
||||
"child.click_to_undo": "Klikk for å angre",
|
||||
"child.claimed_by_another": "Allerede gjort av {name}",
|
||||
"child.claimed_by_another_generic": "Allerede tatt i dag",
|
||||
"child.difficulty_easy": "Lett",
|
||||
"child.difficulty_medium": "Middels",
|
||||
"child.difficulty_hard": "Vanskelig",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Vis — vis normalt (fortsatt ikke trykkbar)",
|
||||
"child.editor.dependency_helper": "Hva som vises når en oppgave avhenger av en annen som ikke er godkjent i dag ennå",
|
||||
"child.blocked_by_dependency": "Gjør {chores} først",
|
||||
"child.parent": "Forelder",
|
||||
"child.editor.due_days_dim": "Dim — vis gråtonet, ikke-interaktiv",
|
||||
"child.editor.due_days_helper": "Gjelder oppgaver med satte dager når i dag ikke er planlagt",
|
||||
"child.editor.due_days_hide": "Skjul — ikke vis",
|
||||
"child.editor.due_days_show": "Vis — vis normalt uansett",
|
||||
"child.editor.first_come_dim": "Dim — vis gråtonet for alle unntatt barnet som fullførte oppgaven",
|
||||
"child.editor.first_come_helper": "Hva som vises for resten av gruppen når en førstemann-oppgave er tatt av ett barn",
|
||||
"child.editor.first_come_hide": "Skjul — fjern når den er tatt (standard, samme som tidligere oppførsel)",
|
||||
"child.editor.first_come_show": "Vis — hold den fullt synlig for alle (fortsatt ikke trykkbar når den er tatt)",
|
||||
"child.editor.first_come_when_completed": "Førstemann-oppgaver — når tatt",
|
||||
"child.editor.elapsed_dim": "Dim — vis gråtonet, ikke-interaktiv",
|
||||
"child.editor.elapsed_helper": "Hva som vises når en morgen/ettermiddag/kveld/natt-oppgave ikke ble fullført og tiden har gått",
|
||||
"child.editor.elapsed_hide": "Skjul — ikke vis",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "Oppgåvene nullstillast snart!",
|
||||
"child.click_to_complete": "Klikk for å fullføre",
|
||||
"child.click_to_undo": "Klikk for å angre",
|
||||
"child.claimed_by_another": "Allereie gjort av {name}",
|
||||
"child.claimed_by_another_generic": "Allereie teken i dag",
|
||||
"child.difficulty_easy": "Lett",
|
||||
"child.difficulty_medium": "Middels",
|
||||
"child.difficulty_hard": "Vanskeleg",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Vis — vis normalt (framleis ikkje trykkbar)",
|
||||
"child.editor.dependency_helper": "Kva som blir vist når ei oppgåve avheng av ei anna som ikkje er godkjend i dag enno",
|
||||
"child.blocked_by_dependency": "Gjer {chores} først",
|
||||
"child.parent": "Forelder",
|
||||
"child.editor.due_days_dim": "Dim — vis gråtona, ikkje-interaktiv",
|
||||
"child.editor.due_days_helper": "Gjeld oppgåver med sette dagar når i dag ikkje er planlagt",
|
||||
"child.editor.due_days_hide": "Gøym — ikkje vis",
|
||||
"child.editor.due_days_show": "Vis — vis normalt uansett",
|
||||
"child.editor.first_come_dim": "Dim — vis gråtona for alle unnateke barnet som fullførte oppgåva",
|
||||
"child.editor.first_come_helper": "Kva som visast for resten av gruppa når ei førstemann-oppgåve er teken av eitt barn",
|
||||
"child.editor.first_come_hide": "Gøym — fjern når ho er teken (standard, same som tidlegare åtferd)",
|
||||
"child.editor.first_come_show": "Vis — hald ho fullt synleg for alle (framleis ikkje trykkbar når ho er teken)",
|
||||
"child.editor.first_come_when_completed": "Førstemann-oppgåver — når teken",
|
||||
"child.editor.elapsed_dim": "Dim — vis gråtona, ikkje-interaktiv",
|
||||
"child.editor.elapsed_helper": "Kva som visast når ei morgon/ettermiddag/kveld/natt-oppgåve ikkje vart fullført og tida har gått",
|
||||
"child.editor.elapsed_hide": "Gøym — ikkje vis",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "As tarefas reiniciam em breve!",
|
||||
"child.click_to_complete": "Clica para concluir",
|
||||
"child.click_to_undo": "Clica para desfazer",
|
||||
"child.claimed_by_another": "Já feita por {name}",
|
||||
"child.claimed_by_another_generic": "Já reivindicada hoje",
|
||||
"child.difficulty_easy": "Fácil",
|
||||
"child.difficulty_medium": "Médio",
|
||||
"child.difficulty_hard": "Difícil",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Mostrar — mostrar normalmente (continua sem toque)",
|
||||
"child.editor.dependency_helper": "O que mostrar quando uma tarefa depende de outra ainda não aprovada hoje",
|
||||
"child.blocked_by_dependency": "Faça primeiro {chores}",
|
||||
"child.parent": "Responsável",
|
||||
"child.editor.due_days_dim": "Atenuar — mostrar em cinzento, sem interação",
|
||||
"child.editor.due_days_helper": "Aplica-se a tarefas com due_days definido quando hoje não está agendado",
|
||||
"child.editor.due_days_hide": "Ocultar — não mostrar",
|
||||
"child.editor.due_days_show": "Mostrar — mostrar normalmente",
|
||||
"child.editor.first_come_dim": "Atenuar — mostrar em cinza para todos, exceto a criança que concluiu a tarefa",
|
||||
"child.editor.first_come_helper": "O que mostrar para o resto do grupo depois que uma tarefa do tipo quem chega primeiro for reivindicada por uma criança",
|
||||
"child.editor.first_come_hide": "Ocultar — remover assim que reivindicada (padrão, igual ao comportamento anterior)",
|
||||
"child.editor.first_come_show": "Mostrar — manter totalmente visível para todos (continua sem toque depois de reivindicada)",
|
||||
"child.editor.first_come_when_completed": "Tarefas Quem Chega Primeiro — Quando Reivindicadas",
|
||||
"child.editor.elapsed_dim": "Atenuar — mostrar em cinzento, sem interação",
|
||||
"child.editor.elapsed_helper": "O que mostrar quando uma tarefa da manhã/tarde/noite/madrugada não foi concluída e o horário já passou",
|
||||
"child.editor.elapsed_hide": "Ocultar — não mostrar",
|
||||
|
||||
@@ -208,6 +208,8 @@
|
||||
"child.chores_resetting_soon": "As tarefas reiniciam em breve!",
|
||||
"child.click_to_complete": "Clica para concluir",
|
||||
"child.click_to_undo": "Clica para desfazer",
|
||||
"child.claimed_by_another": "Já feita por {name}",
|
||||
"child.claimed_by_another_generic": "Já reclamada hoje",
|
||||
"child.difficulty_easy": "Fácil",
|
||||
"child.difficulty_medium": "Médio",
|
||||
"child.difficulty_hard": "Difícil",
|
||||
@@ -236,10 +238,16 @@
|
||||
"child.editor.dependency_show": "Mostrar — mostrar normalmente (continua sem toque)",
|
||||
"child.editor.dependency_helper": "O que mostrar quando uma tarefa depende de outra ainda não aprovada hoje",
|
||||
"child.blocked_by_dependency": "Faz primeiro {chores}",
|
||||
"child.parent": "Responsável",
|
||||
"child.editor.due_days_dim": "Atenuar — mostrar em cinzento, sem interação",
|
||||
"child.editor.due_days_helper": "Aplica-se a tarefas com due_days definido quando hoje não está agendado",
|
||||
"child.editor.due_days_hide": "Ocultar — não mostrar",
|
||||
"child.editor.due_days_show": "Mostrar — mostrar normalmente",
|
||||
"child.editor.first_come_dim": "Atenuar — mostrar em cinzento para todos exceto a criança que concluiu a tarefa",
|
||||
"child.editor.first_come_helper": "O que mostrar ao resto do grupo depois de uma tarefa do tipo quem chega primeiro ser reclamada por uma criança",
|
||||
"child.editor.first_come_hide": "Ocultar — remover assim que reclamada (predefinição, igual ao comportamento anterior)",
|
||||
"child.editor.first_come_show": "Mostrar — manter totalmente visível para todos (continua sem toque depois de reclamada)",
|
||||
"child.editor.first_come_when_completed": "Tarefas Quem Chega Primeiro — Quando Reclamadas",
|
||||
"child.editor.elapsed_dim": "Atenuar — mostrar em cinzento, sem interação",
|
||||
"child.editor.elapsed_helper": "O que mostrar quando uma tarefa da manhã/tarde/noite/madrugada não foi concluída e o horário já passou",
|
||||
"child.editor.elapsed_hide": "Ocultar — não mostrar",
|
||||
|
||||
@@ -1118,6 +1118,29 @@ class TaskMateChildCard extends LitElement {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* First-come chore claimed by a sibling today — dimmed/greyed, same
|
||||
treatment as recurrence-unavailable (first_come_claimed_mode: dim) */
|
||||
.chore-card.first-come-unavailable {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.first-come-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
background: color-mix(in srgb, var(--primary-text-color, #212121) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.first-come-label ha-icon { --mdc-icon-size: 12px; }
|
||||
|
||||
/* Locked preview — chore is visible but not yet claimable because its
|
||||
time-of-day window has not started. Dim with a padlock badge. */
|
||||
.chore-card.chore-locked {
|
||||
@@ -2593,9 +2616,18 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
|
||||
_isChoreDone(chore, child, todaysCompletions) {
|
||||
// async_parent_complete_chore writes child_id="__parent__" to dismiss a
|
||||
// chore for the WHOLE pool with zero points — it never credits a specific
|
||||
// child. Every other assignment mode treats that as "done for whoever is
|
||||
// looking", which is the intended behavior. first_come is exclusive: a
|
||||
// pool-wide dismissal means nobody actually completed it, so it must not
|
||||
// read as this child's own done state (green row + live undo chip) for
|
||||
// anyone — it should fall through to the locked/dimmed row instead
|
||||
// (chore._isFirstComeLocked, set in _filterAndSortChores).
|
||||
const isFirstCome = chore.assignment_mode === "first_come";
|
||||
const childCompletionsToday = (todaysCompletions || []).filter(
|
||||
(comp) => comp.chore_id === chore.id
|
||||
&& (comp.child_id === child.id || comp.child_id === "__parent__")
|
||||
&& (comp.child_id === child.id || (!isFirstCome && comp.child_id === "__parent__"))
|
||||
&& !comp.bonus_subtask_id
|
||||
);
|
||||
let count = childCompletionsToday.length;
|
||||
@@ -2608,7 +2640,11 @@ class TaskMateChildCard extends LitElement {
|
||||
if (!actualTs.some(a => Math.abs(a - t) < 2000)) count += 1;
|
||||
}
|
||||
}
|
||||
const done = count >= (chore.daily_limit || 1);
|
||||
// first_come is a single-winner race whatever quota is stored on the chore —
|
||||
// the filter and the backend both clamp it to 1 (coord_assignments). Reading
|
||||
// the raw daily_limit here would leave the winner's own row looking incomplete
|
||||
// and tappable, and complete_chore would then silently no-op the tap (#805).
|
||||
const done = count >= (isFirstCome ? 1 : (chore.daily_limit || 1));
|
||||
return { done, completions: childCompletionsToday };
|
||||
}
|
||||
|
||||
@@ -2651,6 +2687,7 @@ class TaskMateChildCard extends LitElement {
|
||||
const elapsedTimeMode = this.config.elapsed_time_mode || "dim";
|
||||
const dependencyMode = this.config.dependency_mode || "hide";
|
||||
const recurrenceDoneMode = this.config.recurrence_done_mode || "dim";
|
||||
const firstComeClaimedMode = this.config.first_come_claimed_mode || "hide";
|
||||
|
||||
// Annotate each chore with done-state + handlers, preserving the classic path's logic.
|
||||
const rows = childChores.map((chore, i) => {
|
||||
@@ -2662,8 +2699,12 @@ class TaskMateChildCard extends LitElement {
|
||||
// Recurring chore still inside its cooldown. Excluded when done so the
|
||||
// undo chip stays live, exactly as on the classic row.
|
||||
const recLocked = !!chore._isRecurrenceLocked && !done;
|
||||
// First-come chore claimed by a different pool member (first_come_claimed_mode
|
||||
// dim/show only — hide is filtered upstream same as recurrence). Never true
|
||||
// for the winner's own row: _isFirstComeLocked is only set on siblings.
|
||||
const firstComeLocked = !!chore._isFirstComeLocked && !done;
|
||||
const onAct = () => {
|
||||
if (loading || blocked || recLocked) return;
|
||||
if (loading || blocked || recLocked || firstComeLocked) return;
|
||||
if (done) this._handleUndo(chore, child, completions);
|
||||
else this._handleComplete(chore, child);
|
||||
};
|
||||
@@ -2675,10 +2716,11 @@ class TaskMateChildCard extends LitElement {
|
||||
(chore._isTimeElapsed && elapsedTimeMode === "dim") ||
|
||||
(blocked && dependencyMode === "dim") ||
|
||||
(chore._isRecurrenceLocked && recurrenceDoneMode === "dim") ||
|
||||
(chore._isFirstComeLocked && firstComeClaimedMode === "dim") ||
|
||||
chore._isLockedPreview === true
|
||||
);
|
||||
return {
|
||||
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked,
|
||||
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked,
|
||||
tone: this._designTone(i),
|
||||
glyph: this._choreGlyph(chore),
|
||||
points: chore.effective_points ?? chore.points,
|
||||
@@ -2816,13 +2858,21 @@ class TaskMateChildCard extends LitElement {
|
||||
const recLabel = r.recLocked
|
||||
? (r.chore.recurrence ? r.chore.recurrence.replace(/_/g, " ") : this._t("child.recurring"))
|
||||
: "";
|
||||
// Same idea as recLabel, but for a first-come chore a sibling already
|
||||
// claimed today — name it when we know who, otherwise a generic label.
|
||||
const firstComeLabel = r.firstComeLocked
|
||||
? (r.chore._firstComeCompletedByName
|
||||
? this._t("child.claimed_by_another", { name: r.chore._firstComeCompletedByName })
|
||||
: this._t("child.claimed_by_another_generic"))
|
||||
: "";
|
||||
return html`
|
||||
${r.mandatory || r.photo || depNames.length || recLabel ? html`
|
||||
${r.mandatory || r.photo || depNames.length || recLabel || firstComeLabel ? html`
|
||||
<div class="tmd-meta">
|
||||
${r.mandatory ? html`<span class="tmd-tag mandatory">⚠ ${this._t("child.mandatory")}</span>` : ""}
|
||||
${r.photo ? html`<span class="tmd-tag photo">📷 ${this._t("child.photo_needed")}</span>` : ""}
|
||||
${depNames.length ? html`<span class="tmd-tag">🔒 ${this._t("child.blocked_by_dependency", { chores: depNames.join(", ") })}</span>` : ""}
|
||||
${recLabel ? html`<span class="tmd-tag">🕒 ${recLabel}</span>` : ""}
|
||||
${firstComeLabel ? html`<span class="tmd-tag">✅ ${firstComeLabel}</span>` : ""}
|
||||
</div>` : ""}
|
||||
${showDesc ? html`<div class="tmd-desc">${r.chore.description}</div>` : ""}`;
|
||||
}
|
||||
@@ -2838,7 +2888,7 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
_designDoneBtn(r, label, cls) {
|
||||
return html`<button class="btn ${cls || ""}"
|
||||
?disabled=${r.loading || r.blocked || r.recLocked}
|
||||
?disabled=${r.loading || r.blocked || r.recLocked || r.firstComeLocked}
|
||||
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
|
||||
}
|
||||
|
||||
@@ -3009,6 +3059,7 @@ class TaskMateChildCard extends LitElement {
|
||||
// as unavailable, so hiding is what matches it (#793).
|
||||
const dependencyMode = this.config.dependency_mode || 'hide';
|
||||
const recurrenceDoneMode = this.config.recurrence_done_mode || 'dim';
|
||||
const firstComeClaimedMode = this.config.first_come_claimed_mode || 'hide';
|
||||
const allCompletionsToday = attrs.todays_completions || [];
|
||||
// Availability comes from the backend's own matrix (#803). The card cannot
|
||||
// work a recurrence window out for itself: last_completed is not exposed,
|
||||
@@ -3083,7 +3134,35 @@ class TaskMateChildCard extends LitElement {
|
||||
);
|
||||
const hasPendingBonus = bonusSubtasks.some(b => b && b.id && !completedBonusIds.has(b.id));
|
||||
if (!hasPendingBonus) {
|
||||
isAssignedToChild = false;
|
||||
// first_come_claimed_mode (#first-come-visibility): by default a
|
||||
// claimed first-come chore disappears for the whole pool,
|
||||
// winner included — unchanged from prior behaviour. Under
|
||||
// dim/show it stays in the list instead: the winner's own row
|
||||
// still renders as done via _isChoreDone below, and every
|
||||
// other pool member is flagged locked so the render layer can
|
||||
// grey it out and refuse taps (the backend rejects the
|
||||
// completion anyway).
|
||||
if (assignmentMode === 'first_come' && firstComeClaimedMode !== 'hide') {
|
||||
const completedByThisChild = allTodayCompletions.some(
|
||||
(comp) => comp.chore_id === chore.id
|
||||
&& String(comp.child_id) === childId
|
||||
&& !comp.bonus_subtask_id
|
||||
);
|
||||
chore._isFirstComeLocked = !completedByThisChild;
|
||||
if (chore._isFirstComeLocked) {
|
||||
const winningCompletion = allTodayCompletions.find(
|
||||
(comp) => comp.chore_id === chore.id && !comp.bonus_subtask_id
|
||||
);
|
||||
const winner = winningCompletion
|
||||
? (attrs.children || []).find(c => String(c.id) === String(winningCompletion.child_id))
|
||||
: null;
|
||||
chore._firstComeCompletedByName = winner
|
||||
? winner.name
|
||||
: (winningCompletion && winningCompletion.child_id === '__parent__' ? this._t('child.parent') : '');
|
||||
}
|
||||
} else {
|
||||
isAssignedToChild = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3678,12 +3757,23 @@ class TaskMateChildCard extends LitElement {
|
||||
const isCelebrating = this._celebrating === chore.id;
|
||||
|
||||
// Check how many times this chore was completed today by this child
|
||||
// Both pending (awaiting approval) AND approved completions count toward the daily limit
|
||||
// Both pending (awaiting approval) AND approved completions count toward the daily limit.
|
||||
// Exception: async_parent_complete_chore's child_id="__parent__" dismisses a
|
||||
// first_come chore for the whole pool with zero points and no winner — it must
|
||||
// not read as THIS child's own done state (green row + live undo chip) for
|
||||
// anyone, or every sibling gets a completed chip for a chore none of them did.
|
||||
// It should fall through to the locked/dimmed row instead (chore._isFirstComeLocked
|
||||
// below). Every other assignment mode keeps the existing __parent__-counts-for-
|
||||
// everyone behavior, which is intentional there.
|
||||
const isFirstCome = chore.assignment_mode === "first_come";
|
||||
const childCompletionsToday = todaysCompletions.filter(
|
||||
(comp) => comp.chore_id === chore.id && (comp.child_id === child.id || comp.child_id === "__parent__") && !comp.bonus_subtask_id
|
||||
(comp) => comp.chore_id === chore.id && (comp.child_id === child.id || (!isFirstCome && comp.child_id === "__parent__")) && !comp.bonus_subtask_id
|
||||
);
|
||||
let completionsToday = childCompletionsToday.length;
|
||||
const dailyLimit = chore.daily_limit || 1;
|
||||
// Clamped for first_come the same way the filter and the backend clamp it: a
|
||||
// stored daily_limit > 1 must not make the winner's finished row read as still
|
||||
// outstanding, or it invites a tap that complete_chore silently swallows (#805).
|
||||
const dailyLimit = isFirstCome ? 1 : (chore.daily_limit || 1);
|
||||
|
||||
// Check for optimistic completions (chores just completed but not yet confirmed by HA)
|
||||
const optimisticKey = `${chore.id}_${child.id}`;
|
||||
@@ -3730,6 +3820,12 @@ class TaskMateChildCard extends LitElement {
|
||||
// mode-gated. Same split dependency_mode uses.
|
||||
const recurrenceLocked = !!chore._isRecurrenceLocked && !isCompletedForToday;
|
||||
const notAvailableRecurrence = recurrenceLocked && recurrenceDoneMode === 'dim';
|
||||
// First-come chore claimed by a sibling today (first_come_claimed_mode
|
||||
// dim/show — hide is filtered upstream). Never true on the winner's own
|
||||
// row: _isFirstComeLocked is only set for the other pool members.
|
||||
const firstComeClaimedMode = this.config.first_come_claimed_mode || 'hide';
|
||||
const firstComeLocked = !!chore._isFirstComeLocked && !isCompletedForToday;
|
||||
const notAvailableFirstCome = firstComeLocked && firstComeClaimedMode === 'dim';
|
||||
// Elapsed: only dim incomplete chores — completed ones keep their green "done" style
|
||||
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
|
||||
const timeElapsed = chore._isTimeElapsed && !isCompletedForToday && elapsedTimeMode === 'dim';
|
||||
@@ -3752,13 +3848,14 @@ class TaskMateChildCard extends LitElement {
|
||||
if (isLockedPreview) return; // Next-period preview — not yet claimable
|
||||
if (depBlocked) return; // Prerequisite chore not approved yet
|
||||
if (timeElapsed) return; // Time period passed — not interactive
|
||||
if (firstComeLocked) return; // Claimed by another child today — not interactive
|
||||
if (isCompletedForToday) {
|
||||
this._handleUndo(chore, child, childCompletionsToday);
|
||||
} else {
|
||||
this._handleComplete(chore, child);
|
||||
}
|
||||
};
|
||||
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed);
|
||||
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed || firstComeLocked);
|
||||
const handleRowKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -3774,13 +3871,17 @@ class TaskMateChildCard extends LitElement {
|
||||
? this._t('child.not_scheduled_for_today')
|
||||
: timeElapsed
|
||||
? this._t('child.time_has_passed')
|
||||
: firstComeLocked
|
||||
? (chore._firstComeCompletedByName
|
||||
? this._t('child.claimed_by_another', { name: chore._firstComeCompletedByName })
|
||||
: this._t('child.claimed_by_another_generic'))
|
||||
: isCompletedForToday
|
||||
? this._t('child.click_to_undo')
|
||||
: this._t('child.click_to_complete');
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""} ${depDimmed ? "dependency-blocked" : ""}"
|
||||
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${notAvailableFirstCome ? "first-come-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""} ${depDimmed ? "dependency-blocked" : ""}"
|
||||
role="button"
|
||||
tabindex="${isInteractive ? '0' : '-1'}"
|
||||
aria-disabled="${isInteractive ? 'false' : 'true'}"
|
||||
@@ -3812,6 +3913,14 @@ class TaskMateChildCard extends LitElement {
|
||||
${chore.recurrence ? chore.recurrence.replace(/_/g, ' ') : this._t('child.recurring')}
|
||||
</div>
|
||||
` : ''}
|
||||
${firstComeLocked ? html`
|
||||
<div class="first-come-label">
|
||||
<ha-icon icon="mdi:check-circle-outline"></ha-icon>
|
||||
${chore._firstComeCompletedByName
|
||||
? this._t('child.claimed_by_another', { name: chore._firstComeCompletedByName })
|
||||
: this._t('child.claimed_by_another_generic')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="chore-points">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
+${chore.effective_points ?? chore.points}
|
||||
@@ -4829,6 +4938,19 @@ class TaskMateChildCardEditor extends LitElement {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'first_come_claimed_mode',
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{ value: 'hide', label: this._t('child.editor.first_come_hide') },
|
||||
{ value: 'dim', label: this._t('child.editor.first_come_dim') },
|
||||
{ value: 'show', label: this._t('child.editor.first_come_show') },
|
||||
],
|
||||
mode: 'dropdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dependency_mode',
|
||||
selector: {
|
||||
@@ -4872,6 +4994,7 @@ class TaskMateChildCardEditor extends LitElement {
|
||||
card_design: this._t('common.design.field_label'),
|
||||
due_days_mode: this._t('child.editor.chores_not_due_today'),
|
||||
recurrence_done_mode: this._t('child.editor.recurring_when_completed'),
|
||||
first_come_claimed_mode: this._t('child.editor.first_come_when_completed'),
|
||||
dependency_mode: this._t('child.editor.dependency_mode'),
|
||||
elapsed_time_mode: this._t('child.editor.missed_time_chores'),
|
||||
show_countdown: this._t('child.editor.show_countdown'),
|
||||
@@ -4891,6 +5014,7 @@ class TaskMateChildCardEditor extends LitElement {
|
||||
time_category: this._t('child.editor.time_category_helper'),
|
||||
due_days_mode: this._t('child.editor.due_days_helper'),
|
||||
recurrence_done_mode: this._t('child.editor.recurrence_helper'),
|
||||
first_come_claimed_mode: this._t('child.editor.first_come_helper'),
|
||||
dependency_mode: this._t('child.editor.dependency_helper'),
|
||||
elapsed_time_mode: this._t('child.editor.elapsed_helper'),
|
||||
};
|
||||
@@ -4907,6 +5031,7 @@ class TaskMateChildCardEditor extends LitElement {
|
||||
card_design: this.config.card_design || 'global',
|
||||
due_days_mode: this.config.due_days_mode || 'hide',
|
||||
recurrence_done_mode: this.config.recurrence_done_mode || 'dim',
|
||||
first_come_claimed_mode: this.config.first_come_claimed_mode || 'hide',
|
||||
dependency_mode: this.config.dependency_mode || 'hide',
|
||||
elapsed_time_mode: this.config.elapsed_time_mode || 'dim',
|
||||
show_countdown: this.config.show_countdown !== false,
|
||||
@@ -4953,6 +5078,7 @@ class TaskMateChildCardEditor extends LitElement {
|
||||
show_countdown: true, show_description: false, debug: false,
|
||||
time_category: 'morning', due_days_mode: 'hide',
|
||||
recurrence_done_mode: 'dim', elapsed_time_mode: 'dim',
|
||||
first_come_claimed_mode: 'hide',
|
||||
};
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
delete newConfig[key];
|
||||
|
||||
Reference in New Issue
Block a user