diff --git a/README.md b/README.md index 8413e4dc..74d21980 100755 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ For manual installation and the first-run walkthrough, see [Installation](https: **Gamification** — a [virtual pet](https://tempus2016.github.io/taskmate/Virtual-Pets/) that hatches and grows as chores get done, [personal weekly goals](https://tempus2016.github.io/taskmate/Weekly-Goals/) with a bonus, [personal bests](https://tempus2016.github.io/taskmate/Personal-Bests/) every child can beat whatever their place on the leaderboard, [levels & XP](https://tempus2016.github.io/taskmate/Levels-and-XP/), [achievement badges](https://tempus2016.github.io/taskmate/Achievement-Badges/), [quests](https://tempus2016.github.io/taskmate/Quests/), [challenges](https://tempus2016.github.io/taskmate/Challenges/), [unlockable avatars](https://tempus2016.github.io/taskmate/Avatars/), [celebrations](https://tempus2016.github.io/taskmate/Celebrations/), [leaderboard seasons](https://tempus2016.github.io/taskmate/Leaderboard-Seasons/), [streak freezes](https://tempus2016.github.io/taskmate/Streak-Freezes/) that protect a streak through a missed day, [birthday mode](https://tempus2016.github.io/taskmate/Birthday-Mode/), [surprise inspections](https://tempus2016.github.io/taskmate/Surprise-Inspections/) with a bonus, and "Wrapped"-style [recaps](https://tempus2016.github.io/taskmate/Recaps/) on a schedule you choose. -**Parent tooling** — an [admin panel](https://tempus2016.github.io/taskmate/Admin-Panel/) with a Today home page and a [setup wizard](https://tempus2016.github.io/taskmate/Setup-Wizard/) that suggests chores by age, [ready-made dashboards](https://tempus2016.github.io/taskmate/Ready-Made-Dashboards/) for the kids, the parents and a wall tablet, [notifications](https://tempus2016.github.io/taskmate/Notifications/) in your Home Assistant language, with [quiet hours](https://tempus2016.github.io/taskmate/Quiet-Hours/), [reminder escalation](https://tempus2016.github.io/taskmate/Reminder-Escalation/) and [presence-aware reminders](https://tempus2016.github.io/taskmate/Presence-Aware-Reminders/) that wait until a child is home, [reject reasons](https://tempus2016.github.io/taskmate/Reject-Reasons/) the child can see, [multi-parent approval routing](https://tempus2016.github.io/taskmate/Multi-Parent-Approval-Routing/), a [weekly digest](https://tempus2016.github.io/taskmate/Weekly-Digest/), [insight reports](https://tempus2016.github.io/taskmate/Insights/) on fairness and friction, an [audit log](https://tempus2016.github.io/taskmate/Admin-Audit-Log/), [sick days](https://tempus2016.github.io/taskmate/Sick-Days/) that pause chores and protect streaks, [event entities](https://tempus2016.github.io/taskmate/Event-Entities/) for building automations in the UI editor, [Repairs issues and a diagnostics download](https://tempus2016.github.io/taskmate/Repairs-and-Diagnostics/), [adult profiles](https://tempus2016.github.io/taskmate/Adult-Profiles/) so parents can track their own chores without approving each other's, and [backup & restore](https://tempus2016.github.io/taskmate/Backup-and-Restore/). +**Parent tooling** — an [admin panel](https://tempus2016.github.io/taskmate/Admin-Panel/) with a Today home page and a [setup wizard](https://tempus2016.github.io/taskmate/Setup-Wizard/) that suggests chores by age, [ready-made dashboards](https://tempus2016.github.io/taskmate/Ready-Made-Dashboards/) for the kids, the parents and a wall tablet, [notifications](https://tempus2016.github.io/taskmate/Notifications/) in your Home Assistant language, with [quiet hours](https://tempus2016.github.io/taskmate/Quiet-Hours/), [reminder escalation](https://tempus2016.github.io/taskmate/Reminder-Escalation/) and [presence-aware reminders](https://tempus2016.github.io/taskmate/Presence-Aware-Reminders/) that wait until a child is home, [reject reasons](https://tempus2016.github.io/taskmate/Reject-Reasons/) the child can see and [appeals](https://tempus2016.github.io/taskmate/Appeals/) when they think you got it wrong, [multi-parent approval routing](https://tempus2016.github.io/taskmate/Multi-Parent-Approval-Routing/), a [weekly digest](https://tempus2016.github.io/taskmate/Weekly-Digest/), [insight reports](https://tempus2016.github.io/taskmate/Insights/) on fairness and friction, an [audit log](https://tempus2016.github.io/taskmate/Admin-Audit-Log/), [sick days](https://tempus2016.github.io/taskmate/Sick-Days/) that pause chores and protect streaks, [event entities](https://tempus2016.github.io/taskmate/Event-Entities/) for building automations in the UI editor, [Repairs issues and a diagnostics download](https://tempus2016.github.io/taskmate/Repairs-and-Diagnostics/), [adult profiles](https://tempus2016.github.io/taskmate/Adult-Profiles/) so parents can track their own chores without approving each other's, and [backup & restore](https://tempus2016.github.io/taskmate/Backup-and-Restore/). **Made for kids** — a [sticker chart](https://tempus2016.github.io/taskmate/Sticker-Chart-Card/) where today's jobs are stickers to tap and stars fill up towards a reward, [Routine Mode](https://tempus2016.github.io/taskmate/Routine-Mode/) for guided morning and bedtime flows, [Pre-Reader Mode](https://tempus2016.github.io/taskmate/Pre-Reader-Mode/) for children who can't read yet, [Kiosk Mode](https://tempus2016.github.io/taskmate/Kiosk-Mode/) for a shared wall tablet (where they can claim bounties too, hide what's already done, and show the family's day or a photo slideshow while idle), a [child undo window](https://tempus2016.github.io/taskmate/Child-Undo-Window/) so they can take back a mistaken tap, an ["I'm stuck" button](https://tempus2016.github.io/taskmate/Help-Requests/) that asks a parent for help, an end-of-day [mood check-in](https://tempus2016.github.io/taskmate/Mood-Check-In/), [watch complications](https://tempus2016.github.io/taskmate/Watch-Complications/) for Apple Watch and Wear OS, [Read Aloud](https://tempus2016.github.io/taskmate/Read-Aloud/) to a media player, a [printable fridge chart](https://tempus2016.github.io/taskmate/Printable-Weekly-Chart/), each child's [photo](https://tempus2016.github.io/taskmate/Children/#child-photos) on the cards from their Home Assistant person, [custom completion sounds](https://tempus2016.github.io/taskmate/Completion-Sounds/) you can upload and pick per chore, and six [card design styles](https://tempus2016.github.io/taskmate/Card-Design-Styles/) including a high-contrast accessible theme and a clean corporate one. Cards and the panel are translated into 10 languages and support [right-to-left layouts](https://tempus2016.github.io/taskmate/Localization/). diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 6e8763c7..ee8ec74e 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -375,6 +375,9 @@ def is_valid_completion_sound(value: str | None) -> bool: NOTIF_TYPE_PERSONAL_BEST: Final = "personal_best" # Boost windows: a boost covering the child just began. NOTIF_TYPE_BOOST_STARTED: Final = "boost_started" +# Appeals: a child appealed a rejection or penalty; a parent decided it. +NOTIF_TYPE_APPEAL_SUBMITTED: Final = "appeal_submitted" +NOTIF_TYPE_APPEAL_DECIDED: Final = "appeal_decided" # Reward fulfilment (#1098): default days before parents are reminded a # redeemed reward is still owed (0 = never remind), and the most allowed. diff --git a/custom_components/taskmate/coord_appeals.py b/custom_components/taskmate/coord_appeals.py new file mode 100644 index 00000000..b56b014d --- /dev/null +++ b/custom_components/taskmate/coord_appeals.py @@ -0,0 +1,278 @@ +"""Appeals: a child can ask for a second look at a rejection or a penalty. + +With ``appeals_enabled`` on, a child's card lists what they may appeal: a +chore or reward claim a parent rejected, or a penalty applied to them, from +the last ``APPEAL_WINDOW_HOURS`` hours. One appeal per item, with an optional +message. The parents get a push (``appeal_submitted``, on by default like a +help request) and the appeal waits on the panel's Today page. + +Deciding it: + * **Uphold** — nothing changes; the child sees the parent's note. + * **Overturn** — a rejected chore pays its points, a rejected reward claim + is approved there and then, a penalty is undone (and any open make-it-right + offer for it is closed). + +A penalty the child has already earned back with its make-it-right chore +can't be appealed: it has been put right already. Wishlist redemptions keep +their own decline flow and are never appealable. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any + +from homeassistant.util import dt as dt_util + +from . import notify_strings +from .const import NOTIF_TYPE_APPEAL_DECIDED, NOTIF_TYPE_APPEAL_SUBMITTED +from .models import RewardClaim, clean_label, generate_id, parse_datetime + +_LOGGER = logging.getLogger(__name__) + +APPEAL_WINDOW_HOURS = 48 +APPEAL_MESSAGE_MAX = 200 +APPEAL_NOTE_MAX = 200 +APPEAL_KINDS = ("chore", "reward", "penalty") +# Decided appeals stay on the child's card this long. +APPEAL_SHOW_DECIDED_HOURS = 24 +APPEAL_KEEP = 100 +# How close a make-it-right offer's creation must be to the penalty's +# transaction to count as that penalty's offer. +_OFFER_MATCH = timedelta(minutes=2) +_PENALTY_PREFIX = "Penalty: " + + +def _when(value) -> datetime | None: + try: + return parse_datetime(value) + except (TypeError, ValueError): + return None + + +class AppealsMixin: + """Children appeal rejections and penalties; parents uphold or overturn.""" + + def appeals_enabled(self) -> bool: + v = self.storage.get_setting("appeals_enabled", False) + return v is True or str(v).lower() == "true" + + def _appeals(self) -> list[dict[str, Any]]: + return self.storage.get_section("appeals", list) + + def _appealed_targets(self) -> set[str]: + return {a.get("target_id") for a in self._appeals()} + + def _penalty_offer(self, child_id: str, name: str, at: datetime) -> dict[str, Any] | None: + """The make-it-right offer that penalty transaction created, if any.""" + for offer in self.storage.get_section("penalty_redemptions", list): + if offer.get("child_id") != child_id or offer.get("penalty_name") != name: + continue + created = _when(offer.get("created_at")) + if created is not None and abs(created - at) <= _OFFER_MATCH: + return offer + return None + + def appealable_for_child(self, child_id: str) -> list[dict[str, Any]]: + """What this child may still appeal, newest first.""" + if not self.appeals_enabled(): + return [] + now = dt_util.now() + cutoff = now - timedelta(hours=APPEAL_WINDOW_HOURS) + taken = self._appealed_targets() + out: list[dict[str, Any]] = [] + for row in self.storage.get_rejections(): + if row.get("child_id") != child_id or row.get("wish") or row.get("kind") not in ("chore", "reward"): + continue + at = _when(row.get("rejected_at")) + if at is None or at < cutoff or row.get("id") in taken: + continue + out.append( + { + "kind": row["kind"], + "target_id": row["id"], + "item_id": row.get("item_id", ""), + "name": row.get("item_name", ""), + "points": int(row.get("points", 0) or 0), + "at": at.isoformat(), + } + ) + for txn in self.storage.get_points_transactions(): + if txn.child_id != child_id or not (txn.reason or "").startswith(_PENALTY_PREFIX) or txn.points >= 0: + continue + if txn.created_at < cutoff or txn.id in taken: + continue + name = txn.reason[len(_PENALTY_PREFIX) :] + offer = self._penalty_offer(child_id, name, txn.created_at) + if offer is not None and offer.get("status") == "redeemed": + continue + out.append( + { + "kind": "penalty", + "target_id": txn.id, + "item_id": "", + "name": name, + "points": -txn.points, + "at": txn.created_at.isoformat(), + } + ) + out.sort(key=lambda i: i["at"], reverse=True) + return out[:5] + + async def async_appeal(self, child_id: str, kind: str, target_id: str, message: str = "") -> dict[str, Any]: + """A child appeals one item from their appealable list.""" + if not self.appeals_enabled(): + raise ValueError("Appeals are switched off.") + child = self.get_child(child_id) + if child is None: + raise ValueError(f"Child {child_id} not found") + if target_id in self._appealed_targets(): + raise ValueError("That has already been appealed.") + item = next( + (i for i in self.appealable_for_child(child_id) if i["kind"] == kind and i["target_id"] == target_id), None + ) + if item is None: + raise ValueError("That can't be appealed.") + now = dt_util.now() + record = { + "id": generate_id(), + "child_id": child_id, + "kind": kind, + "target_id": target_id, + "item_id": item["item_id"], + "item_name": item["name"], + "points": item["points"], + "message": clean_label(message, APPEAL_MESSAGE_MAX), + "status": "open", + "created_at": now.isoformat(), + } + appeals = self._appeals() + appeals.append(record) + _trim(appeals) + await self.storage.async_save() + payload = { + "appeal_id": record["id"], + "child_id": child_id, + "child_name": child.name, + "kind": kind, + "item_name": record["item_name"], + "points": record["points"], + "message": record["message"], + } + self.hass.bus.async_fire("taskmate_appeal_submitted", {**payload, "timestamp": now.isoformat()}) + if getattr(self, "notifications", None): + await self.notifications.fire( + NOTIF_TYPE_APPEAL_SUBMITTED, + {**payload, "message_text": f" — “{record['message']}”" if record["message"] else ""}, + ) + await self.async_refresh() + return dict(record) + + def open_appeals(self) -> list[dict[str, Any]]: + """Appeals waiting for a parent, oldest first, with the child's name.""" + names = {c.id: c.name for c in self.storage.get_children()} + return [ + {**a, "child_name": names.get(a.get("child_id"), "")} for a in self._appeals() if a.get("status") == "open" + ] + + def appeals_for_child(self, child_id: str) -> list[dict[str, Any]]: + """This child's open appeals and the ones decided in the last day, compact, newest first.""" + cutoff = dt_util.now() - timedelta(hours=APPEAL_SHOW_DECIDED_HOURS) + out = [] + for a in reversed(self._appeals()): + if a.get("child_id") != child_id: + continue + if a.get("status") != "open": + decided = _when(a.get("decided_at")) + if decided is None or decided < cutoff: + continue + row = {k: a[k] for k in ("id", "kind", "item_name", "points", "status") if k in a} + if a.get("note"): + row["note"] = a["note"] + out.append(row) + return out[:5] + + async def async_decide_appeal(self, appeal_id: str, overturn: bool, note: str = "") -> dict[str, Any]: + """A parent upholds the original decision or overturns it.""" + appeal = next((a for a in self._appeals() if a.get("id") == appeal_id), None) + if appeal is None: + raise ValueError("That appeal no longer exists.") + if appeal.get("status") != "open": + raise ValueError("That appeal has already been decided.") + child = self.get_child(appeal["child_id"]) + if child is None: + raise ValueError("The child is no longer here.") + if overturn: + await self._async_overturn(appeal, child) + now = dt_util.now() + appeal["status"] = "overturned" if overturn else "upheld" + appeal["decided_at"] = now.isoformat() + appeal["note"] = clean_label(note, APPEAL_NOTE_MAX) + await self.storage.async_save() + payload = { + "appeal_id": appeal["id"], + "child_id": child.id, + "child_name": child.name, + "kind": appeal["kind"], + "item_name": appeal["item_name"], + "points": appeal["points"], + "overturned": bool(overturn), + "note": appeal["note"], + } + self.hass.bus.async_fire("taskmate_appeal_decided", {**payload, "timestamp": now.isoformat()}) + if getattr(self, "notifications", None): + await self.notifications.fire( + NOTIF_TYPE_APPEAL_DECIDED, + { + **payload, + "outcome": notify_strings.text( + self.hass, "appeal_outcome_overturned" if overturn else "appeal_outcome_upheld" + ), + "note_text": f": {appeal['note']}" if appeal["note"] else "", + }, + only_recipients={f"child:{child.id}"}, + ) + await self.async_refresh() + return dict(appeal) + + async def _async_overturn(self, appeal: dict[str, Any], child) -> None: + kind = appeal["kind"] + if kind == "chore": + points = int(appeal.get("points", 0) or 0) + if points > 0: + await self.async_add_points(child.id, points, reason=f"Appeal: {appeal['item_name']}") + return + if kind == "reward": + reward = self.get_reward(appeal.get("item_id", "")) + if reward is None: + raise ValueError("That reward no longer exists.") + claim = RewardClaim(reward_id=reward.id, child_id=child.id, claimed_at=dt_util.now()) + self.storage.add_reward_claim(claim) + try: + await self.async_approve_reward(claim.id) + except ValueError: + self.storage.remove_reward_claim(claim.id) + raise + return + if kind == "penalty": + txn = next((t for t in self.storage.get_points_transactions() if t.id == appeal["target_id"]), None) + if txn is None: + raise ValueError("That penalty has already been taken back.") + offer = self._penalty_offer(child.id, appeal["item_name"], txn.created_at) + if offer is not None and offer.get("status") == "redeemed": + raise ValueError("That penalty was already earned back.") + if offer is not None and offer.get("status") == "open": + offer["status"] = "cancelled" + await self.async_undo_transaction(txn.id) + return + raise ValueError(f"Unknown appeal kind {kind}") + + +def _trim(appeals: list[dict[str, Any]]) -> None: + """Keep every open appeal and the newest decided ones.""" + decided = [a for a in appeals if a.get("status") != "open"] + extra = len(decided) - APPEAL_KEEP + if extra > 0: + drop = {id(a) for a in decided[:extra]} + appeals[:] = [a for a in appeals if id(a) not in drop] diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index db42b679..199b7b33 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -1989,7 +1989,16 @@ async def async_reject_chore( chore_name = getattr(chore, "name", "") or getattr(bounty, "title", "") or "" if rejected and not target_completion.bonus_subtask_id: self._record_rejection( - "chore", target_completion.child_id, target_completion.chore_id, chore_name, reason + "chore", + target_completion.child_id, + target_completion.chore_id, + chore_name, + reason, + points=( + target_completion.submitted_points + if target_completion.submitted_points is not None + else getattr(chore, "points", 0) or 0 + ), ) # An open inspection of it is void; a redo it was is owed again (#981). self._inspection_on_completion_removed(completion_id) diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index 5f62e71c..30ceaff7 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -26,6 +26,8 @@ DEFAULT_NOTIFICATION_GROUP, DEFAULT_NOTIFICATION_NAV_URL, NOTIF_TYPE_ALL_CHORES_DONE, + NOTIF_TYPE_APPEAL_DECIDED, + NOTIF_TYPE_APPEAL_SUBMITTED, NOTIF_TYPE_AUCTION_CLOSING, NOTIF_TYPE_AUCTION_OPENED, NOTIF_TYPE_AUCTION_RESULT, @@ -197,6 +199,10 @@ class NotificationTypeMeta: NotificationTypeMeta(NOTIF_TYPE_PERSONAL_BEST, "both", False, False, False, False), # Boost windows: the children a boost covers hear it began. Opt-in. NotificationTypeMeta(NOTIF_TYPE_BOOST_STARTED, "child", False, False, False, False), + # Appeals: on for parents like a help request (only fires once a parent + # has switched appeals on); the child's decision push is opt-in. + NotificationTypeMeta(NOTIF_TYPE_APPEAL_SUBMITTED, "parent", False, False, False, True), + NotificationTypeMeta(NOTIF_TYPE_APPEAL_DECIDED, "child", False, False, False, False), ] NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES} @@ -206,6 +212,8 @@ class NotificationTypeMeta: # automation on the feature event runs once, not twice. _OWN_EVENT_TYPES: frozenset[str] = frozenset( { + NOTIF_TYPE_APPEAL_DECIDED, + NOTIF_TYPE_APPEAL_SUBMITTED, NOTIF_TYPE_AUCTION_OPENED, NOTIF_TYPE_BOOST_STARTED, NOTIF_TYPE_PERSONAL_BEST, @@ -511,6 +519,7 @@ async def send_test(self, type_id: str) -> list[str]: "tickets": 12, "record_label": notify_strings.text(self.hass, "record_longest_streak"), "chores_text": sample["chore"], + "outcome": notify_strings.text(self.hass, "appeal_outcome_overturned"), "value": 12, } ctx["result_text"] = notify_strings.render( diff --git a/custom_components/taskmate/coord_rejections.py b/custom_components/taskmate/coord_rejections.py index 88bb54ca..28b56de8 100644 --- a/custom_components/taskmate/coord_rejections.py +++ b/custom_components/taskmate/coord_rejections.py @@ -41,9 +41,18 @@ def clean_reject_reason(value) -> str: class RejectionsMixin: """Record why something was rejected, and tell the child.""" - def _record_rejection(self, kind: str, child_id: str, item_id: str, item_name: str, reason: str) -> None: - """Log a reasoned rejection. ``kind`` is "chore" or "reward"; no-op without a reason.""" - if not reason or not child_id: + def _record_rejection( + self, kind: str, child_id: str, item_id: str, item_name: str, reason: str, points: int = 0, wish: bool = False + ) -> None: + """Log a rejection. ``kind`` is "chore" or "reward". + + No-op without a reason, unless appeals are on: then every rejection is + kept for the appeal window, and the reasonless ones simply never show + on the card or in the feed. ``points`` is what the chore would have paid + or the reward cost, for an overturned appeal. + """ + appeals_on = bool(getattr(self, "appeals_enabled", None) and self.appeals_enabled()) + if not child_id or not (reason or appeals_on): return now = dt_util.now() cutoff = now - timedelta(days=REJECTION_KEEP_DAYS) @@ -56,6 +65,8 @@ def _record_rejection(self, kind: str, child_id: str, item_id: str, item_name: s "item_id": item_id, "item_name": item_name, "reason": reason, + **({"points": int(points)} if points else {}), + **({"wish": True} if wish else {}), # UTC, like every stored timestamp: the activity feed sorts # these ISO strings against transaction times. "rejected_at": format_datetime(now), @@ -83,7 +94,8 @@ async def _async_notify_rejected(self, kind: str, child, item_name: str, reason: def recent_rejections(self) -> list[dict]: """Every logged rejection still inside the keep window, oldest first.""" cutoff = dt_util.now() - timedelta(days=REJECTION_KEEP_DAYS) - return [r for r in self.storage.get_rejections() if (_at(r) or cutoff) > cutoff] + # Reasonless rows exist only for appeals; the feed shows reasons. + return [r for r in self.storage.get_rejections() if r.get("reason") and (_at(r) or cutoff) > cutoff] def rejections_for_child(self, child_id: str) -> list[dict]: """The reasons still worth showing on this child's card, newest first. @@ -93,7 +105,7 @@ def rejections_for_child(self, child_id: str) -> list[dict]: another go at it since — once they redo the chore or claim the reward again, the old reason no longer describes anything on the card. """ - rows = [r for r in self.storage.get_rejections() if r.get("child_id") == child_id] + rows = [r for r in self.storage.get_rejections() if r.get("child_id") == child_id and r.get("reason")] if not rows: return [] cutoff = dt_util.now() - timedelta(hours=REJECTION_CARD_HOURS) diff --git a/custom_components/taskmate/coord_rewards.py b/custom_components/taskmate/coord_rewards.py index 8b41286b..aef4ec15 100644 --- a/custom_components/taskmate/coord_rewards.py +++ b/custom_components/taskmate/coord_rewards.py @@ -721,7 +721,14 @@ async def async_reject_reward(self, claim_id: str, reason: str = "") -> None: self.storage.remove_reward_claim(claim_id) reward = self.get_reward(claim.reward_id) if claim else None if claim: - self._record_rejection("reward", claim.child_id, claim.reward_id, getattr(reward, "name", ""), reason) + self._record_rejection( + "reward", + claim.child_id, + claim.reward_id, + getattr(reward, "name", ""), + reason, + points=getattr(reward, "cost", 0), + ) await self.storage.async_save() await self.async_refresh() if claim: diff --git a/custom_components/taskmate/coord_wishlist.py b/custom_components/taskmate/coord_wishlist.py index 4bd2fc41..0dae23eb 100644 --- a/custom_components/taskmate/coord_wishlist.py +++ b/custom_components/taskmate/coord_wishlist.py @@ -443,7 +443,7 @@ async def _async_reject_wish_claim(self, claim: RewardClaim, reason: str = "") - wish.status = "active" wish.claim_id = "" self.storage.upsert_wish(wish) - self._record_rejection("reward", claim.child_id, claim.reward_id, getattr(wish, "name", ""), reason) + self._record_rejection("reward", claim.child_id, claim.reward_id, getattr(wish, "name", ""), reason, wish=True) await self.storage.async_save() await self.async_refresh() child = self.get_child(claim.child_id) diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index eec460f3..a3bdbd14 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -18,6 +18,7 @@ from . import notify_strings, photos from .const import DOMAIN +from .coord_appeals import AppealsMixin from .coord_assignments import AssignmentsMixin from .coord_auctions import AuctionsMixin from .coord_auto_approve import AutoApproveMixin @@ -114,6 +115,7 @@ class TaskMateCoordinator( FulfilmentMixin, PersonalBestsMixin, BoostsMixin, + AppealsMixin, BountiesMixin, AuctionsMixin, RecapsMixin, diff --git a/custom_components/taskmate/event.py b/custom_components/taskmate/event.py index a61996f9..7fee61a4 100644 --- a/custom_components/taskmate/event.py +++ b/custom_components/taskmate/event.py @@ -64,6 +64,8 @@ "pet_evolved": notification_event_name(NOTIF_TYPE_PET_EVOLVED), "reward_delivered": "taskmate_reward_delivered", "personal_best": "taskmate_personal_best", + "appeal_submitted": "taskmate_appeal_submitted", + "appeal_decided": "taskmate_appeal_decided", } # Bus events about the whole family. diff --git a/custom_components/taskmate/family_services.py b/custom_components/taskmate/family_services.py index 6471087b..5f4501c5 100644 --- a/custom_components/taskmate/family_services.py +++ b/custom_components/taskmate/family_services.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN +from .coord_appeals import APPEAL_KINDS, APPEAL_MESSAGE_MAX, APPEAL_NOTE_MAX from .coord_boosts import BOOST_MAX_MINUTES, BOOST_MAX_MULTIPLIER, BOOST_MIN_MULTIPLIER, BOOST_NAME_MAX from .coord_goals import WEEKLY_GOAL_MAX from .coord_pets import PET_NAME_MAX, PET_SPECIES @@ -33,12 +34,16 @@ SERVICE_ADOPT_PET = "adopt_pet" SERVICE_MARK_REWARD_DELIVERED = "mark_reward_delivered" SERVICE_START_BOOST = "start_boost" +SERVICE_APPEAL = "appeal" +SERVICE_DECIDE_APPEAL = "decide_appeal" SERVICE_END_BOOST = "end_boost" FAMILY_SERVICES: tuple[str, ...] = ( SERVICE_MARK_REWARD_DELIVERED, SERVICE_START_BOOST, SERVICE_END_BOOST, + SERVICE_APPEAL, + SERVICE_DECIDE_APPEAL, SERVICE_REQUEST_HELP, SERVICE_ANSWER_HELP, SERVICE_LOG_MOOD, @@ -116,6 +121,14 @@ async def start_boost(coordinator, call): async def end_boost(coordinator, call): await coordinator.async_end_boost(call.data["boost_id"]) + async def appeal(coordinator, call): + await coordinator.async_appeal( + call.data["child_id"], call.data["kind"], call.data["target_id"], call.data.get("message", "") + ) + + async def decide_appeal(coordinator, call): + await coordinator.async_decide_appeal(call.data["appeal_id"], call.data["overturn"], call.data.get("note", "")) + # Reward fulfilment (#1098): a parent marks a reward handed over, or the # child confirms their own with ``child_id`` — so the one service has two # gates and picks by who is calling. @@ -205,6 +218,27 @@ async def mark_reward_delivered(call: ServiceCall) -> None: }, ), (SERVICE_END_BOOST, end_boost, False, {vol.Required("boost_id"): cv.string}), + ( + SERVICE_APPEAL, + appeal, + True, + { + vol.Required("child_id"): cv.string, + vol.Required("kind"): vol.In(APPEAL_KINDS), + vol.Required("target_id"): cv.string, + vol.Optional("message", default=""): vol.All(cv.string, vol.Length(max=APPEAL_MESSAGE_MAX)), + }, + ), + ( + SERVICE_DECIDE_APPEAL, + decide_appeal, + False, + { + vol.Required("appeal_id"): cv.string, + vol.Required("overturn"): cv.boolean, + vol.Optional("note", default=""): vol.All(cv.string, vol.Length(max=APPEAL_NOTE_MAX)), + }, + ), ( SERVICE_ADOPT_PET, adopt_pet, diff --git a/custom_components/taskmate/notify_strings.py b/custom_components/taskmate/notify_strings.py index b3623ba4..1d10818a 100644 --- a/custom_components/taskmate/notify_strings.py +++ b/custom_components/taskmate/notify_strings.py @@ -78,6 +78,10 @@ "personal_best": "🏆 New personal best! {child_name}: {record_label} — {value}.", "boost_started": "⚡ Boost! {chores_text} pay ×{multiplier} for the next {minutes} minutes.", "boost_all_chores": "All chores", + "appeal_submitted": "⚖️ {child_name} is appealing '{item_name}'{message_text}", + "appeal_decided": "⚖️ Your appeal about '{item_name}' was {outcome}{note_text}", + "appeal_outcome_overturned": "accepted", + "appeal_outcome_upheld": "turned down", # Personal best record names, used inside the personal_best message. "record_longest_streak": "longest streak (days)", "record_best_day_points": "most {points_name} in a day", diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 3e5d6c15..3c4e3e08 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -271,6 +271,13 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l if (owed := coordinator.owed_rewards(c.id)) else {} ), + # Appeals: what the child may appeal, and their recent appeals. + **( + {"appealable": [{k: i[k] for k in ("kind", "target_id", "name", "points")} for i in can]} + if (can := coordinator.appealable_for_child(c.id)) + else {} + ), + **({"appeals": mine} if (mine := coordinator.appeals_for_child(c.id)) else {}), # Personal bests (#1099): the records board, once there is one. **({"personal_bests": bests} if (bests := coordinator.personal_bests_state(c)) else {}), # Make it right: open offers to earn a penalty back. diff --git a/custom_components/taskmate/services.yaml b/custom_components/taskmate/services.yaml index 155c0bcc..47b06d93 100644 --- a/custom_components/taskmate/services.yaml +++ b/custom_components/taskmate/services.yaml @@ -1939,6 +1939,62 @@ end_boost: selector: text: +appeal: + name: Appeal + description: A child asks for a second look at a rejected chore, a rejected reward claim or a penalty from the last 48 hours. Needs appeals switched on. + fields: + child_id: + name: Child ID + description: The child appealing + required: true + selector: + text: + kind: + name: Kind + description: What is being appealed + required: true + selector: + select: + options: + - chore + - reward + - penalty + target_id: + name: Target ID + description: The item, from the appealable attribute of the child's entry in sensor.taskmate_overview + required: true + selector: + text: + message: + name: Message + description: Optional words from the child, up to 200 characters + required: false + selector: + text: + +decide_appeal: + name: Decide an appeal + description: Overturn an appeal (pay the chore, approve the reward or undo the penalty) or uphold the original decision. + fields: + appeal_id: + name: Appeal ID + description: The appeal + required: true + selector: + text: + overturn: + name: Overturn + description: true to overturn, false to uphold + required: true + selector: + boolean: + note: + name: Note + description: Optional words for the child, up to 200 characters + required: false + selector: + text: + buy_raffle_tickets: name: Buy raffle tickets description: A child spends points on tickets for a raffle. Each ticket is one chance in the draw. diff --git a/custom_components/taskmate/strings.json b/custom_components/taskmate/strings.json index d59c7dd5..ae169899 100644 --- a/custom_components/taskmate/strings.json +++ b/custom_components/taskmate/strings.json @@ -1506,6 +1506,46 @@ "description": "The boost, from the boosts attribute of sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Appeal", + "description": "A child asks for a second look at a rejected chore, a rejected reward claim or a penalty from the last 48 hours. Needs appeals switched on.", + "fields": { + "child_id": { + "name": "Child ID", + "description": "The child appealing" + }, + "kind": { + "name": "Kind", + "description": "What is being appealed" + }, + "target_id": { + "name": "Target ID", + "description": "The item, from the appealable attribute of the child's entry in sensor.taskmate_overview" + }, + "message": { + "name": "Message", + "description": "Optional words from the child, up to 200 characters" + } + } + }, + "decide_appeal": { + "name": "Decide an appeal", + "description": "Overturn an appeal (pay the chore, approve the reward or undo the penalty) or uphold the original decision.", + "fields": { + "appeal_id": { + "name": "Appeal ID", + "description": "The appeal" + }, + "overturn": { + "name": "Overturn", + "description": "true to overturn, false to uphold" + }, + "note": { + "name": "Note", + "description": "Optional words for the child, up to 200 characters" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Raffle won", "pet_evolved": "Pet grew up", "reward_delivered": "Reward handed over", - "personal_best": "Personal best" + "personal_best": "Personal best", + "appeal_submitted": "Appeal submitted", + "appeal_decided": "Appeal decided" } } } diff --git a/custom_components/taskmate/translations/da.json b/custom_components/taskmate/translations/da.json index fabe7cbe..ac77bfb6 100644 --- a/custom_components/taskmate/translations/da.json +++ b/custom_components/taskmate/translations/da.json @@ -1506,6 +1506,46 @@ "description": "Boostet, fra attributten boosts på sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Klag", + "description": "Et barn beder om at få en afvist opgave, et afvist belønningskrav eller en straf fra de sidste 48 timer set på igen. Klager skal være slået til.", + "fields": { + "child_id": { + "name": "Barn-ID", + "description": "Barnet, der klager" + }, + "kind": { + "name": "Type", + "description": "Hvad der klages over" + }, + "target_id": { + "name": "Mål-ID", + "description": "Elementet fra attributten appealable for barnet i sensor.taskmate_overview" + }, + "message": { + "name": "Besked", + "description": "Valgfri ord fra barnet, op til 200 tegn" + } + } + }, + "decide_appeal": { + "name": "Afgør en klage", + "description": "Giv medhold (betal opgaven, godkend belønningen eller fjern straffen) eller fasthold afgørelsen.", + "fields": { + "appeal_id": { + "name": "Klage-ID", + "description": "Klagen" + }, + "overturn": { + "name": "Giv medhold", + "description": "true for medhold, false for at fastholde" + }, + "note": { + "name": "Note", + "description": "Valgfri ord til barnet, op til 200 tegn" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Lodtrækning vundet", "pet_evolved": "Kæledyr voksede", "reward_delivered": "Belønning givet", - "personal_best": "Personlig rekord" + "personal_best": "Personlig rekord", + "appeal_submitted": "Klage indsendt", + "appeal_decided": "Klage afgjort" } } } diff --git a/custom_components/taskmate/translations/de.json b/custom_components/taskmate/translations/de.json index ffb0b0ed..90d4e0a7 100644 --- a/custom_components/taskmate/translations/de.json +++ b/custom_components/taskmate/translations/de.json @@ -1506,6 +1506,46 @@ "description": "Der Boost, aus dem Attribut boosts von sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Einspruch", + "description": "Ein Kind bittet um eine zweite Prüfung einer abgelehnten Aufgabe, eines abgelehnten Belohnungsanspruchs oder einer Strafe der letzten 48 Stunden. Einsprüche müssen eingeschaltet sein.", + "fields": { + "child_id": { + "name": "Kind-ID", + "description": "Das Kind, das Einspruch einlegt" + }, + "kind": { + "name": "Art", + "description": "Worum es geht" + }, + "target_id": { + "name": "Ziel-ID", + "description": "Der Eintrag aus dem Attribut appealable des Kindes in sensor.taskmate_overview" + }, + "message": { + "name": "Nachricht", + "description": "Optionale Worte des Kindes, bis zu 200 Zeichen" + } + } + }, + "decide_appeal": { + "name": "Einspruch entscheiden", + "description": "Einem Einspruch stattgeben (Aufgabe auszahlen, Belohnung genehmigen oder Strafe zurücknehmen) oder die Entscheidung bestehen lassen.", + "fields": { + "appeal_id": { + "name": "Einspruchs-ID", + "description": "Der Einspruch" + }, + "overturn": { + "name": "Stattgeben", + "description": "true zum Stattgeben, false zum Ablehnen" + }, + "note": { + "name": "Notiz", + "description": "Optionale Worte für das Kind, bis zu 200 Zeichen" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Verlosung gewonnen", "pet_evolved": "Haustier gewachsen", "reward_delivered": "Belohnung übergeben", - "personal_best": "Persönliche Bestleistung" + "personal_best": "Persönliche Bestleistung", + "appeal_submitted": "Einspruch eingelegt", + "appeal_decided": "Einspruch entschieden" } } } diff --git a/custom_components/taskmate/translations/en-GB.json b/custom_components/taskmate/translations/en-GB.json index d59c7dd5..ae169899 100644 --- a/custom_components/taskmate/translations/en-GB.json +++ b/custom_components/taskmate/translations/en-GB.json @@ -1506,6 +1506,46 @@ "description": "The boost, from the boosts attribute of sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Appeal", + "description": "A child asks for a second look at a rejected chore, a rejected reward claim or a penalty from the last 48 hours. Needs appeals switched on.", + "fields": { + "child_id": { + "name": "Child ID", + "description": "The child appealing" + }, + "kind": { + "name": "Kind", + "description": "What is being appealed" + }, + "target_id": { + "name": "Target ID", + "description": "The item, from the appealable attribute of the child's entry in sensor.taskmate_overview" + }, + "message": { + "name": "Message", + "description": "Optional words from the child, up to 200 characters" + } + } + }, + "decide_appeal": { + "name": "Decide an appeal", + "description": "Overturn an appeal (pay the chore, approve the reward or undo the penalty) or uphold the original decision.", + "fields": { + "appeal_id": { + "name": "Appeal ID", + "description": "The appeal" + }, + "overturn": { + "name": "Overturn", + "description": "true to overturn, false to uphold" + }, + "note": { + "name": "Note", + "description": "Optional words for the child, up to 200 characters" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Raffle won", "pet_evolved": "Pet grew up", "reward_delivered": "Reward handed over", - "personal_best": "Personal best" + "personal_best": "Personal best", + "appeal_submitted": "Appeal submitted", + "appeal_decided": "Appeal decided" } } } diff --git a/custom_components/taskmate/translations/en.json b/custom_components/taskmate/translations/en.json index d59c7dd5..ae169899 100644 --- a/custom_components/taskmate/translations/en.json +++ b/custom_components/taskmate/translations/en.json @@ -1506,6 +1506,46 @@ "description": "The boost, from the boosts attribute of sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Appeal", + "description": "A child asks for a second look at a rejected chore, a rejected reward claim or a penalty from the last 48 hours. Needs appeals switched on.", + "fields": { + "child_id": { + "name": "Child ID", + "description": "The child appealing" + }, + "kind": { + "name": "Kind", + "description": "What is being appealed" + }, + "target_id": { + "name": "Target ID", + "description": "The item, from the appealable attribute of the child's entry in sensor.taskmate_overview" + }, + "message": { + "name": "Message", + "description": "Optional words from the child, up to 200 characters" + } + } + }, + "decide_appeal": { + "name": "Decide an appeal", + "description": "Overturn an appeal (pay the chore, approve the reward or undo the penalty) or uphold the original decision.", + "fields": { + "appeal_id": { + "name": "Appeal ID", + "description": "The appeal" + }, + "overturn": { + "name": "Overturn", + "description": "true to overturn, false to uphold" + }, + "note": { + "name": "Note", + "description": "Optional words for the child, up to 200 characters" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Raffle won", "pet_evolved": "Pet grew up", "reward_delivered": "Reward handed over", - "personal_best": "Personal best" + "personal_best": "Personal best", + "appeal_submitted": "Appeal submitted", + "appeal_decided": "Appeal decided" } } } diff --git a/custom_components/taskmate/translations/fr.json b/custom_components/taskmate/translations/fr.json index e7ad25e6..d714db16 100644 --- a/custom_components/taskmate/translations/fr.json +++ b/custom_components/taskmate/translations/fr.json @@ -1506,6 +1506,46 @@ "description": "Le boost, depuis l’attribut boosts de sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Faire appel", + "description": "Un enfant demande de revoir une tâche refusée, une demande de récompense refusée ou une pénalité des 48 dernières heures. Les appels doivent être activés.", + "fields": { + "child_id": { + "name": "ID de l’enfant", + "description": "L’enfant qui fait appel" + }, + "kind": { + "name": "Type", + "description": "Ce qui est contesté" + }, + "target_id": { + "name": "ID de l’élément", + "description": "L’élément, depuis l’attribut appealable de l’enfant dans sensor.taskmate_overview" + }, + "message": { + "name": "Message", + "description": "Mots facultatifs de l’enfant, jusqu’à 200 caractères" + } + } + }, + "decide_appeal": { + "name": "Décider d’un appel", + "description": "Accepter un appel (payer la tâche, approuver la récompense ou annuler la pénalité) ou maintenir la décision.", + "fields": { + "appeal_id": { + "name": "ID de l’appel", + "description": "L’appel" + }, + "overturn": { + "name": "Accepter", + "description": "true pour accepter, false pour maintenir" + }, + "note": { + "name": "Note", + "description": "Mots facultatifs pour l’enfant, jusqu’à 200 caractères" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Tombola gagnée", "pet_evolved": "L’animal a grandi", "reward_delivered": "Récompense remise", - "personal_best": "Record personnel" + "personal_best": "Record personnel", + "appeal_submitted": "Appel envoyé", + "appeal_decided": "Appel décidé" } } } diff --git a/custom_components/taskmate/translations/nb.json b/custom_components/taskmate/translations/nb.json index a99fdae1..6839eaa1 100644 --- a/custom_components/taskmate/translations/nb.json +++ b/custom_components/taskmate/translations/nb.json @@ -1506,6 +1506,46 @@ "description": "Boostet, fra attributtet boosts på sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Klag", + "description": "Et barn ber om at en avvist oppgave, et avvist belønningskrav eller en straff fra de siste 48 timene blir sett på igjen. Klager må være slått på.", + "fields": { + "child_id": { + "name": "Barn-ID", + "description": "Barnet som klager" + }, + "kind": { + "name": "Type", + "description": "Hva det klages på" + }, + "target_id": { + "name": "Mål-ID", + "description": "Elementet fra attributtet appealable for barnet i sensor.taskmate_overview" + }, + "message": { + "name": "Melding", + "description": "Valgfrie ord fra barnet, opptil 200 tegn" + } + } + }, + "decide_appeal": { + "name": "Avgjør en klage", + "description": "Gi medhold (betal oppgaven, godkjenn belønningen eller fjern straffen) eller la avgjørelsen stå.", + "fields": { + "appeal_id": { + "name": "Klage-ID", + "description": "Klagen" + }, + "overturn": { + "name": "Gi medhold", + "description": "true for medhold, false for å la den stå" + }, + "note": { + "name": "Notat", + "description": "Valgfrie ord til barnet, opptil 200 tegn" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Loddtrekning vunnet", "pet_evolved": "Kjæledyret vokste", "reward_delivered": "Belønning gitt", - "personal_best": "Personlig rekord" + "personal_best": "Personlig rekord", + "appeal_submitted": "Klage sendt", + "appeal_decided": "Klage avgjort" } } } diff --git a/custom_components/taskmate/translations/nl.json b/custom_components/taskmate/translations/nl.json index 9ebc480c..0e3f9ef7 100644 --- a/custom_components/taskmate/translations/nl.json +++ b/custom_components/taskmate/translations/nl.json @@ -1506,6 +1506,46 @@ "description": "De boost, uit het attribuut boosts van sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Bezwaar maken", + "description": "Een kind vraagt om nog eens te kijken naar een afgekeurde taak, een afgekeurde beloningsaanvraag of een puntenaftrek van de afgelopen 48 uur. Bezwaar maken moet aanstaan.", + "fields": { + "child_id": { + "name": "Kind-ID", + "description": "Het kind dat bezwaar maakt" + }, + "kind": { + "name": "Soort", + "description": "Waartegen bezwaar wordt gemaakt" + }, + "target_id": { + "name": "Doel-ID", + "description": "Het item, uit het attribuut appealable van het kind in sensor.taskmate_overview" + }, + "message": { + "name": "Bericht", + "description": "Optionele woorden van het kind, tot 200 tekens" + } + } + }, + "decide_appeal": { + "name": "Over een bezwaar beslissen", + "description": "Een bezwaar toekennen (de taak uitbetalen, de beloning goedkeuren of de puntenaftrek terugdraaien) of het oorspronkelijke besluit handhaven.", + "fields": { + "appeal_id": { + "name": "Bezwaar-ID", + "description": "Het bezwaar" + }, + "overturn": { + "name": "Toekennen", + "description": "true om toe te kennen, false om te handhaven" + }, + "note": { + "name": "Notitie", + "description": "Optionele woorden voor het kind, tot 200 tekens" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Loterij gewonnen", "pet_evolved": "Huisdier gegroeid", "reward_delivered": "Beloning overhandigd", - "personal_best": "Persoonlijk record" + "personal_best": "Persoonlijk record", + "appeal_submitted": "Bezwaar ingediend", + "appeal_decided": "Over bezwaar beslist" } } } diff --git a/custom_components/taskmate/translations/nn.json b/custom_components/taskmate/translations/nn.json index 90c4cf13..66c61b9d 100644 --- a/custom_components/taskmate/translations/nn.json +++ b/custom_components/taskmate/translations/nn.json @@ -1506,6 +1506,46 @@ "description": "Boostet, frå attributtet boosts på sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Klag", + "description": "Eit barn ber om at ei avvist oppgåve, eit avvist løningskrav eller ei straff frå dei siste 48 timane blir sett på att. Klager må vere slått på.", + "fields": { + "child_id": { + "name": "Barn-ID", + "description": "Barnet som klagar" + }, + "kind": { + "name": "Type", + "description": "Kva det blir klaga på" + }, + "target_id": { + "name": "Mål-ID", + "description": "Elementet frå attributtet appealable for barnet i sensor.taskmate_overview" + }, + "message": { + "name": "Melding", + "description": "Valfrie ord frå barnet, opptil 200 teikn" + } + } + }, + "decide_appeal": { + "name": "Avgjer ei klage", + "description": "Gje medhald (betal oppgåva, godkjenn løninga eller fjern straffa) eller la avgjerda stå.", + "fields": { + "appeal_id": { + "name": "Klage-ID", + "description": "Klaga" + }, + "overturn": { + "name": "Gje medhald", + "description": "true for medhald, false for å la ho stå" + }, + "note": { + "name": "Notat", + "description": "Valfrie ord til barnet, opptil 200 teikn" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Loddtrekning vunnen", "pet_evolved": "Kjæledyret voks", "reward_delivered": "Løning gjeven", - "personal_best": "Personleg rekord" + "personal_best": "Personleg rekord", + "appeal_submitted": "Klage send", + "appeal_decided": "Klage avgjord" } } } diff --git a/custom_components/taskmate/translations/pl.json b/custom_components/taskmate/translations/pl.json index 21282b10..4a07bcbf 100644 --- a/custom_components/taskmate/translations/pl.json +++ b/custom_components/taskmate/translations/pl.json @@ -1506,6 +1506,46 @@ "description": "Boost z atrybutu boosts w sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Odwołanie", + "description": "Dziecko prosi o ponowne sprawdzenie odrzuconego obowiązku, odrzuconej prośby o nagrodę lub kary z ostatnich 48 godzin. Odwołania muszą być włączone.", + "fields": { + "child_id": { + "name": "ID dziecka", + "description": "Dziecko, które się odwołuje" + }, + "kind": { + "name": "Rodzaj", + "description": "Czego dotyczy odwołanie" + }, + "target_id": { + "name": "ID elementu", + "description": "Element z atrybutu appealable dziecka w sensor.taskmate_overview" + }, + "message": { + "name": "Wiadomość", + "description": "Opcjonalne słowa dziecka, do 200 znaków" + } + } + }, + "decide_appeal": { + "name": "Rozpatrz odwołanie", + "description": "Uwzględnij odwołanie (zapłać za obowiązek, zatwierdź nagrodę lub cofnij karę) albo podtrzymaj decyzję.", + "fields": { + "appeal_id": { + "name": "ID odwołania", + "description": "Odwołanie" + }, + "overturn": { + "name": "Uwzględnij", + "description": "true aby uwzględnić, false aby podtrzymać" + }, + "note": { + "name": "Notatka", + "description": "Opcjonalne słowa dla dziecka, do 200 znaków" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Wygrano losowanie", "pet_evolved": "Zwierzak urósł", "reward_delivered": "Nagroda wręczona", - "personal_best": "Rekord życiowy" + "personal_best": "Rekord życiowy", + "appeal_submitted": "Złożono odwołanie", + "appeal_decided": "Rozpatrzono odwołanie" } } } diff --git a/custom_components/taskmate/translations/pt-BR.json b/custom_components/taskmate/translations/pt-BR.json index 9a075f66..233c6eee 100644 --- a/custom_components/taskmate/translations/pt-BR.json +++ b/custom_components/taskmate/translations/pt-BR.json @@ -1506,6 +1506,46 @@ "description": "O boost, do atributo boosts de sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Recorrer", + "description": "Uma criança pede que se reveja uma tarefa recusada, um pedido de recompensa recusado ou uma penalização das últimas 48 horas. Os recursos têm de estar ligados.", + "fields": { + "child_id": { + "name": "ID da criança", + "description": "A criança que recorre" + }, + "kind": { + "name": "Tipo", + "description": "Do que se recorre" + }, + "target_id": { + "name": "ID do item", + "description": "O item, do atributo appealable da criança em sensor.taskmate_overview" + }, + "message": { + "name": "Mensagem", + "description": "Palavras opcionais da criança, até 200 caracteres" + } + } + }, + "decide_appeal": { + "name": "Decidir um recurso", + "description": "Dar razão ao recurso (pagar a tarefa, aprovar a recompensa ou anular a penalização) ou manter a decisão.", + "fields": { + "appeal_id": { + "name": "ID do recurso", + "description": "O recurso" + }, + "overturn": { + "name": "Dar razão", + "description": "true para dar razão, false para manter" + }, + "note": { + "name": "Nota", + "description": "Palavras opcionais para a criança, até 200 caracteres" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Sorteio ganho", "pet_evolved": "O bichinho cresceu", "reward_delivered": "Recompensa entregue", - "personal_best": "Recorde pessoal" + "personal_best": "Recorde pessoal", + "appeal_submitted": "Recurso enviado", + "appeal_decided": "Recurso decidido" } } } diff --git a/custom_components/taskmate/translations/pt.json b/custom_components/taskmate/translations/pt.json index 8aef8edf..4f97f13a 100644 --- a/custom_components/taskmate/translations/pt.json +++ b/custom_components/taskmate/translations/pt.json @@ -1506,6 +1506,46 @@ "description": "O boost, do atributo boosts de sensor.taskmate_overview" } } + }, + "appeal": { + "name": "Recorrer", + "description": "Uma criança pede que se reveja uma tarefa recusada, um pedido de recompensa recusado ou uma penalização das últimas 48 horas. Os recursos têm de estar ligados.", + "fields": { + "child_id": { + "name": "ID da criança", + "description": "A criança que recorre" + }, + "kind": { + "name": "Tipo", + "description": "Do que se recorre" + }, + "target_id": { + "name": "ID do item", + "description": "O item, do atributo appealable da criança em sensor.taskmate_overview" + }, + "message": { + "name": "Mensagem", + "description": "Palavras opcionais da criança, até 200 caracteres" + } + } + }, + "decide_appeal": { + "name": "Decidir um recurso", + "description": "Dar razão ao recurso (pagar a tarefa, aprovar a recompensa ou anular a penalização) ou manter a decisão.", + "fields": { + "appeal_id": { + "name": "ID do recurso", + "description": "O recurso" + }, + "overturn": { + "name": "Dar razão", + "description": "true para dar razão, false para manter" + }, + "note": { + "name": "Nota", + "description": "Palavras opcionais para a criança, até 200 caracteres" + } + } } }, "entity": { @@ -1576,7 +1616,9 @@ "raffle_won": "Sorteio ganho", "pet_evolved": "O animal cresceu", "reward_delivered": "Recompensa entregue", - "personal_best": "Recorde pessoal" + "personal_best": "Recorde pessoal", + "appeal_submitted": "Recurso enviado", + "appeal_decided": "Recurso decidido" } } } diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 5b9407f6..da12254c 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -501,6 +501,8 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: # Boost windows: live and upcoming boosts, the recurring rules, and # the tablets the Settings page can offer to wake. "boosts": coordinator.boosts_state(), + # Appeals waiting for a parent. + "appeals": coordinator.open_appeals(), "boost_rules": [dict(r) for r in coordinator.boost_rules()], "boost_wake_candidates": coordinator.boost_wake_candidates(), "sick_today": [c.id for c in coordinator.storage.get_children() if coordinator.is_sick_day(c.id)], @@ -1997,6 +1999,7 @@ async def _ws_remove_custom_sound(hass, connection, msg, coordinator): "reward_fulfilment_enabled", "reward_fulfilment_reminder_days", "boost_stacking", + "appeals_enabled", "boost_wake_screens", "boost_wake_devices", "pets_enabled", @@ -2293,6 +2296,7 @@ def _validate_chore_undo_seconds(value: Any) -> int: vol.Optional("weekly_goal_bonus"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100000)), vol.Optional("reward_fulfilment_enabled"): bool, vol.Optional("boost_stacking"): vol.In(BOOST_STACKING_MODES), + vol.Optional("appeals_enabled"): bool, vol.Optional("boost_wake_screens"): vol.All([vol.All(str, vol.Match(r"^switch\.[a-z0-9_]+$"))], vol.Length(max=10)), vol.Optional("boost_wake_devices"): vol.All( [vol.All(str, vol.Match(r"^mobile_app_[a-z0-9_]+$"))], vol.Length(max=10) diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index dd01c57d..93092d60 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dage", "panel.boost_from": "Fra", "panel.boost_to": "Til", - "panel.boost_everyone_lc": "alle" + "panel.boost_everyone_lc": "alle", + "kiosk.editor.show_pet": "Vis barnets kæledyr", + "child.appeal_item_chore": "“{name}” blev sendt tilbage ({points})", + "child.appeal_item_reward": "Dit krav på “{name}” blev afvist", + "child.appeal_item_penalty": "Straf: {name} (−{points})", + "child.appeal_hint": "Synes du, det var uretfærdigt? Du kan bede om at få det set på igen.", + "child.appeal_button": "Klag", + "child.appeal_sheet_title": "Bed om at få det set på igen", + "child.appeal_placeholder": "Sig hvorfor (valgfrit)", + "child.appeal_send": "Send klage", + "child.appeal_waiting": "Klage sendt om “{name}” — en voksen kigger på det", + "child.appeal_overturned": "Klage godkendt: “{name}”", + "child.appeal_upheld": "Klage afvist: “{name}”", + "child.editor.show_appeals": "Vis klager", + "panel.appeals_title": "Klager", + "panel.appeal_item_chore": "afvist opgave “{name}” ({points})", + "panel.appeal_item_reward": "afvist belønning “{name}” ({points})", + "panel.appeal_item_penalty": "straf “{name}” (−{points})", + "panel.appeal_uphold": "Fasthold", + "panel.appeal_overturn_chore": "Giv medhold og betal", + "panel.appeal_overturn_reward": "Giv medhold og godkend", + "panel.appeal_overturn_penalty": "Giv medhold og refunder", + "panel.appeal_note_overturn": "En besked til barnet (valgfrit)", + "panel.appeal_note_uphold": "Hvorfor det står ved magt — barnet ser det (valgfrit)", + "panel.appeal_overturned": "Klage godkendt", + "panel.appeal_upheld": "Afgørelsen fastholdt", + "panel.appeals_settings_title": "Klager", + "panel.appeals_settings_hint": "Børn kan inden for 48 timer bede om at få en afvist opgave eller belønning eller en straf set på igen. Du afgør det under I dag.", + "panel.appeals_enabled": "Lad børn klage", + "notify.appeal_submitted": "⚖️ {child_name} klager over '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ Din klage over '{item_name}' blev {outcome}{note_text}", + "notify.appeal_outcome_overturned": "godkendt", + "notify.appeal_outcome_upheld": "afvist", + "notification.appeal_submitted.name": "Klage indsendt", + "notification.appeal_submitted.description": "Et barn beder om at få en afvisning eller straf set på igen. Sendes kun, når klager er slået til.", + "notification.appeal_decided.name": "Klage afgjort", + "notification.appeal_decided.description": "Fortæller barnet, om klagen blev godkendt eller afvist." } diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 89859026..5288d587 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Tage", "panel.boost_from": "Von", "panel.boost_to": "Bis", - "panel.boost_everyone_lc": "alle" + "panel.boost_everyone_lc": "alle", + "kiosk.editor.show_pet": "Haustier des Kindes anzeigen", + "child.appeal_item_chore": "„{name}“ wurde zurückgeschickt ({points})", + "child.appeal_item_reward": "Dein Anspruch auf „{name}“ wurde abgelehnt", + "child.appeal_item_penalty": "Strafe: {name} (−{points})", + "child.appeal_hint": "Findest du das unfair? Du kannst um eine zweite Prüfung bitten.", + "child.appeal_button": "Einspruch", + "child.appeal_sheet_title": "Um eine zweite Prüfung bitten", + "child.appeal_placeholder": "Sag, warum (optional)", + "child.appeal_send": "Einspruch senden", + "child.appeal_waiting": "Einspruch zu „{name}“ gesendet – ein Erwachsener schaut es sich an", + "child.appeal_overturned": "Einspruch angenommen: „{name}“", + "child.appeal_upheld": "Einspruch abgelehnt: „{name}“", + "child.editor.show_appeals": "Einsprüche anzeigen", + "panel.appeals_title": "Einsprüche", + "panel.appeal_item_chore": "abgelehnte Aufgabe „{name}“ ({points})", + "panel.appeal_item_reward": "abgelehnte Belohnung „{name}“ ({points})", + "panel.appeal_item_penalty": "Strafe „{name}“ (−{points})", + "panel.appeal_uphold": "Bleibt bestehen", + "panel.appeal_overturn_chore": "Stattgeben und auszahlen", + "panel.appeal_overturn_reward": "Stattgeben und genehmigen", + "panel.appeal_overturn_penalty": "Stattgeben und erstatten", + "panel.appeal_note_overturn": "Eine Notiz für das Kind (optional)", + "panel.appeal_note_uphold": "Warum es bleibt – das Kind sieht das (optional)", + "panel.appeal_overturned": "Einspruch stattgegeben", + "panel.appeal_upheld": "Einspruch abgelehnt", + "panel.appeals_settings_title": "Einsprüche", + "panel.appeals_settings_hint": "Kinder können innerhalb von 48 Stunden um eine zweite Prüfung einer abgelehnten Aufgabe oder Belohnung oder einer Strafe bitten. Du entscheidest unter Heute.", + "panel.appeals_enabled": "Kinder dürfen Einspruch einlegen", + "notify.appeal_submitted": "⚖️ {child_name} legt Einspruch ein: „{item_name}“{message_text}", + "notify.appeal_decided": "⚖️ Dein Einspruch zu „{item_name}“ wurde {outcome}{note_text}", + "notify.appeal_outcome_overturned": "angenommen", + "notify.appeal_outcome_upheld": "abgelehnt", + "notification.appeal_submitted.name": "Einspruch eingelegt", + "notification.appeal_submitted.description": "Ein Kind bittet um eine zweite Prüfung einer Ablehnung oder Strafe. Nur aktiv, wenn Einsprüche eingeschaltet sind.", + "notification.appeal_decided.name": "Einspruch entschieden", + "notification.appeal_decided.description": "Sagt dem Kind, ob sein Einspruch angenommen oder abgelehnt wurde." } diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 9833c117..aa29f05a 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Days", "panel.boost_from": "From", "panel.boost_to": "To", - "panel.boost_everyone_lc": "everyone" + "panel.boost_everyone_lc": "everyone", + "kiosk.editor.show_pet": "Show the child's pet", + "child.appeal_item_chore": "“{name}” was sent back ({points})", + "child.appeal_item_reward": "Your claim for “{name}” was turned down", + "child.appeal_item_penalty": "Penalty: {name} (−{points})", + "child.appeal_hint": "Think that wasn't fair? You can ask for a second look.", + "child.appeal_button": "Appeal", + "child.appeal_sheet_title": "Ask for a second look", + "child.appeal_placeholder": "Say why (optional)", + "child.appeal_send": "Send appeal", + "child.appeal_waiting": "Appeal sent about “{name}” — waiting for a grown-up", + "child.appeal_overturned": "Appeal accepted: “{name}”", + "child.appeal_upheld": "Appeal turned down: “{name}”", + "child.editor.show_appeals": "Show appeals", + "panel.appeals_title": "Appeals", + "panel.appeal_item_chore": "rejected chore “{name}” ({points})", + "panel.appeal_item_reward": "rejected reward “{name}” ({points})", + "panel.appeal_item_penalty": "penalty “{name}” (−{points})", + "panel.appeal_uphold": "Uphold", + "panel.appeal_overturn_chore": "Overturn and pay", + "panel.appeal_overturn_reward": "Overturn and approve", + "panel.appeal_overturn_penalty": "Overturn and refund", + "panel.appeal_note_overturn": "A note for the child (optional)", + "panel.appeal_note_uphold": "Why it stands — the child will see this (optional)", + "panel.appeal_overturned": "Appeal overturned", + "panel.appeal_upheld": "Appeal upheld", + "panel.appeals_settings_title": "Appeals", + "panel.appeals_settings_hint": "Children can ask for a second look at a rejected chore or reward, or a penalty, within 48 hours. You overturn or uphold it from Today.", + "panel.appeals_enabled": "Let children appeal", + "notify.appeal_submitted": "⚖️ {child_name} is appealing '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ Your appeal about '{item_name}' was {outcome}{note_text}", + "notify.appeal_outcome_overturned": "accepted", + "notify.appeal_outcome_upheld": "turned down", + "notification.appeal_submitted.name": "Appeal submitted", + "notification.appeal_submitted.description": "A child asked for a second look at a rejection or a penalty. Only fires while appeals are on.", + "notification.appeal_decided.name": "Appeal decided", + "notification.appeal_decided.description": "Tells the child whether their appeal was accepted or turned down." } diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 9833c117..aa29f05a 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Days", "panel.boost_from": "From", "panel.boost_to": "To", - "panel.boost_everyone_lc": "everyone" + "panel.boost_everyone_lc": "everyone", + "kiosk.editor.show_pet": "Show the child's pet", + "child.appeal_item_chore": "“{name}” was sent back ({points})", + "child.appeal_item_reward": "Your claim for “{name}” was turned down", + "child.appeal_item_penalty": "Penalty: {name} (−{points})", + "child.appeal_hint": "Think that wasn't fair? You can ask for a second look.", + "child.appeal_button": "Appeal", + "child.appeal_sheet_title": "Ask for a second look", + "child.appeal_placeholder": "Say why (optional)", + "child.appeal_send": "Send appeal", + "child.appeal_waiting": "Appeal sent about “{name}” — waiting for a grown-up", + "child.appeal_overturned": "Appeal accepted: “{name}”", + "child.appeal_upheld": "Appeal turned down: “{name}”", + "child.editor.show_appeals": "Show appeals", + "panel.appeals_title": "Appeals", + "panel.appeal_item_chore": "rejected chore “{name}” ({points})", + "panel.appeal_item_reward": "rejected reward “{name}” ({points})", + "panel.appeal_item_penalty": "penalty “{name}” (−{points})", + "panel.appeal_uphold": "Uphold", + "panel.appeal_overturn_chore": "Overturn and pay", + "panel.appeal_overturn_reward": "Overturn and approve", + "panel.appeal_overturn_penalty": "Overturn and refund", + "panel.appeal_note_overturn": "A note for the child (optional)", + "panel.appeal_note_uphold": "Why it stands — the child will see this (optional)", + "panel.appeal_overturned": "Appeal overturned", + "panel.appeal_upheld": "Appeal upheld", + "panel.appeals_settings_title": "Appeals", + "panel.appeals_settings_hint": "Children can ask for a second look at a rejected chore or reward, or a penalty, within 48 hours. You overturn or uphold it from Today.", + "panel.appeals_enabled": "Let children appeal", + "notify.appeal_submitted": "⚖️ {child_name} is appealing '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ Your appeal about '{item_name}' was {outcome}{note_text}", + "notify.appeal_outcome_overturned": "accepted", + "notify.appeal_outcome_upheld": "turned down", + "notification.appeal_submitted.name": "Appeal submitted", + "notification.appeal_submitted.description": "A child asked for a second look at a rejection or a penalty. Only fires while appeals are on.", + "notification.appeal_decided.name": "Appeal decided", + "notification.appeal_decided.description": "Tells the child whether their appeal was accepted or turned down." } diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index f01ea028..1df22e91 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Jours", "panel.boost_from": "De", "panel.boost_to": "À", - "panel.boost_everyone_lc": "tout le monde" + "panel.boost_everyone_lc": "tout le monde", + "kiosk.editor.show_pet": "Afficher l’animal de l’enfant", + "child.appeal_item_chore": "« {name} » a été refusée ({points})", + "child.appeal_item_reward": "Ta demande pour « {name} » a été refusée", + "child.appeal_item_penalty": "Pénalité : {name} (−{points})", + "child.appeal_hint": "Tu trouves ça injuste ? Tu peux demander qu’on revoie la décision.", + "child.appeal_button": "Faire appel", + "child.appeal_sheet_title": "Demander qu’on revoie la décision", + "child.appeal_placeholder": "Dis pourquoi (facultatif)", + "child.appeal_send": "Envoyer l’appel", + "child.appeal_waiting": "Appel envoyé pour « {name} » — un adulte va regarder", + "child.appeal_overturned": "Appel accepté : « {name} »", + "child.appeal_upheld": "Appel refusé : « {name} »", + "child.editor.show_appeals": "Afficher les appels", + "panel.appeals_title": "Appels", + "panel.appeal_item_chore": "tâche refusée « {name} » ({points})", + "panel.appeal_item_reward": "récompense refusée « {name} » ({points})", + "panel.appeal_item_penalty": "pénalité « {name} » (−{points})", + "panel.appeal_uphold": "Maintenir", + "panel.appeal_overturn_chore": "Annuler et payer", + "panel.appeal_overturn_reward": "Annuler et approuver", + "panel.appeal_overturn_penalty": "Annuler et rembourser", + "panel.appeal_note_overturn": "Un mot pour l’enfant (facultatif)", + "panel.appeal_note_uphold": "Pourquoi la décision tient — l’enfant le verra (facultatif)", + "panel.appeal_overturned": "Appel accepté", + "panel.appeal_upheld": "Décision maintenue", + "panel.appeals_settings_title": "Appels", + "panel.appeals_settings_hint": "Les enfants peuvent demander de revoir une tâche ou une récompense refusée, ou une pénalité, dans les 48 heures. Vous décidez depuis Aujourd’hui.", + "panel.appeals_enabled": "Autoriser les appels", + "notify.appeal_submitted": "⚖️ {child_name} fait appel : « {item_name} »{message_text}", + "notify.appeal_decided": "⚖️ Ton appel pour « {item_name} » a été {outcome}{note_text}", + "notify.appeal_outcome_overturned": "accepté", + "notify.appeal_outcome_upheld": "refusé", + "notification.appeal_submitted.name": "Appel envoyé", + "notification.appeal_submitted.description": "Un enfant demande de revoir un refus ou une pénalité. Actif seulement quand les appels sont activés.", + "notification.appeal_decided.name": "Appel décidé", + "notification.appeal_decided.description": "Dit à l’enfant si son appel a été accepté ou refusé." } diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 3f21f977..acb20d43 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dager", "panel.boost_from": "Fra", "panel.boost_to": "Til", - "panel.boost_everyone_lc": "alle" + "panel.boost_everyone_lc": "alle", + "kiosk.editor.show_pet": "Vis barnets kjæledyr", + "child.appeal_item_chore": "«{name}» ble sendt tilbake ({points})", + "child.appeal_item_reward": "Kravet ditt på «{name}» ble avslått", + "child.appeal_item_penalty": "Straff: {name} (−{points})", + "child.appeal_hint": "Synes du det var urettferdig? Du kan be om at noen ser på det igjen.", + "child.appeal_button": "Klag", + "child.appeal_sheet_title": "Be om at noen ser på det igjen", + "child.appeal_placeholder": "Si hvorfor (valgfritt)", + "child.appeal_send": "Send klage", + "child.appeal_waiting": "Klage sendt om «{name}» – en voksen ser på det", + "child.appeal_overturned": "Klage godtatt: «{name}»", + "child.appeal_upheld": "Klage avslått: «{name}»", + "child.editor.show_appeals": "Vis klager", + "panel.appeals_title": "Klager", + "panel.appeal_item_chore": "avvist oppgave «{name}» ({points})", + "panel.appeal_item_reward": "avvist belønning «{name}» ({points})", + "panel.appeal_item_penalty": "straff «{name}» (−{points})", + "panel.appeal_uphold": "Opprettholdes", + "panel.appeal_overturn_chore": "Gi medhold og betal", + "panel.appeal_overturn_reward": "Gi medhold og godkjenn", + "panel.appeal_overturn_penalty": "Gi medhold og refunder", + "panel.appeal_note_overturn": "En beskjed til barnet (valgfritt)", + "panel.appeal_note_uphold": "Hvorfor det står seg – barnet ser dette (valgfritt)", + "panel.appeal_overturned": "Klage godtatt", + "panel.appeal_upheld": "Avgjørelsen står", + "panel.appeals_settings_title": "Klager", + "panel.appeals_settings_hint": "Barn kan innen 48 timer be om at en avvist oppgave eller belønning, eller en straff, blir sett på igjen. Du avgjør det under I dag.", + "panel.appeals_enabled": "La barna klage", + "notify.appeal_submitted": "⚖️ {child_name} klager på «{item_name}»{message_text}", + "notify.appeal_decided": "⚖️ Klagen din på «{item_name}» ble {outcome}{note_text}", + "notify.appeal_outcome_overturned": "godtatt", + "notify.appeal_outcome_upheld": "avslått", + "notification.appeal_submitted.name": "Klage sendt", + "notification.appeal_submitted.description": "Et barn ber om at en avvisning eller straff blir sett på igjen. Sendes bare når klager er slått på.", + "notification.appeal_decided.name": "Klage avgjort", + "notification.appeal_decided.description": "Forteller barnet om klagen ble godtatt eller avslått." } diff --git a/custom_components/taskmate/www/locales/nl.json b/custom_components/taskmate/www/locales/nl.json index ed27d59d..481e40ac 100644 --- a/custom_components/taskmate/www/locales/nl.json +++ b/custom_components/taskmate/www/locales/nl.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dagen", "panel.boost_from": "Van", "panel.boost_to": "Tot", - "panel.boost_everyone_lc": "iedereen" + "panel.boost_everyone_lc": "iedereen", + "kiosk.editor.show_pet": "Huisdier van het kind tonen", + "child.appeal_item_chore": "“{name}” is teruggestuurd ({points})", + "child.appeal_item_reward": "Je aanvraag voor “{name}” is afgekeurd", + "child.appeal_item_penalty": "Puntenaftrek: {name} (−{points})", + "child.appeal_hint": "Vind je dat niet eerlijk? Je kunt vragen of er nog eens naar gekeken wordt.", + "child.appeal_button": "Bezwaar", + "child.appeal_sheet_title": "Vraag om nog eens te kijken", + "child.appeal_placeholder": "Zeg waarom (optioneel)", + "child.appeal_send": "Bezwaar versturen", + "child.appeal_waiting": "Bezwaar verstuurd over “{name}” — wacht op een volwassene", + "child.appeal_overturned": "Bezwaar toegekend: “{name}”", + "child.appeal_upheld": "Bezwaar afgewezen: “{name}”", + "child.editor.show_appeals": "Bezwaren tonen", + "panel.appeals_title": "Bezwaren", + "panel.appeal_item_chore": "afgekeurde taak “{name}” ({points})", + "panel.appeal_item_reward": "afgekeurde beloning “{name}” ({points})", + "panel.appeal_item_penalty": "puntenaftrek “{name}” (−{points})", + "panel.appeal_uphold": "Handhaven", + "panel.appeal_overturn_chore": "Toekennen en uitbetalen", + "panel.appeal_overturn_reward": "Toekennen en goedkeuren", + "panel.appeal_overturn_penalty": "Toekennen en terugbetalen", + "panel.appeal_note_overturn": "Een notitie voor het kind (optioneel)", + "panel.appeal_note_uphold": "Waarom het blijft staan — het kind ziet dit (optioneel)", + "panel.appeal_overturned": "Bezwaar toegekend", + "panel.appeal_upheld": "Besluit gehandhaafd", + "panel.appeals_settings_title": "Bezwaren", + "panel.appeals_settings_hint": "Kinderen kunnen binnen 48 uur vragen om nog eens te kijken naar een afgekeurde taak of beloning, of een puntenaftrek. Jij kent het bezwaar toe of handhaaft het besluit vanuit Vandaag.", + "panel.appeals_enabled": "Kinderen bezwaar laten maken", + "notify.appeal_submitted": "⚖️ {child_name} maakt bezwaar tegen '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ Je bezwaar over '{item_name}' is {outcome}{note_text}", + "notify.appeal_outcome_overturned": "toegekend", + "notify.appeal_outcome_upheld": "afgewezen", + "notification.appeal_submitted.name": "Bezwaar ingediend", + "notification.appeal_submitted.description": "Een kind vraagt om nog eens te kijken naar een afkeuring of een puntenaftrek. Werkt alleen als bezwaar maken aanstaat.", + "notification.appeal_decided.name": "Over bezwaar beslist", + "notification.appeal_decided.description": "Vertelt het kind of het bezwaar is toegekend of afgewezen." } diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 318ec281..3e6ea46c 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dagar", "panel.boost_from": "Frå", "panel.boost_to": "Til", - "panel.boost_everyone_lc": "alle" + "panel.boost_everyone_lc": "alle", + "kiosk.editor.show_pet": "Vis kjæledyret til barnet", + "child.appeal_item_chore": "«{name}» vart send tilbake ({points})", + "child.appeal_item_reward": "Kravet ditt på «{name}» vart avslått", + "child.appeal_item_penalty": "Straff: {name} (−{points})", + "child.appeal_hint": "Synest du det var urettferdig? Du kan be om at nokon ser på det att.", + "child.appeal_button": "Klag", + "child.appeal_sheet_title": "Be om at nokon ser på det att", + "child.appeal_placeholder": "Sei kvifor (valfritt)", + "child.appeal_send": "Send klage", + "child.appeal_waiting": "Klage send om «{name}» – ein vaksen ser på det", + "child.appeal_overturned": "Klage godteken: «{name}»", + "child.appeal_upheld": "Klage avslått: «{name}»", + "child.editor.show_appeals": "Vis klager", + "panel.appeals_title": "Klager", + "panel.appeal_item_chore": "avvist oppgåve «{name}» ({points})", + "panel.appeal_item_reward": "avvist løning «{name}» ({points})", + "panel.appeal_item_penalty": "straff «{name}» (−{points})", + "panel.appeal_uphold": "Står ved lag", + "panel.appeal_overturn_chore": "Gje medhald og betal", + "panel.appeal_overturn_reward": "Gje medhald og godkjenn", + "panel.appeal_overturn_penalty": "Gje medhald og refunder", + "panel.appeal_note_overturn": "Ei melding til barnet (valfritt)", + "panel.appeal_note_uphold": "Kvifor det står – barnet ser dette (valfritt)", + "panel.appeal_overturned": "Klage godteken", + "panel.appeal_upheld": "Avgjerda står", + "panel.appeals_settings_title": "Klager", + "panel.appeals_settings_hint": "Born kan innan 48 timar be om at ei avvist oppgåve eller løning, eller ei straff, blir sett på att. Du avgjer det under I dag.", + "panel.appeals_enabled": "La borna klage", + "notify.appeal_submitted": "⚖️ {child_name} klagar på «{item_name}»{message_text}", + "notify.appeal_decided": "⚖️ Klaga di på «{item_name}» vart {outcome}{note_text}", + "notify.appeal_outcome_overturned": "godteken", + "notify.appeal_outcome_upheld": "avslått", + "notification.appeal_submitted.name": "Klage send", + "notification.appeal_submitted.description": "Eit barn ber om at ei avvising eller straff blir sett på att. Blir berre sendt når klager er slått på.", + "notification.appeal_decided.name": "Klage avgjord", + "notification.appeal_decided.description": "Fortel barnet om klaga vart godteken eller avslått." } diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index c94580aa..723f144a 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dni", "panel.boost_from": "Od", "panel.boost_to": "Do", - "panel.boost_everyone_lc": "wszystkich" + "panel.boost_everyone_lc": "wszystkich", + "kiosk.editor.show_pet": "Pokaż zwierzaka dziecka", + "child.appeal_item_chore": "„{name}” odesłano do poprawy ({points})", + "child.appeal_item_reward": "Twoja prośba o „{name}” została odrzucona", + "child.appeal_item_penalty": "Kara: {name} (−{points})", + "child.appeal_hint": "Uważasz, że to niesprawiedliwe? Możesz poprosić o ponowne sprawdzenie.", + "child.appeal_button": "Odwołaj się", + "child.appeal_sheet_title": "Poproś o ponowne sprawdzenie", + "child.appeal_placeholder": "Napisz dlaczego (opcjonalnie)", + "child.appeal_send": "Wyślij odwołanie", + "child.appeal_waiting": "Wysłano odwołanie w sprawie „{name}” — dorosły to sprawdzi", + "child.appeal_overturned": "Odwołanie przyjęte: „{name}”", + "child.appeal_upheld": "Odwołanie odrzucone: „{name}”", + "child.editor.show_appeals": "Pokaż odwołania", + "panel.appeals_title": "Odwołania", + "panel.appeal_item_chore": "odrzucony obowiązek „{name}” ({points})", + "panel.appeal_item_reward": "odrzucona nagroda „{name}” ({points})", + "panel.appeal_item_penalty": "kara „{name}” (−{points})", + "panel.appeal_uphold": "Podtrzymaj", + "panel.appeal_overturn_chore": "Uwzględnij i zapłać", + "panel.appeal_overturn_reward": "Uwzględnij i zatwierdź", + "panel.appeal_overturn_penalty": "Uwzględnij i zwróć", + "panel.appeal_note_overturn": "Wiadomość dla dziecka (opcjonalnie)", + "panel.appeal_note_uphold": "Dlaczego decyzja zostaje — dziecko to zobaczy (opcjonalnie)", + "panel.appeal_overturned": "Odwołanie uwzględnione", + "panel.appeal_upheld": "Decyzja podtrzymana", + "panel.appeals_settings_title": "Odwołania", + "panel.appeals_settings_hint": "Dzieci mogą w ciągu 48 godzin poprosić o ponowne sprawdzenie odrzuconego obowiązku, nagrody lub kary. Decydujesz na ekranie Dziś.", + "panel.appeals_enabled": "Pozwól dzieciom się odwoływać", + "notify.appeal_submitted": "⚖️ {child_name} odwołuje się: „{item_name}”{message_text}", + "notify.appeal_decided": "⚖️ Twoje odwołanie w sprawie „{item_name}” zostało {outcome}{note_text}", + "notify.appeal_outcome_overturned": "przyjęte", + "notify.appeal_outcome_upheld": "odrzucone", + "notification.appeal_submitted.name": "Złożono odwołanie", + "notification.appeal_submitted.description": "Dziecko prosi o ponowne sprawdzenie odrzucenia lub kary. Działa tylko, gdy odwołania są włączone.", + "notification.appeal_decided.name": "Rozpatrzono odwołanie", + "notification.appeal_decided.description": "Mówi dziecku, czy jego odwołanie zostało przyjęte, czy odrzucone." } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 7d08156a..f83485ac 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dias", "panel.boost_from": "Das", "panel.boost_to": "Às", - "panel.boost_everyone_lc": "todos" + "panel.boost_everyone_lc": "todos", + "kiosk.editor.show_pet": "Mostrar o bichinho da criança", + "child.appeal_item_chore": "“{name}” foi devolvida ({points})", + "child.appeal_item_reward": "Seu pedido de “{name}” foi recusado", + "child.appeal_item_penalty": "Penalidade: {name} (−{points})", + "child.appeal_hint": "Acha que não foi justo? Você pode pedir para revisarem.", + "child.appeal_button": "Recorrer", + "child.appeal_sheet_title": "Pedir para revisarem", + "child.appeal_placeholder": "Diga por quê (opcional)", + "child.appeal_send": "Enviar recurso", + "child.appeal_waiting": "Recurso enviado sobre “{name}” — um adulto vai olhar", + "child.appeal_overturned": "Recurso aceito: “{name}”", + "child.appeal_upheld": "Recurso recusado: “{name}”", + "child.editor.show_appeals": "Mostrar recursos", + "panel.appeals_title": "Recursos", + "panel.appeal_item_chore": "tarefa recusada “{name}” ({points})", + "panel.appeal_item_reward": "recompensa recusada “{name}” ({points})", + "panel.appeal_item_penalty": "penalidade “{name}” (−{points})", + "panel.appeal_uphold": "Manter", + "panel.appeal_overturn_chore": "Dar razão e pagar", + "panel.appeal_overturn_reward": "Dar razão e aprovar", + "panel.appeal_overturn_penalty": "Dar razão e reembolsar", + "panel.appeal_note_overturn": "Uma nota para a criança (opcional)", + "panel.appeal_note_uphold": "Por que se mantém — a criança vai ver (opcional)", + "panel.appeal_overturned": "Recurso aceito", + "panel.appeal_upheld": "Decisão mantida", + "panel.appeals_settings_title": "Recursos", + "panel.appeals_settings_hint": "As crianças podem pedir, em até 48 horas, a revisão de uma tarefa ou recompensa recusada, ou de uma penalidade. Você decide a partir de Hoje.", + "panel.appeals_enabled": "Deixar as crianças recorrerem", + "notify.appeal_submitted": "⚖️ {child_name} está recorrendo de '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ Seu recurso sobre '{item_name}' foi {outcome}{note_text}", + "notify.appeal_outcome_overturned": "aceito", + "notify.appeal_outcome_upheld": "recusado", + "notification.appeal_submitted.name": "Recurso enviado", + "notification.appeal_submitted.description": "Uma criança pediu a revisão de uma recusa ou penalidade. Só é enviada com os recursos ligados.", + "notification.appeal_decided.name": "Recurso decidido", + "notification.appeal_decided.description": "Diz à criança se o recurso foi aceito ou recusado." } diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 3c890085..c37619dd 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -3569,5 +3569,41 @@ "panel.boost_days": "Dias", "panel.boost_from": "Das", "panel.boost_to": "Às", - "panel.boost_everyone_lc": "todos" + "panel.boost_everyone_lc": "todos", + "kiosk.editor.show_pet": "Mostrar o animal da criança", + "child.appeal_item_chore": "“{name}” foi devolvida ({points})", + "child.appeal_item_reward": "O teu pedido de “{name}” foi recusado", + "child.appeal_item_penalty": "Penalização: {name} (−{points})", + "child.appeal_hint": "Achas que não foi justo? Podes pedir que voltem a ver.", + "child.appeal_button": "Recorrer", + "child.appeal_sheet_title": "Pedir que voltem a ver", + "child.appeal_placeholder": "Diz porquê (opcional)", + "child.appeal_send": "Enviar recurso", + "child.appeal_waiting": "Recurso enviado sobre “{name}” — um adulto vai ver", + "child.appeal_overturned": "Recurso aceite: “{name}”", + "child.appeal_upheld": "Recurso recusado: “{name}”", + "child.editor.show_appeals": "Mostrar recursos", + "panel.appeals_title": "Recursos", + "panel.appeal_item_chore": "tarefa recusada “{name}” ({points})", + "panel.appeal_item_reward": "recompensa recusada “{name}” ({points})", + "panel.appeal_item_penalty": "penalização “{name}” (−{points})", + "panel.appeal_uphold": "Manter", + "panel.appeal_overturn_chore": "Dar razão e pagar", + "panel.appeal_overturn_reward": "Dar razão e aprovar", + "panel.appeal_overturn_penalty": "Dar razão e devolver", + "panel.appeal_note_overturn": "Uma nota para a criança (opcional)", + "panel.appeal_note_uphold": "Porque se mantém — a criança vai ver (opcional)", + "panel.appeal_overturned": "Recurso aceite", + "panel.appeal_upheld": "Decisão mantida", + "panel.appeals_settings_title": "Recursos", + "panel.appeals_settings_hint": "As crianças podem pedir, em 48 horas, que se reveja uma tarefa ou recompensa recusada, ou uma penalização. Decides a partir de Hoje.", + "panel.appeals_enabled": "Deixar as crianças recorrer", + "notify.appeal_submitted": "⚖️ {child_name} está a recorrer de '{item_name}'{message_text}", + "notify.appeal_decided": "⚖️ O teu recurso sobre '{item_name}' foi {outcome}{note_text}", + "notify.appeal_outcome_overturned": "aceite", + "notify.appeal_outcome_upheld": "recusado", + "notification.appeal_submitted.name": "Recurso enviado", + "notification.appeal_submitted.description": "Uma criança pediu que se revisse uma recusa ou penalização. Só é enviada com os recursos ligados.", + "notification.appeal_decided.name": "Recurso decidido", + "notification.appeal_decided.description": "Diz à criança se o recurso foi aceite ou recusado." } diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index a9a4039d..e678033e 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -1442,6 +1442,12 @@ class TaskMateChildCard extends LitElement { .tm-care-owed .ic ha-icon { --mdc-icon-size: 26px; color: #8e44ad; } .tm-care-owed .tm-owed-body { flex: 1; min-width: 0; } .tm-care-record { background: #fff6d6; color: #6b4e00; } + .tm-care-appealable { background: #eef2ff; color: #2b3a7a; } + .tm-care-appealable .tm-appeal-body { flex: 1; min-width: 0; } + .tm-care-appeal { background: #eef2ff; color: #2b3a7a; } + .tm-care-appeal.overturned { background: #ecf8ef; color: #1d5b2c; } + .tm-care-appeal.upheld { background: #f4f6fa; color: #4b5468; } + .tm-appeal-text { width: 100%; box-sizing: border-box; border-radius: 12px; border: 1px solid var(--divider-color, #ddd); padding: 10px; font: inherit; margin: 10px 0; resize: vertical; } /* Boost windows */ ha-card { position: relative; } ha-card:has(.tm-boost-take) { min-height: 460px; } @@ -5207,6 +5213,21 @@ class TaskMateChildCard extends LitElement { @click=${() => this._gotReward(child, o)}>${this._t("child.owed_got_it")} `); } + // Appeals: what this child may ask a second look at, and how it went. + if (this.config.show_appeals !== false) { + for (const a of child.appeals || []) { + const key = a.status === "open" ? "child.appeal_waiting" : a.status === "overturned" ? "child.appeal_overturned" : "child.appeal_upheld"; + parts.push(html`