203 files
This commit is contained in:
@@ -63,6 +63,7 @@ from .const import (
|
||||
ATTR_REWARD_ID,
|
||||
ATTR_SOUND,
|
||||
BADGE_TIERS,
|
||||
CHORE_SUGGESTED_POINTS_MAX,
|
||||
CONF_TASK_GROUP_CHORE_IDS,
|
||||
CONF_TASK_GROUP_ID,
|
||||
CONF_TASK_GROUP_NAME,
|
||||
@@ -527,6 +528,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
child_id,
|
||||
as_parent=as_parent,
|
||||
photo_url=call.data.get("photo_url", ""),
|
||||
note=call.data.get("note", ""),
|
||||
suggested_points=call.data.get("suggested_points", 0),
|
||||
)
|
||||
except ValueError as err:
|
||||
# Only genuinely bad input (unknown chore/child) raises now — expected
|
||||
@@ -581,7 +584,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
completion_id = call.data["completion_id"]
|
||||
await coordinator.async_approve_chore(completion_id)
|
||||
await coordinator.async_approve_chore(completion_id, points=call.data.get("points"))
|
||||
|
||||
async def handle_approve_all_chores(call: ServiceCall) -> None:
|
||||
"""Handle the approve_all_chores service call."""
|
||||
@@ -1127,6 +1130,10 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
vol.Optional(ATTR_AS_PARENT, default=False): cv.boolean,
|
||||
vol.Optional("photo_url"): cv.string,
|
||||
vol.Optional("note"): cv.string,
|
||||
vol.Optional("suggested_points"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=CHORE_SUGGESTED_POINTS_MAX)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1187,6 +1194,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required("completion_id"): cv.string,
|
||||
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -78,6 +78,11 @@ DEFAULT_TIME_PERIODS: Final = [
|
||||
|
||||
MAX_TIME_PERIODS: Final = 24
|
||||
|
||||
# Open-ended chores (#832): the child describes the work and suggests what it
|
||||
# was worth. Both are child-entered free input, so they're bounded before storage.
|
||||
CHORE_NOTE_MAX_LENGTH: Final = 200
|
||||
CHORE_SUGGESTED_POINTS_MAX: Final = 999
|
||||
|
||||
# Platforms
|
||||
PLATFORMS: Final = ["sensor", "button", "binary_sensor"]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import images, photos
|
||||
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX
|
||||
from .models import Chore, ChoreCompletion, PointsTransaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -685,7 +686,13 @@ class ChoresMixin:
|
||||
return chore
|
||||
|
||||
async def async_complete_chore(
|
||||
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
|
||||
self,
|
||||
chore_id: str,
|
||||
child_id: str,
|
||||
as_parent: bool = False,
|
||||
photo_url: str = "",
|
||||
note: str = "",
|
||||
suggested_points: int = 0,
|
||||
) -> ChoreCompletion | None:
|
||||
"""Mark a chore as completed by a child.
|
||||
|
||||
@@ -720,6 +727,18 @@ class ChoresMixin:
|
||||
)
|
||||
photo_url = ""
|
||||
|
||||
# The note and the suggested points are typed by a child, so bound both
|
||||
# before they reach storage: an unbounded note bloats the store and the
|
||||
# sensor attribute slice, and the suggestion is only ever a hint for the
|
||||
# parent. Over-long notes are truncated rather than refused — losing a
|
||||
# child's tail end beats refusing their chore.
|
||||
note = (note or "").strip()[:CHORE_NOTE_MAX_LENGTH]
|
||||
try:
|
||||
suggested_points = int(suggested_points or 0)
|
||||
except (TypeError, ValueError):
|
||||
suggested_points = 0
|
||||
suggested_points = min(max(0, suggested_points), CHORE_SUGGESTED_POINTS_MAX)
|
||||
|
||||
now = dt_util.now()
|
||||
today = dt_util.as_local(now).date()
|
||||
|
||||
@@ -824,7 +843,16 @@ class ChoresMixin:
|
||||
# ServiceValidationError by the service layer.
|
||||
if requires_photo and not as_parent and not (photo_url or "").strip():
|
||||
raise ValueError("This chore requires a photo as evidence.")
|
||||
auto_approve = as_parent or (not chore.requires_approval and not requires_photo)
|
||||
# An open-ended chore is a placeholder for unlisted work, so the note IS
|
||||
# the chore — without it the parent has nothing to review. Same shape as
|
||||
# the photo gate above: enforced here rather than only in the card, so
|
||||
# the button entity, automations and Dev Tools can't bypass it.
|
||||
is_open_ended = bool(getattr(chore, "open_ended", False))
|
||||
if is_open_ended and not as_parent and not note:
|
||||
raise ValueError("Say what you did to submit this one.")
|
||||
# Open-ended completions always go to a parent: the child suggested the
|
||||
# point value, so self-awarding it would let them set their own pay.
|
||||
auto_approve = as_parent or (not chore.requires_approval and not requires_photo and not is_open_ended)
|
||||
effective_points = self._apply_roulette_multiplier(
|
||||
chore,
|
||||
child_id,
|
||||
@@ -842,6 +870,8 @@ class ChoresMixin:
|
||||
approved=auto_approve,
|
||||
points_awarded=effective_points if auto_approve else 0,
|
||||
photo_url=photo_url or "",
|
||||
note=note,
|
||||
suggested_points=suggested_points,
|
||||
)
|
||||
|
||||
# Record the completion before awarding anything. Awarding can suspend
|
||||
@@ -888,7 +918,10 @@ class ChoresMixin:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name,
|
||||
chore.name,
|
||||
chore.points,
|
||||
# An open-ended chore has no points of its own, so quote the
|
||||
# child's own estimate — otherwise every one of these pushes
|
||||
# announces "+0" (#832).
|
||||
completion.suggested_points if is_open_ended else chore.points,
|
||||
completion_id=completion.id,
|
||||
photo_url=completion.photo_url,
|
||||
)
|
||||
@@ -1049,12 +1082,19 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return completion
|
||||
|
||||
async def async_approve_chore(self, completion_id: str, refresh: bool = True) -> None:
|
||||
async def async_approve_chore(self, completion_id: str, refresh: bool = True, points: int | None = None) -> None:
|
||||
"""Approve a chore completion.
|
||||
|
||||
``refresh=False`` is used by ``async_approve_chores_bulk`` so a batch
|
||||
pays for one coordinator rebuild rather than one per completion (#794).
|
||||
The caller must refresh once it is done.
|
||||
|
||||
``points`` lets the approving parent set what the chore was actually
|
||||
worth (#832) — needed for open-ended submissions, useful for any chore.
|
||||
It replaces the chore's *base* points, so the streak and level
|
||||
multipliers ``_award_points`` applies still ride on top exactly as they
|
||||
would for an ordinary approval. ``None`` means "use the chore's value"
|
||||
and leaves the existing behaviour untouched.
|
||||
"""
|
||||
completions = self.storage.get_completions()
|
||||
for completion in completions:
|
||||
@@ -1085,6 +1125,19 @@ class ChoresMixin:
|
||||
pts = self._apply_time_adjustment(
|
||||
chore, self.effective_chore_points(chore), completion.completed_at
|
||||
)
|
||||
if points is not None:
|
||||
# An explicit award overrides every per-chore
|
||||
# calculation above (bonus sub-task, timed rate,
|
||||
# time-of-day adjustment) — the parent has looked at
|
||||
# what was actually done and priced it themselves.
|
||||
try:
|
||||
pts = max(0, int(points))
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.warning(
|
||||
"Ignoring non-numeric points override %r for completion %s",
|
||||
points,
|
||||
completion_id,
|
||||
)
|
||||
# Claim the completion before awarding. The "already
|
||||
# approved" check above and the award are separated by an
|
||||
# await that can suspend, so two approvals landing together
|
||||
|
||||
@@ -283,8 +283,18 @@ class RewardsMixin:
|
||||
# reward can never fail it, so without these two guards a child can
|
||||
# queue unlimited claims — each one a stored record plus a push to
|
||||
# every parent.
|
||||
own_pending = [c for c in self.storage.get_pending_reward_claims() if c.child_id == child_id]
|
||||
if any(c.reward_id == reward_id for c in own_pending):
|
||||
pending = self.storage.get_pending_reward_claims()
|
||||
own_pending = [c for c in pending if c.child_id == child_id]
|
||||
# A jackpot is funded from one shared pool, so it is redeemed once per
|
||||
# funding cycle: a queued claim owns that pool whoever made it. Without
|
||||
# this, a second child's claim is approved after the pool is already
|
||||
# spent, falls through to wallet mode, and charges them the full cost
|
||||
# all over again (#873).
|
||||
if reward.is_jackpot:
|
||||
blocking = [c for c in pending if c.reward_id == reward_id]
|
||||
else:
|
||||
blocking = [c for c in own_pending if c.reward_id == reward_id]
|
||||
if blocking:
|
||||
raise ValueError(f"A claim for '{reward.name}' is already waiting for approval")
|
||||
if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD:
|
||||
raise ValueError("Too many reward claims are already waiting for approval")
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.6.1"
|
||||
"version": "5.7.0"
|
||||
}
|
||||
|
||||
@@ -319,6 +319,11 @@ class Chore:
|
||||
mandatory: bool = False
|
||||
mandatory_penalty_points: int = 0
|
||||
require_photo: bool = False # require an evidence photo; forces parent approval
|
||||
# Open-ended "I did something extra" placeholder (#832): the card asks the
|
||||
# child to describe the work and suggest a point value, and the parent sets
|
||||
# the real points when approving. Forces parent approval — a child types
|
||||
# the suggestion, so it must never self-award.
|
||||
open_ended: bool = False
|
||||
# Dynamic assignment (sibling rotation)
|
||||
assignment_mode: str = "everyone" # everyone | alternating | random
|
||||
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
|
||||
@@ -401,6 +406,7 @@ class Chore:
|
||||
mandatory=bool(data.get("mandatory", False)),
|
||||
mandatory_penalty_points=max(0, int(data.get("mandatory_penalty_points", 0) or 0)),
|
||||
require_photo=data.get("require_photo", False),
|
||||
open_ended=data.get("open_ended", False),
|
||||
assignment_mode=data.get("assignment_mode", "everyone"),
|
||||
assignment_rotation_anchor=data.get("assignment_rotation_anchor", ""),
|
||||
assignment_current_child_id=data.get("assignment_current_child_id", ""),
|
||||
@@ -467,6 +473,7 @@ class Chore:
|
||||
"mandatory": self.mandatory,
|
||||
"mandatory_penalty_points": self.mandatory_penalty_points,
|
||||
"require_photo": self.require_photo,
|
||||
"open_ended": self.open_ended,
|
||||
"assignment_mode": self.assignment_mode,
|
||||
"assignment_rotation_anchor": self.assignment_rotation_anchor,
|
||||
"assignment_current_child_id": self.assignment_current_child_id,
|
||||
@@ -711,6 +718,8 @@ class ChoreCompletion:
|
||||
bonus_subtask_id: str = "" # Non-empty = this completion is for a bonus sub-task
|
||||
timed_duration_seconds: int = 0
|
||||
photo_url: str = "" # optional evidence photo (URL/path) attached at completion
|
||||
note: str = "" # child's own description of the work (open-ended chores, #832)
|
||||
suggested_points: int = 0 # what the child reckons it was worth (#832)
|
||||
id: str = field(default_factory=generate_id)
|
||||
|
||||
@classmethod
|
||||
@@ -729,6 +738,8 @@ class ChoreCompletion:
|
||||
bonus_subtask_id=data.get("bonus_subtask_id", ""),
|
||||
timed_duration_seconds=data.get("timed_duration_seconds", 0),
|
||||
photo_url=data.get("photo_url", ""),
|
||||
note=data.get("note", ""),
|
||||
suggested_points=int(data.get("suggested_points", 0) or 0),
|
||||
id=data.get("id") or generate_id(),
|
||||
)
|
||||
|
||||
@@ -744,6 +755,8 @@ class ChoreCompletion:
|
||||
"bonus_subtask_id": self.bonus_subtask_id,
|
||||
"timed_duration_seconds": self.timed_duration_seconds,
|
||||
"photo_url": self.photo_url,
|
||||
"note": self.note,
|
||||
"suggested_points": self.suggested_points,
|
||||
"id": self.id,
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,10 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
||||
record["mandatory_penalty_points"] = penalty
|
||||
if getattr(c, "require_photo", False):
|
||||
record["require_photo"] = True
|
||||
# Open-ended chores (#832): the child card asks for a description and a
|
||||
# suggested point value instead of completing on a single tap.
|
||||
if getattr(c, "open_ended", False):
|
||||
record["open_ended"] = True
|
||||
recurrence = getattr(c, "recurrence", "weekly")
|
||||
if recurrence != "weekly":
|
||||
record["recurrence"] = recurrence
|
||||
@@ -1387,6 +1391,15 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
||||
photo = getattr(comp, "photo_url", "") or ""
|
||||
if photo:
|
||||
detail["photo_url"] = photo
|
||||
# The child's description and their own estimate (#832). Both
|
||||
# are emitted only when set — an ordinary tap-to-complete
|
||||
# carries neither, and this slice is capped at 16KB.
|
||||
note = getattr(comp, "note", "") or ""
|
||||
if note:
|
||||
detail["note"] = note
|
||||
suggested = getattr(comp, "suggested_points", 0) or 0
|
||||
if suggested:
|
||||
detail["suggested_points"] = suggested
|
||||
completion_details.append(detail)
|
||||
|
||||
reward_details = []
|
||||
|
||||
@@ -27,6 +27,22 @@ complete_chore:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
note:
|
||||
name: Note
|
||||
description: What the child did, in their own words. Required when the chore is open-ended, and shown to the parent during approval.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
suggested_points:
|
||||
name: Suggested points
|
||||
description: What the child reckons the work was worth. A hint for the approving parent — it awards nothing on its own.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 999
|
||||
mode: box
|
||||
|
||||
complete_bonus_subtask:
|
||||
name: Complete Bonus Sub-task
|
||||
@@ -61,6 +77,15 @@ approve_chore:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
points:
|
||||
name: Points
|
||||
description: Award this many points instead of the chore's own value. Leave empty to use the chore's points.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 9999
|
||||
mode: box
|
||||
|
||||
approve_all_chores:
|
||||
name: Approve All Chores
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Child ID",
|
||||
"description": "The ID of the child completing the chore"
|
||||
},
|
||||
"note": {
|
||||
"name": "Note",
|
||||
"description": "What the child did, in their own words. Required when the chore is open-ended, and shown to the parent during approval."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Suggested points",
|
||||
"description": "What the child reckons the work was worth. A hint for the approving parent — it awards nothing on its own."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Completion ID",
|
||||
"description": "The ID of the chore completion to approve"
|
||||
},
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Kinder-ID",
|
||||
"description": "Die ID des Kindes, das die Aufgabe erledigt"
|
||||
},
|
||||
"note": {
|
||||
"name": "Notiz",
|
||||
"description": "Was das Kind getan hat, in seinen eigenen Worten. Bei offenen Aufgaben erforderlich und den Eltern bei der Genehmigung angezeigt."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Vorgeschlagene Punkte",
|
||||
"description": "Was das Kind für die Arbeit für angemessen hält. Nur ein Hinweis für die genehmigenden Eltern — es werden dadurch keine Punkte vergeben."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Abschluss-ID",
|
||||
"description": "Die ID des zu genehmigenden Aufgabenabschlusses"
|
||||
},
|
||||
"points": {
|
||||
"name": "Punkte",
|
||||
"description": "Diese Punktzahl anstelle des Aufgabenwerts vergeben. Leer lassen, um die Punkte der Aufgabe zu verwenden."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Child ID",
|
||||
"description": "The ID of the child completing the chore"
|
||||
},
|
||||
"note": {
|
||||
"name": "Note",
|
||||
"description": "What the child did, in their own words. Required when the chore is open-ended, and shown to the parent during approval."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Suggested points",
|
||||
"description": "What the child reckons the work was worth. A hint for the approving parent — it awards nothing on its own."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Completion ID",
|
||||
"description": "The ID of the chore completion to approve"
|
||||
},
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Child ID",
|
||||
"description": "The ID of the child completing the chore"
|
||||
},
|
||||
"note": {
|
||||
"name": "Note",
|
||||
"description": "What the child did, in their own words. Required when the chore is open-ended, and shown to the parent during approval."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Suggested points",
|
||||
"description": "What the child reckons the work was worth. A hint for the approving parent — it awards nothing on its own."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Completion ID",
|
||||
"description": "The ID of the chore completion to approve"
|
||||
},
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "Award this many points instead of the chore's own value. Leave empty to use the chore's points."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "ID de l'enfant",
|
||||
"description": "L'identifiant de l'enfant effectuant la corvée"
|
||||
},
|
||||
"note": {
|
||||
"name": "Note",
|
||||
"description": "Ce que l'enfant a fait, avec ses propres mots. Obligatoire pour les tâches ouvertes, et affiché au parent lors de l'approbation."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Points suggérés",
|
||||
"description": "Ce que l'enfant estime que le travail valait. Une simple indication pour le parent qui approuve — cela n'attribue aucun point."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "ID de complétion",
|
||||
"description": "L'identifiant de l'exécution à approuver"
|
||||
},
|
||||
"points": {
|
||||
"name": "Points",
|
||||
"description": "Attribuer ce nombre de points au lieu de la valeur de la tâche. Laisser vide pour utiliser les points de la tâche."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Barn-ID",
|
||||
"description": "ID-en til barnet som fullfører oppgaven"
|
||||
},
|
||||
"note": {
|
||||
"name": "Notat",
|
||||
"description": "Hva barnet gjorde, med egne ord. Påkrevd for åpne oppgaver, og vises til forelderen ved godkjenning."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Foreslåtte poeng",
|
||||
"description": "Hva barnet mener arbeidet var verdt. Bare et hint til forelderen som godkjenner — det gir ikke poeng i seg selv."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Fullførings-ID",
|
||||
"description": "ID-en til oppgavefullføringen som skal godkjennes"
|
||||
},
|
||||
"points": {
|
||||
"name": "Poeng",
|
||||
"description": "Gi dette antallet poeng i stedet for oppgavens egen verdi. La stå tomt for å bruke oppgavens poeng."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "Born-ID",
|
||||
"description": "ID-en til bornet som fullfører oppgåva"
|
||||
},
|
||||
"note": {
|
||||
"name": "Notat",
|
||||
"description": "Kva barnet gjorde, med eigne ord. Påkravd for opne oppgåver, og vert vist til forelderen ved godkjenning."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Føreslåtte poeng",
|
||||
"description": "Kva barnet meiner arbeidet var verdt. Berre eit hint til forelderen som godkjenner — det gjev ikkje poeng i seg sjølv."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "Fullførings-ID",
|
||||
"description": "ID-en til oppgåvefullføringa som skal godkjennast"
|
||||
},
|
||||
"points": {
|
||||
"name": "Poeng",
|
||||
"description": "Gje dette talet poeng i staden for oppgåva sin eigen verdi. Lat stå tomt for å bruke poenga til oppgåva."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "ID da criança",
|
||||
"description": "O ID da criança que conclui a tarefa"
|
||||
},
|
||||
"note": {
|
||||
"name": "Observação",
|
||||
"description": "O que a criança fez, com as próprias palavras. Obrigatório em tarefas abertas e mostrado ao responsável na aprovação."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Pontos sugeridos",
|
||||
"description": "Quanto a criança acha que o trabalho valeu. Apenas uma sugestão para quem aprova — não concede pontos por si só."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "ID de conclusão",
|
||||
"description": "O ID da conclusão da tarefa a aprovar"
|
||||
},
|
||||
"points": {
|
||||
"name": "Pontos",
|
||||
"description": "Atribuir esta quantidade de pontos em vez do valor da própria tarefa. Deixe vazio para usar os pontos da tarefa."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@
|
||||
"child_id": {
|
||||
"name": "ID da criança",
|
||||
"description": "O ID da criança que conclui a tarefa"
|
||||
},
|
||||
"note": {
|
||||
"name": "Nota",
|
||||
"description": "O que a criança fez, nas suas próprias palavras. Obrigatório em tarefas abertas e mostrado ao progenitor na aprovação."
|
||||
},
|
||||
"suggested_points": {
|
||||
"name": "Pontos sugeridos",
|
||||
"description": "Quanto a criança acha que o trabalho valeu. Apenas uma sugestão para quem aprova — não atribui pontos por si só."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -167,6 +175,10 @@
|
||||
"completion_id": {
|
||||
"name": "ID de conclusão",
|
||||
"description": "O ID da conclusão da tarefa a aprovar"
|
||||
},
|
||||
"points": {
|
||||
"name": "Pontos",
|
||||
"description": "Atribuir este número de pontos em vez do valor da própria tarefa. Deixe vazio para usar os pontos da tarefa."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -601,6 +601,7 @@ _CHORE_EDITABLE_FIELDS = {
|
||||
"early_bonus",
|
||||
"late_penalty",
|
||||
"require_photo",
|
||||
"open_ended",
|
||||
"mandatory",
|
||||
"mandatory_penalty_points",
|
||||
"assignment_mode",
|
||||
@@ -657,6 +658,7 @@ def _chore_payload_schema(*, require_name: bool):
|
||||
vol.Optional("mandatory"): bool,
|
||||
vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)),
|
||||
vol.Optional("require_photo"): bool,
|
||||
vol.Optional("open_ended"): bool,
|
||||
vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
|
||||
vol.Optional("assignment_rotation_anchor"): str,
|
||||
vol.Optional("require_availability"): bool,
|
||||
@@ -1984,12 +1986,14 @@ async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator):
|
||||
{
|
||||
vol.Required("type"): WS_APPROVE_CHORE,
|
||||
vol.Required("completion_id"): str,
|
||||
# Optional per-approval award (#832) — replaces the chore's own points.
|
||||
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@_admin_only
|
||||
async def _ws_approve_chore(hass, connection, msg, coordinator):
|
||||
await coordinator.async_approve_chore(msg["completion_id"])
|
||||
await coordinator.async_approve_chore(msg["completion_id"], points=msg.get("points"))
|
||||
connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Alles aufgeholt!",
|
||||
"approvals.approve": "Genehmigen",
|
||||
"approvals.approve_all": "Alle genehmigen",
|
||||
"approvals.child_said": "Gesagt",
|
||||
"approvals.suggested": "Vorschlag: {points}",
|
||||
"approvals.award_label": "Vergeben",
|
||||
"approvals.approve_all_busy": "Wird genehmigt…",
|
||||
"approvals.approve_all_confirm": "{count} ausstehende Aufgaben genehmigen?",
|
||||
"approvals.card_description": "Eine Karte zum Verwalten ausstehender Aufgabengenehmigungen für TaskMate",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Angefragt",
|
||||
"child.photo_needed": "Foto erforderlich",
|
||||
"child.photo_required_title": "Foto hinzufügen",
|
||||
"child.extra_title": "Was hast du gemacht?",
|
||||
"child.extra_hint": "Sag, was du gemacht hast und was es dir wert ist.",
|
||||
"child.extra_desc_placeholder": "Ich habe die Veranda aufgeräumt",
|
||||
"child.extra_points_label": "So viele Punkte ist es dir wert",
|
||||
"child.extra_submit": "Senden",
|
||||
"child.extra_needs_desc": "Schreib zuerst, was du gemacht hast.",
|
||||
"child.open_ended_tag": "Beschreiben",
|
||||
"child.photo_take": "Foto aufnehmen",
|
||||
"child.photo_hint": "Zeig deine erledigte Aufgabe",
|
||||
"child.photo_retake": "Neu aufnehmen",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Verspätungsabzug",
|
||||
"panel.chore_require_photo_label": "Fotonachweis verlangen",
|
||||
"panel.chore_require_photo_hint": "Erledigungen müssen von Eltern bestätigt werden; ein Beweisfoto (falls vorhanden) erscheint bei den Genehmigungen.",
|
||||
"panel.chore_open_ended_label": "Offene Aufgabe (Kind beschreibt sie)",
|
||||
"panel.chore_open_ended_hint": "Das Kind schreibt, was es gemacht hat, und schlägt Punkte vor. Erfordert immer Ihre Genehmigung — Sie legen die echten Punkte fest.",
|
||||
"panel.activity_view_photo": "Foto ansehen",
|
||||
"panel.adjust_add": "Hinzufügen",
|
||||
"panel.adjust_add_title": "{child} {amount} {points} hinzufügen",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "All caught up!",
|
||||
"approvals.approve": "Approve",
|
||||
"approvals.approve_all": "Approve all",
|
||||
"approvals.child_said": "They said",
|
||||
"approvals.suggested": "Suggested: {points}",
|
||||
"approvals.award_label": "Award",
|
||||
"approvals.approve_all_busy": "Approving…",
|
||||
"approvals.approve_all_confirm": "Approve {count} pending chores?",
|
||||
"approvals.card_description": "A card to manage pending chore approvals for TaskMate",
|
||||
@@ -221,6 +224,13 @@
|
||||
"child.swap_requested": "Requested",
|
||||
"child.photo_needed": "Photo needed",
|
||||
"child.photo_required_title": "Add a photo",
|
||||
"child.extra_title": "What did you do?",
|
||||
"child.extra_hint": "Tell them what you did, and what you think it's worth.",
|
||||
"child.extra_desc_placeholder": "I tidied the porch",
|
||||
"child.extra_points_label": "Points you think it's worth",
|
||||
"child.extra_submit": "Send",
|
||||
"child.extra_needs_desc": "Write what you did first.",
|
||||
"child.open_ended_tag": "Describe it",
|
||||
"child.photo_take": "Take a photo",
|
||||
"child.photo_hint": "Show your finished chore",
|
||||
"child.photo_retake": "Retake",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Late penalty",
|
||||
"panel.chore_require_photo_label": "Require photo proof",
|
||||
"panel.chore_require_photo_hint": "Completions must be parent-approved; an evidence photo (if provided) shows in approvals.",
|
||||
"panel.chore_open_ended_label": "Open-ended (child describes it)",
|
||||
"panel.chore_open_ended_hint": "The child writes what they did and suggests points. Always needs your approval — you set the real points.",
|
||||
"panel.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "All caught up!",
|
||||
"approvals.approve": "Approve",
|
||||
"approvals.approve_all": "Approve all",
|
||||
"approvals.child_said": "They said",
|
||||
"approvals.suggested": "Suggested: {points}",
|
||||
"approvals.award_label": "Award",
|
||||
"approvals.approve_all_busy": "Approving…",
|
||||
"approvals.approve_all_confirm": "Approve {count} pending chores?",
|
||||
"approvals.card_description": "A card to manage pending chore approvals for TaskMate",
|
||||
@@ -221,6 +224,13 @@
|
||||
"child.swap_requested": "Requested",
|
||||
"child.photo_needed": "Photo needed",
|
||||
"child.photo_required_title": "Add a photo",
|
||||
"child.extra_title": "What did you do?",
|
||||
"child.extra_hint": "Tell them what you did, and what you think it's worth.",
|
||||
"child.extra_desc_placeholder": "I tidied the porch",
|
||||
"child.extra_points_label": "Points you think it's worth",
|
||||
"child.extra_submit": "Send",
|
||||
"child.extra_needs_desc": "Write what you did first.",
|
||||
"child.open_ended_tag": "Describe it",
|
||||
"child.photo_take": "Take a photo",
|
||||
"child.photo_hint": "Show your finished chore",
|
||||
"child.photo_retake": "Retake",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Late penalty",
|
||||
"panel.chore_require_photo_label": "Require photo proof",
|
||||
"panel.chore_require_photo_hint": "Completions must be parent-approved; an evidence photo (if provided) shows in approvals.",
|
||||
"panel.chore_open_ended_label": "Open-ended (child describes it)",
|
||||
"panel.chore_open_ended_hint": "The child writes what they did and suggests points. Always needs your approval — you set the real points.",
|
||||
"panel.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Tout est à jour !",
|
||||
"approvals.approve": "Approuver",
|
||||
"approvals.approve_all": "Tout approuver",
|
||||
"approvals.child_said": "A dit",
|
||||
"approvals.suggested": "Suggéré : {points}",
|
||||
"approvals.award_label": "Attribuer",
|
||||
"approvals.approve_all_busy": "Approbation…",
|
||||
"approvals.approve_all_confirm": "Approuver {count} tâches en attente ?",
|
||||
"approvals.card_description": "Une carte pour gérer les approbations de tâches en attente pour TaskMate",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Demandé",
|
||||
"child.photo_needed": "Photo requise",
|
||||
"child.photo_required_title": "Ajouter une photo",
|
||||
"child.extra_title": "Qu'as-tu fait ?",
|
||||
"child.extra_hint": "Dis ce que tu as fait et ce que tu penses que ça vaut.",
|
||||
"child.extra_desc_placeholder": "J'ai rangé le porche",
|
||||
"child.extra_points_label": "Points que tu penses mériter",
|
||||
"child.extra_submit": "Envoyer",
|
||||
"child.extra_needs_desc": "Écris d'abord ce que tu as fait.",
|
||||
"child.open_ended_tag": "À décrire",
|
||||
"child.photo_take": "Prendre une photo",
|
||||
"child.photo_hint": "Montre ta tâche terminée",
|
||||
"child.photo_retake": "Reprendre",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Pénalité de retard",
|
||||
"panel.chore_require_photo_label": "Exiger une preuve photo",
|
||||
"panel.chore_require_photo_hint": "Les réalisations doivent être approuvées par un parent ; une photo de preuve (si fournie) s'affiche dans les approbations.",
|
||||
"panel.chore_open_ended_label": "Tâche ouverte (l'enfant la décrit)",
|
||||
"panel.chore_open_ended_hint": "L'enfant écrit ce qu'il a fait et propose des points. Nécessite toujours votre approbation — c'est vous qui fixez les points réels.",
|
||||
"panel.activity_view_photo": "Voir la photo",
|
||||
"panel.adjust_add": "Ajouter",
|
||||
"panel.adjust_add_title": "Ajouter {amount} {points} à {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Alt er oppdatert!",
|
||||
"approvals.approve": "Godkjenn",
|
||||
"approvals.approve_all": "Godkjenn alle",
|
||||
"approvals.child_said": "Sa",
|
||||
"approvals.suggested": "Foreslått: {points}",
|
||||
"approvals.award_label": "Gi",
|
||||
"approvals.approve_all_busy": "Godkjenner…",
|
||||
"approvals.approve_all_confirm": "Godkjenne {count} ventende gjøremål?",
|
||||
"approvals.card_description": "Et kort for å håndtere ventende oppgavegodkjenninger",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Forespurt",
|
||||
"child.photo_needed": "Bilde kreves",
|
||||
"child.photo_required_title": "Legg til et bilde",
|
||||
"child.extra_title": "Hva gjorde du?",
|
||||
"child.extra_hint": "Si hva du gjorde, og hva du synes det er verdt.",
|
||||
"child.extra_desc_placeholder": "Jeg ryddet verandaen",
|
||||
"child.extra_points_label": "Poeng du synes det er verdt",
|
||||
"child.extra_submit": "Send",
|
||||
"child.extra_needs_desc": "Skriv først hva du gjorde.",
|
||||
"child.open_ended_tag": "Beskriv",
|
||||
"child.photo_take": "Ta et bilde",
|
||||
"child.photo_hint": "Vis den ferdige oppgaven",
|
||||
"child.photo_retake": "Ta på nytt",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Forsinkelsesstraff",
|
||||
"panel.chore_require_photo_label": "Krev bildebevis",
|
||||
"panel.chore_require_photo_hint": "Fullføringer må godkjennes av forelder; et bevisbilde (hvis lagt ved) vises i godkjenninger.",
|
||||
"panel.chore_open_ended_label": "Åpen oppgave (barnet beskriver den)",
|
||||
"panel.chore_open_ended_hint": "Barnet skriver hva det gjorde og foreslår poeng. Krever alltid din godkjenning — du setter de faktiske poengene.",
|
||||
"panel.activity_view_photo": "Vis bilde",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Alt er oppdatert!",
|
||||
"approvals.approve": "Godkjenn",
|
||||
"approvals.approve_all": "Godkjenn alle",
|
||||
"approvals.child_said": "Sa",
|
||||
"approvals.suggested": "Føreslått: {points}",
|
||||
"approvals.award_label": "Gje",
|
||||
"approvals.approve_all_busy": "Godkjenner…",
|
||||
"approvals.approve_all_confirm": "Godkjenne {count} ventande gjeremål?",
|
||||
"approvals.card_description": "Eit kort for å handtere ventande oppgåvegodkjenningar",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Førespurd",
|
||||
"child.photo_needed": "Bilete krevst",
|
||||
"child.photo_required_title": "Legg til eit bilete",
|
||||
"child.extra_title": "Kva gjorde du?",
|
||||
"child.extra_hint": "Sei kva du gjorde, og kva du meiner det er verdt.",
|
||||
"child.extra_desc_placeholder": "Eg rydda verandaen",
|
||||
"child.extra_points_label": "Poeng du meiner det er verdt",
|
||||
"child.extra_submit": "Send",
|
||||
"child.extra_needs_desc": "Skriv først kva du gjorde.",
|
||||
"child.open_ended_tag": "Beskriv",
|
||||
"child.photo_take": "Ta eit bilete",
|
||||
"child.photo_hint": "Vis den ferdige oppgåva",
|
||||
"child.photo_retake": "Ta på nytt",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Forseinkingsstraff",
|
||||
"panel.chore_require_photo_label": "Krev biletbevis",
|
||||
"panel.chore_require_photo_hint": "Fullføringar må godkjennast av forelder; eit bevisbilete (om lagt ved) vert vist i godkjenningar.",
|
||||
"panel.chore_open_ended_label": "Open oppgåve (barnet skriv kva det er)",
|
||||
"panel.chore_open_ended_hint": "Barnet skriv kva det gjorde og føreslår poeng. Krev alltid di godkjenning — du set dei faktiske poenga.",
|
||||
"panel.activity_view_photo": "Vis bilete",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Está tudo em dia!",
|
||||
"approvals.approve": "Aprovar",
|
||||
"approvals.approve_all": "Aprovar tudo",
|
||||
"approvals.child_said": "Disse",
|
||||
"approvals.suggested": "Sugerido: {points}",
|
||||
"approvals.award_label": "Conceder",
|
||||
"approvals.approve_all_busy": "Aprovando…",
|
||||
"approvals.approve_all_confirm": "Aprovar {count} tarefas pendentes?",
|
||||
"approvals.card_description": "Cartão para gerir aprovações de tarefas pendentes no TaskMate",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Solicitado",
|
||||
"child.photo_needed": "Foto necessária",
|
||||
"child.photo_required_title": "Adicionar uma foto",
|
||||
"child.extra_title": "O que você fez?",
|
||||
"child.extra_hint": "Conte o que você fez e quanto acha que vale.",
|
||||
"child.extra_desc_placeholder": "Arrumei a varanda",
|
||||
"child.extra_points_label": "Pontos que você acha que vale",
|
||||
"child.extra_submit": "Enviar",
|
||||
"child.extra_needs_desc": "Escreva primeiro o que você fez.",
|
||||
"child.open_ended_tag": "Descrever",
|
||||
"child.photo_take": "Tirar uma foto",
|
||||
"child.photo_hint": "Mostre sua tarefa concluída",
|
||||
"child.photo_retake": "Repetir",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Penalidade por atraso",
|
||||
"panel.chore_require_photo_label": "Exigir prova fotográfica",
|
||||
"panel.chore_require_photo_hint": "As conclusões precisam ser aprovadas pelos pais; uma foto de prova (se fornecida) aparece nas aprovações.",
|
||||
"panel.chore_open_ended_label": "Tarefa aberta (a criança descreve)",
|
||||
"panel.chore_open_ended_hint": "A criança escreve o que fez e sugere pontos. Sempre precisa da sua aprovação — você define os pontos reais.",
|
||||
"panel.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} para {child}",
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"approvals.all_caught_up": "Está tudo em dia!",
|
||||
"approvals.approve": "Aprovar",
|
||||
"approvals.approve_all": "Aprovar tudo",
|
||||
"approvals.child_said": "Disse",
|
||||
"approvals.suggested": "Sugerido: {points}",
|
||||
"approvals.award_label": "Atribuir",
|
||||
"approvals.approve_all_busy": "A aprovar…",
|
||||
"approvals.approve_all_confirm": "Aprovar {count} tarefas pendentes?",
|
||||
"approvals.card_description": "Cartão para gerir aprovações de tarefas pendentes no TaskMate",
|
||||
@@ -219,6 +222,13 @@
|
||||
"child.swap_requested": "Pedido",
|
||||
"child.photo_needed": "Foto necessária",
|
||||
"child.photo_required_title": "Adicionar uma foto",
|
||||
"child.extra_title": "O que fizeste?",
|
||||
"child.extra_hint": "Diz o que fizeste e quanto achas que vale.",
|
||||
"child.extra_desc_placeholder": "Arrumei a varanda",
|
||||
"child.extra_points_label": "Pontos que achas que vale",
|
||||
"child.extra_submit": "Enviar",
|
||||
"child.extra_needs_desc": "Escreve primeiro o que fizeste.",
|
||||
"child.open_ended_tag": "Descrever",
|
||||
"child.photo_take": "Tirar uma foto",
|
||||
"child.photo_hint": "Mostra a tua tarefa terminada",
|
||||
"child.photo_retake": "Repetir",
|
||||
@@ -1438,6 +1448,8 @@
|
||||
"panel.chore_late_penalty_label": "Penalização por atraso",
|
||||
"panel.chore_require_photo_label": "Exigir prova fotográfica",
|
||||
"panel.chore_require_photo_hint": "As conclusões têm de ser aprovadas pelos pais; uma foto de prova (se fornecida) aparece nas aprovações.",
|
||||
"panel.chore_open_ended_label": "Tarefa aberta (a criança descreve-a)",
|
||||
"panel.chore_open_ended_hint": "A criança escreve o que fez e sugere pontos. Requer sempre a tua aprovação — és tu que defines os pontos reais.",
|
||||
"panel.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} a {child}",
|
||||
|
||||
@@ -33,6 +33,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
config: { type: Object },
|
||||
_loading: { type: Object },
|
||||
_signed: { state: true },
|
||||
_review: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,6 +369,106 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Review-and-award sheet (#832) */
|
||||
.action-button.adjust {
|
||||
background: var(--secondary-background-color, #f1f1f1);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Fixed, not absolute: ha-card sets overflow:hidden, which would clip an
|
||||
absolutely-positioned overlay to the card's own box. */
|
||||
.tm-rv-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 30, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.tm-rv-sheet {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tm-rv-title { font-size: 1.05rem; font-weight: 800; }
|
||||
.tm-rv-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--secondary-text-color, #6b7280);
|
||||
margin: 2px 0 12px;
|
||||
}
|
||||
|
||||
.tm-rv-quote {
|
||||
border-left: 3px solid var(--accent-color, #ffc107);
|
||||
padding: 6px 0 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.92rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Capped: a portrait photo would otherwise push the award field and the
|
||||
buttons off the bottom of the sheet. */
|
||||
.tm-rv-photo img {
|
||||
width: 100%;
|
||||
max-height: 34vh;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tm-rv-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-text-color, #6b7280);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tm-rv-points {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--divider-color, #e0e0e0);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
}
|
||||
|
||||
.tm-rv-points:focus { outline: none; border-color: #4caf50; }
|
||||
|
||||
.tm-rv-row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.tm-rv-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 11px 0;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tm-rv-btn.ghost {
|
||||
background: var(--secondary-background-color, #f1f1f1);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
}
|
||||
.tm-rv-btn.primary { background: #4caf50; color: #fff; }
|
||||
|
||||
/* Mandatory-miss review actions (#532) */
|
||||
.action-button.penalty {
|
||||
background: var(--fun-red, #e74c3c);
|
||||
@@ -628,6 +729,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
${filteredCompletions.length > 0 ? this._renderApprovals(groupedByDay) : ''}
|
||||
`}
|
||||
</div>
|
||||
${this._renderReview()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
@@ -789,6 +891,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
return html`<ha-card class="tmd" style="--hd:${hd}">
|
||||
${this._designHeader(hd, items.length, completions)}
|
||||
<div class="tmd-bd">${body}</div>
|
||||
${this._renderReview()}
|
||||
</ha-card>`;
|
||||
}
|
||||
|
||||
@@ -815,7 +918,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
if (it.kind === "completion") {
|
||||
const approve = () => this._handleApprove(it.completion);
|
||||
const reject = () => this._handleReject(it.completion);
|
||||
return this._apActionPair(approve, reject, isLoading, shape);
|
||||
return this._apActionPair(approve, reject, isLoading, shape, it.completion);
|
||||
}
|
||||
if (it.kind === "claim") {
|
||||
const approve = () => this._handleApproveReward(it.id);
|
||||
@@ -845,9 +948,15 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
@click="${() => this._handleDismiss(it.id)}">${this._t('approvals.dismiss')}</button>`;
|
||||
}
|
||||
|
||||
_apActionPair(approve, reject, isLoading, shape) {
|
||||
_apActionPair(approve, reject, isLoading, shape, completion = null) {
|
||||
const adjust = completion
|
||||
? html`<button class="btn ghost sm ${shape === "round" ? "round" : ""}" ?disabled="${isLoading}"
|
||||
title="${this._t('approvals.award_label')}"
|
||||
@click="${() => this._openReview(completion)}">${this._reviewGlyph(completion)}</button>`
|
||||
: "";
|
||||
if (shape === "round") {
|
||||
return html`
|
||||
${adjust}
|
||||
<button class="btn good sm round" ?disabled="${isLoading}"
|
||||
title="${this._t('approvals.approve')}" @click="${approve}">✓</button>
|
||||
<button class="btn bad sm round" ?disabled="${isLoading}"
|
||||
@@ -855,10 +964,12 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
if (shape === "ghost") {
|
||||
return html`
|
||||
${adjust}
|
||||
<button class="btn good sm" ?disabled="${isLoading}" @click="${approve}">${this._t('approvals.approve')}</button>
|
||||
<button class="btn ghost sm" ?disabled="${isLoading}" @click="${reject}">${this._t('approvals.reject')}</button>`;
|
||||
}
|
||||
return html`
|
||||
${adjust}
|
||||
<button class="btn good" style="flex:1" ?disabled="${isLoading}" @click="${approve}">👍 ${this._t('approvals.approve')}</button>
|
||||
<button class="btn bad" style="flex:1" ?disabled="${isLoading}" @click="${reject}">👎 ${this._t('approvals.reject')}</button>`;
|
||||
}
|
||||
@@ -1400,6 +1511,12 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="action-buttons right">
|
||||
<button
|
||||
class="action-button adjust"
|
||||
@click="${() => this._openReview(completion)}"
|
||||
title="${this._t('approvals.award_label')}"
|
||||
?disabled="${isLoading}"
|
||||
>${this._reviewGlyph(completion)}</button>
|
||||
<button
|
||||
class="action-button approve ${isLoading ? "loading" : ""}"
|
||||
@click="${() => this._handleApprove(completion)}"
|
||||
@@ -1445,7 +1562,84 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
async _callService(service, completionId) {
|
||||
/* ── Review and award (#832) ──────────────────────────────────────────
|
||||
Every approve stays one tap. This is the second control beside it: read
|
||||
what the child wrote and set what the work was actually worth. The points
|
||||
input is uncontrolled and filled imperatively on open — binding .value
|
||||
would reset the parent's typing on the next coordinator refresh. */
|
||||
|
||||
_reviewGlyph(completion) {
|
||||
return (completion && completion.note) ? "✍️" : "✎";
|
||||
}
|
||||
|
||||
_openReview(completion) {
|
||||
this._review = { completion };
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
|
||||
if (!input) return;
|
||||
input.value = String(completion.suggested_points || completion.points || 0);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
_closeReview() {
|
||||
this._review = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
async _confirmReview() {
|
||||
const review = this._review;
|
||||
if (!review) return;
|
||||
const input = this.renderRoot && this.renderRoot.querySelector("#tm-review-points");
|
||||
const points = Math.max(0, Math.round(Number(input && input.value) || 0));
|
||||
const completionId = review.completion.completion_id;
|
||||
this._closeReview();
|
||||
await this._callService("approve_chore", completionId, { points });
|
||||
}
|
||||
|
||||
_renderReview() {
|
||||
const review = this._review;
|
||||
if (!review) return "";
|
||||
const completion = review.completion;
|
||||
const photoUrl = this._photoHref(completion.photo_url);
|
||||
const stop = (e) => e.stopPropagation();
|
||||
return html`
|
||||
<div class="tm-rv-overlay" @click="${() => this._closeReview()}">
|
||||
<div class="tm-rv-sheet" @click="${stop}">
|
||||
<div class="tm-rv-title">${completion.chore_name}</div>
|
||||
<div class="tm-rv-sub">
|
||||
${completion.child_name}${completion.suggested_points
|
||||
? html` · ${this._t('approvals.suggested', { points: completion.suggested_points })}`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${completion.note ? html`
|
||||
<div class="tm-rv-label">${this._t('approvals.child_said')}</div>
|
||||
<div class="tm-rv-quote">${completion.note}</div>
|
||||
` : ""}
|
||||
|
||||
${photoUrl ? html`<div class="tm-rv-photo"><img src="${photoUrl}" alt="" loading="lazy"></div>` : ""}
|
||||
|
||||
<label class="tm-rv-label" for="tm-review-points">${this._t('approvals.award_label')}</label>
|
||||
<input id="tm-review-points" class="tm-rv-points" type="number"
|
||||
min="0" max="9999" inputmode="numeric">
|
||||
|
||||
<div class="tm-rv-row">
|
||||
<button class="tm-rv-btn ghost" @click="${() => this._closeReview()}">
|
||||
${this._t('common.cancel')}
|
||||
</button>
|
||||
<button class="tm-rv-btn primary" @click="${() => this._confirmReview()}">
|
||||
${this._t('approvals.approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async _callService(service, completionId, extra = null) {
|
||||
if (this._loading[completionId]) return;
|
||||
this._loading = { ...this._loading, [completionId]: true };
|
||||
this.requestUpdate();
|
||||
@@ -1453,6 +1647,7 @@ class TaskMateApprovalsCard extends LitElement {
|
||||
try {
|
||||
await this.hass.callService("taskmate", service, {
|
||||
completion_id: completionId,
|
||||
...(extra || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to call ${service}:`, error);
|
||||
|
||||
@@ -38,6 +38,7 @@ class TaskMateChildCard extends LitElement {
|
||||
_justEarnedBadge: { type: String },
|
||||
_avatarPickerOpen: { type: Boolean },
|
||||
_photoCapture: { type: Object },
|
||||
_extraCapture: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1243,6 +1244,22 @@ class TaskMateChildCard extends LitElement {
|
||||
}
|
||||
@keyframes photo-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Open-ended submission sheet (#832) — shares the photo sheet's shell. */
|
||||
.extra-note, .extra-points {
|
||||
width: 100%; box-sizing: border-box; font: inherit;
|
||||
border-radius: 12px; padding: 10px 12px;
|
||||
border: 2px solid var(--divider-color, #e0e0e0);
|
||||
color: var(--primary-text-color, #33373d);
|
||||
background: var(--card-background-color, #fff);
|
||||
}
|
||||
.extra-note { resize: vertical; font-size: 0.95rem; }
|
||||
.extra-points { font-size: 1.05rem; font-weight: 800; }
|
||||
.extra-note:focus, .extra-points:focus { outline: none; border-color: var(--fun-purple); }
|
||||
.extra-points-label {
|
||||
display: block; margin: 14px 0 6px; font-weight: 700; font-size: 0.85rem;
|
||||
color: var(--secondary-text-color, #6b7280);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -2157,6 +2174,7 @@ class TaskMateChildCard extends LitElement {
|
||||
${this._celebrating ? this._renderCelebration() : ""}
|
||||
${this._confetti.length > 0 ? this._renderConfetti() : ""}
|
||||
${this._renderPhotoCapture()}
|
||||
${this._renderExtraCapture()}
|
||||
<input type="file" id="tm-photo-input" accept="image/*" capture="environment"
|
||||
style="display:none" @change="${this._onPhotoSelected}">
|
||||
|
||||
@@ -2401,6 +2419,7 @@ class TaskMateChildCard extends LitElement {
|
||||
timed: chore.task_type === "timed",
|
||||
mandatory: chore.mandatory === true,
|
||||
photo: chore.require_photo === true,
|
||||
openEnded: chore.open_ended === true,
|
||||
pointsIcon,
|
||||
todaysCompletions,
|
||||
};
|
||||
@@ -2485,6 +2504,7 @@ class TaskMateChildCard extends LitElement {
|
||||
${this._celebrating ? this._renderCelebration() : ""}
|
||||
${this._confetti.length > 0 ? this._renderConfetti() : ""}
|
||||
${this._renderPhotoCapture()}
|
||||
${this._renderExtraCapture()}
|
||||
<input type="file" id="tm-photo-input" accept="image/*" capture="environment"
|
||||
style="display:none" @change="${this._onPhotoSelected}">
|
||||
</ha-card>`;
|
||||
@@ -2540,10 +2560,11 @@ class TaskMateChildCard extends LitElement {
|
||||
: this._t("child.claimed_by_another_generic"))
|
||||
: "";
|
||||
return html`
|
||||
${r.mandatory || r.photo || depNames.length || recLabel || firstComeLabel ? html`
|
||||
${r.mandatory || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel ? html`
|
||||
<div class="tmd-meta">
|
||||
${r.mandatory ? html`<span class="tmd-tag mandatory">⚠ ${this._t("child.mandatory")}</span>` : ""}
|
||||
${r.photo ? html`<span class="tmd-tag photo">📷 ${this._t("child.photo_needed")}</span>` : ""}
|
||||
${r.openEnded ? html`<span class="tmd-tag photo">✍️ ${this._t("child.open_ended_tag")}</span>` : ""}
|
||||
${depNames.length ? html`<span class="tmd-tag">🔒 ${this._t("child.blocked_by_dependency", { chores: depNames.join(", ") })}</span>` : ""}
|
||||
${recLabel ? html`<span class="tmd-tag">🕒 ${recLabel}</span>` : ""}
|
||||
${firstComeLabel ? html`<span class="tmd-tag">✅ ${firstComeLabel}</span>` : ""}
|
||||
@@ -3631,6 +3652,11 @@ class TaskMateChildCard extends LitElement {
|
||||
<span class="photo-badge">📷 ${this._t('child.photo_needed')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${chore.open_ended && !isCompletedForToday ? html`
|
||||
<div class="recurrence-label">
|
||||
<span class="photo-badge">✍️ ${this._t('child.open_ended_tag')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chore-checkbox">
|
||||
@@ -4007,7 +4033,7 @@ class TaskMateChildCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
async _handleComplete(chore, child, photoUrl = null) {
|
||||
async _handleComplete(chore, child, photoUrl = null, extra = null) {
|
||||
const key = `${chore.id}_${child.id}`;
|
||||
const dailyLimit = chore.daily_limit || 1;
|
||||
|
||||
@@ -4019,8 +4045,17 @@ class TaskMateChildCard extends LitElement {
|
||||
// Photo-evidence chores can't be completed with a plain tap — open the
|
||||
// capture overlay first. The overlay uploads the photo and then calls back
|
||||
// into this method with a photoUrl, which takes the service path below.
|
||||
// Open-ended chores can't be completed with a plain tap either — the
|
||||
// child's description IS the chore, so collect it first. Gating here
|
||||
// rather than on the button covers every design's render path at once,
|
||||
// and the sheet calls back in with `extra` filled.
|
||||
if (chore.open_ended && !extra) {
|
||||
this._openExtraCapture(chore, child);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chore.require_photo && !photoUrl) {
|
||||
this._openPhotoCapture(chore, child);
|
||||
this._openPhotoCapture(chore, child, extra);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4071,7 +4106,9 @@ class TaskMateChildCard extends LitElement {
|
||||
// A photo-evidence completion MUST go through the service so it can carry
|
||||
// photo_url — a button-entity press cannot. Other chores prefer the button
|
||||
// entity so HA state-trigger automations fire.
|
||||
const buttonEntityId = photoUrl ? null : (window.__taskmate_find_button
|
||||
// A photo or an open-ended note can only travel on the service call, so
|
||||
// either one rules out the button-entity shortcut.
|
||||
const buttonEntityId = (photoUrl || extra) ? null : (window.__taskmate_find_button
|
||||
&& window.__taskmate_find_button(this.hass, child.id, "complete", chore.id));
|
||||
if (buttonEntityId) {
|
||||
await this.hass.callService("button", "press", { entity_id: buttonEntityId });
|
||||
@@ -4080,6 +4117,8 @@ class TaskMateChildCard extends LitElement {
|
||||
chore_id: chore.id,
|
||||
child_id: child.id,
|
||||
...(photoUrl ? { photo_url: photoUrl } : {}),
|
||||
...(extra && extra.note ? { note: extra.note } : {}),
|
||||
...(extra && extra.suggested_points ? { suggested_points: extra.suggested_points } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4149,9 +4188,9 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
// ── Photo-evidence capture ────────────────────────────────────────────────
|
||||
|
||||
_openPhotoCapture(chore, child) {
|
||||
_openPhotoCapture(chore, child, extra = null) {
|
||||
this._photoCapture = {
|
||||
chore, child, step: "pick", previewUrl: "", blob: null,
|
||||
chore, child, extra, step: "pick", previewUrl: "", blob: null,
|
||||
sizeKb: 0, error: "",
|
||||
};
|
||||
this.requestUpdate();
|
||||
@@ -4254,9 +4293,9 @@ class TaskMateChildCard extends LitElement {
|
||||
const { photo_url: photoUrl } = await resp.json();
|
||||
if (!photoUrl) throw new Error("no photo_url in response");
|
||||
|
||||
const { chore, child } = cap;
|
||||
const { chore, child, extra } = cap;
|
||||
this._closePhotoCapture();
|
||||
await this._handleComplete(chore, child, photoUrl);
|
||||
await this._handleComplete(chore, child, photoUrl, extra);
|
||||
} catch (err) {
|
||||
// Keep the overlay open on the preview so the child can retry.
|
||||
if (this._photoCapture) {
|
||||
@@ -4284,6 +4323,81 @@ class TaskMateChildCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// ── Open-ended submission ("I did something extra", #832) ────────────────
|
||||
//
|
||||
// The two inputs are deliberately uncontrolled and read from the DOM on
|
||||
// submit: binding .value would re-render on every keystroke and fight the
|
||||
// caret. Lit reuses the same elements across re-renders, so the typed text
|
||||
// survives the error-state update below.
|
||||
|
||||
_openExtraCapture(chore, child) {
|
||||
this._extraCapture = { chore, child, error: "" };
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
const note = this.renderRoot && this.renderRoot.querySelector("#tm-extra-note");
|
||||
if (note) note.focus();
|
||||
});
|
||||
}
|
||||
|
||||
_closeExtraCapture() {
|
||||
this._extraCapture = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
async _submitExtraCapture() {
|
||||
const cap = this._extraCapture;
|
||||
if (!cap) return;
|
||||
const root = this.renderRoot;
|
||||
const note = ((root && root.querySelector("#tm-extra-note")?.value) || "").trim();
|
||||
if (!note) {
|
||||
this._extraCapture = { ...cap, error: this._t("child.extra_needs_desc") };
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const raw = Number((root && root.querySelector("#tm-extra-points")?.value) || 0);
|
||||
const suggested = Math.min(Math.max(0, Math.round(raw) || 0), 999);
|
||||
const { chore, child } = cap;
|
||||
this._closeExtraCapture();
|
||||
await this._handleComplete(chore, child, null, { note, suggested_points: suggested });
|
||||
}
|
||||
|
||||
_renderExtraCapture() {
|
||||
const cap = this._extraCapture;
|
||||
if (!cap) return "";
|
||||
const chore = cap.chore || {};
|
||||
const stop = (e) => e.stopPropagation();
|
||||
return html`
|
||||
<div class="photo-overlay" @click="${() => this._closeExtraCapture()}">
|
||||
<div class="photo-sheet" @click="${stop}">
|
||||
<div class="photo-title">✍️ ${this._t("child.extra_title")}</div>
|
||||
<div class="photo-for">${chore.name || ""}</div>
|
||||
<div class="photo-dropzone-hint" style="margin:0 0 10px">${this._t("child.extra_hint")}</div>
|
||||
|
||||
<textarea id="tm-extra-note" class="extra-note" rows="3"
|
||||
maxlength="200"
|
||||
placeholder="${this._t("child.extra_desc_placeholder")}"></textarea>
|
||||
|
||||
<label class="extra-points-label" for="tm-extra-points">
|
||||
${this._t("child.extra_points_label")}
|
||||
</label>
|
||||
<input id="tm-extra-points" class="extra-points" type="number"
|
||||
min="0" max="999" inputmode="numeric" placeholder="0">
|
||||
|
||||
${cap.error ? html`<div class="photo-error">${cap.error}</div>` : ""}
|
||||
|
||||
<div class="photo-btn-row">
|
||||
<button class="photo-btn ghost" @click="${() => this._closeExtraCapture()}">
|
||||
${this._t("child.photo_cancel")}
|
||||
</button>
|
||||
<button class="photo-btn primary" @click="${() => this._submitExtraCapture()}">
|
||||
✓ ${this._t("child.extra_submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPhotoCapture() {
|
||||
const cap = this._photoCapture;
|
||||
if (!cap) return "";
|
||||
|
||||
@@ -1434,6 +1434,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
due_time: "", early_bonus: 0, late_penalty: 0,
|
||||
mandatory: false, mandatory_penalty_points: 0,
|
||||
require_photo: false,
|
||||
open_ended: false,
|
||||
publish_calendar_entities: [],
|
||||
depends_on: [],
|
||||
bonus_subtasks: [],
|
||||
@@ -1681,6 +1682,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
mandatory: !!d.mandatory,
|
||||
mandatory_penalty_points: Math.max(0, Number(d.mandatory_penalty_points) || 0),
|
||||
require_photo: !!d.require_photo,
|
||||
open_ended: !!d.open_ended,
|
||||
publish_calendar_entities: d.publish_calendar_entities || [],
|
||||
bonus_subtasks: (d.bonus_subtasks || []).filter(b => b.name && b.name.trim()).map(b => ({
|
||||
name: b.name.trim(), points: Number(b.points) || 5,
|
||||
@@ -5457,6 +5459,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
this._switch(this._t("panel.chore_approval_label"), "requires_approval", d.requires_approval),
|
||||
this._switch(this._t("panel.chore_require_photo_label"), "require_photo", d.require_photo,
|
||||
this._t("panel.chore_require_photo_hint")),
|
||||
this._switch(this._t("panel.chore_open_ended_label"), "open_ended", d.open_ended,
|
||||
this._t("panel.chore_open_ended_hint")),
|
||||
this._switch(this._t("panel.chore_require_availability"), "require_availability", d.require_availability,
|
||||
this._t("panel.chore_require_availability_hint")),
|
||||
this._switch(this._t("panel.chore_mandatory_label"), "mandatory", d.mandatory,
|
||||
|
||||
@@ -1339,25 +1339,17 @@ class TaskMateRewardsCard extends LitElement {
|
||||
let childContributions = [];
|
||||
|
||||
if (isJackpot) {
|
||||
const sharePerChild = relevantChildren.length > 0
|
||||
? Math.round(displayCost / relevantChildren.length)
|
||||
: displayCost;
|
||||
relevantChildren.forEach((child, index) => {
|
||||
// In pool mode, show allocated points only; in wallet mode show wallet points
|
||||
const points = enablePoolMode
|
||||
? (poolAllocations[child.id] || 0)
|
||||
: (child.points || 0);
|
||||
const shareOfGoal = relevantChildren.length > 0 ? (100 / relevantChildren.length) : 100;
|
||||
const weightedProgress = sharePerChild > 0 ? Math.min((points / sharePerChild) * 100, 100) : 0;
|
||||
|
||||
currentStars += points;
|
||||
childContributions.push({
|
||||
name: child.name,
|
||||
points: points,
|
||||
colorIndex: index % 6,
|
||||
expectedContribution: sharePerChild,
|
||||
weightedProgress: weightedProgress,
|
||||
shareOfGoal: shareOfGoal,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
@@ -1378,8 +1370,11 @@ class TaskMateRewardsCard extends LitElement {
|
||||
const pcAttrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
// Always a list, even if the attribute arrives as something else (#834).
|
||||
const pendingClaims = tmClaimList(pcAttrs.pending_reward_claims);
|
||||
// A jackpot is one shared pool redeemed once per funding cycle, so a claim
|
||||
// from any contributor puts the reward into "awaiting approval" for every
|
||||
// child — not just the one who tapped Redeem (#873).
|
||||
const hasPendingClaim = pendingClaims.some(c =>
|
||||
c.reward_id === reward.id && (!childId || c.child_id === childId)
|
||||
c.reward_id === reward.id && (isJackpot || !childId || c.child_id === childId)
|
||||
);
|
||||
|
||||
// Pool mode affordability / redeem state
|
||||
@@ -1746,25 +1741,16 @@ class TaskMateRewardsCard extends LitElement {
|
||||
_renderJackpotProgress(reward, childContributions, totalStars, pointsIcon, displayCost) {
|
||||
const cost = displayCost || reward.cost;
|
||||
|
||||
// For weighted display: each child's segment shows their progress toward their expected share
|
||||
// The meter shows how much of their "responsibility" each child has fulfilled
|
||||
const hasWeightedData = childContributions.some(c => c.expectedContribution > 0);
|
||||
|
||||
// Calculate weighted segments - each segment width = (child's share of goal) * (their progress %)
|
||||
// This way, if a child has 50% share and is at 100% progress, they fill 50% of the bar
|
||||
// Every segment is measured against the shared goal (#874): a child who
|
||||
// puts in 40 of a 50-point jackpot fills 80% of the bar. Sizing segments
|
||||
// against an equal per-child share instead capped the over-contributor at
|
||||
// their own slice, so a fully funded jackpot drew a part-empty bar.
|
||||
const segments = childContributions.map((contrib) => {
|
||||
let width;
|
||||
if (hasWeightedData && contrib.shareOfGoal > 0) {
|
||||
// Weighted: segment width = share of goal * progress percentage
|
||||
// e.g., 40% share at 50% progress = 20% of bar filled
|
||||
width = (contrib.shareOfGoal / 100) * (contrib.weightedProgress / 100) * 100;
|
||||
} else {
|
||||
// Fallback: raw contribution relative to cost
|
||||
width = cost > 0 ? Math.min((contrib.points / cost) * 100, 100) : 0;
|
||||
}
|
||||
const sharePct = cost > 0 ? Math.min((contrib.points / cost) * 100, 100) : 0;
|
||||
return {
|
||||
...contrib,
|
||||
width: width
|
||||
sharePct: sharePct,
|
||||
width: sharePct,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1780,14 +1766,14 @@ class TaskMateRewardsCard extends LitElement {
|
||||
<span class="progress-text">${totalStars}/${cost} <ha-icon icon="${pointsIcon}" style="--mdc-icon-size: 14px;"></ha-icon></span>
|
||||
</div>
|
||||
<div class="jackpot-breakdown">
|
||||
${childContributions.map((contrib) => html`
|
||||
${segments.map((contrib) => html`
|
||||
<span class="jackpot-child-contribution">
|
||||
<span class="color-dot color-${contrib.colorIndex}"></span>
|
||||
<strong>${contrib.name}</strong>:
|
||||
${contrib.points}
|
||||
<ha-icon icon="${pointsIcon}" style="--mdc-icon-size: 12px;"></ha-icon>
|
||||
${hasWeightedData && contrib.expectedContribution > 0 ? html`
|
||||
<span class="jackpot-pct">(${Math.round(contrib.weightedProgress)}%)</span>
|
||||
${cost > 0 ? html`
|
||||
<span class="jackpot-pct">(${Math.round(contrib.sharePct)}%)</span>
|
||||
` : ''}
|
||||
</span>
|
||||
`)}
|
||||
@@ -1873,8 +1859,11 @@ class TaskMateRewardsCard extends LitElement {
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
// Always a list, even if the attribute arrives as something else (#834).
|
||||
const pendingClaims = tmClaimList(pcAttrs.pending_reward_claims);
|
||||
// A jackpot is one shared pool redeemed once per funding cycle, so a claim
|
||||
// from any contributor puts the reward into "awaiting approval" for every
|
||||
// child — not just the one who tapped Redeem (#873).
|
||||
const hasPendingClaim = pendingClaims.some(c =>
|
||||
c.reward_id === reward.id && (!childId || c.child_id === childId)
|
||||
c.reward_id === reward.id && (isJackpot || !childId || c.child_id === childId)
|
||||
);
|
||||
|
||||
const spendable = relevantChild
|
||||
|
||||
Reference in New Issue
Block a user