This commit is contained in:
Home Assistant Version Control
2026-09-25 04:10:30 +00:00
parent 56c80f896e
commit ad634c1b84
60 changed files with 2895 additions and 794 deletions
+18 -12
View File
@@ -194,9 +194,8 @@ class ClaimRewardButton(TaskMateBaseButton):
Pool-mode pending claims are skipped because their cost was already
deducted from child.points at allocation time.
"""
pending_claims = self.coordinator.data.get("pending_reward_claims", [])
committed = 0
for c in pending_claims:
for c in self.coordinator.storage.get_pending_reward_claims():
if c.child_id == child_id and not self.coordinator.is_pool_mode_claim(c):
reward = self.coordinator.get_reward(c.reward_id)
if reward:
@@ -205,15 +204,19 @@ class ClaimRewardButton(TaskMateBaseButton):
@property
def available(self) -> bool:
"""Return if button is available (accounting for committed points)."""
child = self.coordinator.get_child(self.child_id)
reward = self.coordinator.get_reward(self.reward_id)
"""Offer the button exactly when claiming would be allowed.
if not child or not reward:
Deciding this on the wallet balance alone disagreed with the claim
itself in both directions: a jackpot or a filled savings jar is not
paid from the wallet, and a sold-out, expired, time-locked or
already-claimed reward cannot be claimed however many points a child
has. One rule, asked the same way by both.
"""
try:
self.coordinator.validate_reward_claim(self.reward_id, self.child_id)
except ValueError:
return False
available_points = child.points - self._get_committed_points(child.id)
return available_points >= reward.cost
return True
@property
def extra_state_attributes(self) -> dict:
@@ -226,7 +229,10 @@ class ClaimRewardButton(TaskMateBaseButton):
committed = self._get_committed_points(child.id)
available_points = child.points - committed
can_afford = available_points >= reward.cost
# A jackpot is paid from the shared pool and an ordinary reward may be
# paid from a filled savings jar, so affordability is not a question
# about the wallet.
funding = self.coordinator.reward_claim_funding(reward, child.id)
return {
"child_id": child.id,
@@ -237,8 +243,8 @@ class ClaimRewardButton(TaskMateBaseButton):
"child_points": child.points,
"committed_points": committed,
"available_points": available_points,
"can_afford": can_afford,
"points_needed": max(0, reward.cost - available_points),
"can_afford": funding >= reward.cost,
"points_needed": max(0, reward.cost - funding),
}
async def async_press(self) -> None:
+60 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from calendar import monthrange
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
@@ -833,6 +833,18 @@ class ChoresMixin:
)
return None
# Weekly target (#883): the same cap one level up. Parents completing on
# behalf are bound by it too — the quota is the point of the chore, and
# the daily limit above already treats them the same way.
if self.weekly_target_met(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: weekly target reached for '%s' (%d/%d this week)",
chore.name,
self.weekly_completion_count(chore_id, child_id),
getattr(chore, "weekly_target", 0),
)
return None
# A photo-required chore always goes through parent approval (unless a
# parent is completing on behalf), so the evidence gets reviewed.
requires_photo = bool(getattr(chore, "require_photo", False))
@@ -869,6 +881,7 @@ class ChoresMixin:
completed_at=now,
approved=auto_approve,
points_awarded=effective_points if auto_approve else 0,
submitted_points=effective_points,
photo_url=photo_url or "",
note=note,
suggested_points=suggested_points,
@@ -972,6 +985,7 @@ class ChoresMixin:
approved=True,
approved_at=now,
points_awarded=0,
submitted_points=0,
)
self.storage.add_completion(completion)
@@ -1058,6 +1072,7 @@ class ChoresMixin:
completed_at=now,
approved=not chore.requires_approval,
points_awarded=subtask.points if not chore.requires_approval else 0,
submitted_points=subtask.points,
bonus_subtask_id=bonus_subtask_id,
)
@@ -1093,8 +1108,9 @@ class ChoresMixin:
worth (#832) — needed for open-ended submissions, useful for any chore.
It replaces the chore's *base* points, so the streak and level
multipliers ``_award_points`` applies still ride on top exactly as they
would for an ordinary approval. ``None`` means "use the chore's value"
and leaves the existing behaviour untouched.
would for an ordinary approval. ``None`` means "pay what the submission
was worth when it was made", falling back to the chore's value for
completions recorded before that was stored.
"""
completions = self.storage.get_completions()
for completion in completions:
@@ -1111,7 +1127,15 @@ class ChoresMixin:
if chore and child:
comp_date = dt_util.as_local(completion.completed_at).date()
is_bonus = bool(completion.bonus_subtask_id)
if is_bonus:
if completion.submitted_points is not None:
# Pay what the child was promised when they submitted.
# Recalculating here reads the chore's *current* points
# — so an edit made while the work sat in the queue
# changed the price after the fact — and silently drops
# the speed bonus and roulette multiplier that were in
# the figure they saw.
pts = completion.submitted_points
elif is_bonus:
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
pts = subtask.points if subtask else 0
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
@@ -1601,6 +1625,34 @@ class ChoresMixin:
days_since = (today - last_dt).days
return days_since >= window_days
def weekly_completion_count(self, chore_id: str, child_id: str) -> int:
"""How many times ``child_id`` has completed ``chore_id`` this week.
Monday-anchored, matching the Challenges period convention. Bonus
sub-tasks don't count; pending completions do, so a chore can't be
submitted a fourth time just because the parent hasn't got round to
approving the third yet.
"""
today = dt_util.as_local(dt_util.now()).date()
week_start = today - timedelta(days=today.weekday())
count = 0
for comp in self._cached_completions_for_chore(chore_id):
if comp.child_id != child_id or getattr(comp, "bonus_subtask_id", ""):
continue
try:
if dt_util.as_local(comp.completed_at).date() >= week_start:
count += 1
except (AttributeError, TypeError, ValueError):
continue
return count
def weekly_target_met(self, chore, child_id: str) -> bool:
"""True when a weekly-target chore has had its quota filled this week."""
target = int(getattr(chore, "weekly_target", 0) or 0)
if target <= 0:
return False
return self.weekly_completion_count(chore.id, child_id) >= target
def _is_chore_completable_by_child(self, chore, child_id: str) -> bool:
"""Whether ``child_id`` may complete ``chore`` right now (card parity).
@@ -1657,6 +1709,10 @@ class ChoresMixin:
continue
if done >= limit:
continue
# Weekly target (#883): filled the week's quota, so nothing is
# owed until Monday even on a day the chore is scheduled.
if self.weekly_target_met(chore, child_id):
continue
out.append(chore)
return out
@@ -119,6 +119,11 @@ class MandatoryMixin:
continue
if self._child_completed_today(chore.id, child_id, day):
continue
# Weekly target (#883): the week's quota is already filled, so
# nothing is outstanding — even though the chore is still
# "scheduled" every day.
if self.weekly_target_met(chore, child_id):
continue
if (chore.id, child_id, day.isoformat()) in existing:
continue
miss = MandatoryMiss(
@@ -538,27 +538,42 @@ class NotificationCoordinator:
return
if action.startswith("TASKMATE_APPROVE_"):
entry_id = action[len("TASKMATE_APPROVE_") :]
try:
await coordinator.async_approve_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_approve_reward(entry_id)
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
await self._review_from_mobile(
action,
action[len("TASKMATE_APPROVE_") :],
coordinator.async_approve_chore,
coordinator.async_approve_reward,
)
elif action.startswith("TASKMATE_REJECT_"):
entry_id = action[len("TASKMATE_REJECT_") :]
try:
await coordinator.async_reject_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_reject_reward(entry_id)
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
await self._review_from_mobile(
action,
action[len("TASKMATE_REJECT_") :],
coordinator.async_reject_chore,
coordinator.async_reject_reward,
)
async def _review_from_mobile(self, action: str, entry_id: str, review_chore, review_reward) -> None:
"""Send a mobile review to whichever record the id belongs to.
A chore completion id and a reward claim id look alike, so this used to
try the chore path and fall back to the reward path when it raised. It
never does: approving or rejecting an unknown completion is a no-op,
not an error, so the fallback was unreachable and every reward push
button did nothing at all. The id itself decides.
"""
if any(c.id == entry_id for c in self.storage.get_completions()):
review = review_chore
elif any(c.id == entry_id for c in self.storage.get_reward_claims()):
review = review_reward
else:
_LOGGER.info("Mobile action %s — entry not found", action)
return
try:
await review(entry_id)
except (ValueError, KeyError) as err:
# A stale push: the item was reviewed elsewhere, or the reward has
# since sold out. Nothing to do, but say why in the log.
_LOGGER.info("Mobile action %s could not be applied: %s", action, err)
# ------------------------------------------------------------------
# Scheduler — time-gated callbacks
+13 -8
View File
@@ -681,6 +681,11 @@ class PointsMixin:
continue
if child_id not in self._compute_active_children(chore, day):
continue
# Weekly target (#883): the child picks the days, so "every chore
# due today" can't include it — it's owed by Sunday, not by any one
# evening.
if int(getattr(chore, "weekly_target", 0) or 0) > 0:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
out.add(chore.id)
@@ -763,14 +768,14 @@ class PointsMixin:
weekend_bonus,
points,
)
# Log weekend bonus as a separate transaction for activity history
transaction = PointsTransaction(
child_id=child.id,
points=weekend_bonus,
reason=f"Weekend bonus (×{multiplier:.0f})",
created_at=now,
)
self.storage.add_points_transaction(transaction)
# Deliberately NOT written as its own transaction. The bonus is part
# of what `total_points` pays, and the completion records that in
# points_awarded — so an extra row here would be the same points
# counted twice by anything summing completions and transactions
# together (the graph, weekly and leaderboard cards all do).
# Households upgrading from an earlier version still have these
# rows in their history, so the "Weekend bonus" reason stays in
# _UNDO_DENY_PREFIXES and in the locales.
# ── Streak tracking ─────────────────────────────────────────────────
streak_reset_occurred = False
+176 -20
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
if TYPE_CHECKING:
@@ -52,19 +52,37 @@ class RewardsMixin:
return self.storage.get_reward(reward_id)
def is_pool_mode_claim(self, claim: RewardClaim) -> bool:
"""True if the claim is covered by pool allocations (points already deducted).
"""True if the claim is funded from the pool rather than a wallet.
Pool-mode claims must NOT be counted against a child's spendable balance,
because their cost was already removed from child.points at allocation time.
Pool-funded claims must NOT be counted against a child's spendable
balance, because their cost was already removed from child.points at
allocation time. A jackpot is always pool-funded (#552), even if the
pool has since dipped below the cost — it is still not the claimer's
wallet that is on the hook for it.
"""
reward = self.get_reward(claim.reward_id)
if not reward:
return False
if getattr(reward, "is_jackpot", False):
return self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
return True
alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
return bool(alloc and alloc.allocated_points >= reward.cost)
def _require_funded_jackpot(self, reward: Reward, cost: int) -> None:
"""Raise unless the shared pool covers a jackpot's cost.
Jackpots are funded from one shared pool and never from a wallet
(#552). The pool can fall short of a pending claim — the cost is
raised, a contributor is unassigned, a child is deleted — and without
this guard the fallback path charged the full jackpot to whichever
child happened to be holding the claim.
"""
pool_total = self.storage.get_total_allocated_for_reward(reward.id)
if pool_total < cost:
raise ValueError(
f"'{reward.name}' is a shared jackpot and its pool is not full. Need {cost}, have {pool_total} saved up"
)
async def async_add_reward(
self,
name: str,
@@ -102,12 +120,22 @@ class RewardsMixin:
can't appear as e.g. 11/10. If the edit makes the reward unavailable
(quantity set to 0, or expires_at moved into the past) any pool
allocations on that reward are refunded in full.
An edit can also take the savings jar itself away: switching a reward
between jackpot and ordinary, turning pool mode off, or dropping a
child from ``assigned_to``. Allocations are locked with no withdraw
operation, so anything left behind by those edits is a child's points
stranded for good — they are refunded, and any pending claim the edit
has invalidated is cancelled with it.
"""
old = self.get_reward(reward.id)
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
if old and reward.cost != old.cost:
self._freeze_unrecorded_prices(old)
self.storage.update_reward(reward)
cancelled_claim_ids = self._settle_pool_after_edit(old, reward) if old else []
if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = (
@@ -121,6 +149,63 @@ class RewardsMixin:
await self.storage.async_save()
await self.async_refresh()
# Dismiss the approval pushes for claims this edit just cancelled.
if cancelled_claim_ids and getattr(self, "notifications", None):
for claim_id in cancelled_claim_ids:
await self.notifications.clear_approval("pending_reward_claim", claim_id)
def _settle_pool_after_edit(self, old: Reward, reward: Reward) -> list[str]:
"""Refund savings and cancel claims an edit has just invalidated.
Returns the ids of the cancelled claims so the caller can clear their
approval notifications once the change is saved.
"""
# Funding changed: the shared jar became per-child jars, or the other
# way round, or pool mode was switched off altogether. Whatever is in
# the pool was saved towards something that no longer exists.
funding_changed = reward.is_jackpot != old.is_jackpot or (old.pool_enabled and not reward.pool_enabled)
if funding_changed:
self._refund_all_pool_allocations(reward, "Pool refund (reward funding changed)")
else:
# Narrowed assignment: a child who can no longer be given this
# reward can no longer redeem what they saved towards it either.
for alloc in list(self.storage.get_pool_allocations()):
if alloc.reward_id == reward.id and not self._reward_is_for_child(reward, alloc.child_id):
self._apply_pool_refund(
alloc, alloc.allocated_points, reward, "Pool refund (reward assignment changed)"
)
cancelled: list[str] = []
for claim in self.storage.get_reward_claims():
if claim.reward_id != reward.id or claim.approved:
continue
# A pool-funded claim whose pool has just been refunded would fall
# through to the claimer's wallet on approval, and an unassigned
# child's claim should not be approvable at all.
if funding_changed or not self._reward_is_for_child(reward, claim.child_id):
self.storage.remove_reward_claim(claim.id)
cancelled.append(claim.id)
if cancelled:
_LOGGER.info(
"Cancelled %d pending claim(s) for '%s' — the edit changed how it is funded or who it is for",
len(cancelled),
reward.name,
)
return cancelled
def _freeze_unrecorded_prices(self, old: Reward) -> None:
"""Stamp the current price onto approved claims that never recorded one.
Claims approved before the price was stored alongside them have no
record of what was paid, so history reads the reward's live cost. This
is the last moment that cost is still the one those purchases were
approved at, so it is written down before the edit lands.
"""
for claim in self.storage.get_reward_claims():
if claim.reward_id == old.id and claim.approved and claim.approved_cost is None:
claim.approved_cost = old.cost
self.storage.update_reward_claim(claim)
async def async_remove_reward(self, reward_id: str) -> None:
"""Remove a reward and clean up any pending claims and pool allocations referencing it."""
self.storage.remove_reward_claims_for_reward(reward_id)
@@ -253,14 +338,34 @@ class RewardsMixin:
)
)
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
def reward_claim_funding(self, reward: Reward, child_id: str) -> int:
"""What is available to pay for this reward, from whichever purse applies.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
A jackpot is paid from the shared pool, an ordinary reward from either
a filled savings jar or the child's uncommitted balance — so "can they
afford it" cannot be answered from the wallet alone.
"""
if reward.is_jackpot:
return self.storage.get_total_allocated_for_reward(reward.id)
child = self.get_child(child_id)
if not child:
return 0
committed = 0
for claim in self.storage.get_pending_reward_claims():
if claim.child_id == child_id and not self.is_pool_mode_claim(claim):
pending_reward = self.get_reward(claim.reward_id)
if pending_reward:
committed += pending_reward.cost
wallet = child.points - committed
allocation = self.storage.get_pool_allocation(child_id, reward.id)
return max(wallet, allocation.allocated_points) if allocation else wallet
def validate_reward_claim(self, reward_id: str, child_id: str) -> tuple[Reward, Child]:
"""Check a claim is allowed, raising ValueError with the reason if not.
Every rule that decides whether a child may claim right now lives here,
so the claim itself and anything that offers it — the per-reward button
entity, for one — cannot drift apart. Mutates nothing.
"""
reward = self.get_reward(reward_id)
if not reward:
@@ -306,9 +411,10 @@ class RewardsMixin:
# or for jackpots the summed pool across all children reaches cost.
pool_filled = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(reward_id)
if pool_total >= effective_cost:
pool_filled = True
# A short pool is the end of it: a jackpot is never redeemed out of
# one child's wallet, however many points they happen to have.
self._require_funded_jackpot(reward, effective_cost)
pool_filled = True
else:
allocation = self.storage.get_pool_allocation(child_id, reward_id)
if allocation and allocation.allocated_points >= effective_cost:
@@ -330,6 +436,19 @@ class RewardsMixin:
if available_points < effective_cost:
raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
return reward, child
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
"""
reward, child = self.validate_reward_claim(reward_id, child_id)
claim = RewardClaim(
reward_id=reward_id,
child_id=child_id,
@@ -378,7 +497,8 @@ class RewardsMixin:
continue
when = claim.approved_at or claim.claimed_at
if when and dt_util.as_local(when).date() >= start:
total += reward_cost.get(claim.reward_id, 0)
paid = claim.approved_cost
total += paid if paid is not None else reward_cost.get(claim.reward_id, 0)
return total
def _enforce_spend_cap(self, child_id: str, cost: int) -> None:
@@ -436,9 +556,12 @@ class RewardsMixin:
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
is_pool_mode = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(claim.reward_id)
if pool_total >= effective_cost:
is_pool_mode = True
# The pool can have drained since the claim was made, and
# the wallet path below is not an acceptable fallback for a
# shared reward — it would charge the whole jackpot to the
# one child holding the claim.
self._require_funded_jackpot(reward, effective_cost)
is_pool_mode = True
elif pool_alloc and pool_alloc.allocated_points >= effective_cost:
is_pool_mode = True
@@ -475,13 +598,34 @@ class RewardsMixin:
claim.approved = True
claim.approved_at = dt_util.now()
claim.approved_cost = effective_cost
self.storage.update_reward_claim(claim)
# One pool, one redemption. Claims stored before a jackpot was
# limited to a single pending claim (#873) can still be sitting
# in the queue; the pool that would have paid for them has just
# been spent, so approving one of those later would fall
# through to its claimer's wallet. Retire them with the pool.
superseded = []
if reward.is_jackpot:
superseded = [c for c in claims if c.id != claim.id and c.reward_id == reward.id and not c.approved]
for other in superseded:
self.storage.remove_reward_claim(other.id)
if superseded:
_LOGGER.info(
"Retired %d duplicate pending claim(s) for jackpot '%s' on redemption",
len(superseded),
reward.name,
)
await self.storage.async_save()
await self.async_refresh()
# Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None):
await self.notifications.clear_approval("pending_reward_claim", claim_id)
for other in superseded:
await self.notifications.clear_approval("pending_reward_claim", other.id)
# Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child)
@@ -506,8 +650,20 @@ class RewardsMixin:
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
"""Reject a reward claim — no refund needed as points were never deducted."""
"""Reject a *pending* reward claim — no refund needed as points were never deducted.
That "no refund needed" only holds while the claim is pending. Two
parents can review the same claim at once — one in the panel, one from
a mobile approval push — and the second one is acting on a list that no
longer matches storage. Deleting an approved claim there would erase
the purchase from history while its points, its stock and any timed
unlock stayed spent, so the stale review is refused instead.
"""
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
if claim is not None and claim.approved:
reward = self.get_reward(claim.reward_id)
name = reward.name if reward else "This reward"
raise ValueError(f"'{name}' has already been approved and can no longer be rejected")
self.storage.remove_reward_claim(claim_id)
await self.storage.async_save()
await self.async_refresh()
@@ -128,6 +128,7 @@ class TimedMixin:
completed_at=now,
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
submitted_points=pts,
timed_duration_seconds=total_seconds,
)
@@ -251,6 +252,7 @@ class TimedMixin:
completed_at=dt_util.now(),
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
submitted_points=pts,
timed_duration_seconds=total_seconds,
)
if not chore.requires_approval:
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.7.0"
"version": "5.8.0"
}
+37
View File
@@ -54,6 +54,24 @@ def parse_datetime(value: str | datetime | None) -> datetime | None:
return None
def parse_optional_points(value: Any) -> int | None:
"""Parse a stored points figure, or None for "not recorded".
Used for fields that snapshot a value at the moment it applied. ``None``
marks a record written before the field existed, so callers can fall back
to recalculating; anything unusable is treated the same way rather than
being trusted as a number.
"""
if value is None or isinstance(value, bool):
return None
try:
points = int(value)
except (ValueError, TypeError, OverflowError):
_LOGGER.warning("Ignoring unusable stored points value %r", value)
return None
return None if points < 0 else points
def format_datetime(dt: datetime | None) -> str | None:
"""Format a datetime as ISO string with timezone info.
@@ -260,6 +278,12 @@ class Chore:
time_category: str = "anytime" # morning, afternoon, evening, night, anytime
claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight.
daily_limit: int = 1
# Weekly target (#883): "do this N times this week, on whichever days you
# like" — instrument practice, reading, exercise. 0 = off. Monday-anchored
# to match Challenges. It caps the week the way daily_limit caps the day,
# and counts pending completions too, so a slow approval never hands the
# child a spare go.
weekly_target: int = 0
completion_sound: str = "coin" # Sound to play on completion
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
# one per chore; everything else falls back to the time-of-day icon.
@@ -375,6 +399,7 @@ class Chore:
time_category=data.get("time_category", "anytime"),
claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)),
daily_limit=data.get("daily_limit", 1),
weekly_target=int(data.get("weekly_target", 0) or 0),
completion_sound=data.get("completion_sound", "coin"),
icon=str(data.get("icon", "") or ""),
image_url=str(data.get("image_url", "") or ""),
@@ -442,6 +467,7 @@ class Chore:
"time_category": self.time_category,
"claim_allowance_minutes": self.claim_allowance_minutes,
"daily_limit": self.daily_limit,
"weekly_target": self.weekly_target,
"completion_sound": self.completion_sound,
"icon": self.icon,
"image_url": self.image_url,
@@ -721,6 +747,10 @@ class ChoreCompletion:
note: str = "" # child's own description of the work (open-ended chores, #832)
suggested_points: int = 0 # what the child reckons it was worth (#832)
id: str = field(default_factory=generate_id)
# The award this submission was worth when it was made, before the streak
# and weekend multipliers that _award_points adds on top. None on records
# written before the field existed.
submitted_points: int | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion:
@@ -741,6 +771,7 @@ class ChoreCompletion:
note=data.get("note", ""),
suggested_points=int(data.get("suggested_points", 0) or 0),
id=data.get("id") or generate_id(),
submitted_points=parse_optional_points(data.get("submitted_points")),
)
def to_dict(self) -> dict[str, Any]:
@@ -758,6 +789,7 @@ class ChoreCompletion:
"note": self.note,
"suggested_points": self.suggested_points,
"id": self.id,
"submitted_points": self.submitted_points,
}
@@ -820,6 +852,9 @@ class RewardClaim:
approved: bool = False
approved_at: datetime | None = None
id: str = field(default_factory=generate_id)
# What this purchase actually cost, recorded when it was approved. None on
# a pending claim, and on claims approved before the field existed.
approved_cost: int | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RewardClaim:
@@ -834,6 +869,7 @@ class RewardClaim:
approved=data.get("approved", False),
approved_at=approved_at,
id=data.get("id") or generate_id(),
approved_cost=parse_optional_points(data.get("approved_cost")),
)
def to_dict(self) -> dict[str, Any]:
@@ -845,6 +881,7 @@ class RewardClaim:
"approved": self.approved,
"approved_at": format_datetime(self.approved_at),
"id": self.id,
"approved_cost": self.approved_cost,
}
+1 -1
View File
@@ -21,7 +21,7 @@ _SELECTS = [
(
"card_design",
"card_design",
["classic", "playroom", "console", "cleanpro", "accessible"],
["classic", "playroom", "console", "cleanpro", "accessible", "graphite"],
"classic",
"mdi:palette",
),
+41 -4
View File
@@ -146,6 +146,14 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
for c in children:
committed_amount = committed.get(c.id, 0)
lvl = coordinator.level_info(c)
# Weekly targets (#883): {chore_id: done so far this week} for the
# chores that have one, so a card can render "2/3 this week" without
# its own pass over completions. Empty — and so absent — for most.
weekly_progress = {
ch.id: coordinator.weekly_completion_count(ch.id, c.id)
for ch in common["chores"]
if int(getattr(ch, "weekly_target", 0) or 0) > 0
}
summary.append(
{
"level": lvl["level"],
@@ -195,6 +203,7 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"quests": coordinator.quest_progress_for_child(c.id),
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
**({"weekly_chore_progress": weekly_progress} if weekly_progress else {}),
}
)
return summary
@@ -243,6 +252,11 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
daily_limit = getattr(c, "daily_limit", 1)
if daily_limit != 1:
record["daily_limit"] = daily_limit
# Weekly target (#883) — off for almost every chore, so it only goes
# into the record when a family actually uses one.
weekly_target = int(getattr(c, "weekly_target", 0) or 0)
if weekly_target:
record["weekly_target"] = weekly_target
claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
if claim_allowance_minutes:
record["claim_allowance_minutes"] = claim_allowance_minutes
@@ -355,6 +369,27 @@ def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) ->
return availability
def _completion_award(completion, recalculated: int) -> int:
"""What a completion is worth, recorded rather than re-derived.
Approved: what the child was actually paid, weekend multiplier included.
Re-deriving it from the chore reported the chore's *current* points, so
re-pricing a chore rewrote every past completion of it.
Pending: what the child was promised at submission, including a speed
bonus or roulette multiplier that may no longer apply — the same figure
approval will pay.
Neither recorded (a completion from before those fields existed): fall
back to the caller's calculation.
"""
if completion.approved:
return completion.points_awarded
if completion.submitted_points is not None:
return completion.submitted_points
return recalculated
def _build_todays_completions(common: dict) -> list[dict]:
"""Build today's completions (both approved and pending)."""
now = dt_util.now()
@@ -391,7 +426,7 @@ def _build_todays_completions(common: dict) -> list[dict]:
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": display_name,
"points": display_points,
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -573,7 +608,7 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": matched_chore.name if matched_chore else "",
"points": display_points,
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -657,7 +692,9 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
"reward_id": rc.reward_id,
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
"points": -reward.cost,
# An approved purchase is worth what it cost that day, not what
# the reward has been re-priced to since.
"points": -(rc.approved_cost if rc.approved and rc.approved_cost is not None else reward.cost),
"approved": rc.approved,
"created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
}
@@ -1378,7 +1415,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
"child_id": child.id,
"chore_name": chore_name,
"chore_id": chore.id,
"points": pts,
"points": _completion_award(comp, pts),
"time_category": chore.time_category,
"completed_at": comp.completed_at.isoformat(),
"bonus_subtask_id": bonus_subtask_id,
+1 -1
View File
@@ -1277,7 +1277,7 @@ class TaskMateStorage:
from .photos import is_taskmate_photo_url
_STREAK_MODES = ("reset", "pause")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible", "graphite")
# Per-record numeric fields. The WebSocket schemas coerce these on every
# normal write; import bypasses the schemas entirely, so a crafted
# backup could leave a string (or inf/NaN) where the rest of the code —
+5 -1
View File
@@ -575,6 +575,7 @@ _CHORE_EDITABLE_FIELDS = {
"time_category",
"claim_allowance_minutes",
"daily_limit",
"weekly_target",
"completion_sound",
"icon",
"image_url",
@@ -629,6 +630,9 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("time_category"): str,
vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)),
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
# Weekly target (#883): 0 = no quota, which is the case for almost
# every chore, so it has to be a valid value rather than a floor of 1.
vol.Optional("weekly_target"): vol.All(int, vol.Range(min=0)),
vol.Optional("completion_sound"): _completion_sound,
vol.Optional("icon"): str,
vol.Optional("image_url"): _image_url_or_blank,
@@ -1640,7 +1644,7 @@ async def _ws_remove_custom_sound(hass, connection, msg, coordinator):
# Top-level fields stored at storage._data root
_TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
# Allowed values for the global default card-design style (per-card design styles).
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible", "graphite"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"require_linked_child",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphit",
"panel.settings_card_design_label": "Standard-Kartendesign",
"panel.settings_card_design_hint": "Der Stil neuer Karten, sofern nicht pro Karte überschrieben.",
"activity.a_chore": "eine lästige Pflicht",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Strafe: {name}",
"activity.reason_perfect_week": "Perfekter Wochenbonus!",
"activity.reason_pool_refund_assignment_changed": "Poolrückerstattung (Zuweisung der Prämie geändert): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool-Rückerstattung (Prämienkosten reduziert): {name}",
"activity.reason_pool_refund_deleted": "Poolrückerstattung (Prämie gelöscht): {name}",
"activity.reason_pool_refund_expired": "Poolrückerstattung (Prämie abgelaufen): {name}",
"activity.reason_pool_refund_funding_changed": "Poolrückerstattung (Finanzierung der Prämie geändert): {name}",
"activity.reason_pool_refund_sold_out": "Poolrückerstattung (Prämie ausverkauft): {name}",
"activity.reason_streak_milestone": "Serie-Meilensteinbonus ({days} Tage Serie!)",
"activity.reason_weekend_bonus": "Wochenendbonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} nicht gefunden",
"badges.label": "Abzeichen",
"badges.next_up": "Als Nächstes",
"badges.one_more": "Nur noch eins – dann gehört es dir!",
"badges.title_with_name": "Abzeichen von {name}",
"bonuses.add_bonus": "Bonus hinzufügen",
"bonuses.applying_to": "Beantragung bei {childName} – aktuelles Guthaben: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Senden",
"child.extra_needs_desc": "Schreib zuerst, was du gemacht hast.",
"child.open_ended_tag": "Beschreiben",
"child.weekly_target_done": "Für diese Woche erledigt",
"child.weekly_target_progress": "{done}/{target} diese Woche",
"child.photo_take": "Foto aufnehmen",
"child.photo_hint": "Zeig deine erledigte Aufgabe",
"child.photo_retake": "Neu aufnehmen",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Tageslimit (Minuten, 0 = unbegrenzt)",
"panel.chore_daily_limit_label": "Tageslimit",
"panel.chore_days_label": "Wochentage (leer = jeden Tag)",
"panel.chore_weekly_target_pill": "{count}×/Woche",
"panel.chore_weekly_target_hint": "Wie oft pro Woche die Aufgabe erledigt werden muss, an beliebigen der gewählten Tage. Ist die Anzahl erreicht, wird sie nicht mehr angezeigt; montags beginnt die Zählung neu.",
"panel.chore_weekly_target_label": "Wochenziel (0 = aus)",
"panel.chore_description_label": "Beschreibung (optional)",
"panel.chore_enabled_label": "Aktiviert",
"panel.chore_first_occurrence_label": "Erstes Vorkommen",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Default card design",
"panel.settings_card_design_hint": "The style new cards use unless overridden per card.",
"activity.a_chore": "a chore",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Penalty: {name}",
"activity.reason_perfect_week": "Perfect week bonus!",
"activity.reason_pool_refund_assignment_changed": "Pool refund (reward assignment changed): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool refund (reward cost reduced): {name}",
"activity.reason_pool_refund_deleted": "Pool refund (reward deleted): {name}",
"activity.reason_pool_refund_expired": "Pool refund (reward expired): {name}",
"activity.reason_pool_refund_funding_changed": "Pool refund (reward funding changed): {name}",
"activity.reason_pool_refund_sold_out": "Pool refund (reward sold out): {name}",
"activity.reason_streak_milestone": "Streak milestone bonus ({days} day streak!)",
"activity.reason_weekend_bonus": "Weekend bonus (×{multiplier})",
@@ -159,6 +162,7 @@
"badges.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.one_more": "One more and it's yours!",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -231,6 +235,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Write what you did first.",
"child.open_ended_tag": "Describe it",
"child.weekly_target_done": "Done for this week",
"child.weekly_target_progress": "{done}/{target} this week",
"child.photo_take": "Take a photo",
"child.photo_hint": "Show your finished chore",
"child.photo_retake": "Retake",
@@ -654,6 +660,9 @@
"panel.chore_daily_cap_label": "Daily cap (minutes, 0 = unlimited)",
"panel.chore_daily_limit_label": "Daily limit",
"panel.chore_days_label": "Days of week (leave empty = every day)",
"panel.chore_weekly_target_pill": "{count}×/week",
"panel.chore_weekly_target_hint": "How many times a week this has to be done, on any of the chosen days. It stops being offered once the count is reached, and resets on Monday.",
"panel.chore_weekly_target_label": "Weekly target (0 = off)",
"panel.chore_description_label": "Description (optional)",
"panel.chore_enabled_label": "Enabled",
"panel.chore_first_occurrence_label": "First occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Default card design",
"panel.settings_card_design_hint": "The style new cards use unless overridden per card.",
"activity.a_chore": "a chore",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Penalty: {name}",
"activity.reason_perfect_week": "Perfect week bonus!",
"activity.reason_pool_refund_assignment_changed": "Pool refund (reward assignment changed): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool refund (reward cost reduced): {name}",
"activity.reason_pool_refund_deleted": "Pool refund (reward deleted): {name}",
"activity.reason_pool_refund_expired": "Pool refund (reward expired): {name}",
"activity.reason_pool_refund_funding_changed": "Pool refund (reward funding changed): {name}",
"activity.reason_pool_refund_sold_out": "Pool refund (reward sold out): {name}",
"activity.reason_streak_milestone": "Streak milestone bonus ({days} day streak!)",
"activity.reason_weekend_bonus": "Weekend bonus (×{multiplier})",
@@ -159,6 +162,7 @@
"badges.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.one_more": "One more and it's yours!",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -231,6 +235,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Write what you did first.",
"child.open_ended_tag": "Describe it",
"child.weekly_target_done": "Done for this week",
"child.weekly_target_progress": "{done}/{target} this week",
"child.photo_take": "Take a photo",
"child.photo_hint": "Show your finished chore",
"child.photo_retake": "Retake",
@@ -654,6 +660,9 @@
"panel.chore_daily_cap_label": "Daily cap (minutes, 0 = unlimited)",
"panel.chore_daily_limit_label": "Daily limit",
"panel.chore_days_label": "Days of week (leave empty = every day)",
"panel.chore_weekly_target_pill": "{count}×/week",
"panel.chore_weekly_target_hint": "How many times a week this has to be done, on any of the chosen days. It stops being offered once the count is reached, and resets on Monday.",
"panel.chore_weekly_target_label": "Weekly target (0 = off)",
"panel.chore_description_label": "Description (optional)",
"panel.chore_enabled_label": "Enabled",
"panel.chore_first_occurrence_label": "First occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Design de carte par défaut",
"panel.settings_card_design_hint": "Le style des nouvelles cartes, sauf remplacement par carte.",
"activity.a_chore": "une tâche",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus : {name}",
"activity.reason_penalty": "Pénalité : {name}",
"activity.reason_perfect_week": "Bonus de semaine parfaite !",
"activity.reason_pool_refund_assignment_changed": "Remboursement de cagnotte (attribution de la récompense modifiée) : {name}",
"activity.reason_pool_refund_cost_reduced": "Remboursement de cagnotte (coût réduit) : {name}",
"activity.reason_pool_refund_deleted": "Remboursement de cagnotte (récompense supprimée) : {name}",
"activity.reason_pool_refund_expired": "Remboursement de cagnotte (récompense expirée) : {name}",
"activity.reason_pool_refund_funding_changed": "Remboursement de cagnotte (financement de la récompense modifié) : {name}",
"activity.reason_pool_refund_sold_out": "Remboursement de cagnotte (récompense épuisée) : {name}",
"activity.reason_streak_milestone": "Bonus de palier de série ({days} jours de suite !)",
"activity.reason_weekend_bonus": "Bonus de week-end (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} introuvable",
"badges.label": "Badges",
"badges.next_up": "Prochain",
"badges.one_more": "Encore un et il est à toi !",
"badges.title_with_name": "Badges de {name}",
"bonuses.add_bonus": "Ajouter un bonus",
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Envoyer",
"child.extra_needs_desc": "Écris d'abord ce que tu as fait.",
"child.open_ended_tag": "À décrire",
"child.weekly_target_done": "Terminé pour cette semaine",
"child.weekly_target_progress": "{done}/{target} cette semaine",
"child.photo_take": "Prendre une photo",
"child.photo_hint": "Montre ta tâche terminée",
"child.photo_retake": "Reprendre",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Plafond journalier (minutes, 0 = illimité)",
"panel.chore_daily_limit_label": "Limite quotidienne",
"panel.chore_days_label": "Jours de la semaine (vide = tous les jours)",
"panel.chore_weekly_target_pill": "{count}×/semaine",
"panel.chore_weekly_target_hint": "Combien de fois par semaine la tâche doit être faite, n'importe lesquels des jours choisis. Elle n'est plus proposée une fois le compte atteint et repart le lundi.",
"panel.chore_weekly_target_label": "Objectif hebdomadaire (0 = désactivé)",
"panel.chore_description_label": "Description (optionnel)",
"panel.chore_enabled_label": "Activé",
"panel.chore_first_occurrence_label": "Première occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafitt",
"panel.settings_card_design_label": "Standard kortdesign",
"panel.settings_card_design_hint": "Stilen nye kort bruker med mindre den overstyres per kort.",
"activity.a_chore": "en oppgave",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Straff: {name}",
"activity.reason_perfect_week": "Perfekt uke-bonus!",
"activity.reason_pool_refund_assignment_changed": "Refusjon fra sparegris (belønningens tildeling endret): {name}",
"activity.reason_pool_refund_cost_reduced": "Refusjon fra sparegris (kostnad redusert): {name}",
"activity.reason_pool_refund_deleted": "Refusjon fra sparegris (belønning slettet): {name}",
"activity.reason_pool_refund_expired": "Refusjon fra sparegris (belønning utløpt): {name}",
"activity.reason_pool_refund_funding_changed": "Refusjon fra sparegris (belønningens finansiering endret): {name}",
"activity.reason_pool_refund_sold_out": "Refusjon fra sparegris (belønning utsolgt): {name}",
"activity.reason_streak_milestone": "Milepælsbonus ({days} dagers strek!)",
"activity.reason_weekend_bonus": "Helgebonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} ikke funnet",
"badges.label": "Merker",
"badges.next_up": "Neste",
"badges.one_more": "Bare én til, så er det ditt!",
"badges.title_with_name": "{name}s merker",
"bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Skriv først hva du gjorde.",
"child.open_ended_tag": "Beskriv",
"child.weekly_target_done": "Ferdig for denne uka",
"child.weekly_target_progress": "{done}/{target} denne uka",
"child.photo_take": "Ta et bilde",
"child.photo_hint": "Vis den ferdige oppgaven",
"child.photo_retake": "Ta på nytt",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Daglig tak (minutter, 0 = ubegrenset)",
"panel.chore_daily_limit_label": "Daglig grense",
"panel.chore_days_label": "Ukedager (la stå tomt = hver dag)",
"panel.chore_weekly_target_pill": "{count}×/uke",
"panel.chore_weekly_target_hint": "Hvor mange ganger i uka oppgaven må gjøres, på hvilke som helst av de valgte dagene. Den vises ikke lenger når antallet er nådd, og nullstilles på mandag.",
"panel.chore_weekly_target_label": "Ukesmål (0 = av)",
"panel.chore_description_label": "Beskrivelse (valgfritt)",
"panel.chore_enabled_label": "Aktivert",
"panel.chore_first_occurrence_label": "Første forekomst",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafitt",
"panel.settings_card_design_label": "Standard kortdesign",
"panel.settings_card_design_hint": "Stilen nye kort brukar med mindre den blir overstyrt per kort.",
"activity.a_chore": "ei oppgåve",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Straff: {name}",
"activity.reason_perfect_week": "Perfekt veke-bonus!",
"activity.reason_pool_refund_assignment_changed": "Refusjon frå sparegris (løninga si tildeling endra): {name}",
"activity.reason_pool_refund_cost_reduced": "Refusjon frå sparegris (kostnad redusert): {name}",
"activity.reason_pool_refund_deleted": "Refusjon frå sparegris (løning sletta): {name}",
"activity.reason_pool_refund_expired": "Refusjon frå sparegris (løning utløpt): {name}",
"activity.reason_pool_refund_funding_changed": "Refusjon frå sparegris (løninga si finansiering endra): {name}",
"activity.reason_pool_refund_sold_out": "Refusjon frå sparegris (løning utseld): {name}",
"activity.reason_streak_milestone": "Milepælsbonus ({days} dagar på rad!)",
"activity.reason_weekend_bonus": "Helgebonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} ikkje funne",
"badges.label": "Merke",
"badges.next_up": "Neste",
"badges.one_more": "Berre éin til, så er det ditt!",
"badges.title_with_name": "Merka til {name}",
"bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Skriv først kva du gjorde.",
"child.open_ended_tag": "Beskriv",
"child.weekly_target_done": "Ferdig for denne veka",
"child.weekly_target_progress": "{done}/{target} denne veka",
"child.photo_take": "Ta eit bilete",
"child.photo_hint": "Vis den ferdige oppgåva",
"child.photo_retake": "Ta på nytt",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Dagleg tak (minutt, 0 = uavgrensa)",
"panel.chore_daily_limit_label": "Dagleg grense",
"panel.chore_days_label": "Vekedagar (la stå tomt = kvar dag)",
"panel.chore_weekly_target_pill": "{count}×/veke",
"panel.chore_weekly_target_hint": "Kor mange gonger i veka oppgåva må gjerast, på kva som helst av dei valde dagane. Ho blir ikkje vist meir når talet er nådd, og blir nullstilt på måndag.",
"panel.chore_weekly_target_label": "Vekemål (0 = av)",
"panel.chore_description_label": "Skildring (valfri)",
"panel.chore_enabled_label": "Aktivert",
"panel.chore_first_occurrence_label": "Fyrste førekomst",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafite",
"panel.settings_card_design_label": "Design de card padrão",
"panel.settings_card_design_hint": "O estilo que novos cards usam, a menos que substituído por card.",
"activity.a_chore": "uma tarefa",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bônus: {name}",
"activity.reason_penalty": "Penalidade: {name}",
"activity.reason_perfect_week": "Bônus de semana perfeita!",
"activity.reason_pool_refund_assignment_changed": "Reembolso do cofrinho (atribuição da recompensa alterada): {name}",
"activity.reason_pool_refund_cost_reduced": "Reembolso do cofrinho (custo reduzido): {name}",
"activity.reason_pool_refund_deleted": "Reembolso do cofrinho (recompensa excluída): {name}",
"activity.reason_pool_refund_expired": "Reembolso do cofrinho (recompensa expirada): {name}",
"activity.reason_pool_refund_funding_changed": "Reembolso do cofrinho (financiamento da recompensa alterado): {name}",
"activity.reason_pool_refund_sold_out": "Reembolso do cofrinho (recompensa esgotada): {name}",
"activity.reason_streak_milestone": "Bônus de marco de sequência ({days} dias seguidos!)",
"activity.reason_weekend_bonus": "Bônus de fim de semana (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.one_more": "Só mais um e é seu!",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Enviar",
"child.extra_needs_desc": "Escreva primeiro o que você fez.",
"child.open_ended_tag": "Descrever",
"child.weekly_target_done": "Concluída esta semana",
"child.weekly_target_progress": "{done}/{target} esta semana",
"child.photo_take": "Tirar uma foto",
"child.photo_hint": "Mostre sua tarefa concluída",
"child.photo_retake": "Repetir",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Limite diário (minutos, 0 = ilimitado)",
"panel.chore_daily_limit_label": "Limite diário",
"panel.chore_days_label": "Dias da semana (vazio = todos os dias)",
"panel.chore_weekly_target_pill": "{count}×/semana",
"panel.chore_weekly_target_hint": "Quantas vezes por semana a tarefa precisa ser feita, em qualquer um dos dias escolhidos. Ela deixa de aparecer quando a contagem é atingida e reinicia na segunda-feira.",
"panel.chore_weekly_target_label": "Meta semanal (0 = desligado)",
"panel.chore_description_label": "Descrição (opcional)",
"panel.chore_enabled_label": "Ativada",
"panel.chore_first_occurrence_label": "Primeira ocorrência",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafite",
"panel.settings_card_design_label": "Design de cartão predefinido",
"panel.settings_card_design_hint": "O estilo que os novos cartões usam, salvo substituição por cartão.",
"activity.a_chore": "uma tarefa",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bónus: {name}",
"activity.reason_penalty": "Penalidade: {name}",
"activity.reason_perfect_week": "Bónus de semana perfeita!",
"activity.reason_pool_refund_assignment_changed": "Reembolso do mealheiro (atribuição da recompensa alterada): {name}",
"activity.reason_pool_refund_cost_reduced": "Reembolso do mealheiro (custo reduzido): {name}",
"activity.reason_pool_refund_deleted": "Reembolso do mealheiro (recompensa eliminada): {name}",
"activity.reason_pool_refund_expired": "Reembolso do mealheiro (recompensa expirada): {name}",
"activity.reason_pool_refund_funding_changed": "Reembolso do mealheiro (financiamento da recompensa alterado): {name}",
"activity.reason_pool_refund_sold_out": "Reembolso do mealheiro (recompensa esgotada): {name}",
"activity.reason_streak_milestone": "Bónus de marco de sequência ({days} dias seguidos!)",
"activity.reason_weekend_bonus": "Bónus de fim de semana (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.one_more": "Só mais um e é teu!",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Enviar",
"child.extra_needs_desc": "Escreve primeiro o que fizeste.",
"child.open_ended_tag": "Descrever",
"child.weekly_target_done": "Concluída esta semana",
"child.weekly_target_progress": "{done}/{target} esta semana",
"child.photo_take": "Tirar uma foto",
"child.photo_hint": "Mostra a tua tarefa terminada",
"child.photo_retake": "Repetir",
@@ -621,6 +627,9 @@
"panel.chore_daily_cap_label": "Limite diário (minutos, 0 = ilimitado)",
"panel.chore_daily_limit_label": "Limite diário",
"panel.chore_days_label": "Dias da semana (deixe vazio = todos os dias)",
"panel.chore_weekly_target_pill": "{count}×/semana",
"panel.chore_weekly_target_hint": "Quantas vezes por semana a tarefa tem de ser feita, em qualquer um dos dias escolhidos. Deixa de ser apresentada quando a contagem é atingida e reinicia à segunda-feira.",
"panel.chore_weekly_target_label": "Meta semanal (0 = desligado)",
"panel.chore_description_label": "Descrição (opcional)",
"panel.chore_enabled_label": "Ativada",
"panel.chore_first_occurrence_label": "Primeira ocorrência",
@@ -73,6 +73,8 @@ class TaskMateActivityCard extends LitElement {
['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'],
['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'],
['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'],
['Pool refund (reward funding changed):', 'activity.reason_pool_refund_funding_changed'],
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
];
@@ -603,7 +603,9 @@ class TaskMateApprovalsCard extends LitElement {
.ap-cn-photo img { width: 100%; height: 100%; object-fit: cover; }
/* Clean Pro — divided list */
.ap-cp-item { padding: 10px 0; }
.ap-cp-item { padding: 10px 0; flex-wrap: wrap; row-gap: 8px; }
.ap-cp-mid { flex: 1 1 130px; min-width: 130px; }
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-left: auto; }
.ap-cp-title { font-weight: 600; }
.ap-cp-sub { font-size: 12px; }
.ap-cp-photo { width: 32px; height: 32px; border-radius: 8px; overflow: hidden;
@@ -1048,13 +1050,13 @@ class TaskMateApprovalsCard extends LitElement {
return html`
<div class="row ap-cp-item" style="--ac:${tone}">
${this._av(it.childName, this._childAvatar(it.childId), tone, 38)}
<div style="flex:1;min-width:0">
<div class="ap-cp-mid">
<div class="ap-cp-title">${it.title}</div>
<div class="muted ap-cp-sub">${it.childName}${time ? html` · ${time}` : ""}</div>
</div>
${this._apPhotoDesigned(it, "ap-cp-photo")}
<span class="chip soft ap-gold-soft">+${it.points}</span>
${this._designActions(it, "ghost")}
<div class="ap-cp-actions">${this._designActions(it, "ghost")}</div>
</div>`;
}
@@ -1716,6 +1716,12 @@ class TaskMateChildCard extends LitElement {
border-radius: 3px;
transition: width 0.4s ease;
}
.next-badge-nudge {
margin-top: 5px;
font-size: 11px;
font-weight: 700;
color: var(--primary-color);
}
/* ══════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro)
@@ -1886,6 +1892,9 @@ class TaskMateChildCard extends LitElement {
.tmd-next-badge .bar i {
display: block; height: 100%; border-radius: 999px; transition: width .4s ease;
}
.tmd-next-badge .nudge {
margin-top: 5px; font-size: 11px; font-weight: 800; color: var(--tmd-accent);
}
/* Playroom is chunkier, console squares everything off, accessible needs
a solid ring rather than a dashed one to stay legible. */
:host([data-tm-design="playroom"]) .tmd-next-badge .bar { height: 8px; }
@@ -1895,6 +1904,7 @@ class TaskMateChildCard extends LitElement {
:host([data-tm-design="accessible"]) .tmd-next-badge .bar { height: 10px; }
:host([data-tm-design="accessible"]) .tmd-next-badge .nm,
:host([data-tm-design="accessible"]) .tmd-next-badge .cnt { font-size: 14px; }
:host([data-tm-design="accessible"]) .tmd-next-badge .nudge { font-size: 13px; }
/* Designed: vacation banner + swappable section */
.tmd-vacation {
@@ -1928,6 +1938,7 @@ class TaskMateChildCard extends LitElement {
show_due_days_only: true, // Whether to apply due_days filtering at all
show_badges: true, // Show badge strip between points and chores
show_next_badge: true, // Show progress toward the closest unearned badge
show_badge_nudge: true, // Encouraging line when one away from that badge
header_color: '#9b59b6',
...config,
};
@@ -2131,6 +2142,9 @@ class TaskMateChildCard extends LitElement {
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width: ${nextBadge.pct}%; background: ${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
${this._badgeNudgeText(nextBadge)
? html`<div class="next-badge-nudge">${this._badgeNudgeText(nextBadge)}</div>`
: ''}
</div>
</div>
` : ''}
@@ -2249,6 +2263,21 @@ class TaskMateChildCard extends LitElement {
return best;
}
/* "Nearly there" (#890) — the line of encouragement under the next-badge bar.
It fires on the last remaining unit and nothing else, measured in the
criterion's own units rather than percent: 90% of a ten-week badge is one
week away, 90% of a hundred-point badge is ten points away. Rare on
purpose — a line that shows for half the climb has stopped meaning
anything by the time it matters. Manual-award badges carry no criterion
and so never nudge. */
_badgeNudgeText(nextBadge) {
if (this.config.show_badge_nudge === false) return "";
const c = nextBadge?.badge?.closest_criterion;
if (!c) return "";
return Number(c.target) - Number(c.current) === 1 ? this._t("badges.one_more") : "";
}
// Built-in badge names arrive from the sensor in English; the localised
// name lives under badge.name_<suffix> (same scheme as the panel).
_badgeName(b) {
@@ -2307,6 +2336,23 @@ class TaskMateChildCard extends LitElement {
}
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
// How many times this chore can still be ticked TODAY.
//
// Normally the chore's own daily_limit, clamped to 1 for a first_come race.
// A weekly target (#883) clamps it again: once the week's quota is full there
// is no allowance left, so a chore with daily_limit 5 and 2/2 done reads as
// finished rather than staying tappable — the tap would only be one
// complete_chore refuses (#805). Only applied once the child has ticked it
// today; a quota filled on earlier days leaves the row locked instead, so a
// green "done" row never appears with no completion behind it to undo.
_effectiveDailyLimit(chore, child, countToday, isFirstCome) {
const limit = isFirstCome ? 1 : (chore.daily_limit || 1);
const target = Number(chore.weekly_target) || 0;
if (target <= 0 || countToday <= 0) return limit;
const doneThisWeek = Number((child?.weekly_chore_progress || {})[chore.id] || 0);
return Math.min(limit, countToday + Math.max(0, target - doneThisWeek));
}
_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
@@ -2336,7 +2382,7 @@ class TaskMateChildCard extends LitElement {
// 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));
const done = count >= this._effectiveDailyLimit(chore, child, count, isFirstCome);
return { done, completions: childCompletionsToday };
}
@@ -2395,8 +2441,11 @@ class TaskMateChildCard extends LitElement {
// 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;
// Weekly quota filled — nothing owed until Monday, so the tap is refused
// the same way a recurrence window refuses one.
const weeklyDone = !!chore._weeklyTargetMet && !done;
const onAct = () => {
if (loading || blocked || recLocked || firstComeLocked) return;
if (loading || blocked || recLocked || firstComeLocked || weeklyDone) return;
if (done) this._handleUndo(chore, child, completions);
else this._handleComplete(chore, child);
};
@@ -2409,10 +2458,11 @@ class TaskMateChildCard extends LitElement {
(blocked && dependencyMode === "dim") ||
(chore._isRecurrenceLocked && recurrenceDoneMode === "dim") ||
(chore._isFirstComeLocked && firstComeClaimedMode === "dim") ||
weeklyDone ||
chore._isLockedPreview === true
);
return {
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked,
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked, weeklyDone,
tone: this._designTone(i),
glyph: this._choreGlyph(chore),
points: chore.effective_points ?? chore.points,
@@ -2494,6 +2544,9 @@ class TaskMateChildCard extends LitElement {
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width:${nextBadge.pct}%;background:${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
${this._badgeNudgeText(nextBadge)
? html`<div class="nudge">${this._badgeNudgeText(nextBadge)}</div>`
: ""}
</div>
</div>` : ""}
${sectionLine}
@@ -2559,8 +2612,13 @@ class TaskMateChildCard extends LitElement {
? this._t("child.claimed_by_another", { name: r.chore._firstComeCompletedByName })
: this._t("child.claimed_by_another_generic"))
: "";
// Weekly target (#883): shown from the first tick so the child can see
// where they are, not only once the quota is full.
const weeklyLabel = r.chore._weeklyTarget > 0
? this._t("child.weekly_target_progress", { done: r.chore._weeklyProgress, target: r.chore._weeklyTarget })
: "";
return html`
${r.mandatory || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel ? html`
${r.mandatory || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel || weeklyLabel ? 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>` : ""}
@@ -2568,6 +2626,7 @@ class TaskMateChildCard extends LitElement {
${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>` : ""}
${weeklyLabel ? html`<span class="tmd-tag">📅 ${weeklyLabel}</span>` : ""}
</div>` : ""}
${showDesc ? html`<div class="tmd-desc">${r.chore.description}</div>` : ""}`;
}
@@ -2583,7 +2642,7 @@ class TaskMateChildCard extends LitElement {
_designDoneBtn(r, label, cls) {
return html`<button class="btn ${cls || ""}"
?disabled=${r.loading || r.blocked || r.recLocked || r.firstComeLocked}
?disabled=${r.loading || r.blocked || r.recLocked || r.firstComeLocked || r.weeklyDone}
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
}
@@ -2990,6 +3049,20 @@ class TaskMateChildCard extends LitElement {
chore._isRecurrenceLocked = chore._isRecurring && !availableNow && !completedToday;
if (chore._isRecurrenceLocked && recurrenceDoneMode === 'hide') return false;
// Weekly target (#883): the child picks which days, so the chore keeps
// being offered until the week's quota is filled. The count comes from
// the per-child map on the overview sensor — the card only ever sees
// today's completions, so it can't total a week by itself. Excluding
// completedToday keeps the row live for an undo when today's tick was
// the one that filled it, exactly as the recurrence lock does.
chore._weeklyTarget = Number(chore.weekly_target) || 0;
chore._weeklyProgress = chore._weeklyTarget > 0
? Number((child.weekly_chore_progress || {})[chore.id] || 0)
: 0;
chore._weeklyTargetMet = chore._weeklyTarget > 0
&& chore._weeklyProgress >= chore._weeklyTarget
&& !completedToday;
// Mark whether this chore's time period has elapsed (grace-aware).
chore._isTimeElapsed = this._isTimePeriodElapsed(chore);
@@ -3494,7 +3567,7 @@ class TaskMateChildCard extends LitElement {
// 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);
const dailyLimit = this._effectiveDailyLimit(chore, child, completionsToday, isFirstCome);
// Check for optimistic completions (chores just completed but not yet confirmed by HA)
const optimisticKey = `${chore.id}_${child.id}`;
@@ -3547,6 +3620,9 @@ class TaskMateChildCard extends LitElement {
const firstComeClaimedMode = this.config.first_come_claimed_mode || 'hide';
const firstComeLocked = !!chore._isFirstComeLocked && !isCompletedForToday;
const notAvailableFirstCome = firstComeLocked && firstComeClaimedMode === 'dim';
// Weekly quota filled (#883). Dimmed and non-interactive: the backend
// would refuse the completion, so a live row would just swallow the tap.
const weeklyDone = !!chore._weeklyTargetMet && !isCompletedForToday;
// 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';
@@ -3570,13 +3646,14 @@ class TaskMateChildCard extends LitElement {
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 (weeklyDone) return; // Weekly target already met — nothing owed until Monday
if (isCompletedForToday) {
this._handleUndo(chore, child, childCompletionsToday);
} else {
this._handleComplete(chore, child);
}
};
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed || firstComeLocked);
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed || firstComeLocked || weeklyDone);
const handleRowKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -3596,13 +3673,15 @@ class TaskMateChildCard extends LitElement {
? (chore._firstComeCompletedByName
? this._t('child.claimed_by_another', { name: chore._firstComeCompletedByName })
: this._t('child.claimed_by_another_generic'))
: weeklyDone
? this._t('child.weekly_target_done')
: 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" : ""} ${notAvailableFirstCome ? "first-come-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 || weeklyDone ? "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'}"
@@ -3646,6 +3725,7 @@ class TaskMateChildCard extends LitElement {
<ha-icon icon="${pointsIcon}"></ha-icon>
+${chore.effective_points ?? chore.points}
${dailyLimit > 1 ? html`<span style="font-size: 0.8em; opacity: 0.7;">(${completionsToday}/${dailyLimit})</span>` : ''}
${chore._weeklyTarget > 0 ? html`<span style="font-size: 0.8em; opacity: 0.7;">${this._t('child.weekly_target_progress', { done: chore._weeklyProgress, target: chore._weeklyTarget })}</span>` : ''}
</div>
${chore.require_photo && !isCompletedForToday ? html`
<div class="recurrence-label">
@@ -13,11 +13,21 @@
* console — dark gamified HUD
* cleanpro — calm productivity / SaaS
* accessible — high contrast, colour-blind safe, dyslexia-friendly type
* graphite — corporate/system look; reuses the cleanpro LAYOUT (see
* LAYOUT_ALIAS) and restyles it entirely through tokens + kit
*
* Mirrors window.__taskmate_localize: exposed globally, no ES module imports.
*/
(function () {
const IDS = ["classic", "playroom", "console", "cleanpro", "accessible"];
const IDS = ["classic", "playroom", "console", "cleanpro", "accessible", "graphite"];
// A design whose markup is another design's. apply() stamps the real id on
// the host (so the style's CSS applies) but hands cards the layout id, so
// every existing `design === "cleanpro"` branch picks graphite up too. Without
// this a new id falls down each card's final `else`, which is playroom in
// some cards and cleanpro in others — the classic "works on one style,
// invisible on the rest" bug.
const LAYOUT_ALIAS = { graphite: "cleanpro" };
// Fonts must be loaded at the document level (an @import inside a shadow
// stylesheet is honoured, but loading once globally avoids N duplicate fetches).
@@ -56,6 +66,23 @@
--tmd-c1:#4F6BED;--tmd-c2:#0FB5A8;--tmd-c3:#E0A100;--tmd-c4:#7A5AF0;--tmd-c5:#E0567A;--tmd-c6:#2BA84A;
}
:host([data-tm-design="graphite"]),[data-tm-design="graphite"]{
/* Neutrals carry the card; the only saturated element is the header band
(per-card header_color). Actions are near-black rather than a coloured
accent, and colour is reserved for meaning: good / warn / bad. */
--tmd-bg:#EFEFF2;--tmd-surface:#FFFFFF;--tmd-surface-2:#F5F5F7;--tmd-border:#D2D2D7;
--tmd-text:#1D1D1F;--tmd-dim:#6E6E73;
--tmd-accent:#1D1D1F;--tmd-accent2:#6E6E73;--tmd-good:#1D7A4C;--tmd-warn:#B25E00;--tmd-bad:#B3261E;--tmd-gold:#B25E00;
--tmd-radius:18px;--tmd-radius-sm:12px;--tmd-shadow:none;--tmd-hd-text:#fff;
/* The system stack resolves to real SF Pro on Apple hardware; Inter (already
in FONT_IMPORT) is the fallback everywhere else. Figures are kept aligned
with font-variant-numeric rather than a separate monospace face. */
--tmd-font-display:-apple-system,BlinkMacSystemFont,"SF Pro Display","Inter",system-ui,sans-serif;
--tmd-font-body:-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
--tmd-font-mono:-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
--tmd-c1:#1F5F8B;--tmd-c2:#146356;--tmd-c3:#8A4B12;--tmd-c4:#3E4B8B;--tmd-c5:#6B3D6B;--tmd-c6:#2F4858;
}
/* ── Dark-mode variants ──────────────────────────────────────────────────
Cards stamp data-tm-dark on the host when HA is in dark mode. Each design has
a light base (above) and a dark override here, so all three follow HA's
@@ -93,6 +120,16 @@
--tmd-text:#E6EAF1;--tmd-dim:#97A1B0;--tmd-accent:#6E86F5;--tmd-accent2:#1FC7B8;
--tmd-shadow:0 1px 2px rgba(0,0,0,.4),0 8px 18px rgba(0,0,0,.35);
}
:host([data-tm-design="graphite"][data-tm-dark]),[data-tm-design="graphite"][data-tm-dark]{
/* True black behind elevated sheets. --tmd-accent stays a mid neutral so a
card with no header_color still has a legible band with white text; the
button colour is derived from --tmd-text instead, so it inverts properly. */
--tmd-bg:#000000;--tmd-surface:#1C1C1E;--tmd-surface-2:#2C2C2E;--tmd-border:#38383A;
--tmd-text:#F5F5F7;--tmd-dim:#98989F;
--tmd-accent:#3A3A3C;--tmd-accent2:#98989F;--tmd-good:#30A46C;--tmd-warn:#D98324;--tmd-bad:#E5484D;--tmd-gold:#D98324;
--tmd-shadow:none;
--tmd-c1:#2C7CB0;--tmd-c2:#1E8C77;--tmd-c3:#B06A26;--tmd-c4:#5B6AC0;--tmd-c5:#945993;--tmd-c6:#4A6B80;
}
:host([data-tm-design="accessible"][data-tm-dark]),[data-tm-design="accessible"][data-tm-dark]{
/* Pure black would bloom on OLED and is harsh for astigmatism; #0A0A0A with
near-white text still clears AAA. Hues are the Okabe-Ito light variants,
@@ -145,7 +182,142 @@
.stat{background:var(--tmd-surface-2);border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);padding:10px 11px}
.stat .k{font-size:11px;font-weight:700;color:var(--tmd-dim);text-transform:uppercase;letter-spacing:.04em}
.stat .v{font-family:var(--tmd-font-display);font-weight:800;font-size:20px;margin-top:2px}
.grid{display:grid;gap:10px}`;
.grid{display:grid;gap:10px}
/* ── Graphite ─────────────────────────────────────────────────────────────
Graphite has no render path of its own: it borrows the Clean Pro markup
(LAYOUT_ALIAS) and is defined entirely by the rules below, so all cards
adopt it at once. Selectors are :host([...]) + class, which out-specifies
the '.tmd-check .c-num' pattern the cards use for their own design CSS. */
:host([data-tm-design="graphite"]) .tmd{border:1px solid var(--tmd-border);box-shadow:none}
/* The header band stays full colour — it is the one saturated element. */
:host([data-tm-design="graphite"]) .tmd-hd{padding:13px 15px 12px}
:host([data-tm-design="graphite"]) .tmd-hd .tt{font-weight:600;font-size:17px;letter-spacing:-.42px}
:host([data-tm-design="graphite"]) .tmd-hd .tt small{font-weight:400;font-size:13px;opacity:.78;letter-spacing:-.06px;margin-top:2px}
:host([data-tm-design="graphite"]) .tmd-hd .ic{border-radius:50%;background:rgba(255,255,255,.2)}
:host([data-tm-design="graphite"]) .tmd-hd .pill{background:transparent;padding:0;font-weight:500;font-size:15px;letter-spacing:-.2px;text-transform:none}
:host([data-tm-design="graphite"]) .tmd-hd .cnt{background:rgba(255,255,255,.22);font-weight:500;font-variant-numeric:tabular-nums}
/* Actions are near-black on light and near-white on dark. Deriving them from
--tmd-text (not --tmd-accent) is what makes both directions invert. */
:host([data-tm-design="graphite"]) .btn{background:var(--tmd-text);color:var(--tmd-surface);
font-family:var(--tmd-font-body);font-weight:500;letter-spacing:-.2px;
border-radius:9px;box-shadow:none}
:host([data-tm-design="graphite"]) .btn.ghost{background:transparent;color:var(--tmd-text);border:1px solid var(--tmd-border)}
/* "good" marks the primary confirm (Approve / Dismiss). Graphite draws the
primary action in near-black like every other one; green is reserved for
reporting a completed state, not for a button. */
:host([data-tm-design="graphite"]) .btn.good{background:var(--tmd-text);color:var(--tmd-surface)}
:host([data-tm-design="graphite"]) .btn.bad{background:transparent;color:var(--tmd-bad)}
:host([data-tm-design="graphite"]) .btn.round{border-radius:50%}
/* Figures hold their column as they tick over. */
:host([data-tm-design="graphite"]) .num,
:host([data-tm-design="graphite"]) .big,
:host([data-tm-design="graphite"]) .stat .v{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
/* A chip reports a value here; it is not a highlight. */
:host([data-tm-design="graphite"]) .chip{background:transparent;border-color:transparent;color:var(--tmd-dim);
font-weight:500;font-variant-numeric:tabular-nums}
:host([data-tm-design="graphite"]) .chip.soft{color:var(--tmd-dim)}
/* 4px track rather than a fat rounded pill. */
:host([data-tm-design="graphite"]) .bar{height:4px;border:0;border-radius:2px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .bar>i{box-shadow:none}
:host([data-tm-design="graphite"]) .av{box-shadow:none}
:host([data-tm-design="graphite"]) .divide{background:var(--tmd-border)}
/* Stat cells are separated by hairlines, not drawn as nested boxes. */
:host([data-tm-design="graphite"]) .stat{background:transparent;border:0;border-radius:0;padding:0 12px}
:host([data-tm-design="graphite"]) .stat .k{text-transform:none;letter-spacing:-.04px;font-weight:400;font-size:12.5px}
/* Grouped inset list: one hairline container, separators starting past the
leading control, and the leading badge reduced to a completion mark. */
:host([data-tm-design="graphite"]) .tmd-checklist{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .tmd-check{position:relative;padding:9px 12px;min-height:46px;box-sizing:border-box}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check{border-top:0}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;left:46px;right:0;height:1px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-check .c-num{width:22px;height:22px;border-radius:50%;
background:transparent;border:1.5px solid var(--tmd-border);color:transparent;font-size:0}
:host([data-tm-design="graphite"]) .tmd-check.done .c-num{background:var(--tmd-good);border-color:var(--tmd-good);color:#fff;font-size:13px}
:host([data-tm-design="graphite"]) .tmd-check .c-name{font-weight:450;font-size:15px;letter-spacing:-.18px}
/* Dimming already says "done"; the strike-through is redundant noise. */
:host([data-tm-design="graphite"]) .tmd-check.done .c-name{text-decoration:none}
:host([data-tm-design="graphite"]) .tmd-check.done{opacity:.52}
/* Meta reads as quiet text, not as a row of coloured pills. */
:host([data-tm-design="graphite"]) .tmd-tag{background:transparent;border-color:transparent;
color:var(--tmd-dim);font-weight:400;font-size:12.5px;letter-spacing:-.02px;padding:0;text-transform:none}
:host([data-tm-design="graphite"]) .tmd-tag.mandatory{color:var(--tmd-bad)}
:host([data-tm-design="graphite"]) .tmd-tag.photo{color:var(--tmd-dim)}
/* The decorative emoji tile in a card header is the loudest "kids app" tell
left in the designed markup, and it is chrome rather than user data — the
child card puts a real avatar here instead, which is kept. */
:host([data-tm-design="graphite"]) .tmd-hd .ic{display:none}
/* Sentence case throughout: tracked-out uppercase labels are not this style. */
:host([data-tm-design="graphite"]) .lbl,
:host([data-tm-design="graphite"]) .ap-group-label,
:host([data-tm-design="graphite"]) .rw-jackpot-label,
:host([data-tm-design="graphite"]) .tmd-badges .lbl{text-transform:none;letter-spacing:-.04px;font-weight:500;font-size:13px;color:var(--tmd-dim)}
/* Badge strip + next-badge: outlines rather than filled colour discs. */
:host([data-tm-design="graphite"]) .tmd-badges,
:host([data-tm-design="graphite"]) .tmd-next-badge{background:transparent;border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .tmd-badge-mini{background:transparent;border:1px solid var(--tmd-border);box-shadow:none}
/* The glyphs inside these discs are drawn white for a filled tier colour;
once the fill is gone they have to take the text colour or they vanish. */
:host([data-tm-design="graphite"]) .tmd-badge-mini,
:host([data-tm-design="graphite"]) .tmd-badge-mini ha-icon,
:host([data-tm-design="graphite"]) .tmd-next-badge .ic,
:host([data-tm-design="graphite"]) .tmd-next-badge .ic ha-icon{color:var(--tmd-text)}
:host([data-tm-design="graphite"]) .tmd-next-badge .ic{background:transparent;box-shadow:none}
:host([data-tm-design="graphite"]) .tmd-next-badge .bar{height:4px;border:0;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-next-badge .nm{font-weight:500}
/* Points display: tiles become a plain figure block. */
:host([data-tm-design="graphite"]) .cp-tile{background:transparent;border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);box-shadow:none}
:host([data-tm-design="graphite"]) .cp-pts,
:host([data-tm-design="graphite"]) .cp-total{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-1.2px}
:host([data-tm-design="graphite"]) .cp-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .cp-rank,
:host([data-tm-design="graphite"]) .cp-chips .chip{background:transparent;border-color:transparent;color:var(--tmd-dim);font-weight:500}
/* Rewards: a grouped list of fixed costs, not a deck of tinted cards. */
:host([data-tm-design="graphite"]) .rw-list{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);gap:0}
:host([data-tm-design="graphite"]) .rw-card{background:transparent;border:0;border-radius:0;box-shadow:none}
:host([data-tm-design="graphite"]) .rw-card+.rw-card{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .rw-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .rw-cost{font-variant-numeric:tabular-nums;font-weight:500;color:var(--tmd-dim)}
:host([data-tm-design="graphite"]) .rw-emoji{font-size:0}
:host([data-tm-design="graphite"]) .rw-pool-bar{height:4px;border:0;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .rw-jackpot{background:transparent;border:1px solid var(--tmd-border)}
/* Approvals: one grouped queue; the destructive action is text, not a slab. */
:host([data-tm-design="graphite"]) .ap-cp-item{background:transparent;border:0;border-radius:0;box-shadow:none}
:host([data-tm-design="graphite"]) .ap-cp-item+.ap-cp-item{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ap-cp-title{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .ap-cp-sub{color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .ap-cp-photo{border-radius:8px;border:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ap-gold-soft{background:transparent;color:var(--tmd-dim)}
:host([data-tm-design="graphite"]) .approve-all-btn{background:transparent;color:#fff;border:1px solid rgba(255,255,255,.45);font-weight:500}
/* Leaderboard: rank, name, figure — right-aligned and tabular. */
:host([data-tm-design="graphite"]) .lb-cp-row{background:transparent;border:0;border-radius:0}
:host([data-tm-design="graphite"]) .lb-cp-row+.lb-cp-row{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .lb-cp-rank{background:transparent;border:0;color:var(--tmd-dim);font-variant-numeric:tabular-nums;font-weight:500}
:host([data-tm-design="graphite"]) .lb-cp-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .lb-cp-score{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .lb-cp-unit{color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .lb-cp-row.win{background:transparent}
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-right:3px;color:var(--tmd-dim)}
/* Overview: hairline-separated stats, then a grouped per-child list. */
:host([data-tm-design="graphite"]) .ov-kids{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .ov-kid{background:transparent;border:0;border-radius:0}
:host([data-tm-design="graphite"]) .ov-kid+.ov-kid{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ov-kid-flag{background:transparent;border:0;color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .ov-alert{background:transparent;border:1px solid var(--tmd-border);color:var(--tmd-text)}
:host([data-tm-design="graphite"]) .ov-today .val,
:host([data-tm-design="graphite"]) .pts{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .nm,
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}`;
if (!document.getElementById("taskmate-design-fonts")) {
const styleEl = document.createElement("style");
@@ -249,7 +421,14 @@
el.setAttribute("data-tm-design", design);
el.toggleAttribute("data-tm-dark", design !== "classic" && isDark(hass, el));
}
return design;
// The host keeps the real id (its CSS keys off it); the card gets the
// layout id so aliased styles reuse an existing render path.
return LAYOUT_ALIAS[design] || design;
}
/** The real design id stamped on a card host (not the layout alias). */
function active(el) {
return (el && el.getAttribute("data-tm-design")) || "classic";
}
/** ha-form select options for a per-card design override (includes "use global"). */
@@ -262,6 +441,7 @@
{ value: "console", label: label("console", "Console") },
{ value: "cleanpro", label: label("cleanpro", "Clean Pro") },
{ value: "accessible", label: label("accessible", "Accessible") },
{ value: "graphite", label: label("graphite", "Graphite") },
];
}
@@ -307,7 +487,7 @@
`;
}
window.__taskmate_design = { IDS, resolve, isDark, apply, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
window.__taskmate_design = { IDS, resolve, isDark, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
/**
* The single place that decides what a chore looks like (#750).
@@ -659,10 +659,17 @@ class TaskMateLeaderboardCard extends LitElement {
_lbMetaChips(r, ctx, cls) {
const sortBy = ctx.sortBy;
const chips = [];
if (this.config.show_streak !== false && sortBy !== "streak") chips.push(html`<span>🔥 ${r.streak}</span>`);
if (this.config.show_weekly !== false && sortBy !== "weekly") chips.push(html`<span>📅 ${r.weekly}</span>`);
if (sortBy !== "points") chips.push(html`<span>⭐ ${r.points}</span>`);
if (this.config.show_career !== false && sortBy !== "career") chips.push(html`<span>🏆 ${r.career}</span>`);
// Graphite carries no emoji chrome, so the same figures get monoline
// icons there. active() returns the real design id (apply() hands the
// render path the layout alias, which for graphite is "cleanpro").
const mono = window.__taskmate_design?.active?.(this) === "graphite";
const stat = (icon, emoji, value) => (mono
? html`<span><ha-icon icon="${icon}"></ha-icon>${value}</span>`
: html`<span>${emoji} ${value}</span>`);
if (this.config.show_streak !== false && sortBy !== "streak") chips.push(stat("mdi:fire", "🔥", r.streak));
if (this.config.show_weekly !== false && sortBy !== "weekly") chips.push(stat("mdi:calendar-blank-outline", "📅", r.weekly));
if (sortBy !== "points") chips.push(stat("mdi:star-outline", "⭐", r.points));
if (this.config.show_career !== false && sortBy !== "career") chips.push(stat("mdi:trophy-outline", "🏆", r.career));
if (!chips.length) return "";
return html`<div class="row muted ${cls}">${chips}</div>`;
}
@@ -581,6 +581,12 @@ class TaskMateOverviewCard extends LitElement {
const perChild = availability[c.id];
if (perChild && perChild[child.id] === false) return false;
}
// Weekly target (#883): the week's quota is filled, so the chore isn't
// outstanding today. Filtering here rather than at the two call sites
// keeps it out of the progress ring as well — otherwise the ring could
// never reach 100% once a weekly chore was finished for the week.
const weeklyTarget = Number(c.weekly_target) || 0;
if (weeklyTarget > 0 && Number((child.weekly_chore_progress || {})[c.id] || 0) >= weeklyTarget) return false;
return true;
});
}
@@ -345,6 +345,8 @@ class TaskMatePanel extends HTMLElement {
['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'],
['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'],
['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'],
['Pool refund (reward funding changed):', 'activity.reason_pool_refund_funding_changed'],
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
];
@@ -1417,6 +1419,7 @@ class TaskMatePanel extends HTMLElement {
timed_rate_points: 10, timed_rate_minutes: 5, timed_max_daily_minutes: 0,
assigned_to: [], requires_approval: true,
time_category: "anytime", completion_sound: "coin", daily_limit: 1,
weekly_target: 0,
difficulty: "medium",
claim_allowance_minutes: 0,
schedule_mode: "specific_days",
@@ -1657,6 +1660,7 @@ class TaskMatePanel extends HTMLElement {
completion_sound: d.completion_sound || "coin",
difficulty: d.difficulty || "medium",
daily_limit: Number(d.daily_limit) || 1,
weekly_target: Math.max(0, Number(d.weekly_target) || 0),
schedule_mode: d.schedule_mode || "specific_days",
due_days: d.due_days || [],
recurrence: d.recurrence || "weekly",
@@ -3235,7 +3239,7 @@ class TaskMatePanel extends HTMLElement {
ts: c.approved_at || c.claimed_at, kind: "claim",
child: (childById[c.child_id] || {}).name || "?",
label: `${this._t("panel.activity_claimed_reward", {name: (rewardById[c.reward_id] || {}).name || this._t("panel.activity_deleted_reward")})}`,
points: -((rewardById[c.reward_id] || {}).cost || 0),
points: -(c.approved_cost ?? (rewardById[c.reward_id] || {}).cost ?? 0),
}));
transactions.forEach(t => events.push({
ts: t.created_at, kind: "manual",
@@ -3283,6 +3287,10 @@ class TaskMatePanel extends HTMLElement {
chorePoints = Math.floor(c.timed_duration_seconds / rateSeconds) * (chore.timed_rate_points || 0);
}
}
// What the child was promised when they submitted beats anything
// recalculated here: a chore edited while the work sat in the
// queue, or a speed/roulette bonus that no longer applies.
chorePoints = c.submitted_points ?? chorePoints;
const photoCap = [choreName, (child && child.name) || "", c.completed_at ? new Date(c.completed_at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""].filter(Boolean).join(" · ");
return `
<div class="tm-approval-item">
@@ -3464,6 +3472,11 @@ class TaskMatePanel extends HTMLElement {
? this._t("panel.common_one_shot")
: ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · "));
const schedClass = c.schedule_mode === "recurring" ? "tm-pill-accent" : c.schedule_mode === "one_shot" ? "tm-pill-warn" : "tm-pill-success";
// A weekly quota doesn't replace the day list — the chore is still only
// offered on those days, it just stops once the week's count is filled.
const weeklyPill = Number(c.weekly_target) > 0
? ` <span class="tm-pill tm-pill-accent">${this._t("panel.chore_weekly_target_pill", { count: Number(c.weekly_target) })}</span>`
: "";
const modeBadge = c.assignment_mode && c.assignment_mode !== "everyone"
? `<span class="tm-pill tm-pill-${this._esc(c.assignment_mode)}">${this._t(`panel.assign_${c.assignment_mode}_short`)}</span>` : "";
const nameCell = renaming
@@ -3485,7 +3498,7 @@ class TaskMatePanel extends HTMLElement {
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
<td>${assignedNames} ${modeBadge}</td>
<td>${currentName}</td>
<td><span class="tm-pill ${schedClass} tm-pill-dot">${this._esc(schedLabel)}</span></td>
<td><span class="tm-pill ${schedClass} tm-pill-dot">${this._esc(schedLabel)}</span>${weeklyPill}</td>
<td>${c.requires_approval ? `<span class='tm-yes'>${this._t("panel.common_yes")}</span>` : `<span class='tm-no'>${this._t("panel.common_no")}</span>`}</td>
</tr>
`;
@@ -4539,7 +4552,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_card_design_label")}<small>${this._t("panel.settings_card_design_hint")}</small></div>
<select class="tm-select" data-setting="card_design">
${["classic","playroom","console","cleanpro"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
${["classic","playroom","console","cleanpro","accessible","graphite"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
</select>
</div>
<div class="tm-setting-row">
@@ -5433,6 +5446,8 @@ class TaskMatePanel extends HTMLElement {
`).join("")}
</div>
</div>
${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number",
this._t("panel.chore_weekly_target_hint"))}
` : "",
showRecurring ? `
<div class="tm-field-row">
@@ -1042,6 +1042,10 @@ class TaskMateParentDashboardCard extends LitElement {
if (c.schedule_mode === 'recurring') {
if (perChild && perChild[child.id] === false) return false;
}
// Weekly target (#883): quota filled for the week, so it is neither
// outstanding nor part of today's total.
const weeklyTarget = Number(c.weekly_target) || 0;
if (weeklyTarget > 0 && Number((child.weekly_chore_progress || {})[c.id] || 0) >= weeklyTarget) return false;
return true;
});
const childChoreIds = new Set(childChores.map(c => c.id));