59 files
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.7.0"
|
||||
"version": "5.8.0"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ _SELECTS = [
|
||||
(
|
||||
"card_design",
|
||||
"card_design",
|
||||
["classic", "playroom", "console", "cleanpro", "accessible"],
|
||||
["classic", "playroom", "console", "cleanpro", "accessible", "graphite"],
|
||||
"classic",
|
||||
"mdi:palette",
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user