diff --git a/custom_components/taskmate/coord_auctions.py b/custom_components/taskmate/coord_auctions.py index fc8b939d..0855c48c 100644 --- a/custom_components/taskmate/coord_auctions.py +++ b/custom_components/taskmate/coord_auctions.py @@ -50,6 +50,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util +from . import notify_strings from .const import ( AUCTION_POINTS_MAX, AUCTION_REMINDER_MINUTES, @@ -354,13 +355,15 @@ async def _async_tell_bidders_called_off(self, auction: Auction, bidders: list[s """The "called off" push, to the children who bid on ``auction``.""" if not bidders: return + await notify_strings.async_load(self.hass) await self.notifications.fire( NOTIF_TYPE_AUCTION_RESULT, { "chore_name": auction.chore_name, "date": self._auction_date_label(auction), - "result_text": f"the auction for '{auction.chore_name}' was called off. " - "It goes back to the normal rota.", + "result_text": notify_strings.render( + self.hass, "auction_called_off", {"chore_name": auction.chore_name} + ), }, only_recipients={f"child:{c}" for c in bidders}, ) @@ -471,13 +474,21 @@ async def _async_after_auction_closed(self, auction: Auction) -> None: """Announce a settled auction: bus event and the results push.""" winner = self.get_child(auction.winner_id) if auction.winner_id else None self._fire_auction_event("taskmate_auction_closed", auction, auction.winner_id) + await notify_strings.async_load(self.hass) if winner is not None: - result = ( - f"{winner.name} won '{auction.chore_name}' on {self._auction_date_label(auction)} " - f"for {auction.price} {self.storage.get_points_name()}." + result = notify_strings.render( + self.hass, + "auction_won", + { + "child_name": winner.name, + "chore_name": auction.chore_name, + "date": self._auction_date_label(auction), + "points": auction.price, + "points_name": self.storage.get_points_name(), + }, ) else: - result = f"no bids for '{auction.chore_name}', so it goes back to the normal rota." + result = notify_strings.render(self.hass, "auction_no_bids", {"chore_name": auction.chore_name}) recipients = {f"child:{c}" for c in auction.eligible_child_ids} recipients |= {p.id for p in self.storage.get_parent_recipients()} await self.notifications.fire( diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index befd7bfa..5e740ead 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change -from . import authz +from . import authz, notify_strings from .const import ( DEFAULT_NOTIFICATION_GROUP, DEFAULT_NOTIFICATION_NAV_URL, @@ -64,24 +64,21 @@ DEFAULT_MORNING_NOTIFY_TIME = "08:00" -# Appended to actionable notifications sent to non-mobile_app backends, which -# silently ignore tap actions. Gives those recipients a way to act. -_APPROVE_IN_PANEL_HINT = "Open the TaskMate panel to approve or reject." - # Mobile action id prefix for "approve with an N-star quality rating" (#927): # TASKMATE_RATE__. Distinct from TASKMATE_APPROVE_ so the # original Approve/Reject ids keep working unchanged when ratings are off. _RATE_ACTION_PREFIX = "TASKMATE_RATE_" -def _reject_action(entry_id: str) -> dict[str, str]: +def _reject_action(hass: HomeAssistant, entry_id: str) -> dict[str, str]: """The Reject button of an approval push, with a reply box for the reason (#976).""" + reject = notify_strings.text(hass, "action_reject") return { "action": f"TASKMATE_REJECT_{entry_id}", - "title": "Reject", + "title": reject, "behavior": "textInput", - "textInputButtonTitle": "Reject", - "textInputPlaceholder": "Reason (optional)", + "textInputButtonTitle": reject, + "textInputPlaceholder": notify_strings.text(hass, "action_reject_placeholder"), } @@ -255,11 +252,7 @@ def _presence_is_home(state: str | None) -> bool | None: _is_within_quiet_hours = is_within_window -class _SafeDict(dict): - """str.format_map dict that leaves missing keys as `{key}` literal.""" - - def __missing__(self, key: str) -> str: - return "{" + key + "}" +_SafeDict = notify_strings.SafeDict class NotificationCoordinator: @@ -300,6 +293,7 @@ async def fire( return recipients_fired: list[str] = [] + await notify_strings.async_load(self.hass) message = self._render_template(meta, context) nav_url = self._resolve_nav_url(cfg) group = self._resolve_group(cfg) @@ -448,6 +442,7 @@ async def send_test(self, type_id: str) -> list[str]: "note_text": ": Brilliant job!", "points_name": self.storage.get_points_name(), } + await notify_strings.async_load(self.hass) message = "[TEST] " + self._render_template(meta, ctx) cfg = self.storage.get_notification_config(type_id) nav_url = self._resolve_nav_url(cfg) @@ -506,47 +501,17 @@ def _resolve_notify_service(self, recipient_id: str) -> str: return "" def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any]) -> str: - # Built-in types use a baked-in default; will be replaced by translations - # in a later task. For now use a safe English fallback so dispatch works. - templates = { - NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.", - NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!", - NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!", - NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!", - NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", - NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", - NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!", - NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉", - NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}", - NOTIF_TYPE_CELEBRATION: "🎉 {message}", - NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.", - NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.", - NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}", - NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", - NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", - NOTIF_TYPE_BIRTHDAY: "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.", - NOTIF_TYPE_STREAK_FREEZE_USED: "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).", - NOTIF_TYPE_PRESENCE_ARRIVAL: "🏠 You're home, {child_name} — {count} chores left today.", - NOTIF_TYPE_WISH_REQUESTED: "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.", - NOTIF_TYPE_WISH_PLEDGED: "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!", - NOTIF_TYPE_BOUNTY_POSTED: "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!", - NOTIF_TYPE_BOUNTY_CLAIM_LAPSING: "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board.", - NOTIF_TYPE_RECAP_READY: "✨ Your {period} recap is ready, {child_name}! Tap to watch it.", - NOTIF_TYPE_ITEM_REJECTED: "↩️ {child_name}, '{item_name}' was sent back{reason_text}", - NOTIF_TYPE_AUCTION_OPENED: "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!", - NOTIF_TYPE_AUCTION_CLOSING: "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.", - NOTIF_TYPE_AUCTION_RESULT: "🔨 Auction closed: {result_text}", - NOTIF_TYPE_INSPECTION_STARTED: "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. Keep it looking great for +{bonus} {points_name}!", - NOTIF_TYPE_INSPECTION_PASSED: "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}", - NOTIF_TYPE_INSPECTION_REMINDER: "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}.", - } - tpl = context.get("message_template") or templates.get(meta.id, "") + # Built-in types read their wording from the locale catalogue in HA's + # language (#1037); a custom message_template is the user's own text. + custom = context.get("message_template") + if not custom: + return notify_strings.render(self.hass, meta.id, context) try: - return tpl.format_map(_SafeDict(context)) + return custom.format_map(_SafeDict(context)) except (ValueError, IndexError, KeyError): # Malformed user template (e.g. stray '{') — fall back to raw text - _LOGGER.warning("Malformed notification template %r, sending raw", tpl) - return tpl + _LOGGER.warning("Malformed notification template %r, sending raw", custom) + return custom async def _send_to( self, @@ -587,7 +552,7 @@ async def _send_to( push["tag"] = _approval_tag(entry_id) push["actions"] = self._approval_actions(meta.id, entry_id) else: - data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}" + data["message"] = f"{message} {notify_strings.text(self.hass, 'approve_in_panel_hint')}" if attach_photo: # "image" renders inline on Android; iOS needs it under @@ -647,11 +612,11 @@ def _approval_actions(self, type_id: str, entry_id: str) -> list[dict[str, str]] if rated: return [ *({"action": f"{_RATE_ACTION_PREFIX}{n}_{entry_id}", "title": "★" * n} for n in QUALITY_RATINGS), - _reject_action(entry_id), + _reject_action(self.hass, entry_id), ] return [ - {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, - _reject_action(entry_id), + {"action": f"TASKMATE_APPROVE_{entry_id}", "title": notify_strings.text(self.hass, "action_approve")}, + _reject_action(self.hass, entry_id), ] async def clear_approval(self, type_id: str, entry_id: str) -> None: diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index e919949f..770e19c5 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import photos +from . import notify_strings, photos from .const import DOMAIN from .coord_assignments import AssignmentsMixin from .coord_auctions import AuctionsMixin @@ -470,6 +470,7 @@ def _async_weekly_digest_check(self, now: datetime) -> None: async def _async_send_weekly_digest(self) -> None: """Build and send the weekly digest to parents (opt-in).""" + await notify_strings.async_load(self.hass) summary = self._build_weekly_digest() if not summary: return @@ -492,7 +493,14 @@ def _build_weekly_digest(self) -> str: done[comp.child_id] = done.get(comp.child_id, 0) + 1 earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0) pts = self.storage.get_points_name() - lines = [f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children] + lines = [ + notify_strings.render( + self.hass, + "weekly_digest_line", + {"child_name": c.name, "chores": done.get(c.id, 0), "points": earned.get(c.id, 0), "points_name": pts}, + ) + for c in children + ] return "\n".join(lines) async def _async_send_monthly_report(self) -> None: @@ -500,6 +508,7 @@ async def _async_send_monthly_report(self) -> None: today = dt_util.now().date() month_end = today.replace(day=1) - timedelta(days=1) month_start = month_end.replace(day=1) + await notify_strings.async_load(self.hass) summary = self._build_monthly_report(month_start, month_end) if not summary: return @@ -528,8 +537,18 @@ def _build_monthly_report(self, month_start: date, month_end: date) -> str: earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0) pts = self.storage.get_points_name() lines = [ - f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts}, " - f"level {getattr(c, 'level', 1)}, best streak {getattr(c, 'best_streak', 0)}" + notify_strings.render( + self.hass, + "monthly_report_line", + { + "child_name": c.name, + "chores": done.get(c.id, 0), + "points": earned.get(c.id, 0), + "points_name": pts, + "level": getattr(c, "level", 1), + "streak": getattr(c, "best_streak", 0), + }, + ) for c in children ] return "\n".join(lines) diff --git a/custom_components/taskmate/notify_strings.py b/custom_components/taskmate/notify_strings.py new file mode 100644 index 00000000..8166ab4d --- /dev/null +++ b/custom_components/taskmate/notify_strings.py @@ -0,0 +1,145 @@ +"""Localised text for TaskMate's push notifications (#1037). + +The wording lives in the card/panel catalogue (``www/locales/.json``) +under ``notify.*`` keys, so translators keep one file per language. English is +also baked in here: it is the fallback for a missing locale, a missing key, or +a translation whose placeholders don't format, so a notification always goes +out with something readable. + +Usage: ``await async_load(hass)`` once before building text (it reads the +locale file off the event loop and caches it), then ``render(hass, key, ...)`` +synchronously with a dict of placeholders. Without a load — or on a hass with no language — render falls +back to English. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +_LOGGER = logging.getLogger(__name__) + +_PREFIX = "notify." +_LOCALES_DIR = Path(__file__).parent / "www" / "locales" + +# English source text, keyed without the ``notify.`` prefix. en.json carries +# the same strings (a test keeps the two in step). +DEFAULTS: dict[str, str] = { + "bedtime_reminder": "{child_name}, you still have chores to do before bedtime.", + "streak_at_risk": "{child_name}, complete a chore today to keep your {streak}-day streak!", + "all_chores_done": "{child_name} finished every chore today!", + "badge_earned": "{child_name} earned the {badge_name} badge!", + "pending_chore_approval": "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", + "pending_reward_claim": "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", + "streak_milestone": "{child_name} hit a {days}-day streak — +{points} {points_name}!", + "level_up": "{child_name} reached level {level}! 🎉", + "weekly_digest": "TaskMate weekly digest:\n{summary}", + "celebration": "🎉 {message}", + "mandatory_reminder": "{child_name}, you still need to do '{chore_name}'.", + "mandatory_parent_alert": "{child_name} still hasn't done the mandatory chore '{chore_name}'.", + "monthly_report": "TaskMate {month} report:\n{summary}", + "season_champion": "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", + "family_goal_reached": "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", + "birthday": "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.", + "streak_freeze_used": "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).", + "presence_arrival": "🏠 You're home, {child_name} — {count} chores left today.", + "wish_requested": "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.", + "wish_pledged": "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!", + "bounty_posted": "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!", + "bounty_claim_lapsing": ( + "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board." + ), + "recap_ready": "✨ Your {period} recap is ready, {child_name}! Tap to watch it.", + "item_rejected": "↩️ {child_name}, '{item_name}' was sent back{reason_text}", + "auction_opened": "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!", + "auction_closing": "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.", + "auction_result": "🔨 Auction closed: {result_text}", + "inspection_started": ( + "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. " + "Keep it looking great for +{bonus} {points_name}!" + ), + "inspection_passed": ( + "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}" + ), + "inspection_reminder": ( + "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}." + ), + # Pieces that callers assemble into the messages above. + "auction_won": "{child_name} won '{chore_name}' on {date} for {points} {points_name}.", + "auction_no_bids": "no bids for '{chore_name}', so it goes back to the normal rota.", + "auction_called_off": "the auction for '{chore_name}' was called off. It goes back to the normal rota.", + "weekly_digest_line": "• {child_name}: {chores} chores, {points} {points_name} earned", + "monthly_report_line": "• {child_name}: {chores} chores, {points} {points_name}, level {level}, best streak {streak}", + # Approval push buttons and the hint for backends that can't show them. + "action_approve": "Approve", + "action_reject": "Reject", + "action_reject_placeholder": "Reason (optional)", + "approve_in_panel_hint": "Open the TaskMate panel to approve or reject.", +} + +# language -> merged strings (DEFAULTS overlaid with the locale's own). +_cache: dict[str, dict[str, str]] = {} + + +class SafeDict(dict): + """str.format_map dict that leaves missing keys as `{key}` literal.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def _language(hass: Any) -> str: + config = getattr(hass, "config", None) + lang = getattr(config, "language", None) + return lang if isinstance(lang, str) and lang else "en" + + +def _read_locale(lang: str) -> dict[str, str]: + """English, overlaid with the base language (pt for pt-BR), then the exact one.""" + strings = dict(DEFAULTS) + candidates = [lang.split("-", 1)[0], lang] if "-" in lang else [lang] + for code in candidates: + path = _LOCALES_DIR / f"{code}.json" + if not path.is_file(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + _LOGGER.warning("Could not read TaskMate locale %s for notifications", path.name) + continue + for key, value in data.items(): + if key.startswith(_PREFIX) and isinstance(value, str) and value.strip(): + strings[key[len(_PREFIX) :]] = value + return strings + + +async def async_load(hass: Any) -> None: + """Cache the notification strings for HA's current language.""" + lang = _language(hass) + if lang in _cache: + return + run: Any = getattr(hass, "async_add_executor_job", None) + if not callable(run): + return + try: + _cache[lang] = await run(_read_locale, lang) + except Exception: # noqa: BLE001 - wording is cosmetic; never block a notification + _LOGGER.debug("Could not load TaskMate notification strings for %s", lang, exc_info=True) + + +def text(hass: Any, key: str) -> str: + """The raw template for ``key`` in HA's language (English if not loaded).""" + return _cache.get(_language(hass), DEFAULTS).get(key) or DEFAULTS.get(key, "") + + +def render(hass: Any, key: str, params: Mapping[str, Any]) -> str: + """Format ``key`` with ``params``; a broken translation falls back to English.""" + tpl = text(hass, key) + try: + return tpl.format_map(SafeDict(params)) + except (ValueError, IndexError, KeyError, AttributeError): + _LOGGER.warning("Malformed TaskMate notification text for %s, using English", key) + return DEFAULTS.get(key, "").format_map(SafeDict(params)) diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index c7b94d6c..4438e770 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Slå fra for en tavle, der kun kan ses", "chore_board.editor.show_legend": "Vis forklaring", "chore_board.editor.hide_done_periods": "Skjul færdige tidspunkter", - "chore_board.editor.hide_done_periods_helper": "Fjern et tidspunkt på dagen, når alle børn har klaret deres pligter" + "chore_board.editor.hide_done_periods_helper": "Fjern et tidspunkt på dagen, når alle børn har klaret deres pligter", + "notify.bedtime_reminder": "{child_name}, du har stadig opgaver at lave før sengetid.", + "notify.streak_at_risk": "{child_name}, lav en opgave i dag for at holde din stime på {streak} dage!", + "notify.all_chores_done": "{child_name} har klaret alle dagens opgaver!", + "notify.badge_earned": "{child_name} har fået mærket \"{badge_name}\"!", + "notify.pending_chore_approval": "{child_name} har lavet \"{chore_name}\" (+{points} {points_name}) — venter på godkendelse.", + "notify.pending_reward_claim": "{child_name} har indløst \"{reward_name}\" ({cost} {points_name}) — venter på godkendelse.", + "notify.streak_milestone": "{child_name} har nået en stime på {days} dage — +{points} {points_name}!", + "notify.level_up": "{child_name} er nået til niveau {level}! 🎉", + "notify.weekly_digest": "TaskMate ugeoversigt:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, du mangler stadig at lave \"{chore_name}\".", + "notify.mandatory_parent_alert": "{child_name} har stadig ikke lavet den obligatoriske opgave \"{chore_name}\".", + "notify.monthly_report": "TaskMate rapport for {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} vandt ranglisten for {month} med {points} {points_name}!", + "notify.family_goal_reached": "🎉 Familiemål nået: {goal_name}! Tid til {goal_reward}.", + "notify.birthday": "🎂 Tillykke med fødselsdagen, {child_name}! Alle opgaver giver {multiplier}× i dag.", + "notify.streak_freeze_used": "❄️ En stimefrysning reddede {child_name}s stime på {streak} dage ({freezes_left} tilbage).", + "notify.presence_arrival": "🏠 Du er hjemme, {child_name} — {count} opgaver tilbage i dag.", + "notify.wish_requested": "{child_name} ønsker sig \"{wish_name}\" ({target} {points_name}) — venter på din godkendelse.", + "notify.wish_pledged": "💝 {pledger} har lagt {points} {points_name} i dit ønske \"{wish_name}\"!", + "notify.bounty_posted": "🏁 Ny dusør: {bounty_name} — {points} {points_name}. Den første, der tager den, får den!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, {minutes} minutter tilbage til at gøre \"{bounty_name}\" færdig, før den kommer tilbage på tavlen.", + "notify.recap_ready": "✨ Dit {period}-tilbageblik er klar, {child_name}! Tryk for at se det.", + "notify.item_rejected": "↩️ {child_name}, \"{item_name}\" blev sendt tilbage{reason_text}", + "notify.auction_opened": "🔨 Ny auktion: {chore_name} den {date}, op til {max_points} {points_name}. Laveste bud vinder!", + "notify.auction_closing": "⏳ {child_name}, budrunden på \"{chore_name}\" lukker om {minutes} minutter.", + "notify.auction_result": "🔨 Auktion afsluttet: {result_text}", + "notify.inspection_started": "🔍 {child_name}, en voksen kommer og tjekker \"{chore_name}\" før {until}. Hold det pænt for +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspektion bestået, {child_name}! \"{chore_name}\" så flot ud: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 Inspektionen af {child_name}s \"{chore_name}\" lukker om {minutes} minutter — godkend eller afvis den før {until}.", + "notify.auction_won": "{child_name} vandt \"{chore_name}\" den {date} for {points} {points_name}.", + "notify.auction_no_bids": "ingen bud på \"{chore_name}\", så den går tilbage til den normale tur.", + "notify.auction_called_off": "auktionen over \"{chore_name}\" blev aflyst. Den går tilbage til den normale tur.", + "notify.weekly_digest_line": "• {child_name}: {chores} opgaver, {points} {points_name} optjent", + "notify.monthly_report_line": "• {child_name}: {chores} opgaver, {points} {points_name}, niveau {level}, bedste stime {streak}", + "notify.action_approve": "Godkend", + "notify.action_reject": "Afvis", + "notify.action_reject_placeholder": "Begrundelse (valgfri)", + "notify.approve_in_panel_hint": "Åbn TaskMate-panelet for at godkende eller afvise." } diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 2f897f2c..b3bf0b68 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Ausschalten für eine reine Anzeige", "chore_board.editor.show_legend": "Legende anzeigen", "chore_board.editor.hide_done_periods": "Erledigte Tageszeiten ausblenden", - "chore_board.editor.hide_done_periods_helper": "Eine Tageszeit ausblenden, sobald alle Kinder ihre Aufgaben erledigt haben" + "chore_board.editor.hide_done_periods_helper": "Eine Tageszeit ausblenden, sobald alle Kinder ihre Aufgaben erledigt haben", + "notify.bedtime_reminder": "{child_name}, du hast vor dem Schlafengehen noch Aufgaben zu erledigen.", + "notify.streak_at_risk": "{child_name}, erledige heute eine Aufgabe, um deine {streak}-Tage-Serie zu halten!", + "notify.all_chores_done": "{child_name} hat heute alle Aufgaben erledigt!", + "notify.badge_earned": "{child_name} hat das Abzeichen „{badge_name}“ verdient!", + "notify.pending_chore_approval": "{child_name} hat „{chore_name}“ erledigt (+{points} {points_name}) – wartet auf Freigabe.", + "notify.pending_reward_claim": "{child_name} hat „{reward_name}“ eingelöst ({cost} {points_name}) – wartet auf Freigabe.", + "notify.streak_milestone": "{child_name} hat eine {days}-Tage-Serie geschafft – +{points} {points_name}!", + "notify.level_up": "{child_name} hat Level {level} erreicht! 🎉", + "notify.weekly_digest": "TaskMate-Wochenübersicht:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, du musst noch „{chore_name}“ erledigen.", + "notify.mandatory_parent_alert": "{child_name} hat die Pflichtaufgabe „{chore_name}“ immer noch nicht erledigt.", + "notify.monthly_report": "TaskMate-Bericht {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} hat die Bestenliste im {month} mit {points} {points_name} gewonnen!", + "notify.family_goal_reached": "🎉 Familienziel erreicht: {goal_name}! Zeit für {goal_reward}.", + "notify.birthday": "🎂 Alles Gute zum Geburtstag, {child_name}! Heute zählt jede Aufgabe {multiplier}×.", + "notify.streak_freeze_used": "❄️ Ein Serienschutz hat die {streak}-Tage-Serie von {child_name} gerettet (noch {freezes_left}).", + "notify.presence_arrival": "🏠 Du bist zu Hause, {child_name} – heute sind noch {count} Aufgaben offen.", + "notify.wish_requested": "{child_name} wünscht sich „{wish_name}“ ({target} {points_name}) – wartet auf deine Freigabe.", + "notify.wish_pledged": "💝 {pledger} hat {points} {points_name} zu deinem Wunsch „{wish_name}“ beigesteuert!", + "notify.bounty_posted": "🏁 Neue Prämie: {bounty_name} – {points} {points_name}. Wer sie zuerst beansprucht, bekommt sie!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, noch {minutes} Minuten, um „{bounty_name}“ zu erledigen, bevor sie wieder frei wird.", + "notify.recap_ready": "✨ Dein Rückblick für {period} ist fertig, {child_name}! Tippe, um ihn anzusehen.", + "notify.item_rejected": "↩️ {child_name}, „{item_name}“ wurde zurückgegeben{reason_text}", + "notify.auction_opened": "🔨 Neue Auktion: {chore_name} am {date}, bis zu {max_points} {points_name}. Das niedrigste Gebot gewinnt!", + "notify.auction_closing": "⏳ {child_name}, die Gebote für „{chore_name}“ enden in {minutes} Minuten.", + "notify.auction_result": "🔨 Auktion beendet: {result_text}", + "notify.inspection_started": "🔍 {child_name}, ein Erwachsener prüft „{chore_name}“ vor {until}. Halte es schön ordentlich für +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Prüfung bestanden, {child_name}! „{chore_name}“ sah toll aus: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 Die Prüfung von „{chore_name}“ bei {child_name} endet in {minutes} Minuten – bestätige oder lehne sie vor {until} ab.", + "notify.auction_won": "{child_name} hat „{chore_name}“ am {date} für {points} {points_name} gewonnen.", + "notify.auction_no_bids": "keine Gebote für „{chore_name}“, die Aufgabe geht zurück in den normalen Plan.", + "notify.auction_called_off": "die Auktion für „{chore_name}“ wurde abgesagt. Die Aufgabe geht zurück in den normalen Plan.", + "notify.weekly_digest_line": "• {child_name}: {chores} Aufgaben, {points} {points_name} verdient", + "notify.monthly_report_line": "• {child_name}: {chores} Aufgaben, {points} {points_name}, Level {level}, beste Serie {streak}", + "notify.action_approve": "Genehmigen", + "notify.action_reject": "Ablehnen", + "notify.action_reject_placeholder": "Grund (optional)", + "notify.approve_in_panel_hint": "Öffne das TaskMate-Panel, um zu genehmigen oder abzulehnen." } diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 0e4adce7..29849ce4 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Turn off for a view-only board", "chore_board.editor.show_legend": "Show the legend", "chore_board.editor.hide_done_periods": "Hide finished time periods", - "chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores" + "chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores", + "notify.bedtime_reminder": "{child_name}, you still have chores to do before bedtime.", + "notify.streak_at_risk": "{child_name}, complete a chore today to keep your {streak}-day streak!", + "notify.all_chores_done": "{child_name} finished every chore today!", + "notify.badge_earned": "{child_name} earned the {badge_name} badge!", + "notify.pending_chore_approval": "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", + "notify.pending_reward_claim": "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", + "notify.streak_milestone": "{child_name} hit a {days}-day streak — +{points} {points_name}!", + "notify.level_up": "{child_name} reached level {level}! 🎉", + "notify.weekly_digest": "TaskMate weekly digest:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, you still need to do '{chore_name}'.", + "notify.mandatory_parent_alert": "{child_name} still hasn't done the mandatory chore '{chore_name}'.", + "notify.monthly_report": "TaskMate {month} report:\n{summary}", + "notify.season_champion": "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", + "notify.family_goal_reached": "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", + "notify.birthday": "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.", + "notify.streak_freeze_used": "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).", + "notify.presence_arrival": "🏠 You're home, {child_name} — {count} chores left today.", + "notify.wish_requested": "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.", + "notify.wish_pledged": "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!", + "notify.bounty_posted": "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board.", + "notify.recap_ready": "✨ Your {period} recap is ready, {child_name}! Tap to watch it.", + "notify.item_rejected": "↩️ {child_name}, '{item_name}' was sent back{reason_text}", + "notify.auction_opened": "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!", + "notify.auction_closing": "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.", + "notify.auction_result": "🔨 Auction closed: {result_text}", + "notify.inspection_started": "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. Keep it looking great for +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}.", + "notify.auction_won": "{child_name} won '{chore_name}' on {date} for {points} {points_name}.", + "notify.auction_no_bids": "no bids for '{chore_name}', so it goes back to the normal rota.", + "notify.auction_called_off": "the auction for '{chore_name}' was called off. It goes back to the normal rota.", + "notify.weekly_digest_line": "• {child_name}: {chores} chores, {points} {points_name} earned", + "notify.monthly_report_line": "• {child_name}: {chores} chores, {points} {points_name}, level {level}, best streak {streak}", + "notify.action_approve": "Approve", + "notify.action_reject": "Reject", + "notify.action_reject_placeholder": "Reason (optional)", + "notify.approve_in_panel_hint": "Open the TaskMate panel to approve or reject." } diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 0e4adce7..29849ce4 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Turn off for a view-only board", "chore_board.editor.show_legend": "Show the legend", "chore_board.editor.hide_done_periods": "Hide finished time periods", - "chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores" + "chore_board.editor.hide_done_periods_helper": "Drop a time of day once every child has finished its chores", + "notify.bedtime_reminder": "{child_name}, you still have chores to do before bedtime.", + "notify.streak_at_risk": "{child_name}, complete a chore today to keep your {streak}-day streak!", + "notify.all_chores_done": "{child_name} finished every chore today!", + "notify.badge_earned": "{child_name} earned the {badge_name} badge!", + "notify.pending_chore_approval": "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", + "notify.pending_reward_claim": "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", + "notify.streak_milestone": "{child_name} hit a {days}-day streak — +{points} {points_name}!", + "notify.level_up": "{child_name} reached level {level}! 🎉", + "notify.weekly_digest": "TaskMate weekly digest:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, you still need to do '{chore_name}'.", + "notify.mandatory_parent_alert": "{child_name} still hasn't done the mandatory chore '{chore_name}'.", + "notify.monthly_report": "TaskMate {month} report:\n{summary}", + "notify.season_champion": "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", + "notify.family_goal_reached": "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", + "notify.birthday": "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.", + "notify.streak_freeze_used": "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).", + "notify.presence_arrival": "🏠 You're home, {child_name} — {count} chores left today.", + "notify.wish_requested": "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.", + "notify.wish_pledged": "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!", + "notify.bounty_posted": "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board.", + "notify.recap_ready": "✨ Your {period} recap is ready, {child_name}! Tap to watch it.", + "notify.item_rejected": "↩️ {child_name}, '{item_name}' was sent back{reason_text}", + "notify.auction_opened": "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!", + "notify.auction_closing": "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.", + "notify.auction_result": "🔨 Auction closed: {result_text}", + "notify.inspection_started": "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. Keep it looking great for +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}.", + "notify.auction_won": "{child_name} won '{chore_name}' on {date} for {points} {points_name}.", + "notify.auction_no_bids": "no bids for '{chore_name}', so it goes back to the normal rota.", + "notify.auction_called_off": "the auction for '{chore_name}' was called off. It goes back to the normal rota.", + "notify.weekly_digest_line": "• {child_name}: {chores} chores, {points} {points_name} earned", + "notify.monthly_report_line": "• {child_name}: {chores} chores, {points} {points_name}, level {level}, best streak {streak}", + "notify.action_approve": "Approve", + "notify.action_reject": "Reject", + "notify.action_reject_placeholder": "Reason (optional)", + "notify.approve_in_panel_hint": "Open the TaskMate panel to approve or reject." } diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 3470690f..7ae1c0dd 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Désactivez pour un tableau en lecture seule", "chore_board.editor.show_legend": "Afficher la légende", "chore_board.editor.hide_done_periods": "Masquer les moments terminés", - "chore_board.editor.hide_done_periods_helper": "Retirer un moment de la journée dès que tous les enfants ont fini leurs tâches" + "chore_board.editor.hide_done_periods_helper": "Retirer un moment de la journée dès que tous les enfants ont fini leurs tâches", + "notify.bedtime_reminder": "{child_name}, tu as encore des tâches à faire avant d'aller au lit.", + "notify.streak_at_risk": "{child_name}, fais une tâche aujourd'hui pour garder ta série de {streak} jours !", + "notify.all_chores_done": "{child_name} a terminé toutes ses tâches aujourd'hui !", + "notify.badge_earned": "{child_name} a gagné le badge « {badge_name} » !", + "notify.pending_chore_approval": "{child_name} a terminé « {chore_name} » (+{points} {points_name}) — en attente de validation.", + "notify.pending_reward_claim": "{child_name} a demandé « {reward_name} » ({cost} {points_name}) — en attente de validation.", + "notify.streak_milestone": "{child_name} a atteint une série de {days} jours — +{points} {points_name} !", + "notify.level_up": "{child_name} a atteint le niveau {level} ! 🎉", + "notify.weekly_digest": "Résumé hebdomadaire TaskMate :\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, tu dois encore faire « {chore_name} ».", + "notify.mandatory_parent_alert": "{child_name} n'a toujours pas fait la tâche obligatoire « {chore_name} ».", + "notify.monthly_report": "Rapport TaskMate de {month} :\n{summary}", + "notify.season_champion": "🏆 {child_name} a remporté le classement de {month} avec {points} {points_name} !", + "notify.family_goal_reached": "🎉 Objectif familial atteint : {goal_name} ! C'est l'heure de {goal_reward}.", + "notify.birthday": "🎂 Joyeux anniversaire, {child_name} ! Chaque tâche rapporte {multiplier}× aujourd'hui.", + "notify.streak_freeze_used": "❄️ Un gel de série a sauvé la série de {streak} jours de {child_name} (il en reste {freezes_left}).", + "notify.presence_arrival": "🏠 Te voilà à la maison, {child_name} — encore {count} tâches aujourd'hui.", + "notify.wish_requested": "{child_name} souhaite « {wish_name} » ({target} {points_name}) — en attente de ta validation.", + "notify.wish_pledged": "💝 {pledger} a ajouté {points} {points_name} à ton souhait « {wish_name} » !", + "notify.bounty_posted": "🏁 Nouvelle prime : {bounty_name} — {points} {points_name}. Le premier qui la réclame la remporte !", + "notify.bounty_claim_lapsing": "⏳ {child_name}, plus que {minutes} minutes pour finir « {bounty_name} » avant qu'elle ne retourne au tableau.", + "notify.recap_ready": "✨ Ton récap {period} est prêt, {child_name} ! Touche pour le regarder.", + "notify.item_rejected": "↩️ {child_name}, « {item_name} » a été renvoyé{reason_text}", + "notify.auction_opened": "🔨 Nouvelle enchère : {chore_name} le {date}, jusqu'à {max_points} {points_name}. L'offre la plus basse gagne !", + "notify.auction_closing": "⏳ {child_name}, les enchères pour « {chore_name} » se terminent dans {minutes} minutes.", + "notify.auction_result": "🔨 Enchère terminée : {result_text}", + "notify.inspection_started": "🔍 {child_name}, un adulte va inspecter « {chore_name} » avant {until}. Garde-le impeccable pour +{bonus} {points_name} !", + "notify.inspection_passed": "🌟 Inspection réussie, {child_name} ! « {chore_name} » était parfait : +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 L'inspection de « {chore_name} » de {child_name} se termine dans {minutes} minutes — valide-la ou refuse-la avant {until}.", + "notify.auction_won": "{child_name} a remporté « {chore_name} » le {date} pour {points} {points_name}.", + "notify.auction_no_bids": "aucune offre pour « {chore_name} », la tâche retourne dans la rotation normale.", + "notify.auction_called_off": "l'enchère pour « {chore_name} » a été annulée. La tâche retourne dans la rotation normale.", + "notify.weekly_digest_line": "• {child_name} : {chores} tâches, {points} {points_name} gagnés", + "notify.monthly_report_line": "• {child_name} : {chores} tâches, {points} {points_name}, niveau {level}, meilleure série {streak}", + "notify.action_approve": "Valider", + "notify.action_reject": "Refuser", + "notify.action_reject_placeholder": "Raison (facultatif)", + "notify.approve_in_panel_hint": "Ouvre le panneau TaskMate pour valider ou refuser." } diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index d31731a0..adfcddf5 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Slå av for en tavle som bare kan vises", "chore_board.editor.show_legend": "Vis forklaring", "chore_board.editor.hide_done_periods": "Skjul ferdige tidspunkter", - "chore_board.editor.hide_done_periods_helper": "Fjern en tid på dagen når alle barna har gjort oppgavene sine" + "chore_board.editor.hide_done_periods_helper": "Fjern en tid på dagen når alle barna har gjort oppgavene sine", + "notify.bedtime_reminder": "{child_name}, du har fortsatt oppgaver å gjøre før leggetid.", + "notify.streak_at_risk": "{child_name}, gjør en oppgave i dag for å holde rekken på {streak} dager!", + "notify.all_chores_done": "{child_name} har gjort alle dagens oppgaver!", + "notify.badge_earned": "{child_name} har fått merket «{badge_name}»!", + "notify.pending_chore_approval": "{child_name} har gjort «{chore_name}» (+{points} {points_name}) — venter på godkjenning.", + "notify.pending_reward_claim": "{child_name} har løst inn «{reward_name}» ({cost} {points_name}) — venter på godkjenning.", + "notify.streak_milestone": "{child_name} har nådd en rekke på {days} dager — +{points} {points_name}!", + "notify.level_up": "{child_name} har nådd nivå {level}! 🎉", + "notify.weekly_digest": "TaskMate ukesoversikt:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, du må fortsatt gjøre «{chore_name}».", + "notify.mandatory_parent_alert": "{child_name} har fortsatt ikke gjort den obligatoriske oppgaven «{chore_name}».", + "notify.monthly_report": "TaskMate-rapport for {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} vant ledertavla for {month} med {points} {points_name}!", + "notify.family_goal_reached": "🎉 Familiemålet er nådd: {goal_name}! Tid for {goal_reward}.", + "notify.birthday": "🎂 Gratulerer med dagen, {child_name}! Alle oppgaver gir {multiplier}× i dag.", + "notify.streak_freeze_used": "❄️ En rekkefrys reddet {child_name} sin rekke på {streak} dager ({freezes_left} igjen).", + "notify.presence_arrival": "🏠 Du er hjemme, {child_name} — {count} oppgaver igjen i dag.", + "notify.wish_requested": "{child_name} ønsker seg «{wish_name}» ({target} {points_name}) — venter på din godkjenning.", + "notify.wish_pledged": "💝 {pledger} har lagt {points} {points_name} i ønsket ditt «{wish_name}»!", + "notify.bounty_posted": "🏁 Ny dusør: {bounty_name} — {points} {points_name}. Førstemann som tar den, får den!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, {minutes} minutter igjen til å fullføre «{bounty_name}» før den går tilbake på tavla.", + "notify.recap_ready": "✨ {period}-tilbakeblikket ditt er klart, {child_name}! Trykk for å se det.", + "notify.item_rejected": "↩️ {child_name}, «{item_name}» ble sendt tilbake{reason_text}", + "notify.auction_opened": "🔨 Ny auksjon: {chore_name} den {date}, opptil {max_points} {points_name}. Laveste bud vinner!", + "notify.auction_closing": "⏳ {child_name}, budrunden på «{chore_name}» stenger om {minutes} minutter.", + "notify.auction_result": "🔨 Auksjonen er avsluttet: {result_text}", + "notify.inspection_started": "🔍 {child_name}, en voksen kommer for å sjekke «{chore_name}» før {until}. Hold det fint for +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspeksjonen er bestått, {child_name}! «{chore_name}» så flott ut: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 Inspeksjonen av «{chore_name}» til {child_name} stenger om {minutes} minutter — godkjenn eller avvis den før {until}.", + "notify.auction_won": "{child_name} vant «{chore_name}» den {date} for {points} {points_name}.", + "notify.auction_no_bids": "ingen bud på «{chore_name}», så den går tilbake til vanlig tur.", + "notify.auction_called_off": "auksjonen for «{chore_name}» ble avlyst. Den går tilbake til vanlig tur.", + "notify.weekly_digest_line": "• {child_name}: {chores} oppgaver, {points} {points_name} opptjent", + "notify.monthly_report_line": "• {child_name}: {chores} oppgaver, {points} {points_name}, nivå {level}, beste rekke {streak}", + "notify.action_approve": "Godkjenn", + "notify.action_reject": "Avvis", + "notify.action_reject_placeholder": "Grunn (valgfritt)", + "notify.approve_in_panel_hint": "Åpne TaskMate-panelet for å godkjenne eller avvise." } diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 9c682857..44a19d1b 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Slå av for ei tavle som berre kan visast", "chore_board.editor.show_legend": "Vis forklaring", "chore_board.editor.hide_done_periods": "Skjul ferdige tidspunkt", - "chore_board.editor.hide_done_periods_helper": "Fjern ei tid på dagen når alle borna har gjort oppgåvene sine" + "chore_board.editor.hide_done_periods_helper": "Fjern ei tid på dagen når alle borna har gjort oppgåvene sine", + "notify.bedtime_reminder": "{child_name}, du har framleis oppgåver å gjere før leggjetid.", + "notify.streak_at_risk": "{child_name}, gjer ei oppgåve i dag for å halde rekkja på {streak} dagar!", + "notify.all_chores_done": "{child_name} har gjort alle oppgåvene i dag!", + "notify.badge_earned": "{child_name} har fått merket «{badge_name}»!", + "notify.pending_chore_approval": "{child_name} har gjort «{chore_name}» (+{points} {points_name}) — ventar på godkjenning.", + "notify.pending_reward_claim": "{child_name} har løyst inn «{reward_name}» ({cost} {points_name}) — ventar på godkjenning.", + "notify.streak_milestone": "{child_name} har nådd ei rekkje på {days} dagar — +{points} {points_name}!", + "notify.level_up": "{child_name} har nådd nivå {level}! 🎉", + "notify.weekly_digest": "TaskMate vekeoversikt:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, du må framleis gjere «{chore_name}».", + "notify.mandatory_parent_alert": "{child_name} har framleis ikkje gjort den obligatoriske oppgåva «{chore_name}».", + "notify.monthly_report": "TaskMate-rapport for {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} vann leiartavla for {month} med {points} {points_name}!", + "notify.family_goal_reached": "🎉 Familiemålet er nådd: {goal_name}! Tid for {goal_reward}.", + "notify.birthday": "🎂 Gratulerer med dagen, {child_name}! Alle oppgåver gir {multiplier}× i dag.", + "notify.streak_freeze_used": "❄️ Ein rekkjefrys redda rekkja til {child_name} på {streak} dagar ({freezes_left} att).", + "notify.presence_arrival": "🏠 Du er heime, {child_name} — {count} oppgåver att i dag.", + "notify.wish_requested": "{child_name} ønskjer seg «{wish_name}» ({target} {points_name}) — ventar på godkjenninga di.", + "notify.wish_pledged": "💝 {pledger} har lagt {points} {points_name} i ønsket ditt «{wish_name}»!", + "notify.bounty_posted": "🏁 Ny dusør: {bounty_name} — {points} {points_name}. Den første som tek ho, får ho!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, {minutes} minutt att til å fullføre «{bounty_name}» før ho går tilbake på tavla.", + "notify.recap_ready": "✨ {period}-tilbakeblikket ditt er klart, {child_name}! Trykk for å sjå det.", + "notify.item_rejected": "↩️ {child_name}, «{item_name}» vart sendt tilbake{reason_text}", + "notify.auction_opened": "🔨 Ny auksjon: {chore_name} den {date}, opptil {max_points} {points_name}. Lågaste bod vinn!", + "notify.auction_closing": "⏳ {child_name}, bodrunden på «{chore_name}» stengjer om {minutes} minutt.", + "notify.auction_result": "🔨 Auksjonen er avslutta: {result_text}", + "notify.inspection_started": "🔍 {child_name}, ein vaksen kjem for å sjekke «{chore_name}» før {until}. Hald det fint for +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspeksjonen er bestått, {child_name}! «{chore_name}» såg flott ut: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 Inspeksjonen av «{chore_name}» til {child_name} stengjer om {minutes} minutt — godkjenn eller avvis ho før {until}.", + "notify.auction_won": "{child_name} vann «{chore_name}» den {date} for {points} {points_name}.", + "notify.auction_no_bids": "ingen bod på «{chore_name}», så ho går tilbake til vanleg tur.", + "notify.auction_called_off": "auksjonen for «{chore_name}» vart avlyst. Ho går tilbake til vanleg tur.", + "notify.weekly_digest_line": "• {child_name}: {chores} oppgåver, {points} {points_name} tente", + "notify.monthly_report_line": "• {child_name}: {chores} oppgåver, {points} {points_name}, nivå {level}, beste rekkje {streak}", + "notify.action_approve": "Godkjenn", + "notify.action_reject": "Avvis", + "notify.action_reject_placeholder": "Grunn (valfritt)", + "notify.approve_in_panel_hint": "Opne TaskMate-panelet for å godkjenne eller avvise." } diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index bed243d3..f6264917 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Wyłącz, aby tablica służyła tylko do podglądu", "chore_board.editor.show_legend": "Pokazuj legendę", "chore_board.editor.hide_done_periods": "Ukrywaj ukończone pory dnia", - "chore_board.editor.hide_done_periods_helper": "Ukryj porę dnia, gdy każde dziecko wykona jej obowiązki" + "chore_board.editor.hide_done_periods_helper": "Ukryj porę dnia, gdy każde dziecko wykona jej obowiązki", + "notify.bedtime_reminder": "{child_name}, przed snem masz jeszcze zadania do zrobienia.", + "notify.streak_at_risk": "{child_name}, wykonaj dziś zadanie, aby utrzymać serię {streak} dni!", + "notify.all_chores_done": "{child_name} wykonał(a) dziś wszystkie zadania!", + "notify.badge_earned": "{child_name} zdobył(a) odznakę „{badge_name}”!", + "notify.pending_chore_approval": "{child_name} wykonał(a) „{chore_name}” (+{points} {points_name}) — czeka na zatwierdzenie.", + "notify.pending_reward_claim": "{child_name} odebrał(a) „{reward_name}” ({cost} {points_name}) — czeka na zatwierdzenie.", + "notify.streak_milestone": "{child_name} osiągnął(-ęła) serię {days} dni — +{points} {points_name}!", + "notify.level_up": "{child_name} osiągnął(-ęła) poziom {level}! 🎉", + "notify.weekly_digest": "Tygodniowe podsumowanie TaskMate:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, musisz jeszcze zrobić „{chore_name}”.", + "notify.mandatory_parent_alert": "{child_name} wciąż nie wykonał(a) obowiązkowego zadania „{chore_name}”.", + "notify.monthly_report": "Raport TaskMate za {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} wygrał(a) ranking za {month} z wynikiem {points} {points_name}!", + "notify.family_goal_reached": "🎉 Cel rodzinny osiągnięty: {goal_name}! Czas na {goal_reward}.", + "notify.birthday": "🎂 Wszystkiego najlepszego, {child_name}! Dziś każde zadanie liczy się {multiplier}×.", + "notify.streak_freeze_used": "❄️ Zamrożenie serii uratowało serię {streak} dni ({child_name}, pozostało: {freezes_left}).", + "notify.presence_arrival": "🏠 Jesteś w domu, {child_name} — na dziś zostało zadań: {count}.", + "notify.wish_requested": "{child_name} chce „{wish_name}” ({target} {points_name}) — czeka na Twoje zatwierdzenie.", + "notify.wish_pledged": "💝 {pledger} dodał(a) {points} {points_name} do Twojego życzenia „{wish_name}”!", + "notify.bounty_posted": "🏁 Nowa nagroda specjalna: {bounty_name} — {points} {points_name}. Kto pierwszy, ten lepszy!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, zostało {minutes} min na dokończenie „{bounty_name}”, zanim wróci na tablicę.", + "notify.recap_ready": "✨ Twoje podsumowanie ({period}) jest gotowe, {child_name}! Dotknij, aby je obejrzeć.", + "notify.item_rejected": "↩️ {child_name}, „{item_name}” zostało odesłane{reason_text}", + "notify.auction_opened": "🔨 Nowa aukcja: {chore_name} w dniu {date}, do {max_points} {points_name}. Wygrywa najniższa oferta!", + "notify.auction_closing": "⏳ {child_name}, licytacja „{chore_name}” kończy się za {minutes} min.", + "notify.auction_result": "🔨 Aukcja zakończona: {result_text}", + "notify.inspection_started": "🔍 {child_name}, dorosły sprawdzi „{chore_name}” przed {until}. Utrzymaj porządek, a dostaniesz +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Kontrola zaliczona, {child_name}! „{chore_name}” wyglądało świetnie: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 Kontrola „{chore_name}” ({child_name}) kończy się za {minutes} min — zatwierdź lub odrzuć ją przed {until}.", + "notify.auction_won": "{child_name} wygrał(a) „{chore_name}” w dniu {date} za {points} {points_name}.", + "notify.auction_no_bids": "brak ofert na „{chore_name}”, więc zadanie wraca do zwykłej kolejki.", + "notify.auction_called_off": "aukcja „{chore_name}” została odwołana. Zadanie wraca do zwykłej kolejki.", + "notify.weekly_digest_line": "• {child_name}: zadania: {chores}, zdobyte {points_name}: {points}", + "notify.monthly_report_line": "• {child_name}: zadania: {chores}, {points} {points_name}, poziom {level}, najlepsza seria {streak}", + "notify.action_approve": "Zatwierdź", + "notify.action_reject": "Odrzuć", + "notify.action_reject_placeholder": "Powód (opcjonalnie)", + "notify.approve_in_panel_hint": "Otwórz panel TaskMate, aby zatwierdzić lub odrzucić." } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index bce6520d..053acc45 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Desative para um quadro só de visualização", "chore_board.editor.show_legend": "Mostrar a legenda", "chore_board.editor.hide_done_periods": "Ocultar períodos concluídos", - "chore_board.editor.hide_done_periods_helper": "Remover um período do dia quando todas as crianças terminarem as tarefas" + "chore_board.editor.hide_done_periods_helper": "Remover um período do dia quando todas as crianças terminarem as tarefas", + "notify.bedtime_reminder": "{child_name}, você ainda tem tarefas para fazer antes de dormir.", + "notify.streak_at_risk": "{child_name}, faça uma tarefa hoje para manter sua sequência de {streak} dias!", + "notify.all_chores_done": "{child_name} terminou todas as tarefas de hoje!", + "notify.badge_earned": "{child_name} ganhou a medalha \"{badge_name}\"!", + "notify.pending_chore_approval": "{child_name} concluiu \"{chore_name}\" (+{points} {points_name}) — aguardando aprovação.", + "notify.pending_reward_claim": "{child_name} resgatou \"{reward_name}\" ({cost} {points_name}) — aguardando aprovação.", + "notify.streak_milestone": "{child_name} chegou a uma sequência de {days} dias — +{points} {points_name}!", + "notify.level_up": "{child_name} chegou ao nível {level}! 🎉", + "notify.weekly_digest": "Resumo semanal do TaskMate:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, você ainda precisa fazer \"{chore_name}\".", + "notify.mandatory_parent_alert": "{child_name} ainda não fez a tarefa obrigatória \"{chore_name}\".", + "notify.monthly_report": "Relatório TaskMate de {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} venceu o ranking de {month} com {points} {points_name}!", + "notify.family_goal_reached": "🎉 Meta da família alcançada: {goal_name}! Hora de {goal_reward}.", + "notify.birthday": "🎂 Feliz aniversário, {child_name}! Hoje cada tarefa vale {multiplier}×.", + "notify.streak_freeze_used": "❄️ Um congelamento salvou a sequência de {streak} dias de {child_name} (restam {freezes_left}).", + "notify.presence_arrival": "🏠 Você chegou em casa, {child_name} — faltam {count} tarefas hoje.", + "notify.wish_requested": "{child_name} deseja \"{wish_name}\" ({target} {points_name}) — aguardando sua aprovação.", + "notify.wish_pledged": "💝 {pledger} colocou {points} {points_name} no seu desejo \"{wish_name}\"!", + "notify.bounty_posted": "🏁 Nova recompensa extra: {bounty_name} — {points} {points_name}. Quem pegar primeiro leva!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, faltam {minutes} minutos para terminar \"{bounty_name}\" antes que volte para o quadro.", + "notify.recap_ready": "✨ Sua retrospectiva {period} está pronta, {child_name}! Toque para assistir.", + "notify.item_rejected": "↩️ {child_name}, \"{item_name}\" foi devolvido{reason_text}", + "notify.auction_opened": "🔨 Novo leilão: {chore_name} em {date}, até {max_points} {points_name}. O menor lance vence!", + "notify.auction_closing": "⏳ {child_name}, os lances para \"{chore_name}\" terminam em {minutes} minutos.", + "notify.auction_result": "🔨 Leilão encerrado: {result_text}", + "notify.inspection_started": "🔍 {child_name}, um adulto vai inspecionar \"{chore_name}\" antes das {until}. Mantenha tudo caprichado para ganhar +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspeção aprovada, {child_name}! \"{chore_name}\" ficou ótimo: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 A inspeção de \"{chore_name}\" de {child_name} termina em {minutes} minutos — aprove ou reprove antes das {until}.", + "notify.auction_won": "{child_name} ganhou \"{chore_name}\" em {date} por {points} {points_name}.", + "notify.auction_no_bids": "nenhum lance para \"{chore_name}\", então ela volta para o rodízio normal.", + "notify.auction_called_off": "o leilão de \"{chore_name}\" foi cancelado. Ela volta para o rodízio normal.", + "notify.weekly_digest_line": "• {child_name}: {chores} tarefas, {points} {points_name} ganhos", + "notify.monthly_report_line": "• {child_name}: {chores} tarefas, {points} {points_name}, nível {level}, melhor sequência {streak}", + "notify.action_approve": "Aprovar", + "notify.action_reject": "Rejeitar", + "notify.action_reject_placeholder": "Motivo (opcional)", + "notify.approve_in_panel_hint": "Abra o painel do TaskMate para aprovar ou rejeitar." } diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index a6531e6f..41cba9f5 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -2894,5 +2894,44 @@ "chore_board.editor.tap_to_complete_helper": "Desative para um quadro só de visualização", "chore_board.editor.show_legend": "Mostrar a legenda", "chore_board.editor.hide_done_periods": "Ocultar períodos concluídos", - "chore_board.editor.hide_done_periods_helper": "Retirar um período do dia quando todas as crianças terminaram as tarefas" + "chore_board.editor.hide_done_periods_helper": "Retirar um período do dia quando todas as crianças terminaram as tarefas", + "notify.bedtime_reminder": "{child_name}, ainda tens tarefas para fazer antes de ir para a cama.", + "notify.streak_at_risk": "{child_name}, faz uma tarefa hoje para manter a tua sequência de {streak} dias!", + "notify.all_chores_done": "{child_name} terminou todas as tarefas de hoje!", + "notify.badge_earned": "{child_name} ganhou o distintivo \"{badge_name}\"!", + "notify.pending_chore_approval": "{child_name} concluiu \"{chore_name}\" (+{points} {points_name}) — a aguardar aprovação.", + "notify.pending_reward_claim": "{child_name} pediu \"{reward_name}\" ({cost} {points_name}) — a aguardar aprovação.", + "notify.streak_milestone": "{child_name} chegou a uma sequência de {days} dias — +{points} {points_name}!", + "notify.level_up": "{child_name} chegou ao nível {level}! 🎉", + "notify.weekly_digest": "Resumo semanal do TaskMate:\n{summary}", + "notify.celebration": "🎉 {message}", + "notify.mandatory_reminder": "{child_name}, ainda tens de fazer \"{chore_name}\".", + "notify.mandatory_parent_alert": "{child_name} ainda não fez a tarefa obrigatória \"{chore_name}\".", + "notify.monthly_report": "Relatório TaskMate de {month}:\n{summary}", + "notify.season_champion": "🏆 {child_name} venceu a classificação de {month} com {points} {points_name}!", + "notify.family_goal_reached": "🎉 Objetivo da família alcançado: {goal_name}! Está na hora de {goal_reward}.", + "notify.birthday": "🎂 Feliz aniversário, {child_name}! Hoje cada tarefa vale {multiplier}×.", + "notify.streak_freeze_used": "❄️ Um congelamento salvou a sequência de {streak} dias de {child_name} (restam {freezes_left}).", + "notify.presence_arrival": "🏠 Estás em casa, {child_name} — faltam {count} tarefas hoje.", + "notify.wish_requested": "{child_name} deseja \"{wish_name}\" ({target} {points_name}) — a aguardar a tua aprovação.", + "notify.wish_pledged": "💝 {pledger} juntou {points} {points_name} ao teu desejo \"{wish_name}\"!", + "notify.bounty_posted": "🏁 Novo prémio: {bounty_name} — {points} {points_name}. Quem o pedir primeiro fica com ele!", + "notify.bounty_claim_lapsing": "⏳ {child_name}, faltam {minutes} minutos para terminares \"{bounty_name}\" antes de voltar ao quadro.", + "notify.recap_ready": "✨ O teu resumo {period} está pronto, {child_name}! Toca para o ver.", + "notify.item_rejected": "↩️ {child_name}, \"{item_name}\" foi devolvido{reason_text}", + "notify.auction_opened": "🔨 Novo leilão: {chore_name} em {date}, até {max_points} {points_name}. Ganha a oferta mais baixa!", + "notify.auction_closing": "⏳ {child_name}, as licitações para \"{chore_name}\" terminam dentro de {minutes} minutos.", + "notify.auction_result": "🔨 Leilão terminado: {result_text}", + "notify.inspection_started": "🔍 {child_name}, um adulto vai inspecionar \"{chore_name}\" antes das {until}. Mantém tudo impecável para ganhares +{bonus} {points_name}!", + "notify.inspection_passed": "🌟 Inspeção aprovada, {child_name}! \"{chore_name}\" estava ótimo: +{bonus} {points_name}{note_text}", + "notify.inspection_reminder": "🔍 A inspeção de \"{chore_name}\" de {child_name} termina dentro de {minutes} minutos — aprova ou reprova antes das {until}.", + "notify.auction_won": "{child_name} ganhou \"{chore_name}\" em {date} por {points} {points_name}.", + "notify.auction_no_bids": "não houve ofertas para \"{chore_name}\", por isso volta à rotação normal.", + "notify.auction_called_off": "o leilão de \"{chore_name}\" foi cancelado. Volta à rotação normal.", + "notify.weekly_digest_line": "• {child_name}: {chores} tarefas, {points} {points_name} ganhos", + "notify.monthly_report_line": "• {child_name}: {chores} tarefas, {points} {points_name}, nível {level}, melhor sequência {streak}", + "notify.action_approve": "Aprovar", + "notify.action_reject": "Rejeitar", + "notify.action_reject_placeholder": "Motivo (opcional)", + "notify.approve_in_panel_hint": "Abre o painel do TaskMate para aprovar ou rejeitar." } diff --git a/tests/data/locale_english_baseline.json b/tests/data/locale_english_baseline.json index bc31d717..9d741896 100644 --- a/tests/data/locale_english_baseline.json +++ b/tests/data/locale_english_baseline.json @@ -16,6 +16,7 @@ "common.design.console", "common.design.playroom", "dashboard.tab_rotation", + "notify.celebration", "overview.default_title", "panel.badge_tab_title", "panel.badge_table_badge", @@ -62,6 +63,7 @@ "common.design.console", "common.design.playroom", "dashboard.tab_rotation", + "notify.celebration", "overview.default_title", "panel.badge_name_label", "panel.badge_table_bonus", @@ -126,6 +128,7 @@ "dashboard.tab_rotation", "graph.total", "inspection.tag_open", + "notify.celebration", "overview.default_title", "overview.footer_total_points", "panel.activity_points", @@ -201,6 +204,7 @@ "common.design.cleanpro", "common.design.console", "common.design.playroom", + "notify.celebration", "overview.default_title", "panel.badge_table_bonus", "panel.bounty_col_status", @@ -247,6 +251,7 @@ "common.design.cleanpro", "common.design.console", "common.design.playroom", + "notify.celebration", "overview.default_title", "panel.badge_table_bonus", "panel.bounty_col_status", @@ -287,6 +292,7 @@ "child.start", "child.timed_cap_label", "common.design.cleanpro", + "notify.celebration", "overview.default_title", "panel.bounty_col_status", "panel.bounty_completed_meta", @@ -318,6 +324,7 @@ "common.design.playroom", "common.total", "graph.total", + "notify.celebration", "overview.default_title", "panel.bounty_col_status", "panel.bounty_completed_meta", @@ -353,6 +360,7 @@ "common.design.playroom", "common.total", "graph.total", + "notify.celebration", "overview.default_title", "panel.bounty_completed_meta", "panel.child_avatar_label", diff --git a/tests/test_monthly_report.py b/tests/test_monthly_report.py index 3c6756ef..e17871de 100644 --- a/tests/test_monthly_report.py +++ b/tests/test_monthly_report.py @@ -16,6 +16,7 @@ def _coord(children, completions): c = object.__new__(TaskMateCoordinator) + c.hass = None s = MagicMock() s.get_children = MagicMock(return_value=children) s.get_completions = MagicMock(return_value=completions) diff --git a/tests/test_notifications_fallback.py b/tests/test_notifications_fallback.py index 50f9d42c..b863bad9 100644 --- a/tests/test_notifications_fallback.py +++ b/tests/test_notifications_fallback.py @@ -11,13 +11,13 @@ import pytest -from custom_components.taskmate.coord_notifications import ( - _APPROVE_IN_PANEL_HINT, - NotificationCoordinator, -) +from custom_components.taskmate.coord_notifications import NotificationCoordinator from custom_components.taskmate.models import NotificationRoute, ParentRecipient +from custom_components.taskmate.notify_strings import DEFAULTS from custom_components.taskmate.storage import TaskMateStorage +_APPROVE_IN_PANEL_HINT = DEFAULTS["approve_in_panel_hint"] + @pytest.fixture async def coord(hass): diff --git a/tests/test_notify_strings.py b/tests/test_notify_strings.py new file mode 100644 index 00000000..3237f837 --- /dev/null +++ b/tests/test_notify_strings.py @@ -0,0 +1,153 @@ +"""Localised push notification text and action buttons (#1037).""" + +from __future__ import annotations + +import json +import string +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.taskmate import notify_strings +from custom_components.taskmate.coord_notifications import NotificationCoordinator +from custom_components.taskmate.models import NotificationRoute, ParentRecipient +from custom_components.taskmate.storage import TaskMateStorage + +LOCALES = Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "www" / "locales" + + +def _placeholders(text: str) -> set[str]: + return {name for _, name, _, _ in string.Formatter().parse(text) if name} + + +def _notify_keys(path: Path) -> dict[str, str]: + data = json.loads(path.read_text(encoding="utf-8")) + return {k[len("notify.") :]: v for k, v in data.items() if k.startswith("notify.")} + + +@pytest.fixture(autouse=True) +def _fresh_cache(): + notify_strings._cache.clear() + yield + notify_strings._cache.clear() + + +def _lang(hass, language: str): + """Give the fake hass a language and a (synchronous) executor.""" + hass.config = MagicMock(language=language) + + async def _run(func, *args): + return func(*args) + + hass.async_add_executor_job = _run + return hass + + +def test_en_json_matches_the_baked_in_english(): + assert _notify_keys(LOCALES / "en.json") == notify_strings.DEFAULTS + + +@pytest.mark.parametrize("path", sorted(LOCALES.glob("*.json")), ids=lambda p: p.stem) +def test_every_translation_keeps_the_english_placeholders(path): + strings = _notify_keys(path) + assert set(strings) == set(notify_strings.DEFAULTS) + for key, english in notify_strings.DEFAULTS.items(): + assert _placeholders(strings[key]) == _placeholders(english), f"{path.name}: notify.{key}" + + +@pytest.mark.asyncio +async def test_german_message_and_buttons(hass): + _lang(hass, "de") + await notify_strings.async_load(hass) + msg = notify_strings.render( + hass, + "pending_chore_approval", + {"child_name": "Alex", "chore_name": "Spülmaschine ausräumen", "points": 5, "points_name": "Punkte"}, + ) + assert msg == "Alex hat „Spülmaschine ausräumen“ erledigt (+5 Punkte) – wartet auf Freigabe." + assert notify_strings.text(hass, "action_approve") == "Genehmigen" + assert notify_strings.text(hass, "action_reject") == "Ablehnen" + + +@pytest.mark.asyncio +async def test_regional_language_uses_its_base_file(hass): + _lang(hass, "de-CH") + await notify_strings.async_load(hass) + assert notify_strings.text(hass, "action_approve") == "Genehmigen" + + +@pytest.mark.asyncio +async def test_unknown_language_and_no_language_fall_back_to_english(hass): + _lang(hass, "xx") + await notify_strings.async_load(hass) + assert notify_strings.text(hass, "action_approve") == "Approve" + # The plain test hass has no config at all. + assert notify_strings.text(object(), "action_reject") == "Reject" + + +@pytest.mark.asyncio +async def test_broken_translation_falls_back_to_english(hass): + _lang(hass, "de") + await notify_strings.async_load(hass) + notify_strings._cache["de"]["level_up"] = "{child_name Level {level}" + assert notify_strings.render(hass, "level_up", {"child_name": "Alex", "level": 3}) == "Alex reached level 3! 🎉" + + +@pytest.fixture +async def coord(hass): + storage = TaskMateStorage(hass, "notify_strings") + await storage.async_load() + hass.services.async_call = AsyncMock() + hass.bus.async_fire = MagicMock() + return NotificationCoordinator(hass, storage) + + +def _route_parent(coord, service: str) -> None: + parent = ParentRecipient(name="Sam", notify_service=service) + coord.storage.upsert_parent_recipient(parent) + coord.storage.set_notification_master("pending_chore_approval", True) + coord.storage.set_notification_route("pending_chore_approval", parent.id, NotificationRoute(enabled=True)) + + +def _sent(hass) -> dict: + return next(c for c in hass.services.async_call.call_args_list if c[0][0] == "notify")[0][2] + + +@pytest.mark.asyncio +async def test_approval_push_is_sent_in_ha_language(coord, hass): + _lang(hass, "de") + _route_parent(coord, "notify.mobile_app_phone") + await coord.fire( + "pending_chore_approval", + {"entry_id": "c1", "child_name": "Alex", "chore_name": "Bad putzen", "points": 5, "points_name": "Punkte"}, + ) + data = _sent(hass) + assert data["message"] == "Alex hat „Bad putzen“ erledigt (+5 Punkte) – wartet auf Freigabe." + approve, reject = data["data"]["actions"] + assert approve == {"action": "TASKMATE_APPROVE_c1", "title": "Genehmigen"} + assert reject["action"] == "TASKMATE_REJECT_c1" + assert reject["title"] == reject["textInputButtonTitle"] == "Ablehnen" + assert reject["textInputPlaceholder"] == "Grund (optional)" + + +@pytest.mark.asyncio +async def test_panel_hint_is_translated_for_non_mobile_backends(coord, hass): + _lang(hass, "fr") + _route_parent(coord, "notify.telegram") + await coord.fire( + "pending_chore_approval", + {"entry_id": "c1", "child_name": "Alex", "chore_name": "Lit", "points": 5, "points_name": "étoiles"}, + ) + assert _sent(hass)["message"].endswith("Ouvre le panneau TaskMate pour valider ou refuser.") + + +@pytest.mark.asyncio +async def test_custom_message_template_is_left_alone(coord, hass): + _lang(hass, "de") + _route_parent(coord, "notify.phone") + await coord.fire( + "pending_chore_approval", + {"entry_id": "c1", "child_name": "Alex", "message_template": "Hey {child_name}"}, + ) + assert _sent(hass)["message"].startswith("Hey Alex") diff --git a/tests/test_weekly_digest.py b/tests/test_weekly_digest.py index 5eff4d97..f91524ab 100644 --- a/tests/test_weekly_digest.py +++ b/tests/test_weekly_digest.py @@ -22,6 +22,7 @@ def run(coro): def _coord(children, completions): coord = object.__new__(TaskMateCoordinator) + coord.hass = None storage = MagicMock() storage.get_children = MagicMock(return_value=children) storage.get_completions = MagicMock(return_value=completions)