158 files
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import photos
|
||||
from . import images, photos
|
||||
from .models import Chore, ChoreCompletion, PointsTransaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -428,6 +428,7 @@ class ChoresMixin:
|
||||
existing = self.storage.get_chore(chore.id)
|
||||
prev_entities = list(getattr(existing, "publish_calendar_entities", []) or []) if existing else []
|
||||
prev_name = (existing.name if existing else "") or ""
|
||||
prev_image = (getattr(existing, "image_url", "") or "") if existing else ""
|
||||
# Persist the incoming chore so _compute_daily_assignments sees the
|
||||
# latest pool / mode / etc. when applying group policies.
|
||||
self.storage.update_chore(chore)
|
||||
@@ -455,15 +456,46 @@ class ChoresMixin:
|
||||
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
|
||||
)
|
||||
self.storage.update_chore(chore)
|
||||
# Replacing or clearing the picture orphans the old file; delete it —
|
||||
# unless a clone still shows it (#768).
|
||||
if prev_image and prev_image != (chore.image_url or ""):
|
||||
await self._async_release_image(prev_image, excluding_chore_id=chore.id)
|
||||
await self._publish_chore_to_calendars(chore, today)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
async def _async_release_image(
|
||||
self, image_url: str, *, excluding_chore_id: str = ""
|
||||
) -> None:
|
||||
"""Delete a chore image file, but only if nothing else still shows it.
|
||||
|
||||
`async_clone_chore` copies `image_url` straight from the source, so two
|
||||
chores routinely share one file on disk. Unlinking on the first delete
|
||||
or re-picture would leave the other chore rendering a broken image
|
||||
(#768). `excluding_chore_id` skips the chore being edited or removed —
|
||||
on the update path storage already holds its new value, and on the
|
||||
remove path it is about to go, so neither counts as a reference.
|
||||
"""
|
||||
if not image_url:
|
||||
return
|
||||
for other in self.storage.get_chores():
|
||||
if other.id == excluding_chore_id:
|
||||
continue
|
||||
if (getattr(other, "image_url", "") or "") == image_url:
|
||||
return
|
||||
await images.async_delete_image(self.hass, image_url)
|
||||
|
||||
async def async_remove_chore(self, chore_id: str) -> None:
|
||||
"""Remove a chore and all associated data."""
|
||||
existing = self.storage.get_chore(chore_id)
|
||||
if existing is not None and getattr(existing, "publish_calendar_entities", []):
|
||||
await self._cleanup_chore_from_calendars(existing)
|
||||
# Nothing sweeps taskmate_images, so the file has to go with the chore —
|
||||
# unless a clone still shows it (#768).
|
||||
if existing is not None and getattr(existing, "image_url", ""):
|
||||
await self._async_release_image(
|
||||
existing.image_url, excluding_chore_id=chore_id
|
||||
)
|
||||
self.storage.remove_chore(chore_id)
|
||||
self.storage.remove_completions_for_chore(chore_id)
|
||||
self.storage.remove_last_completed_for_chore(chore_id)
|
||||
|
||||
@@ -415,6 +415,7 @@ class RewardsMixin:
|
||||
self.hass.bus.async_fire("taskmate_reward_approved", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"reward_id": reward.id, "reward_name": reward.name,
|
||||
"claim_id": claim.id,
|
||||
"cost": effective_cost,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
@@ -439,6 +440,7 @@ class RewardsMixin:
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"reward_id": claim.reward_id,
|
||||
"reward_name": getattr(reward, "name", ""),
|
||||
"claim_id": claim.id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
# Dismiss the mobile approval push for this reviewed claim.
|
||||
|
||||
@@ -118,6 +118,10 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
from .http_photos import async_register_photo_views
|
||||
async_register_photo_views(hass)
|
||||
|
||||
# Admin-gated upload / authenticated serve for chore pictures (#750).
|
||||
from .http_images import async_register_image_views
|
||||
async_register_image_views(hass)
|
||||
|
||||
# Token-gated ICS calendar feed (FEAT-10).
|
||||
from .http_calendar import async_register_calendar_view
|
||||
async_register_calendar_view(hass)
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.0.4"
|
||||
"version": "5.1.0"
|
||||
}
|
||||
|
||||
@@ -262,6 +262,9 @@ class Chore:
|
||||
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
|
||||
# one per chore; everything else falls back to the time-of-day icon.
|
||||
icon: str = ""
|
||||
# Optional uploaded photograph (#750). Takes precedence over `icon` at
|
||||
# every render site; stored as a /api/taskmate/image/<name> URL.
|
||||
image_url: str = ""
|
||||
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
|
||||
# Scheduling
|
||||
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
|
||||
@@ -354,6 +357,7 @@ class Chore:
|
||||
daily_limit=data.get("daily_limit", 1),
|
||||
completion_sound=data.get("completion_sound", "coin"),
|
||||
icon=str(data.get("icon", "") or ""),
|
||||
image_url=str(data.get("image_url", "") or ""),
|
||||
difficulty=data.get("difficulty", "medium"),
|
||||
schedule_mode=schedule_mode,
|
||||
due_days=list(data.get("due_days", [])),
|
||||
@@ -417,6 +421,7 @@ class Chore:
|
||||
"daily_limit": self.daily_limit,
|
||||
"completion_sound": self.completion_sound,
|
||||
"icon": self.icon,
|
||||
"image_url": self.image_url,
|
||||
"difficulty": self.difficulty,
|
||||
"schedule_mode": self.schedule_mode,
|
||||
"due_days": self.due_days,
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import photos
|
||||
from . import images, photos
|
||||
from .const import DOMAIN
|
||||
from .coordinator import TaskMateCoordinator
|
||||
from .models import Child
|
||||
@@ -305,6 +305,11 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
||||
icon = getattr(c, 'icon', '')
|
||||
if icon:
|
||||
record["icon"] = icon
|
||||
# Signed so the card's <img> loads; emitted only when set, matching
|
||||
# `icon` above, to keep records under the 16KB recorder limit.
|
||||
image_url = getattr(c, 'image_url', '')
|
||||
if image_url:
|
||||
record["image_url"] = images.sign_image_url(common["hass"], image_url)
|
||||
completion_sound = getattr(c, 'completion_sound', 'coin')
|
||||
if completion_sound and completion_sound != 'coin':
|
||||
record["completion_sound"] = completion_sound
|
||||
|
||||
@@ -56,7 +56,7 @@ import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import photos
|
||||
from . import images, photos
|
||||
from .const import (
|
||||
DEFAULT_NOTIFICATION_NAV_URL,
|
||||
DEFAULT_TIME_PERIODS,
|
||||
@@ -311,7 +311,13 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
|
||||
return {
|
||||
"version": "2",
|
||||
"children": list(data.get("children", [])),
|
||||
"chores": list(data.get("chores", [])),
|
||||
# Sign picture URLs so the panel's <img> loads — browsers do not
|
||||
# send the bearer token on image requests, so a bare URL 401s.
|
||||
"chores": [
|
||||
{**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])}
|
||||
if c.get("image_url") else c
|
||||
for c in data.get("chores", [])
|
||||
],
|
||||
"chore_display_order": list(data.get("chore_display_order", [])),
|
||||
"scheduled_changes": list(data.get("scheduled_changes", [])),
|
||||
"rewards": list(data.get("rewards", [])),
|
||||
@@ -477,13 +483,26 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
|
||||
# Chores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _image_url_or_blank(value):
|
||||
"""Accept a blank string (clears the picture) or one of our image URLs.
|
||||
|
||||
A bare predicate can't be used as a voluptuous validator: voluptuous treats
|
||||
a callable as a coercer, so returning False would be a *value*, not a
|
||||
rejection. This raises instead.
|
||||
"""
|
||||
text = str(value or "")
|
||||
if not text or images.is_taskmate_image_url(text):
|
||||
return text
|
||||
raise vol.Invalid("image_url must be a TaskMate image URL")
|
||||
|
||||
|
||||
# Fields the panel is allowed to set directly. Anything else (skip_date,
|
||||
# assignment_current_child_id, publish_calendar_published_dates, etc.) is
|
||||
# coordinator-managed runtime state and intentionally not exposed.
|
||||
_CHORE_EDITABLE_FIELDS = {
|
||||
"name", "description", "points", "assigned_to", "depends_on", "requires_approval",
|
||||
"time_category", "claim_allowance_minutes", "daily_limit", "completion_sound",
|
||||
"icon", "difficulty",
|
||||
"icon", "image_url", "difficulty",
|
||||
"schedule_mode", "due_days", "recurrence", "recurrence_day",
|
||||
"recurrence_start", "first_occurrence_mode", "visibility_entity",
|
||||
"visibility_state", "visibility_operator",
|
||||
@@ -514,6 +533,7 @@ def _chore_payload_schema(*, require_name: bool):
|
||||
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
|
||||
vol.Optional("completion_sound"): str,
|
||||
vol.Optional("icon"): str,
|
||||
vol.Optional("image_url"): _image_url_or_blank,
|
||||
vol.Optional("difficulty"): vol.In(["easy", "medium", "hard"]),
|
||||
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
|
||||
vol.Optional("due_days"): [str],
|
||||
@@ -1336,7 +1356,7 @@ _ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessib
|
||||
_SUBKEY_SETTINGS = {
|
||||
"history_days", "streak_reset_mode", "card_design",
|
||||
"weekend_multiplier", "streak_milestones_enabled", "perfect_week_enabled",
|
||||
"perfect_week_bonus", "streak_milestones",
|
||||
"perfect_week_bonus", "streak_milestones", "quick_point_amounts",
|
||||
"streak_requires_all_chores", "perfect_week_requires_all_chores",
|
||||
"difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard",
|
||||
"unlock_allowlist", "parent_routing",
|
||||
@@ -1539,6 +1559,10 @@ _UPDATE_SETTINGS_SCHEMA = {
|
||||
vol.Optional("family_goal_target"): vol.All(vol.Coerce(int), vol.Range(min=1, max=10000000)),
|
||||
vol.Optional("family_goal_reward"): vol.All(str, vol.Length(max=200)),
|
||||
vol.Optional("streak_milestones"): str,
|
||||
# Comma-separated quick point-adjust amounts, e.g. "5, 10, 20" (#746). Kept a
|
||||
# string like streak_milestones so the panel's generic settings collector can
|
||||
# round-trip it as a plain text input; the panel parses and bounds it.
|
||||
vol.Optional("quick_point_amounts"): vol.All(str, vol.Length(max=60)),
|
||||
vol.Optional("notify_service"): str,
|
||||
vol.Optional("calendar_projection_days"): vol.All(int, vol.Range(min=1, max=90)),
|
||||
vol.Optional("vacation_calendar"): str,
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "Punkte manuell eingeben",
|
||||
"activity.reason_allocated_to_pool": "Dem Pool zugewiesen: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuelle Anpassung",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Strafe: {name}",
|
||||
"activity.reason_perfect_week": "Perfekter Wochenbonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Wähle ein MDI-Symbol",
|
||||
"panel.settings_points_icon_label": "Punkte-Symbol",
|
||||
"panel.settings_points_name_hint": "Wie du sie nennst, z.B. Sterne oder Münzen",
|
||||
"panel.settings_quick_points_label": "Schnellbeträge für Punkte",
|
||||
"panel.settings_quick_points_hint": "Kommagetrennte Beträge für die +/−-Schaltflächen auf jeder Kinderkarte. Es werden bis zu drei verwendet.",
|
||||
"panel.settings_points_name_label": "Punktename",
|
||||
"panel.settings_retention_hint": "Wie viele Tage Erledigungsverlauf gespeichert werden",
|
||||
"panel.settings_retention_label": "Aufbewahrung",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Foto ansehen",
|
||||
"panel.adjust_add": "Hinzufügen",
|
||||
"panel.adjust_add_title": "{child} {amount} {points} hinzufügen",
|
||||
"panel.adjust_amount": "Betrag",
|
||||
"panel.adjust_custom_title": "Punkte von {child} um einen eigenen Betrag anpassen",
|
||||
"panel.adjust_dialog_title": "Punkte anpassen — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} für {child}",
|
||||
"panel.adjust_err_amount": "Gib einen Betrag zwischen 1 und 10000 ein.",
|
||||
"panel.adjust_reason": "Grund (optional)",
|
||||
"panel.adjust_remove": "Abziehen",
|
||||
"panel.adjust_remove_title": "{child} {amount} {points} abziehen",
|
||||
"panel.swap_btn": "Tausch anfragen",
|
||||
"panel.swap_dialog_title": "Tausch: {chore}",
|
||||
"panel.swap_requester": "Heutigen Dienst übergeben an",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Versäumt",
|
||||
"panel.health_count_storage": "Speicher",
|
||||
"panel.chore_icon_label": "Bild",
|
||||
"panel.chore_image_label": "Bild",
|
||||
"panel.chore_image_hint": "Ein Foto, das statt des Symbols angezeigt wird. Beim Hochladen auf 512px verkleinert.",
|
||||
"panel.chore_image_upload": "Hochladen",
|
||||
"panel.chore_image_remove": "Entfernen",
|
||||
"panel.chore_image_empty": "Kein Bild",
|
||||
"panel.chore_image_uploading": "Wird hochgeladen…",
|
||||
"panel.chore_image_failed": "Hochladen fehlgeschlagen",
|
||||
"panel.chore_image_too_large": "Dieses Bild ist zu groß (max. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Wähle ein JPEG-, PNG- oder WebP-Bild.",
|
||||
"child.editor.pre_reader": "Nur-Bilder-Modus (für kleine Kinder)",
|
||||
"child.editor.pre_reader_labels": "Namen im Bildmodus anzeigen",
|
||||
"common.design.accessible": "Barrierefrei"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manually",
|
||||
"activity.reason_allocated_to_pool": "Allocated to pool: {name}",
|
||||
"activity.reason_admin_adjustment": "Manual adjustment",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Penalty: {name}",
|
||||
"activity.reason_perfect_week": "Perfect week bonus!",
|
||||
@@ -839,6 +840,8 @@
|
||||
"panel.settings_points_icon_hint": "Pick any MDI icon",
|
||||
"panel.settings_points_icon_label": "Points icon",
|
||||
"panel.settings_points_name_hint": "What you call them, e.g. Stars or Coins",
|
||||
"panel.settings_quick_points_label": "Quick point amounts",
|
||||
"panel.settings_quick_points_hint": "Comma-separated amounts for the + and − buttons on each child card. Up to three are used.",
|
||||
"panel.settings_points_name_label": "Points name",
|
||||
"panel.settings_retention_hint": "How many days of completion history to keep",
|
||||
"panel.settings_retention_label": "Retention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
"panel.adjust_amount": "Amount",
|
||||
"panel.adjust_custom_title": "Adjust {child}'s points by a custom amount",
|
||||
"panel.adjust_dialog_title": "Adjust points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} for {child}",
|
||||
"panel.adjust_err_amount": "Enter an amount between 1 and 10000.",
|
||||
"panel.adjust_reason": "Reason (optional)",
|
||||
"panel.adjust_remove": "Remove",
|
||||
"panel.adjust_remove_title": "Remove {amount} {points} from {child}",
|
||||
"panel.swap_btn": "Request swap",
|
||||
"panel.swap_dialog_title": "Swap: {chore}",
|
||||
"panel.swap_requester": "Give today's turn to",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Misses",
|
||||
"panel.health_count_storage": "Storage",
|
||||
"panel.chore_icon_label": "Picture",
|
||||
"panel.chore_image_label": "Picture",
|
||||
"panel.chore_image_hint": "A photo shown instead of the icon. Resized to 512px on upload.",
|
||||
"panel.chore_image_upload": "Upload",
|
||||
"panel.chore_image_remove": "Remove",
|
||||
"panel.chore_image_empty": "No picture",
|
||||
"panel.chore_image_uploading": "Uploading…",
|
||||
"panel.chore_image_failed": "Upload failed",
|
||||
"panel.chore_image_too_large": "That image is too large (max 2 MB).",
|
||||
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
|
||||
"child.editor.pre_reader": "Picture-only mode (for young children)",
|
||||
"child.editor.pre_reader_labels": "Show names in picture mode",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manually",
|
||||
"activity.reason_allocated_to_pool": "Allocated to pool: {name}",
|
||||
"activity.reason_admin_adjustment": "Manual adjustment",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Penalty: {name}",
|
||||
"activity.reason_perfect_week": "Perfect week bonus!",
|
||||
@@ -839,6 +840,8 @@
|
||||
"panel.settings_points_icon_hint": "Pick any MDI icon",
|
||||
"panel.settings_points_icon_label": "Points icon",
|
||||
"panel.settings_points_name_hint": "What you call them, e.g. Stars or Coins",
|
||||
"panel.settings_quick_points_label": "Quick point amounts",
|
||||
"panel.settings_quick_points_hint": "Comma-separated amounts for the + and − buttons on each child card. Up to three are used.",
|
||||
"panel.settings_points_name_label": "Points name",
|
||||
"panel.settings_retention_hint": "How many days of completion history to keep",
|
||||
"panel.settings_retention_label": "Retention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "View photo",
|
||||
"panel.adjust_add": "Add",
|
||||
"panel.adjust_add_title": "Add {amount} {points} to {child}",
|
||||
"panel.adjust_amount": "Amount",
|
||||
"panel.adjust_custom_title": "Adjust {child}'s points by a custom amount",
|
||||
"panel.adjust_dialog_title": "Adjust points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} for {child}",
|
||||
"panel.adjust_err_amount": "Enter an amount between 1 and 10000.",
|
||||
"panel.adjust_reason": "Reason (optional)",
|
||||
"panel.adjust_remove": "Remove",
|
||||
"panel.adjust_remove_title": "Remove {amount} {points} from {child}",
|
||||
"panel.swap_btn": "Request swap",
|
||||
"panel.swap_dialog_title": "Swap: {chore}",
|
||||
"panel.swap_requester": "Give today's turn to",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Misses",
|
||||
"panel.health_count_storage": "Storage",
|
||||
"panel.chore_icon_label": "Picture",
|
||||
"panel.chore_image_label": "Picture",
|
||||
"panel.chore_image_hint": "A photo shown instead of the icon. Resized to 512px on upload.",
|
||||
"panel.chore_image_upload": "Upload",
|
||||
"panel.chore_image_remove": "Remove",
|
||||
"panel.chore_image_empty": "No picture",
|
||||
"panel.chore_image_uploading": "Uploading…",
|
||||
"panel.chore_image_failed": "Upload failed",
|
||||
"panel.chore_image_too_large": "That image is too large (max 2 MB).",
|
||||
"panel.chore_image_bad_type": "Choose a JPEG, PNG or WebP image.",
|
||||
"child.editor.pre_reader": "Picture-only mode (for young children)",
|
||||
"child.editor.pre_reader_labels": "Show names in picture mode",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "points manuellement",
|
||||
"activity.reason_allocated_to_pool": "Alloué à la cagnotte : {name}",
|
||||
"activity.reason_admin_adjustment": "Ajustement manuel",
|
||||
"activity.reason_bonus": "Bonus : {name}",
|
||||
"activity.reason_penalty": "Pénalité : {name}",
|
||||
"activity.reason_perfect_week": "Bonus de semaine parfaite !",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Choisissez une icône MDI",
|
||||
"panel.settings_points_icon_label": "Icône des points",
|
||||
"panel.settings_points_name_hint": "Comment vous les appelez, ex. Étoiles ou Pièces",
|
||||
"panel.settings_quick_points_label": "Montants rapides de points",
|
||||
"panel.settings_quick_points_hint": "Montants séparés par des virgules pour les boutons + et − de chaque carte enfant. Trois au maximum sont utilisés.",
|
||||
"panel.settings_points_name_label": "Nom des points",
|
||||
"panel.settings_retention_hint": "Combien de jours d'historique de complétion garder",
|
||||
"panel.settings_retention_label": "Rétention",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Voir la photo",
|
||||
"panel.adjust_add": "Ajouter",
|
||||
"panel.adjust_add_title": "Ajouter {amount} {points} à {child}",
|
||||
"panel.adjust_amount": "Montant",
|
||||
"panel.adjust_custom_title": "Ajuster les points de {child} d'un montant personnalisé",
|
||||
"panel.adjust_dialog_title": "Ajuster les points — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} pour {child}",
|
||||
"panel.adjust_err_amount": "Saisissez un montant entre 1 et 10000.",
|
||||
"panel.adjust_reason": "Motif (facultatif)",
|
||||
"panel.adjust_remove": "Retirer",
|
||||
"panel.adjust_remove_title": "Retirer {amount} {points} à {child}",
|
||||
"panel.swap_btn": "Demander un échange",
|
||||
"panel.swap_dialog_title": "Échange : {chore}",
|
||||
"panel.swap_requester": "Donner le tour d'aujourd'hui à",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Manquements",
|
||||
"panel.health_count_storage": "Stockage",
|
||||
"panel.chore_icon_label": "Image",
|
||||
"panel.chore_image_label": "Image",
|
||||
"panel.chore_image_hint": "Une photo affichée à la place de l'icône. Redimensionnée à 512px à l'envoi.",
|
||||
"panel.chore_image_upload": "Envoyer",
|
||||
"panel.chore_image_remove": "Retirer",
|
||||
"panel.chore_image_empty": "Aucune image",
|
||||
"panel.chore_image_uploading": "Envoi…",
|
||||
"panel.chore_image_failed": "Échec de l'envoi",
|
||||
"panel.chore_image_too_large": "Cette image est trop volumineuse (max 2 Mo).",
|
||||
"panel.chore_image_bad_type": "Choisissez une image JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Mode images seules (jeunes enfants)",
|
||||
"child.editor.pre_reader_labels": "Afficher les noms en mode images",
|
||||
"common.design.accessible": "Accessible"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "poeng manuelt",
|
||||
"activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuell justering",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Straff: {name}",
|
||||
"activity.reason_perfect_week": "Perfekt uke-bonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Velg et MDI-ikon",
|
||||
"panel.settings_points_icon_label": "Poengikon",
|
||||
"panel.settings_points_name_hint": "Hva du kaller dem, f.eks. Stjerner eller Mynter",
|
||||
"panel.settings_quick_points_label": "Hurtigbeløp for poeng",
|
||||
"panel.settings_quick_points_hint": "Kommaseparerte beløp for +- og −-knappene på hvert barnekort. Opptil tre brukes.",
|
||||
"panel.settings_points_name_label": "Poengnavn",
|
||||
"panel.settings_retention_hint": "Hvor mange dager med fullføringshistorikk som beholdes",
|
||||
"panel.settings_retention_label": "Oppbevaring",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Vis bilde",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
"panel.adjust_amount": "Beløp",
|
||||
"panel.adjust_custom_title": "Juster poengene til {child} med et eget beløp",
|
||||
"panel.adjust_dialog_title": "Juster poeng — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} til {child}",
|
||||
"panel.adjust_err_amount": "Skriv inn et beløp mellom 1 og 10000.",
|
||||
"panel.adjust_reason": "Årsak (valgfritt)",
|
||||
"panel.adjust_remove": "Trekk fra",
|
||||
"panel.adjust_remove_title": "Trekk fra {amount} {points} fra {child}",
|
||||
"panel.swap_btn": "Be om bytte",
|
||||
"panel.swap_dialog_title": "Bytte: {chore}",
|
||||
"panel.swap_requester": "Gi dagens tur til",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Glemt",
|
||||
"panel.health_count_storage": "Lagring",
|
||||
"panel.chore_icon_label": "Bilde",
|
||||
"panel.chore_image_label": "Bilde",
|
||||
"panel.chore_image_hint": "Et bilde som vises i stedet for ikonet. Skaleres til 512px ved opplasting.",
|
||||
"panel.chore_image_upload": "Last opp",
|
||||
"panel.chore_image_remove": "Fjern",
|
||||
"panel.chore_image_empty": "Ingen bilde",
|
||||
"panel.chore_image_uploading": "Laster opp…",
|
||||
"panel.chore_image_failed": "Opplasting mislyktes",
|
||||
"panel.chore_image_too_large": "Bildet er for stort (maks 2 MB).",
|
||||
"panel.chore_image_bad_type": "Velg et JPEG-, PNG- eller WebP-bilde.",
|
||||
"child.editor.pre_reader": "Kun bilder (for små barn)",
|
||||
"child.editor.pre_reader_labels": "Vis navn i bildemodus",
|
||||
"common.design.accessible": "Tilgjengelig"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "poeng manuelt",
|
||||
"activity.reason_allocated_to_pool": "Tildelt til sparegris: {name}",
|
||||
"activity.reason_admin_adjustment": "Manuell justering",
|
||||
"activity.reason_bonus": "Bonus: {name}",
|
||||
"activity.reason_penalty": "Straff: {name}",
|
||||
"activity.reason_perfect_week": "Perfekt veke-bonus!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Vel eit MDI-ikon",
|
||||
"panel.settings_points_icon_label": "Poengikon",
|
||||
"panel.settings_points_name_hint": "Kva du kallar dei, t.d. Stjerner eller Myntar",
|
||||
"panel.settings_quick_points_label": "Snøggbeløp for poeng",
|
||||
"panel.settings_quick_points_hint": "Kommaseparerte beløp for +- og −-knappane på kvart barnekort. Opptil tre blir brukte.",
|
||||
"panel.settings_points_name_label": "Poengnamn",
|
||||
"panel.settings_retention_hint": "Kor mange dagar med fullføringshistorikk å ta vare på",
|
||||
"panel.settings_retention_label": "Oppbevaring",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Vis bilete",
|
||||
"panel.adjust_add": "Legg til",
|
||||
"panel.adjust_add_title": "Legg til {amount} {points} til {child}",
|
||||
"panel.adjust_amount": "Beløp",
|
||||
"panel.adjust_custom_title": "Juster poenga til {child} med eit eige beløp",
|
||||
"panel.adjust_dialog_title": "Juster poeng — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} til {child}",
|
||||
"panel.adjust_err_amount": "Skriv inn eit beløp mellom 1 og 10000.",
|
||||
"panel.adjust_reason": "Årsak (valfritt)",
|
||||
"panel.adjust_remove": "Trekk frå",
|
||||
"panel.adjust_remove_title": "Trekk frå {amount} {points} frå {child}",
|
||||
"panel.swap_btn": "Be om byte",
|
||||
"panel.swap_dialog_title": "Byte: {chore}",
|
||||
"panel.swap_requester": "Gi dagens tur til",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Gløymt",
|
||||
"panel.health_count_storage": "Lagring",
|
||||
"panel.chore_icon_label": "Bilete",
|
||||
"panel.chore_image_label": "Bilete",
|
||||
"panel.chore_image_hint": "Eit bilete som blir vist i staden for ikonet. Skalert til 512px ved opplasting.",
|
||||
"panel.chore_image_upload": "Last opp",
|
||||
"panel.chore_image_remove": "Fjern",
|
||||
"panel.chore_image_empty": "Ikkje noko bilete",
|
||||
"panel.chore_image_uploading": "Lastar opp…",
|
||||
"panel.chore_image_failed": "Opplastinga mislukkast",
|
||||
"panel.chore_image_too_large": "Biletet er for stort (maks 2 MB).",
|
||||
"panel.chore_image_bad_type": "Vel eit JPEG-, PNG- eller WebP-bilete.",
|
||||
"child.editor.pre_reader": "Berre bilete (for små born)",
|
||||
"child.editor.pre_reader_labels": "Vis namn i biletemodus",
|
||||
"common.design.accessible": "Tilgjengeleg"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "pontos manualmente",
|
||||
"activity.reason_allocated_to_pool": "Alocado ao cofrinho: {name}",
|
||||
"activity.reason_admin_adjustment": "Ajuste manual",
|
||||
"activity.reason_bonus": "Bônus: {name}",
|
||||
"activity.reason_penalty": "Penalidade: {name}",
|
||||
"activity.reason_perfect_week": "Bônus de semana perfeita!",
|
||||
@@ -801,6 +802,8 @@
|
||||
"panel.settings_points_icon_hint": "Escolha qualquer ícone MDI",
|
||||
"panel.settings_points_icon_label": "Ícone dos pontos",
|
||||
"panel.settings_points_name_hint": "Como você os chama, ex: Estrelas ou Moedas",
|
||||
"panel.settings_quick_points_label": "Valores rápidos de pontos",
|
||||
"panel.settings_quick_points_hint": "Valores separados por vírgulas para os botões + e − em cada cartão de criança. São usados até três.",
|
||||
"panel.settings_points_name_label": "Nome dos pontos",
|
||||
"panel.settings_retention_hint": "Quantos dias de histórico de conclusões manter",
|
||||
"panel.settings_retention_label": "Retenção",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} para {child}",
|
||||
"panel.adjust_amount": "Valor",
|
||||
"panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado",
|
||||
"panel.adjust_dialog_title": "Ajustar pontos — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} para {child}",
|
||||
"panel.adjust_err_amount": "Digite um valor entre 1 e 10000.",
|
||||
"panel.adjust_reason": "Motivo (opcional)",
|
||||
"panel.adjust_remove": "Remover",
|
||||
"panel.adjust_remove_title": "Remover {amount} {points} de {child}",
|
||||
"panel.swap_btn": "Pedir troca",
|
||||
"panel.swap_dialog_title": "Troca: {chore}",
|
||||
"panel.swap_requester": "Dar a vez de hoje a",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Falhas",
|
||||
"panel.health_count_storage": "Armazenamento",
|
||||
"panel.chore_icon_label": "Imagem",
|
||||
"panel.chore_image_label": "Imagem",
|
||||
"panel.chore_image_hint": "Uma foto exibida no lugar do ícone. Redimensionada para 512px no envio.",
|
||||
"panel.chore_image_upload": "Enviar",
|
||||
"panel.chore_image_remove": "Remover",
|
||||
"panel.chore_image_empty": "Sem imagem",
|
||||
"panel.chore_image_uploading": "Enviando…",
|
||||
"panel.chore_image_failed": "Falha no envio",
|
||||
"panel.chore_image_too_large": "Essa imagem é muito grande (máx. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
|
||||
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
|
||||
"common.design.accessible": "Acessível"
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"activity.no_events_for_filter": "No matching events",
|
||||
"activity.points_manually": "pontos manualmente",
|
||||
"activity.reason_allocated_to_pool": "Alocado ao mealheiro: {name}",
|
||||
"activity.reason_admin_adjustment": "Ajuste manual",
|
||||
"activity.reason_bonus": "Bónus: {name}",
|
||||
"activity.reason_penalty": "Penalidade: {name}",
|
||||
"activity.reason_perfect_week": "Bónus de semana perfeita!",
|
||||
@@ -806,6 +807,8 @@
|
||||
"panel.settings_points_icon_hint": "Escolha qualquer ícone MDI",
|
||||
"panel.settings_points_icon_label": "Ícone dos pontos",
|
||||
"panel.settings_points_name_hint": "Como os chama, ex. Estrelas ou Moedas",
|
||||
"panel.settings_quick_points_label": "Valores rápidos de pontos",
|
||||
"panel.settings_quick_points_hint": "Valores separados por vírgulas para os botões + e − em cada cartão de criança. São usados até três.",
|
||||
"panel.settings_points_name_label": "Nome dos pontos",
|
||||
"panel.settings_retention_hint": "Quantos dias de histórico de conclusões manter",
|
||||
"panel.settings_retention_label": "Retenção",
|
||||
@@ -1402,6 +1405,16 @@
|
||||
"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.activity_view_photo": "Ver foto",
|
||||
"panel.adjust_add": "Adicionar",
|
||||
"panel.adjust_add_title": "Adicionar {amount} {points} a {child}",
|
||||
"panel.adjust_amount": "Valor",
|
||||
"panel.adjust_custom_title": "Ajustar os pontos de {child} com um valor personalizado",
|
||||
"panel.adjust_dialog_title": "Ajustar pontos — {name}",
|
||||
"panel.adjust_done": "{sign}{amount} {points} para {child}",
|
||||
"panel.adjust_err_amount": "Introduza um valor entre 1 e 10000.",
|
||||
"panel.adjust_reason": "Motivo (opcional)",
|
||||
"panel.adjust_remove": "Retirar",
|
||||
"panel.adjust_remove_title": "Retirar {amount} {points} a {child}",
|
||||
"panel.swap_btn": "Pedir troca",
|
||||
"panel.swap_dialog_title": "Troca: {chore}",
|
||||
"panel.swap_requester": "Dar a vez de hoje a",
|
||||
@@ -1713,6 +1726,15 @@
|
||||
"panel.health_count_mandatory_misses": "Falhas",
|
||||
"panel.health_count_storage": "Armazenamento",
|
||||
"panel.chore_icon_label": "Imagem",
|
||||
"panel.chore_image_label": "Imagem",
|
||||
"panel.chore_image_hint": "Uma foto mostrada em vez do ícone. Redimensionada para 512px no envio.",
|
||||
"panel.chore_image_upload": "Carregar",
|
||||
"panel.chore_image_remove": "Remover",
|
||||
"panel.chore_image_empty": "Sem imagem",
|
||||
"panel.chore_image_uploading": "A carregar…",
|
||||
"panel.chore_image_failed": "Falha no carregamento",
|
||||
"panel.chore_image_too_large": "Essa imagem é demasiado grande (máx. 2 MB).",
|
||||
"panel.chore_image_bad_type": "Escolha uma imagem JPEG, PNG ou WebP.",
|
||||
"child.editor.pre_reader": "Modo só imagens (crianças pequenas)",
|
||||
"child.editor.pre_reader_labels": "Mostrar nomes no modo imagens",
|
||||
"common.design.accessible": "Acessível"
|
||||
|
||||
@@ -85,6 +85,11 @@ class TaskMateActivityCard extends LitElement {
|
||||
if (reason.startsWith('Perfect week bonus!')) {
|
||||
return this._t('activity.reason_perfect_week');
|
||||
}
|
||||
// Manual adjustment from the admin panel (#746). Deliberately NOT in
|
||||
// _UNDO_DENY_PREFIXES — a manual adjustment must stay reversible.
|
||||
if (reason === 'Admin panel adjustment') {
|
||||
return this._t('activity.reason_admin_adjustment');
|
||||
}
|
||||
const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/);
|
||||
if (weekendMatch) {
|
||||
return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] });
|
||||
|
||||
@@ -914,6 +914,24 @@ class TaskMateChildCard extends LitElement {
|
||||
|
||||
/* A chore picture sits where the digit would, so it has to carry the
|
||||
same white-on-colour weight the numeral gets from its text-shadow. */
|
||||
/* Uploaded chore pictures (#750). min-width/min-height:0 is load-bearing:
|
||||
grid and flex items default to min-*:auto, whose content-based minimum
|
||||
is the image's intrinsic aspect ratio, so without it a PORTRAIT photo
|
||||
overflows a square slot (measured 64x90 in a 64x64 pre-reader tile)
|
||||
because that minimum beats height:100%. Landscape photos hide it. */
|
||||
.chore-badge-img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: inherit; display: block;
|
||||
}
|
||||
.tmd-glyph-img {
|
||||
width: 1.4em; height: 1.4em; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: 6px; display: block;
|
||||
}
|
||||
.pre-tile-icon img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: 12px; display: block;
|
||||
}
|
||||
|
||||
.chore-number-badge ha-icon {
|
||||
--mdc-icon-size: 22px;
|
||||
color: #fff;
|
||||
@@ -2365,9 +2383,14 @@ class TaskMateChildCard extends LitElement {
|
||||
* The icon inherits the row's accent so it reads as part of the style.
|
||||
*/
|
||||
_choreGlyph(chore) {
|
||||
return chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}" class="tmd-glyph-icon"></ha-icon>`
|
||||
: this._choreEmoji(chore);
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
if (v.kind === "image") {
|
||||
return html`<img class="tmd-glyph-img" src="${v.url}" alt="" loading="lazy">`;
|
||||
}
|
||||
if (v.kind === "icon") {
|
||||
return html`<ha-icon icon="${v.icon}" class="tmd-glyph-icon"></ha-icon>`;
|
||||
}
|
||||
return this._choreEmoji(chore);
|
||||
}
|
||||
|
||||
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
|
||||
@@ -3302,7 +3325,8 @@ class TaskMateChildCard extends LitElement {
|
||||
const points = chore.effective_points ?? chore.points ?? 0;
|
||||
const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1));
|
||||
|
||||
const icon = chore.icon
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
const icon = (v.kind === "icon" ? v.icon : "")
|
||||
|| this._getTimeCategoryIcon(chore.time_category)
|
||||
|| 'mdi:checkbox-marked-circle-outline';
|
||||
|
||||
@@ -3316,7 +3340,11 @@ class TaskMateChildCard extends LitElement {
|
||||
? this._handleUndo(chore, child, childCompletionsToday)
|
||||
: this._handleComplete(chore, child))}
|
||||
>
|
||||
<div class="pre-tile-icon"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="pre-tile-icon">
|
||||
${v.kind === "image"
|
||||
? html`<img src="${v.url}" alt="" loading="lazy">`
|
||||
: html`<ha-icon icon="${icon}"></ha-icon>`}
|
||||
</div>
|
||||
${isDone ? html`<div class="pre-tile-tick"><ha-icon icon="mdi:check-bold"></ha-icon></div>` : ''}
|
||||
<div class="pre-tile-stars">
|
||||
${Array.from({ length: stars }, () => html`<ha-icon icon="mdi:star"></ha-icon>`)}
|
||||
@@ -3336,11 +3364,14 @@ class TaskMateChildCard extends LitElement {
|
||||
* a picture can't work on one and be invisible on the other.
|
||||
*/
|
||||
_choreNumberBadge(chore, colorClass, choreNumber) {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return html`
|
||||
<div class="chore-number-badge ${colorClass}">
|
||||
${chore.icon
|
||||
? html`<ha-icon icon="${chore.icon}"></ha-icon>`
|
||||
: choreNumber}
|
||||
${v.kind === "image"
|
||||
? html`<img class="chore-badge-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: v.kind === "icon"
|
||||
? html`<ha-icon icon="${v.icon}"></ha-icon>`
|
||||
: choreNumber}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,24 @@
|
||||
|
||||
window.__taskmate_design = { IDS, resolve, isDark, apply, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
|
||||
|
||||
/**
|
||||
* The single place that decides what a chore looks like (#750).
|
||||
*
|
||||
* Precedence: uploaded picture → MDI icon → the call site's own fallback.
|
||||
*
|
||||
* Returns a plain descriptor rather than a template, for two reasons: this
|
||||
* file has no ES-module imports and no Lit dependency and must keep it that
|
||||
* way, and the `icon` case is not uniform across sites — the reorder card
|
||||
* renders chore.icon as an <ha-icon> only when it starts with "mdi:" and as
|
||||
* raw text otherwise, because the field can hold an emoji. A descriptor lets
|
||||
* each site keep its existing icon and fallback handling untouched.
|
||||
*/
|
||||
window.__taskmate_chore_visual = window.__taskmate_chore_visual || function (chore) {
|
||||
if (chore && chore.image_url) return { kind: "image", url: chore.image_url };
|
||||
if (chore && chore.icon) return { kind: "icon", icon: chore.icon };
|
||||
return { kind: "none" };
|
||||
};
|
||||
|
||||
// ── Card-picker entity suggestions (HA 2026.6+) ────────────────────────────
|
||||
// Custom cards may add getEntitySuggestion(hass, entityId) to their
|
||||
// window.customCards entry; when a user picks an entity in the card picker,
|
||||
|
||||
@@ -330,6 +330,27 @@ class TaskMatePanel extends HTMLElement {
|
||||
return fn ? fn(this._hass, key, params) : key;
|
||||
}
|
||||
|
||||
// Reason prefixes for *derived* (automatic) transactions, which can't be
|
||||
// undone in isolation — mirrors the backend deny-list in coord_points.py and
|
||||
// the copy in taskmate-activity-card.js. Everything else (penalty, bonus,
|
||||
// gift, manual add/remove) is reversible.
|
||||
//
|
||||
// This is a deny-list, NOT an allow-list (#761): manual adjustments carry
|
||||
// arbitrary or empty reasons, so they cannot be recognised positively.
|
||||
// tests/test_panel_undo_deny_list.py pins all three copies together.
|
||||
static get _UNDO_DENY_PREFIXES() {
|
||||
return [
|
||||
"Weekend bonus", "Streak milestone bonus", "Perfect week bonus",
|
||||
"Allocated to pool:", "Pool refund", "Points decay",
|
||||
"Savings interest", "Badge",
|
||||
];
|
||||
}
|
||||
|
||||
_txnReversible(reason) {
|
||||
const r = reason || "";
|
||||
return !TaskMatePanel._UNDO_DENY_PREFIXES.some(p => r.startsWith(p));
|
||||
}
|
||||
|
||||
// Transaction reasons are stored in English in the DB (e.g. "Penalty: Messy room").
|
||||
// Mirror the activity-card mapping so panel views render in the user's language.
|
||||
_translateReason(reason) {
|
||||
@@ -352,6 +373,11 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (reason.startsWith('Perfect week bonus!')) {
|
||||
return this._t('activity.reason_perfect_week');
|
||||
}
|
||||
// Manual adjustment from the admin panel (#746). An exact match rather than a
|
||||
// prefix: unlike "Bonus: <name>" there is no trailing detail to extract.
|
||||
if (reason === 'Admin panel adjustment') {
|
||||
return this._t('activity.reason_admin_adjustment');
|
||||
}
|
||||
const weekendMatch = reason.match(/^Weekend bonus \(×(\d+)\)$/);
|
||||
if (weekendMatch) {
|
||||
return this._t('activity.reason_weekend_bonus', { multiplier: weekendMatch[1] });
|
||||
@@ -496,6 +522,18 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (act === "add-child") { this._openChildDialog(null); return; }
|
||||
if (act === "gift-points") { this._openGiftDialog(); return; }
|
||||
if (act === "save-gift") { this._doGiftPoints(); return; }
|
||||
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
|
||||
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
|
||||
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
|
||||
if (act === "chore-image-remove") {
|
||||
// Clears the dialog value only — the file is deleted on save by the
|
||||
// coordinator, so cancelling the dialog cannot destroy the picture.
|
||||
this._dialog.data.image_url = "";
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
if (act === "save-adjust-add") { this._saveAdjustDialog(1); return; }
|
||||
if (act === "save-adjust-remove") { this._saveAdjustDialog(-1); return; }
|
||||
if (act === "edit-child") { this._openChildDialog(t.dataset.id); return; }
|
||||
if (act === "delete-child") { this._confirmDelete("child", t.dataset.id); return; }
|
||||
if (act === "save-child") { this._doSaveChild(); return; }
|
||||
@@ -1402,6 +1440,85 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_pickChoreImage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/jpeg,image/png,image/webp";
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (file) this._uploadChoreImage(file);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
// Downscale to 512px before upload: these render in slots between 20 and
|
||||
// 128px, so anything larger is bytes every device re-downloads for nothing.
|
||||
_downscaleChoreImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const max = 512;
|
||||
let { width, height } = img;
|
||||
if (width > max || height > max) {
|
||||
const scale = Math.min(max / width, max / height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(b => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", 0.8);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("decode failed")); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async _uploadChoreImage(file) {
|
||||
if (this._choreImageBusy) return;
|
||||
this._choreImageBusy = true;
|
||||
this._render();
|
||||
try {
|
||||
const blob = await this._downscaleChoreImage(file);
|
||||
const post = () => {
|
||||
const token = this._hass && this._hass.auth && this._hass.auth.data
|
||||
&& this._hass.auth.data.access_token;
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "chore.jpg");
|
||||
return fetch("/api/taskmate/image", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
};
|
||||
// The cached access token can go stale mid-session, so refresh and retry
|
||||
// once on a 401 before giving up. Mirrors the child card's photo upload.
|
||||
if (this._hass && this._hass.auth && this._hass.auth.expired
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
|
||||
}
|
||||
let resp = await post();
|
||||
if (resp.status === 401 && this._hass && this._hass.auth
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
await this._hass.auth.refreshAccessToken();
|
||||
resp = await post();
|
||||
}
|
||||
if (resp.status === 413) { this._showToast("err", this._t("panel.chore_image_too_large")); return; }
|
||||
if (resp.status === 400) { this._showToast("err", this._t("panel.chore_image_bad_type")); return; }
|
||||
if (!resp.ok) { this._showToast("err", this._t("panel.chore_image_failed")); return; }
|
||||
const body = await resp.json();
|
||||
if (this._dialog && body.image_url) this._dialog.data.image_url = body.image_url;
|
||||
} catch (err) {
|
||||
this._showToast("err", this._t("panel.chore_image_failed"));
|
||||
} finally {
|
||||
this._choreImageBusy = false;
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
async _doSaveChore() {
|
||||
this._syncIconPickers();
|
||||
const d = this._dialog.data;
|
||||
@@ -1411,6 +1528,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
name: d.name.trim(),
|
||||
description: d.description || "",
|
||||
icon: d.icon || "",
|
||||
image_url: d.image_url || "",
|
||||
points: Number(d.points) || 0,
|
||||
assigned_to: d.assigned_to || [],
|
||||
requires_approval: !!d.requires_approval,
|
||||
@@ -2914,6 +3032,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_points_earned || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_earned")}</div></div>
|
||||
<div class="tm-stat"><div class="tm-stat-value">${this._fmtNum(child.total_chores_completed || 0)}</div><div class="tm-stat-label">${this._t("panel.child_stat_done")}</div></div>
|
||||
</div>
|
||||
${this._renderAdjustStrip(child, pointsName)}
|
||||
<div class="tm-card-foot">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="edit-child" data-id="${this._esc(child.id)}">${this._t("panel.btn_edit")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="reorder-chores-for-child" data-id="${this._esc(child.id)}" title="${this._t("panel.chore_order_title")}">⇅ ${this._t("panel.chore_order_title")}</button>
|
||||
@@ -2923,6 +3042,36 @@ class TaskMatePanel extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Manual point adjustment (#746). Minus amounts descend and plus amounts
|
||||
// ascend so the two smallest meet in the middle and the row reads outward
|
||||
// from zero. The two `-set` spans are load-bearing: with all seven buttons as
|
||||
// direct flex children, a narrow card wraps only the last one and orphans ⋯
|
||||
// on a row of its own. Grouping makes each side wrap as a whole unit.
|
||||
_renderAdjustStrip(child, pointsName) {
|
||||
const amounts = this._quickPointAmounts();
|
||||
const id = this._esc(child.id);
|
||||
const name = child.name || this._t("panel.child_unnamed");
|
||||
const btn = (delta) => {
|
||||
const amount = Math.abs(delta);
|
||||
const label = this._t(delta < 0 ? "panel.adjust_remove_title" : "panel.adjust_add_title",
|
||||
{ amount, points: pointsName, child: name });
|
||||
return `<button type="button" class="tm-btn tm-btn-sm ${delta < 0 ? "tm-btn-danger" : "tm-btn-raised"}"
|
||||
data-act="adjust-points" data-id="${id}" data-delta="${delta}"
|
||||
title="${this._esc(label)}" aria-label="${this._esc(label)}">${delta < 0 ? "−" : "+"}${amount}</button>`;
|
||||
};
|
||||
const customLabel = this._t("panel.adjust_custom_title", { child: name });
|
||||
return `
|
||||
<div class="tm-points-adjust">
|
||||
<span class="tm-points-adjust-set">${amounts.slice().reverse().map(a => btn(-a)).join("")}</span>
|
||||
<span class="tm-points-adjust-gap"></span>
|
||||
<span class="tm-points-adjust-set">
|
||||
${amounts.map(a => btn(a)).join("")}
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="adjust-points-custom" data-id="${id}"
|
||||
title="${this._esc(customLabel)}" aria-label="${this._esc(customLabel)}">⋯</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// -- Activity tab ------------------------------------------------------
|
||||
_renderActivityTab() {
|
||||
const pendingCompletions = this._state.pending_completions || [];
|
||||
@@ -3080,7 +3229,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<tbody>
|
||||
${[...transactions].reverse().map(t => {
|
||||
const child = childById[t.child_id];
|
||||
const undoable = typeof t.reason === "string" && (t.reason.startsWith("Penalty: ") || t.reason.startsWith("Bonus: "));
|
||||
const undoable = this._txnReversible(t.reason);
|
||||
return `
|
||||
<tr class="tm-row">
|
||||
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
|
||||
@@ -4197,6 +4346,10 @@ class TaskMatePanel extends HTMLElement {
|
||||
${["classic","playroom","console","cleanpro"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_quick_points_label")}<small>${this._t("panel.settings_quick_points_hint")}</small></div>
|
||||
<input type="text" class="tm-input" data-setting="quick_point_amounts" value="${this._esc(s.quick_point_amounts == null ? "" : s.quick_point_amounts)}" placeholder="5, 10, 20">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4773,6 +4926,73 @@ class TaskMatePanel extends HTMLElement {
|
||||
this._showToast("ok", this._t("panel.gift_sent"));
|
||||
}
|
||||
|
||||
_openAdjustDialog(childId) {
|
||||
const child = (this._state.children || []).find(c => c.id === childId);
|
||||
if (!child) return;
|
||||
this._openDialog({ kind: "adjust", data: { child_id: childId, points: 10, reason: "" } });
|
||||
}
|
||||
|
||||
_renderAdjustDialog() {
|
||||
const d = this._dialog.data;
|
||||
const child = (this._state.children || []).find(c => c.id === d.child_id) || {};
|
||||
return this._dialogShell(
|
||||
this._t("panel.adjust_dialog_title", { name: child.name || this._t("panel.child_unnamed") }),
|
||||
[
|
||||
this._field(this._t("panel.adjust_amount"), "points", d.points, "number"),
|
||||
this._field(this._t("panel.adjust_reason"), "reason", d.reason, "text"),
|
||||
].join(""),
|
||||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-danger" data-act="save-adjust-remove">${this._t("panel.adjust_remove")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-raised" data-act="save-adjust-add">${this._t("panel.adjust_add")}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
// `sign` is +1 or -1, taken from which footer button was pressed, so the
|
||||
// dialog needs no direction control of its own.
|
||||
async _saveAdjustDialog(sign) {
|
||||
const d = this._dialog.data;
|
||||
const amount = Number(d.points);
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > 10000) {
|
||||
this._showToast("err", this._t("panel.adjust_err_amount"));
|
||||
return;
|
||||
}
|
||||
const reason = String(d.reason || "").trim() || "Admin panel adjustment";
|
||||
const ok = await this._doAdjustPoints(d.child_id, sign * amount, reason);
|
||||
if (ok) this._closeDialog(true);
|
||||
}
|
||||
|
||||
// Manual point adjustment (#746). The busy key is the CHILD, not the button,
|
||||
// so a laggy tablet can neither double-award one amount nor race +5 against
|
||||
// −10 on the same balance.
|
||||
async _doAdjustPoints(childId, delta, reason = "Admin panel adjustment") {
|
||||
const amount = Math.abs(Number(delta) || 0);
|
||||
if (!childId || !Number.isInteger(amount) || amount < 1 || amount > 10000) {
|
||||
this._showToast("err", this._t("panel.adjust_err_amount"));
|
||||
return false;
|
||||
}
|
||||
if (!this._adjustBusy) this._adjustBusy = new Set();
|
||||
if (this._adjustBusy.has(childId)) return false;
|
||||
this._adjustBusy.add(childId);
|
||||
try {
|
||||
const service = delta > 0 ? "add_points" : "remove_points";
|
||||
const data = { child_id: childId, points: amount };
|
||||
if (reason) data.reason = reason;
|
||||
const { ok, err } = await this._callService(service, data);
|
||||
if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", { error: err })); return false; }
|
||||
await this._fetchState();
|
||||
const child = (this._state.children || []).find(c => c.id === childId);
|
||||
this._showToast("ok", this._t("panel.adjust_done", {
|
||||
sign: delta > 0 ? "+" : "−",
|
||||
amount,
|
||||
points: this._state.settings.points_name || this._t("common.points"),
|
||||
child: (child && child.name) || "",
|
||||
}));
|
||||
return true;
|
||||
} finally {
|
||||
this._adjustBusy.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
_openSwapDialog(choreId) {
|
||||
const c = (this._state.chores || []).find(x => x.id === choreId);
|
||||
if (!c) return;
|
||||
@@ -4813,6 +5033,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
_renderDialog() {
|
||||
if (this._dialog.kind === "swap") return this._renderSwapDialog();
|
||||
if (this._dialog.kind === "gift") return this._renderGiftDialog();
|
||||
if (this._dialog.kind === "adjust") return this._renderAdjustDialog();
|
||||
if (this._dialog.kind === "child") return this._renderChildDialog();
|
||||
if (this._dialog.kind === "chore") return this._renderChoreDialog();
|
||||
if (this._dialog.kind === "reward") return this._renderRewardDialog();
|
||||
@@ -4935,6 +5156,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
`<div class="tm-field-row">
|
||||
${this._field(this._t("panel.chore_description_label"), "description", d.description, "text")}
|
||||
${this._iconPickerField(this._t("panel.chore_icon_label"), "icon", d.icon)}
|
||||
${this._choreImageField(d.image_url || "")}
|
||||
</div>`,
|
||||
this._select(this._t("panel.chore_task_type_label"), "task_type", d.task_type || "standard", [
|
||||
{ v: "standard", l: this._t("panel.chore_task_type_standard") },
|
||||
@@ -5706,6 +5928,27 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** The chore picture well (#750): thumbnail or empty state, Upload, Remove. */
|
||||
_choreImageField(value) {
|
||||
const busy = this._choreImageBusy;
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._t("panel.chore_image_label")}</span>
|
||||
<div class="tm-image-well">
|
||||
${value
|
||||
? `<img class="tm-image-thumb" src="${this._esc(value)}" alt="">`
|
||||
: `<div class="tm-image-empty">${this._t("panel.chore_image_empty")}</div>`}
|
||||
<div class="tm-image-actions">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="chore-image-pick" ${busy ? "disabled" : ""}>
|
||||
${busy ? this._t("panel.chore_image_uploading") : this._t("panel.chore_image_upload")}
|
||||
</button>
|
||||
${value ? `<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="chore-image-remove">${this._t("panel.chore_image_remove")}</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span class="tm-field-hint">${this._t("panel.chore_image_hint")}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_iconPickerField(label, name, value) {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
@@ -5929,6 +6172,20 @@ class TaskMatePanel extends HTMLElement {
|
||||
return new Intl.NumberFormat().format(n);
|
||||
}
|
||||
|
||||
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
|
||||
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
|
||||
// collector can round-trip it as a plain text input. Junk degrades to the
|
||||
// default rather than rendering a broken button row.
|
||||
_quickPointAmounts() {
|
||||
const raw = (this._state.settings || {}).quick_point_amounts;
|
||||
const parsed = String(raw == null ? "" : raw)
|
||||
.split(",")
|
||||
.map(p => Number(p.trim()))
|
||||
.filter(n => Number.isInteger(n) && n >= 1 && n <= 10000);
|
||||
const unique = [...new Set(parsed)].slice(0, 3);
|
||||
return unique.length ? unique : [5, 10, 20];
|
||||
}
|
||||
|
||||
_mdi(name) {
|
||||
return `<ha-icon icon="${this._esc(name || "mdi:account-circle")}"></ha-icon>`;
|
||||
}
|
||||
@@ -6408,6 +6665,36 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
.tm-stat-highlight .tm-stat-value { color: var(--tm-gold); }
|
||||
|
||||
/* Manual point adjustment strip on child cards (#746). Each -set is a
|
||||
nowrap group so the row folds as "minus / plus + ⋯" on a narrow card
|
||||
instead of orphaning the ⋯ button on a line of its own. */
|
||||
/* Chore picture well (#750). */
|
||||
.tm-image-well {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px; border: 1px solid var(--tm-border);
|
||||
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
|
||||
}
|
||||
.tm-image-thumb {
|
||||
width: 56px; height: 56px; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: var(--tm-radius-sm); display: block;
|
||||
border: 1px solid var(--tm-border);
|
||||
}
|
||||
.tm-image-empty {
|
||||
width: 56px; height: 56px; display: grid; place-items: center;
|
||||
border: 1px dashed var(--tm-border); border-radius: var(--tm-radius-sm);
|
||||
color: var(--tm-text-faint); font-size: 11px; text-align: center;
|
||||
}
|
||||
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.tm-points-adjust {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.tm-points-adjust-set { display: flex; gap: 4px; align-items: center; }
|
||||
.tm-points-adjust-gap { flex: 1 1 8px; min-width: 4px; }
|
||||
.tm-points-adjust .tm-btn { min-width: 40px; justify-content: center; padding-inline: 8px; }
|
||||
|
||||
.tm-card-foot {
|
||||
display: flex; gap: 6px;
|
||||
margin-top: auto;
|
||||
|
||||
@@ -224,6 +224,20 @@ class TaskMateReorderCard extends LitElement {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Uploaded chore picture (#750). Sized explicitly, NOT width:100%:
|
||||
.chore-icon is applied straight to the element with no wrapper box, so
|
||||
a percentage would resolve against the flex row and stretch the photo
|
||||
across it. min-*:0 stops a portrait photo overflowing the square slot
|
||||
via the grid/flex min-*:auto intrinsic-ratio minimum. */
|
||||
.chore-icon-img {
|
||||
width: 24px; height: 24px; min-width: 0; min-height: 0;
|
||||
flex: 0 0 24px;
|
||||
object-fit: cover; border-radius: 6px; display: block;
|
||||
}
|
||||
/* The designed row nests the glyph in a content-sized <span class="d-emoji">. */
|
||||
.d-emoji .chore-icon-img {
|
||||
width: 20px; height: 20px; flex: 0 0 20px; border-radius: 5px;
|
||||
}
|
||||
.chore-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -911,10 +925,13 @@ class TaskMateReorderCard extends LitElement {
|
||||
_renderDesignedChoreItem(chore, index, total, category, pointsIcon) {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === total - 1;
|
||||
const choreIcon = chore.icon || "mdi:broom";
|
||||
const emoji = choreIcon.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${choreIcon}"></ha-icon>`
|
||||
: html`${choreIcon}`;
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
const choreIcon = (v.kind === "icon" ? v.icon : "") || "mdi:broom";
|
||||
const emoji = v.kind === "image"
|
||||
? html`<img class="chore-icon-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: choreIcon.startsWith("mdi:")
|
||||
? html`<ha-icon icon="${choreIcon}"></ha-icon>`
|
||||
: html`${choreIcon}`;
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -972,7 +989,12 @@ class TaskMateReorderCard extends LitElement {
|
||||
<ha-icon icon="mdi:drag-vertical"></ha-icon>
|
||||
</div>
|
||||
<span class="order-number">${index + 1}</span>
|
||||
<ha-icon class="chore-icon" icon="${chore.icon || "mdi:broom"}"></ha-icon>
|
||||
${(() => {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return v.kind === "image"
|
||||
? html`<img class="chore-icon chore-icon-img" src="${v.url}" alt="" loading="lazy">`
|
||||
: html`<ha-icon class="chore-icon" icon="${v.kind === "icon" ? v.icon : "mdi:broom"}"></ha-icon>`;
|
||||
})()}
|
||||
<div class="chore-info">
|
||||
<span class="chore-name">${chore.name}</span>
|
||||
<span class="chore-points">
|
||||
|
||||
@@ -275,7 +275,12 @@ class TaskMateRoutineCard extends LitElement {
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="icon"><ha-icon icon="${chore.icon || "mdi:checkbox-marked-circle-outline"}"></ha-icon></div>
|
||||
${(() => {
|
||||
const v = window.__taskmate_chore_visual(chore);
|
||||
return v.kind === "image"
|
||||
? html`<div class="icon"><img class="chore-icon-img" src="${v.url}" alt="" loading="lazy"></div>`
|
||||
: html`<div class="icon"><ha-icon icon="${v.kind === "icon" ? v.icon : "mdi:checkbox-marked-circle-outline"}"></ha-icon></div>`;
|
||||
})()}
|
||||
<div class="task">${chore.name}</div>
|
||||
${chore.description ? html`<div class="desc">${chore.description}</div>` : ""}
|
||||
<div class="points">
|
||||
@@ -441,6 +446,13 @@ class TaskMateRoutineCard extends LitElement {
|
||||
padding: 20px 26px 8px;
|
||||
gap: 14px;
|
||||
}
|
||||
/* Uploaded chore picture (#750). .icon IS a sized box (128px, 104px on
|
||||
small screens), so 100% is correct here. min-*:0 stops a portrait
|
||||
photo overflowing it via the grid min-*:auto intrinsic-ratio minimum. */
|
||||
.icon .chore-icon-img {
|
||||
width: 100%; height: 100%; min-width: 0; min-height: 0;
|
||||
object-fit: cover; border-radius: inherit; display: block;
|
||||
}
|
||||
.icon {
|
||||
width: 128px; height: 128px;
|
||||
border-radius: var(--tmd-radius, 34px);
|
||||
|
||||
Reference in New Issue
Block a user