This commit is contained in:
Home Assistant Version Control
2026-09-25 04:10:30 +00:00
parent 56c80f896e
commit ad634c1b84
60 changed files with 2895 additions and 794 deletions
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.9.3", "start_ts": 1790211396.172996}
{"pid": 72, "version": 1, "ha_version": "2026.9.3", "start_ts": 1790309385.7261584}
+12 -12
View File
@@ -201,7 +201,7 @@
},
{
"id": "4d1a17aa96b74064af713fc5e9334862",
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680153",
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680160",
"type": "module"
},
{
@@ -231,7 +231,7 @@
},
{
"id": "449ff43140654e4bbe2c7b2d361dfacc",
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019294",
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019295",
"type": "module"
},
{
@@ -314,16 +314,6 @@
"url": "/taskmate/taskmate-sounds.js?v=5.7.0",
"type": "module"
},
{
"id": "2dfca9e286bb4c5fb92ac3a46332fb3b",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.10.0",
"type": "module"
},
{
"id": "f7036d587eed4a2ea6d30c5271d7a78e",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
},
{
"id": "f4b0f22e5ce74727afbde9d7168a141e",
"url": "/ha_creality_ws/k_printer_card.js?v=1790211801",
@@ -333,6 +323,16 @@
"id": "5e4ca4c959f04b4c9b1dd8da0c4cb3b7",
"url": "/ha_creality_ws/k_cfs_card.js?v=1790211801",
"type": "module"
},
{
"id": "0415d839dea24062b977830fdd85f135",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
},
{
"id": "038f9bf71bde42548113bc0355fa4ec0",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.11.0",
"type": "module"
}
]
}
+93 -1
View File
@@ -5,12 +5,22 @@ import json
import logging
import os
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_PREVIOUS_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .const import (
SERVICE_HIDE_PHOTO,
SERVICE_UNDO_HIDE,
SERVICE_RESTORE_PHOTOS,
SERVICE_RESTORE_ALL_PHOTOS,
SERVICE_LIST_HIDDEN_PHOTOS,
)
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
@@ -353,6 +363,83 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
return True
def _register_photo_services(hass: HomeAssistant) -> None:
def camera_for(call):
camera = hass.data.get(DOMAIN, {}).get(call.data[ATTR_ENTRY_ID], {}).get("camera")
if camera is None:
raise ServiceValidationError("This album slideshow is not loaded")
return camera
async def hide_photo(call):
camera = camera_for(call)
try:
await camera.async_hide_photo(
photo_ids=call.data.get("photo_ids"),
position=call.data.get("position"),
frame_id=call.data.get("frame_id"),
)
except (ValueError, OSError) as err:
raise ServiceValidationError(str(err)) from err
async def undo_hide(call):
await camera_for(call).async_restore_photos(undo=True)
async def restore_photos(call):
await camera_for(call).async_restore_photos(call.data["photo_ids"])
async def restore_all_photos(call):
await camera_for(call).async_restore_photos()
async def list_hidden_photos(call):
camera = camera_for(call)
hidden = sorted(camera.store.hidden_photo_ids)
offset = call.data.get("offset", 0)
limit = call.data.get("limit", 50)
items = {
item.photo_id: item
for item in (camera.coordinator.data or {}).get("items", [])
if item.photo_id in camera.store.hidden_photo_ids
}
return {
"total": len(hidden),
"offset": offset,
"photos": [
{
"photo_id": photo_id,
"name": (items[photo_id].filename if photo_id in items else None) or f"Photo {photo_id[-8:]}",
"in_album": photo_id in items,
}
for photo_id in hidden[offset:offset + limit]
],
}
identifiers = vol.All([str], vol.Length(min=1))
registrations = (
(SERVICE_HIDE_PHOTO, hide_photo, {
vol.Optional("photo_ids"): identifiers,
vol.Optional("position"): vol.In(["first", "second", "both"]),
vol.Optional("frame_id"): vol.All(vol.Coerce(int), vol.Range(min=0)),
}),
(SERVICE_UNDO_HIDE, undo_hide, {}),
(SERVICE_RESTORE_PHOTOS, restore_photos, {vol.Required("photo_ids"): identifiers}),
(SERVICE_RESTORE_ALL_PHOTOS, restore_all_photos, {}),
(SERVICE_LIST_HIDDEN_PHOTOS, list_hidden_photos, {
vol.Optional("offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("limit", default=50): vol.All(vol.Coerce(int), vol.Range(min=1, max=100)),
}),
)
for name, handler, fields in registrations:
if not hass.services.has_service(DOMAIN, name):
hass.services.async_register(
DOMAIN, name, handler,
schema=vol.Schema({vol.Required(ATTR_ENTRY_ID): str, **fields}),
supports_response=(
SupportsResponse.ONLY if name == SERVICE_LIST_HIDDEN_PHOTOS
else SupportsResponse.NONE
),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
from .coordinator import AlbumCoordinator
@@ -372,6 +459,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
try:
await store.async_load_hidden_photos(hass, entry.entry_id)
except Exception as err:
raise ConfigEntryNotReady("Could not load hidden photos; slideshow has not started") from err
coordinator = AlbumCoordinator(hass, entry, store)
await coordinator.async_config_entry_first_refresh()
@@ -423,6 +514,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
_register_photo_services(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
store.notify()
@@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .const import SERVICE_HIDE_PHOTO, SERVICE_UNDO_HIDE
from .coordinator import AlbumCoordinator
@@ -16,6 +17,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
PreviousSlideButton(hass, entry, coordinator),
NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator),
HidePhotoButton(hass, entry, coordinator),
UndoHideButton(hass, entry, coordinator),
]
)
@@ -77,3 +80,29 @@ class RefreshAlbumButton(_BaseButton):
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
class HidePhotoButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_hide_photo"
self._attr_name = "Hide current photo"
self._attr_icon = "mdi:eye-off"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN, SERVICE_HIDE_PHOTO, {ATTR_ENTRY_ID: self.entry.entry_id}, blocking=True
)
class UndoHideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_undo_hide"
self._attr_name = "Undo hide"
self._attr_icon = "mdi:undo"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN, SERVICE_UNDO_HIDE, {ATTR_ENTRY_ID: self.entry.entry_id}, blocking=True
)
+200 -16
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict, deque
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
import logging
import random
@@ -303,6 +303,25 @@ class AlbumSlideshowCamera(Camera):
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
return {
"entry_id": self.entry.entry_id,
"displayed_photo_ids": (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None and not any(
photo_id in self.store.hidden_photo_ids
for photo_id in self._current_frame.meta.get("photo_ids", [])
) else []
),
"hidden_photo_count": len(self.store.hidden_photo_ids),
"hidden_revision": self.store.hidden_revision,
"undo_hide_available": bool(self.store.last_hidden_photo_ids),
"empty_reason": (
None if items else (
"all_hidden" if data.get("items") and all(
item.photo_id in self.store.hidden_photo_ids
for item in data["items"]
) else "no_matching_photos"
)
),
"album_title": data.get("title"),
"media_count": len(items),
"media_count_total": len(data.get("items", []) or []),
@@ -412,12 +431,14 @@ class AlbumSlideshowCamera(Camera):
self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode,
self.store.hidden_revision,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
return self._effective_cache[1]
filtered = playlist.filter_items(
raw,
[item for item in raw if item.photo_id and item.photo_id not in self.store.hidden_photo_ids]
if self.store.hidden_photo_ids else raw,
mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
)
@@ -476,6 +497,161 @@ class AlbumSlideshowCamera(Camera):
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
async def async_hide_photo(
self,
*,
photo_ids: list[str] | None = None,
position: str | None = None,
frame_id: int | None = None,
) -> None:
async with self._navigation_lock:
if frame_id is not None and frame_id != self._frame_id:
raise ValueError("The displayed slide changed; try again")
if photo_ids is not None and position is not None:
raise ValueError("Choose photo IDs or a position, not both")
if photo_ids is None:
current_ids = (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None else []
)
if not current_ids:
raise ValueError("There is no photo to hide")
if len(current_ids) > 1 and position is None:
raise ValueError("This slide is paired; choose first, second, or both")
if position == "second" and len(current_ids) < 2:
raise ValueError("This slide has no second photo")
if position not in (None, "first", "second", "both"):
raise ValueError("Invalid photo position")
photo_ids = (
current_ids if position == "both"
else [current_ids[1 if position == "second" else 0]]
)
known_ids = {
item.photo_id for item in (self.coordinator.data or {}).get("items", [])
if item.photo_id
}
for frame in [self._current_frame, *self._previous_frames, *self._next_frames]:
if frame is not None:
known_ids.update(photo_id for photo_id in frame.meta.get("photo_ids", []) if photo_id)
if not photo_ids or any(not photo_id or photo_id not in known_ids for photo_id in photo_ids):
raise ValueError("Photo identifier unavailable; refresh the album and try again")
await self._async_change_exclusions(photo_ids, hidden=True)
async def async_restore_photos(
self, photo_ids: list[str] | None = None, *, undo: bool = False
) -> None:
async with self._navigation_lock:
selected = (
self.store.last_hidden_photo_ids if undo
else self.store.hidden_photo_ids if photo_ids is None
else photo_ids
)
await self._async_change_exclusions(selected, hidden=False)
async def _async_change_exclusions(self, photo_ids, *, hidden: bool) -> None:
generation = self._timeline_generation
buffered_frames = (
() if self._timeline_dirty
else tuple(frame for frame in (self._current_frame, *self._next_frames) if frame is not None)
)
if await self.store.async_set_photos_hidden(photo_ids, hidden=hidden):
if self._timeline_generation != generation + 1:
buffered_frames = ()
await self._rebuild_after_exclusion_change(buffered_frames)
async def _rebuild_after_exclusion_change(
self, buffered_frames: tuple[_RenderedFrame, ...] = ()
) -> None:
self._effective_cache = None
self._invalidate_timeline()
self._random_order = []
self._random_pos = 0
self._recent_urls = []
items = self._effective_items()
positions = {item.photo_id: index for index, item in enumerate(items) if item.photo_id}
reusable = []
for frame in buffered_frames:
photo_ids = frame.meta.get("photo_ids", [])
if not photo_ids or any(photo_id not in positions for photo_id in photo_ids):
continue
cursor = replace(
frame.cursor,
index=positions[photo_ids[0]],
random_order=(),
random_pos=0,
recent_urls=(),
)
reusable.append(replace(frame, cursor=cursor))
if reusable:
self._timeline_dirty = False
self._next_frames.extend(reusable[1:])
self._trim_timeline()
self._apply_frame(reusable[0])
self._schedule_preload()
return
generation = self._timeline_generation
await self._clear_current_frame()
for frame in buffered_frames:
if generation != self._timeline_generation:
break
photo_ids = frame.meta.get("photo_ids", [])
orientation = frame.meta.get("pair_orientation")
if len(photo_ids) != 2 or orientation not in ("horizontal", "vertical"):
continue
remaining = [
(half, photo_id) for half, photo_id in enumerate(photo_ids)
if photo_id in positions
]
if len(remaining) != 1:
continue
half, photo_id = remaining[0]
try:
encoded = await self._async_image_job(
ip.render_pair_photo, frame.data, half, orientation == "vertical",
max(0, int(self.store.pair_divider_px)), self.store.fill_mode,
)
except Exception as err:
_LOGGER.debug("Album Slideshow: could not reuse paired photo: %s", err)
continue
if generation != self._timeline_generation:
break
cursor = replace(
frame.cursor, index=positions[photo_id],
random_order=(), random_pos=0, recent_urls=(),
)
meta = {
**frame.meta,
"photo_ids": [photo_id],
"captured_at_pair": None,
"pair_frames": None,
"pair_orientation": None,
}
self._timeline_dirty = False
self._apply_frame(_RenderedFrame(encoded, cursor, meta))
self._schedule_preload()
return
try:
await self._rebuild_current_frame()
except Exception as err:
_LOGGER.warning("Album Slideshow: photos updated but replacement render failed: %s", err)
async def _clear_current_frame(self) -> None:
self._current_frame = None
self._framebuffer = None
self.store.last_frame = None
self._last_is_portrait = None
self._last_captured_at_pair = None
self._last_pair_frames = None
self._last_pair_orientation = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
return ip.encode_image(image)
self._framebuffer = await self.hass.async_add_executor_job(blank_frame)
self._frame_id += 1
self.async_write_ha_state()
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
return self._framebuffer
@@ -577,14 +753,13 @@ class AlbumSlideshowCamera(Camera):
composed: Image.Image | None = None
try:
async with self._compose_semaphore:
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
encoded = await self.hass.async_add_executor_job(
ip.encode_image, composed
)
encoded = await self._async_image_job(ip.encode_image, composed)
return _RenderedFrame(encoded, cursor, meta or {})
except asyncio.CancelledError:
raise
@@ -800,6 +975,7 @@ class AlbumSlideshowCamera(Camera):
self._timeline_dirty = False
items = self._effective_items()
if not items:
await self._clear_current_frame()
return False
cursor = (
@@ -908,6 +1084,10 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem
return sem
async def _async_image_job(self, function, *args):
async with self._compose_semaphore:
return await self.hass.async_add_executor_job(function, *args)
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
@@ -978,7 +1158,7 @@ class AlbumSlideshowCamera(Camera):
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
img = await self.hass.async_add_executor_job(
img = await self._async_image_job(
ip.open_image, cur_bytes, (width, height)
)
try:
@@ -1000,7 +1180,7 @@ class AlbumSlideshowCamera(Camera):
pair_frames: list[dict] | None = None
try:
if other_img is not None:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.pair_images, img, other_img, width, height, fill_mode,
is_portrait_canvas, divider, divider_fill, transparent_divider,
)
@@ -1022,12 +1202,16 @@ class AlbumSlideshowCamera(Camera):
]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height,
)
finally:
ip.safe_close(other_img)
meta = {
"photo_ids": (
[getattr(cur, "photo_id", None), getattr(other_item, "photo_id", None)]
if pair_frames else [getattr(cur, "photo_id", None)]
),
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
@@ -1041,7 +1225,7 @@ class AlbumSlideshowCamera(Camera):
}
return composed, meta
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1086,14 +1270,14 @@ class AlbumSlideshowCamera(Camera):
if not b:
self._peek_advance(count, items)
continue
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1116,10 +1300,10 @@ class AlbumSlideshowCamera(Camera):
b = await self._fetch_bytes(item.url)
if not b:
return None, None
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
cur_is_portrait = ip.is_portrait_item(item, img)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1139,7 +1323,7 @@ class AlbumSlideshowCamera(Camera):
if composed is None:
return None
try:
return await self.hass.async_add_executor_job(ip.encode_image, composed)
return await self._async_image_job(ip.encode_image, composed)
finally:
ip.safe_close(composed)
@@ -1213,7 +1397,7 @@ class AlbumSlideshowCamera(Camera):
continue
try:
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
except Exception:
continue
@@ -1,5 +1,11 @@
DOMAIN = "album_slideshow"
SERVICE_HIDE_PHOTO = "hide_photo"
SERVICE_UNDO_HIDE = "undo_hide"
SERVICE_RESTORE_PHOTOS = "restore_photos"
SERVICE_RESTORE_ALL_PHOTOS = "restore_all_photos"
SERVICE_LIST_HIDDEN_PHOTOS = "list_hidden_photos"
CONF_PROVIDER = "provider"
CONF_ALBUM_URL = "album_url"
CONF_ALBUM_NAME = "album_name"
@@ -3,8 +3,10 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from hashlib import sha256
import json
import logging
import os
from pathlib import Path
import re
from typing import Any
@@ -130,6 +132,17 @@ class MediaItem:
# background enrichment task skip already-processed files even after
# an HA restart (the flag round-trips through the items cache).
exif_scanned: bool = False
photo_id: str | None = None
def __post_init__(self) -> None:
if self.photo_id is None:
self.photo_id = _photo_identifier(self.source_id)
def _photo_identifier(source_id: str | None) -> str | None:
if not source_id:
return None
return "v1:" + sha256(str(source_id).encode("utf-8")).hexdigest()
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
@@ -1198,6 +1211,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)),
photo_id=raw.get("photo_id"),
))
except Exception:
continue
@@ -1230,6 +1244,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned,
"photo_id": it.photo_id,
}
for it in items
],
@@ -1290,6 +1305,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
height=None,
mime_type=None,
filename=p.name,
source_id=f"file://{os.path.abspath(p)}",
)
)
@@ -1349,6 +1365,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
height=None,
mime_type=resolved[1],
filename=title,
source_id=cid,
)
)
@@ -2037,6 +2054,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
# The DAV href, so enrichment can fetch the original bytes
# even when the display URL is a preview.
source_id=href,
photo_id=_photo_identifier(p.get("file_id") or href),
)
)
@@ -2552,6 +2570,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
page_items = _find_largest_item_list(result) or _find_largest_item_list(data)
api_items: list[MediaItem] = []
dropped_videos = 0
scraped_ids = {
_photo_base_key(item.url): item.source_id
for item in scraped_items
if item.source_id
}
if page_items:
seen_urls: set[str] = set()
for raw in page_items:
@@ -2586,6 +2609,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
captured_at=captured_at,
uploaded_at=None,
byte_size=byte_size,
source_id=raw.get("id") or scraped_ids.get(_photo_base_key(url)),
))
# Cross-source date enrichment: publicalbum.org often returns a fuller
@@ -394,6 +394,7 @@ def _parse_album_item(raw: Any) -> MediaItem | None:
captured_at=captured_at,
uploaded_at=uploaded_at,
byte_size=byte_size,
source_id=_media_key(raw),
)
@@ -165,6 +165,32 @@ def pair_images(
return canvas
def render_pair_photo(
data: bytes,
photo_position: int,
portrait_canvas: bool,
divider: int,
fill_mode: str,
) -> bytes:
"""Render one safe photo from an already composed pair without source I/O."""
if photo_position not in (0, 1):
raise ValueError("Invalid paired-photo position")
with open_image(data) as paired:
width, height = paired.size
length = height if portrait_canvas else width
first_length = max(1, (length - divider) // 2)
start, end = (
(0, first_length) if photo_position == 0
else (first_length + divider, length)
)
if not 0 <= start < end <= length:
raise ValueError("Paired photo is outside the rendered canvas")
box = (0, start, width, end) if portrait_canvas else (start, 0, end, height)
with paired.crop(box) as photo:
with render_image(photo, fill_mode, width, height) as rendered:
return encode_image(rendered)
def encode_image(img: Image.Image) -> bytes:
"""Encode a PIL image to a client-compatible JPEG or PNG.
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
"version": "1.10.0"
"version": "1.11.0"
}
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
AlbumCountSensor(entry, coordinator),
AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator),
HiddenPhotoCountSensor(entry, coordinator),
]
if coordinator.provider in ENRICHING_PROVIDERS:
# Diagnostic surface for the background enrichment pass (per-photo
@@ -68,6 +69,20 @@ class AlbumCountSensor(_BaseAlbumSensor):
return len(data.get("items", []))
class HiddenPhotoCountSensor(_BaseAlbumSensor):
_attr_icon = "mdi:eye-off"
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_hidden_photo_count"
self._attr_name = "Hidden photos"
coordinator.store.add_listener(self.async_write_ha_state)
@property
def native_value(self):
return len(self.coordinator.store.hidden_photo_ids)
class AlbumTitleSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
@@ -1,3 +1,94 @@
hide_photo:
name: Hide photo
description: Exclude a photo from this slideshow without changing the source library. Paired slides require an explicit position or photo IDs.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
photo_ids:
name: Photo IDs
description: Optional list of IDs from displayed_photo_ids. These target the selected photos even if the slide changes.
selector:
object:
position:
name: Position
description: First is left or top; second is right or bottom. Omit for a single photo.
selector:
select:
options:
- first
- second
- both
frame_id:
name: Expected frame ID
description: Optional guard that rejects the action if the current frame has changed.
selector:
number:
min: 0
mode: box
undo_hide:
name: Undo hide
description: Restore the photos hidden by the last hide action for this slideshow.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
restore_photos:
name: Restore photos
description: Remove selected photo IDs from this slideshow's exclusion list.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
photo_ids:
name: Photo IDs
required: true
selector:
object:
restore_all_photos:
name: Restore all hidden photos
description: Clear this slideshow's exclusion list. Previously hidden photos become eligible for display again.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
list_hidden_photos:
name: List hidden photos
description: Return a page of hidden photo IDs and names without putting the full list into entity state.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
offset:
name: Offset
default: 0
selector:
number:
min: 0
mode: box
limit:
name: Page size
default: 50
selector:
number:
min: 1
max: 100
mode: box
next_slide:
name: Next slide
description: Advance the slideshow to the next image for a specific config entry.
+69 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Callable
from typing import Callable, Iterable
from homeassistant.helpers.storage import Store
from .const import (
DOMAIN,
DEFAULT_SLIDE_INTERVAL,
DEFAULT_REFRESH_HOURS,
DEFAULT_FILL_MODE,
@@ -52,8 +56,72 @@ class SlideshowStore:
# the previous slide instantly across a camera reload.
last_frame: bytes | None = None
hidden_photo_ids: frozenset[str] = field(default_factory=frozenset, init=False)
last_hidden_photo_ids: tuple[str, ...] = field(default=(), init=False)
hidden_revision: int = field(default=0, init=False)
_hidden_storage: Store | None = field(default=None, init=False, repr=False)
_hidden_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
_listeners: list[Listener] = field(default_factory=list)
async def async_load_hidden_photos(self, hass, entry_id: str) -> None:
self._hidden_storage = Store(hass, 1, f"{DOMAIN}.{entry_id}.hidden")
data = await self._hidden_storage.async_load()
if data is None:
return
if not isinstance(data, dict):
raise ValueError("Invalid hidden-photo storage")
hidden = data.get("hidden", [])
last_hidden = data.get("last_hidden", [])
revision = data.get("revision", 0)
if type(revision) is not int or revision < 0:
raise ValueError("Invalid hidden-photo revision")
if any(
not isinstance(values, list)
or any(not isinstance(photo_id, str) or not photo_id for photo_id in values)
for values in (hidden, last_hidden)
):
raise ValueError("Invalid hidden-photo identifiers")
self.hidden_photo_ids = frozenset(hidden)
self.hidden_revision = revision
self.last_hidden_photo_ids = tuple(
dict.fromkeys(photo_id for photo_id in last_hidden if photo_id in self.hidden_photo_ids)
)
async def async_set_photos_hidden(
self, photo_ids: Iterable[str], *, hidden: bool
) -> bool:
requested = frozenset(photo_ids)
if any(not isinstance(photo_id, str) or not photo_id for photo_id in requested):
raise ValueError("Invalid photo identifier")
async with self._hidden_lock:
if self._hidden_storage is None:
raise RuntimeError("Photo exclusions have not been loaded")
updated = (
self.hidden_photo_ids | requested
if hidden
else self.hidden_photo_ids - requested
)
if updated == self.hidden_photo_ids:
return False
last_hidden = (
tuple(sorted(updated - self.hidden_photo_ids))
if hidden
else tuple(photo_id for photo_id in self.last_hidden_photo_ids if photo_id in updated)
)
await self._hidden_storage.async_save(
{
"hidden": sorted(updated),
"last_hidden": list(last_hidden),
"revision": self.hidden_revision + 1,
}
)
self.hidden_photo_ids = updated
self.last_hidden_photo_ids = last_hidden
self.hidden_revision += 1
self.last_frame = None
self.notify()
return True
def add_listener(self, cb: Listener) -> None:
if cb not in self._listeners:
self._listeners.append(cb)
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.10.0";
const VERSION = "1.11.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,6 +46,19 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
const PHOTO_CONTROL_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "on_demand", label: "On demand" },
{ value: "always", label: "Always" },
];
function normalizePhotoControls(value) {
if (value == null || value === false) return "off";
if (value === true) return "always";
if (PHOTO_CONTROL_OPTIONS.some((option) => option.value === value)) return value;
throw new Error(`album-slideshow-card: unknown photo controls mode '${value}'`);
}
// Caption overlay (date / location / description). ``show`` is an ordered
// subset of these fields; ``position`` is one of a 3x3 anchor grid;
// ``date_format`` is one of the named presets below or a custom token string.
@@ -92,6 +105,436 @@ function isAlbumSlideshowCamera(state) {
);
}
class PhotoControls {
constructor(container, getHass, onActivity = () => {}, onNavigate = () => {}) {
this._getHass = getHass;
this._onActivity = onActivity;
this._onNavigate = onNavigate;
this._state = {};
this._busy = false;
this._root = container.attachShadow({ mode: "open" });
this._root.innerHTML = `
<style>
:host { display: block; max-width: 100%; font-family: var(--paper-font-body1_-_font-family, sans-serif); }
:host([hidden]) { display: none !important; }
button { font: inherit; color: inherit; cursor: pointer; border: 0; border-radius: 6px; background: var(--secondary-background-color, #eee); padding: 8px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; }
button:hover { filter: brightness(.94); }
button:disabled { opacity: .4; cursor: default; }
button:focus-visible { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
.toolbar { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); width: 300px; max-width: 100%; box-sizing: border-box; gap: 4px 12px; padding: 4px; border-radius: 6px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
.control-group { display: flex; gap: 4px; justify-content: center; }
.icon { width: 44px; height: 44px; padding: 10px; flex: 0 0 44px; }
ha-icon { --mdc-icon-size: 22px; }
dialog { box-sizing: border-box; width: min(460px, calc(100vw - 32px)); max-width: calc(100vw - 32px); max-height: min(640px, calc(100dvh - 40px)); border: 1px solid var(--divider-color, #ddd); border-radius: 8px; padding: 16px; font-size: 14px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
dialog::backdrop { background: rgba(0, 0, 0, .45); }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
h3 { margin: 0; font-size: 18px; letter-spacing: 0; }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--divider-color, #ddd); }
.name { min-width: 0; overflow-wrap: anywhere; }
small { display: block; color: var(--secondary-text-color, #666); margin-top: 4px; }
footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.pagination { display: flex; gap: 8px; align-items: center; font-size: 13px; }
.error { margin: 8px 0; max-width: 320px; padding: 8px; background: var(--card-background-color, #fff); color: var(--error-color, #b71c1c); overflow-wrap: anywhere; }
[hidden] { display: none !important; }
</style>
<div class="toolbar">
<div class="control-group" role="group" aria-label="Slideshow navigation">
<button type="button" class="icon" id="previous" title="Previous slide" aria-label="Previous slide"><ha-icon icon="mdi:skip-previous"></ha-icon></button>
<button type="button" class="icon" id="pause" title="Pause slideshow" aria-label="Pause slideshow"><ha-icon icon="mdi:pause"></ha-icon></button>
<button type="button" class="icon" id="next" title="Next slide" aria-label="Next slide"><ha-icon icon="mdi:skip-next"></ha-icon></button>
</div>
<div class="control-group" role="group" aria-label="Photo visibility">
<button type="button" class="icon" id="hide" title="Hide photo" aria-label="Hide photo"><ha-icon icon="mdi:eye-off"></ha-icon></button>
<button type="button" class="icon" id="undo" title="Undo hide" aria-label="Undo hide"><ha-icon icon="mdi:undo"></ha-icon></button>
<button type="button" class="icon" id="manage" title="Hidden photos" aria-label="Hidden photos"><ha-icon icon="mdi:image-off-outline"></ha-icon></button>
</div>
</div>
<div id="error" class="error" role="alert" hidden></div>
<dialog aria-labelledby="title">
<header><h3 id="title">Hidden photos</h3><button type="button" class="icon" id="close" title="Close" aria-label="Close"><ha-icon icon="mdi:close"></ha-icon></button></header>
<div id="dialog-error" class="error" role="alert" hidden></div>
<div id="content"></div>
</dialog>
`;
this._dialog = this._root.querySelector("dialog");
this._dialog.addEventListener("close", () => this._onActivity());
container.addEventListener("click", (event) => event.stopPropagation());
this._root.getElementById("previous").addEventListener("click", () => this._navigate("previous_slide"));
this._root.getElementById("next").addEventListener("click", () => this._navigate("next_slide"));
this._root.getElementById("pause").addEventListener("click", () => this._togglePause());
this._root.getElementById("hide").addEventListener("click", () => this._hide());
this._root.getElementById("undo").addEventListener("click", () => this._run("undo_hide"));
this._root.getElementById("manage").addEventListener("click", () => this._showHidden(0));
this._root.getElementById("close").addEventListener("click", () => this._dialog.close());
}
get active() {
return this._busy || this._dialog.open;
}
close() {
this._dialog.close();
}
update(state) {
if (this._state.entryId && this._state.entryId !== state.entryId) {
this._dialog.close();
}
this._state = { ...state, photoIds: [...(state.photoIds || [])] };
const unavailable = this._busy || !state.entryId;
this._root.getElementById("previous").disabled = unavailable || !state.canPrevious;
this._root.getElementById("next").disabled = unavailable || !state.canNext;
const pause = this._root.getElementById("pause");
pause.disabled = unavailable || typeof state.paused !== "boolean";
pause.title = state.paused ? "Resume slideshow" : "Pause slideshow";
pause.setAttribute("aria-label", pause.title);
pause.querySelector("ha-icon").setAttribute("icon", state.paused ? "mdi:play" : "mdi:pause");
this._root.getElementById("hide").disabled = this._busy || !state.entryId || !this._state.photoIds.some(Boolean);
this._root.getElementById("undo").disabled = this._busy || !state.entryId || !state.canUndo;
this._root.getElementById("manage").disabled = this._busy || !state.entryId;
this._root.getElementById("manage").title = `Hidden photos (${state.hiddenCount || 0})`;
}
async _call(service, data = {}, entryId = this._state.entryId) {
if (!entryId) throw new Error("This slideshow does not support these controls");
const result = await this._getHass().callWS({
type: "call_service",
domain: "album_slideshow",
service,
service_data: { ...data, entry_id: entryId },
...(service === "list_hidden_photos" ? { return_response: true } : {}),
});
return result?.response;
}
async _navigate(service) {
if (this._busy || !this._state.entryId) return false;
const entryId = this._state.entryId;
this._onNavigate?.("start", entryId);
const success = await this._run(service, {}, entryId);
this._onNavigate?.(success ? "complete" : "failed", entryId);
return success;
}
async _togglePause() {
const { entryId, paused } = this._state;
if (!entryId || typeof paused !== "boolean") return false;
return this._runAction(async () => {
const hass = this._getHass();
const registry = await hass.callWS({ type: "config/entity_registry/list" });
const entity = registry.find((candidate) =>
candidate.config_entry_id === entryId &&
candidate.unique_id === `${entryId}_paused` &&
candidate.entity_id.startsWith("switch.") &&
!candidate.disabled_by,
);
if (!entity) throw new Error("The pause switch is unavailable for this slideshow");
await hass.callWS({
type: "call_service",
domain: "switch",
service: paused ? "turn_off" : "turn_on",
service_data: { entity_id: entity.entity_id },
});
});
}
_error(message = "") {
for (const id of ["error", "dialog-error"]) {
const element = this._root.getElementById(id);
element.textContent = message;
element.hidden = !message;
}
}
async _run(service, data = {}, entryId = this._state.entryId) {
return this._runAction(() => this._call(service, data, entryId));
}
async _runAction(action) {
if (this._busy) return false;
this._busy = true;
this._onActivity?.();
this._error();
this.update(this._state);
const buttons = [...this._dialog.querySelectorAll("#content button")].map((button) => ({ button, disabled: button.disabled }));
buttons.forEach(({ button }) => { button.disabled = true; });
try {
await action();
this._dialog.close();
return true;
} catch (error) {
this._error(error.message || "Photo action failed");
return false;
} finally {
this._busy = false;
this.update(this._state);
buttons.forEach(({ button, disabled }) => { button.disabled = disabled; });
this._onActivity?.();
}
}
_button(label, icon, action, iconOnly = false) {
const button = document.createElement("button");
button.type = "button";
button.title = label;
button.setAttribute("aria-label", label);
if (iconOnly) button.className = "icon";
const symbol = document.createElement("ha-icon");
symbol.setAttribute("icon", icon);
button.appendChild(symbol);
if (!iconOnly) button.appendChild(document.createTextNode(label));
button.addEventListener("click", action);
return button;
}
_open(title) {
this._viewGeneration = (this._viewGeneration || 0) + 1;
this._error();
this._root.getElementById("title").textContent = title;
const content = this._root.getElementById("content");
content.replaceChildren();
if (!this._dialog.open) this._dialog.showModal();
this._onActivity?.();
return content;
}
_hide() {
const { photoIds, orientation, entryId } = this._state;
if (photoIds.length === 1 && photoIds[0]) {
this._run("hide_photo", { photo_ids: [...photoIds] }, entryId);
return;
}
const content = this._open("Hide photo");
content.className = "choices";
const labels = orientation === "vertical" ? ["Hide top photo", "Hide bottom photo"] : ["Hide left photo", "Hide right photo"];
photoIds.forEach((photoId, index) => {
const button = this._button(labels[index], "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [photoId] }, entryId));
button.disabled = !photoId;
content.appendChild(button);
});
const both = this._button("Hide both photos", "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [...photoIds] }, entryId));
both.disabled = photoIds.length !== 2 || photoIds.some((photoId) => !photoId);
content.appendChild(both);
}
async _showHidden(offset) {
const entryId = this._state.entryId;
const content = this._open("Hidden photos");
const generation = this._viewGeneration;
content.className = "";
content.textContent = "Loading...";
try {
const page = await this._call("list_hidden_photos", { offset, limit: 25 }, entryId);
if (generation !== this._viewGeneration || this._state.entryId !== entryId || !this._dialog.open) return;
if (offset && !page.photos.length) {
await this._showHidden(Math.max(0, offset - 25));
return;
}
content.replaceChildren();
if (!page.photos.length) content.textContent = "No hidden photos";
for (const photo of page.photos) {
const row = document.createElement("div");
row.className = "row";
const name = document.createElement("span");
name.className = "name";
name.textContent = photo.name;
if (!photo.in_album) {
const status = document.createElement("small");
status.textContent = "Not in the current album";
name.appendChild(status);
}
row.append(name, this._button(`Restore ${photo.name}`, "mdi:restore", async () => {
if (await this._run("restore_photos", { photo_ids: [photo.photo_id] }, entryId)) await this._showHidden(offset);
}, true));
content.appendChild(row);
}
if (!page.total) return;
const footer = document.createElement("footer");
footer.appendChild(this._button("Restore all", "mdi:restore", () => {
const confirm = this._open("Restore all hidden photos?");
confirm.className = "choices";
confirm.append(
this._button("Restore all", "mdi:restore", async () => {
if (await this._run("restore_all_photos", {}, entryId)) await this._showHidden(0);
}),
this._button("Cancel", "mdi:close", () => this._showHidden(offset)),
);
}));
const pagination = document.createElement("div");
pagination.className = "pagination";
const previous = this._button("Previous page", "mdi:chevron-left", () => this._showHidden(Math.max(0, offset - 25)), true);
const next = this._button("Next page", "mdi:chevron-right", () => this._showHidden(offset + 25), true);
previous.disabled = offset === 0;
next.disabled = offset + page.photos.length >= page.total;
pagination.append(previous, document.createTextNode(`${offset + 1}-${offset + page.photos.length} of ${page.total}`), next);
footer.appendChild(pagination);
content.appendChild(footer);
} catch (error) {
if (generation !== this._viewGeneration || this._state.entryId !== entryId) return;
content.textContent = "";
this._error(error.message || "Could not load hidden photos");
}
}
}
class PhotoControlsReveal {
constructor(card, container, controls, resume) {
this._card = card;
this._container = container;
this._controls = controls;
this._resume = resume;
this._visible = false;
this._pointer = null;
this._listeners = [];
container.hidden = true;
this._listen(card, "pointerenter", (event) => {
if (event.pointerType === "mouse") this.show();
});
this._listen(card, "pointerleave", (event) => {
this._cancelPress();
if (event.pointerType === "mouse" && !this._keyboardFocused()) this.hide();
else this.activity();
});
this._listen(card, "pointerdown", (event) => this._pointerDown(event));
this._listen(card, "pointermove", (event) => this._pointerMove(event));
this._listen(card, "pointerup", () => { this._cancelPress(); this.activity(); });
this._listen(card, "pointercancel", () => { this._cancelPress(); this.activity(); });
this._listen(card, "click", (event) => {
if (event.composedPath().includes(container)) {
this._suppressClick = false;
this.activity();
} else if (this._suppressClick && event.detail !== 0) {
this._suppressClick = false;
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);
this._listen(card, "contextmenu", (event) => {
if (this._suppressClick || (this._pointer && this._pointer.type !== "mouse")) {
event.preventDefault();
event.stopPropagation();
this._suppressClick = true;
this.show();
}
});
this._listen(card, "focusin", (event) => {
if (!this._ignoreFocus && event.composedPath()[0]?.matches?.(":focus-visible")) this.show();
else this.activity();
});
this._listen(card, "focusout", () => this.activity());
this._listen(card, "keydown", (event) => this._keyDown(event));
this._listen(card.ownerDocument, "pointerdown", (event) => {
if (!event.composedPath().includes(card)) this.hide();
}, true);
}
get holding() {
return !this._disposed && (this._visible || this._pointer !== null);
}
_listen(target, type, listener, capture = false) {
target.addEventListener(type, listener, capture);
this._listeners.push(() => target.removeEventListener(type, listener, capture));
}
show() {
if (this._disposed) return;
this._visible = true;
this._container.hidden = false;
this.activity();
}
hide() {
if (this._disposed || this._controls.active) return;
const wasHolding = this.holding;
this._visible = false;
this._container.hidden = true;
clearTimeout(this._idleTimer);
this._idleTimer = null;
this._cancelPress(false);
if (wasHolding) this._resume();
}
activity() {
if (this._disposed || !this._visible) return;
clearTimeout(this._idleTimer);
this._idleTimer = setTimeout(() => {
this._idleTimer = null;
if (this._controls.active || this._pointer || this._keyboardFocused()) return;
this.hide();
}, 5000);
}
_keyboardFocused() {
return this._card.matches(":focus-visible") || Boolean(this._container.shadowRoot?.querySelector(":focus-visible"));
}
_pointerDown(event) {
if (event.composedPath().includes(this._container)) {
this.activity();
return;
}
this._suppressClick = false;
if (event.isPrimary === false || event.button > 0) {
this._cancelPress();
return;
}
this._cancelPress(false);
this._pointer = { id: event.pointerId, type: event.pointerType, x: event.clientX, y: event.clientY };
this._pressTimer = setTimeout(() => {
this._pressTimer = null;
if (!this._pointer || this._disposed) return;
this._suppressClick = true;
this.show();
}, 500);
}
_pointerMove(event) {
if (this._pointer) {
if (event.pointerId === this._pointer.id && Math.hypot(event.clientX - this._pointer.x, event.clientY - this._pointer.y) > 10) this._cancelPress();
} else if (event.pointerType === "mouse") {
this.show();
}
}
_cancelPress(resume = true) {
const wasPressed = this._pointer !== null;
clearTimeout(this._pressTimer);
this._pressTimer = null;
this._pointer = null;
if (resume && wasPressed && !this._visible && !this._disposed) this._resume();
}
_keyDown(event) {
if (event.key === "Escape" && this._visible && !this._controls.active) {
event.preventDefault();
event.stopPropagation();
this.hide();
this._ignoreFocus = true;
this._card.focus({ preventScroll: true });
this._ignoreFocus = false;
} else if ((event.key === "Enter" || event.key === " ") && event.composedPath()[0] === this._card) {
event.preventDefault();
event.stopPropagation();
this.show();
this._container.shadowRoot?.querySelector("button:not([disabled])")?.focus();
} else {
this.activity();
}
}
dispose() {
this._disposed = true;
clearTimeout(this._idleTimer);
this._cancelPress(false);
this._listeners.forEach((remove) => remove());
this._listeners = [];
this._controls.close();
this._visible = false;
}
}
// The card class is built lazily by a factory so the base class can be
// resolved from the *live* ``window.HTMLElement`` at registration time.
// See ``defineAlbumSlideshowCards`` for why this matters with the
@@ -135,6 +578,13 @@ function createAlbumSlideshowCardClass(Base) {
// schedules a deferred swap that runs once the hold expires.
this._holdSwapsUntil = 0;
this._holdSwapTimer = null;
this._loadGeneration = 0;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._navigationRequest = null;
this._navigationTimer = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
}
setConfig(config) {
@@ -164,6 +614,7 @@ function createAlbumSlideshowCardClass(Base) {
// Empty/missing background means inherit theme.
background: typeof config.background === "string" ? config.background : "",
tap_action: config.tap_action === "more-info" ? "more-info" : "none",
photo_controls: normalizePhotoControls(config.photo_controls),
// Number of seconds the card freezes its visible slide after a
// tap, so the more-info dialog can settle without the slideshow
// marching forward beneath it. Set to 0 to disable.
@@ -232,7 +683,21 @@ function createAlbumSlideshowCardClass(Base) {
if (!this._rendered) {
this._renderShell();
this._rendered = true;
} else {
this._setupPhotoControlsReveal();
}
this._maybeSwap();
}
disconnectedCallback() {
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
this._loadGeneration += 1;
this._lastFrameId = null;
this._lastEntityPicture = null;
}
set hass(hass) {
@@ -257,6 +722,14 @@ function createAlbumSlideshowCardClass(Base) {
_renderShell() {
const c = this._config;
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
const aspect = c.aspect_ratio === "auto" ? "auto" : c.aspect_ratio;
// When the user did not set ``background`` we fall through to the
// theme's --ha-card-background, so the card naturally inherits the
@@ -275,6 +748,7 @@ function createAlbumSlideshowCardClass(Base) {
position: relative;
padding: 0;
}
ha-card:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.stage {
position: absolute;
inset: 0;
@@ -283,6 +757,7 @@ function createAlbumSlideshowCardClass(Base) {
background: ${stageBg};
border-radius: inherit;
overflow: hidden;
${c.photo_controls === "on_demand" ? "-webkit-touch-callout: none; user-select: none;" : ""}
}
.blur-bg {
position: absolute;
@@ -347,6 +822,7 @@ function createAlbumSlideshowCardClass(Base) {
font-family: var(--paper-font-body1_-_font-family, sans-serif);
}
.cap-line { font-weight: inherit; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
.cap-box.cap-shadow {
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.9),
@@ -363,13 +839,66 @@ function createAlbumSlideshowCardClass(Base) {
<div class="captions" id="captions" aria-hidden="true"></div>
<div class="placeholder" id="placeholder">Waiting for first frame...</div>
</div>
<div id="photo-controls" ${c.photo_controls === "always" ? "" : "hidden"}></div>
</ha-card>
`;
this._photoControls = new PhotoControls(
this.shadowRoot.getElementById("photo-controls"), () => this._hass,
() => this._controlsReveal?.activity(),
(phase, entryId) => this._controlNavigation(phase, entryId),
);
const card = this.shadowRoot.querySelector("ha-card");
if (this._config.tap_action === "more-info") {
card.addEventListener("click", () => this._fireMoreInfo());
card.style.cursor = "pointer";
}
this._setupPhotoControlsReveal();
}
_setupPhotoControlsReveal() {
this._controlsReveal?.dispose();
this._controlsReveal = null;
const card = this.shadowRoot.querySelector("ha-card");
const container = this.shadowRoot.getElementById("photo-controls");
container.hidden = this._config.photo_controls !== "always";
if (this._config.photo_controls === "on_demand") {
card.tabIndex = 0;
card.setAttribute("role", "group");
card.setAttribute("aria-label", "Slideshow photo controls");
if (this.isConnected) {
this._controlsReveal = new PhotoControlsReveal(card, container, this._photoControls, () => this._maybeSwap());
}
}
}
_cancelControlNavigation() {
clearTimeout(this._navigationTimer);
this._navigationTimer = null;
this._navigationRequest = null;
}
_controlNavigation(phase, entryId) {
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
if (attrs.entry_id !== entryId) return;
if (phase === "start") {
this._cancelControlNavigation();
this._navigationRequest = { entryId, frameId: attrs.frame_id, pending: true };
this._holdSwapsUntil = 0;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
} else if (this._navigationRequest?.entryId === entryId) {
if (phase === "failed") {
this._cancelControlNavigation();
} else {
this._navigationRequest.pending = false;
if (this._displayedFrameId !== this._navigationRequest.frameId && this._displayedFrameId === attrs.frame_id) {
this._cancelControlNavigation();
} else {
this._navigationTimer = setTimeout(() => this._cancelControlNavigation(), 5000);
}
}
}
this._maybeSwap();
}
_transitionStyles() {
@@ -439,6 +968,18 @@ function createAlbumSlideshowCardClass(Base) {
this._setPlaceholder(`Entity not found: ${this._config.entity}`);
return;
}
const attrs = state.attributes || {};
if (this._hiddenRevision !== undefined && attrs.hidden_revision !== this._hiddenRevision) {
this._clearDisplayedPhotos();
}
this._hiddenRevision = attrs.hidden_revision;
this._refreshPhotoControls(attrs);
if (attrs.empty_reason || (Array.isArray(attrs.displayed_photo_ids) && !attrs.displayed_photo_ids.length)) {
this._clearDisplayedPhotos();
this._setPlaceholder(attrs.empty_reason === "all_hidden" ? "All photos hidden" : attrs.empty_reason ? "No matching photos" : "Preparing next photo...");
return;
}
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) return;
// Hold visual swaps for the configured grace period after a tap.
// The state cursor (`_lastFrameId`/`_lastEntityPicture`) is left
// untouched during the hold; once the hold expires we re-enter
@@ -454,7 +995,6 @@ function createAlbumSlideshowCardClass(Base) {
}
return;
}
const attrs = state.attributes || {};
// ``frame_id`` increments on every slide commit; that's our primary
// "new frame ready" signal. The integration also embeds frame_id in
// ``entity_picture`` so that HA core surfaces (more-info, picture
@@ -495,15 +1035,79 @@ function createAlbumSlideshowCardClass(Base) {
description: attrs.description,
}
: null;
this._loadAndSwap(url, fit, blurBackdrop, captionData);
const photoData = {
photoIds: [...(attrs.displayed_photo_ids || [])],
entryId: attrs.entry_id,
orientation: attrs.pair_orientation,
frameId,
hiddenRevision: attrs.hidden_revision,
entityId: this._config.entity,
};
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
}
_loadAndSwap(url, fit, blurBackdrop, captionData) {
_clearDisplayedPhotos() {
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._lastFrameId = null;
this._lastEntityPicture = null;
this._holdSwapsUntil = 0;
if (this._holdSwapTimer) clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
for (const id of ["a", "b", "blur-a", "blur-b"]) {
const image = this.shadowRoot.getElementById(id);
image.removeAttribute("src");
image.classList.remove("show", "exit", "enter");
}
this.shadowRoot.getElementById("captions").replaceChildren();
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
}
_refreshPhotoControls(attrs) {
this._photoControls?.update({
entryId: attrs.entry_id,
photoIds: this._displayedPhotoIds,
orientation: this._photoOrientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_loadAndSwap(url, fit, blurBackdrop, captionData, photoData) {
const generation = ++this._loadGeneration;
// Pre-decode the new image so the swap is instant.
const next = new Image();
next.decoding = "async";
next.onload = () => this._performSwap(url, fit, blurBackdrop, captionData);
next.onerror = () => this._setPlaceholder("Failed to load slide");
next.onload = () => {
if (generation !== this._loadGeneration) return;
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) {
this._lastFrameId = null;
this._lastEntityPicture = null;
return;
}
const attrs = this._hass.states[this._config.entity]?.attributes || {};
if (photoData.entityId !== this._config.entity || (attrs.frame_id ?? null) !== photoData.frameId || attrs.hidden_revision !== photoData.hiddenRevision) {
this._lastFrameId = null;
this._maybeSwap();
return;
}
this._displayedPhotoIds = [...photoData.photoIds];
this._displayedFrameId = photoData.frameId;
this._photoEntryId = photoData.entryId;
this._photoOrientation = photoData.orientation;
this._performSwap(url, fit, blurBackdrop, captionData);
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
this._cancelControlNavigation();
}
this._refreshPhotoControls(attrs);
};
next.onerror = () => {
if (generation === this._loadGeneration) this._setPlaceholder("Failed to load slide");
};
next.src = url;
}
@@ -934,6 +1538,7 @@ const CAPTION_DATE_FORMAT_OPTIONS = [
];
const DEFAULTS = {
photo_controls: "off",
transition: "random",
duration: 600,
easing: "ease-in-out",
@@ -1036,6 +1641,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
// see entity state updates.
const form = this.shadowRoot.querySelector("ha-form");
if (form) form.hass = hass;
this._updatePhotoControls();
// Re-run a full update when the camera set changes (warning box) or
// when any surfaced integration entity changed state, so the live
// controls stay in sync with the integration.
@@ -1277,6 +1883,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
title: "Interaction",
icon: "mdi:gesture-tap",
schema: [
{
name: "photo_controls",
selector: { select: { mode: "dropdown", options: PHOTO_CONTROL_OPTIONS } },
},
{
name: "tap_action",
selector: { select: { mode: "dropdown", options: TAP_OPTIONS } },
@@ -1364,6 +1974,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const c = this._config || {};
return {
entity: c.entity || "",
photo_controls: normalizePhotoControls(c.photo_controls),
transition: c.transition || DEFAULTS.transition,
duration: c.duration != null ? Number(c.duration) : DEFAULTS.duration,
easing: c.easing || DEFAULTS.easing,
@@ -1441,6 +2052,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
background: "Background (optional)",
tap_action: "Tap action",
tap_pause_seconds: "Tap pause (seconds)",
photo_controls: "Photo controls",
caption_enabled: "Show caption overlay",
caption_show: "Show",
caption_position: "Position",
@@ -1517,12 +2129,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
<div class="info-slot"></div>
<ha-form></ha-form>
<div class="actions" hidden></div>
<div class="photo-controls"></div>
</div>
`;
const form = this.shadowRoot.querySelector("ha-form");
form.computeLabel = this._computeLabel;
form.computeHelper = this._computeHelper;
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
this._photoControls = new PhotoControls(
this.shadowRoot.querySelector(".photo-controls"), () => this._hass,
);
this._rendered = true;
this._update();
}
@@ -1553,12 +2169,30 @@ function createAlbumSlideshowCardEditorClass(Base) {
}
this._renderActions();
this._updatePhotoControls();
}
_updatePhotoControls() {
if (!this._photoControls) return;
const attrs = this._hass?.states[this._config?.entity]?.attributes || {};
this.shadowRoot.querySelector(".photo-controls").hidden = !attrs.entry_id;
this._photoControls.update({
entryId: attrs.entry_id,
photoIds: attrs.displayed_photo_ids,
orientation: attrs.pair_orientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_renderActions() {
const wrap = this.shadowRoot.querySelector(".actions");
if (!wrap) return;
if (!this._hasActions()) {
const hasToolbarNavigation = !!this._hass?.states[this._config?.entity]?.attributes?.entry_id;
if (!this._hasActions() || (hasToolbarNavigation && !this._siblings.refresh_button)) {
wrap.hidden = true;
wrap.innerHTML = "";
return;
@@ -1568,8 +2202,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
wrap.innerHTML = `
<div class="actions-title">Actions</div>
<div class="actions-row">
${s.previous_button ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.previous_button && !hasToolbarNavigation ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button && !hasToolbarNavigation ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""}
</div>
`;
@@ -1651,6 +2285,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
const n = { type: "custom:album-slideshow-card" };
const photoControls = normalizePhotoControls(data.photo_controls);
if (photoControls === "always") n.photo_controls = true;
if (photoControls === "on_demand") n.photo_controls = "on_demand";
if (data.entity) n.entity = data.entity;
const t = data.transition || DEFAULTS.transition;
@@ -9,5 +9,5 @@
"documentation": "https://github.com/slashback100/presence_simulation",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/slashback100/presence_simulation/issues",
"version": "5.3"
"version": "6.0"
}
@@ -27,6 +27,10 @@ start:
name: Set light brightness
description: Set a specific brightness fot lights that allows it
example: 75
history_end:
name: End of source history
description: ISO 8601 date and time at which source history ends. The selected history is replayed cyclically from that moment.
example: "2026-08-09T00:00:00+02:00"
stop:
description: Stop the presence simulation
name: Presence simulation stop
@@ -14,7 +14,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of source history",
"history_end_time": "End time of source history"
}
}
}
@@ -34,7 +36,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of source history",
"history_end_time": "End time of source history"
}
}
}
@@ -15,7 +15,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of historic",
"history_end_time": "End time of historic"
}
}
}
@@ -35,7 +37,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of historic",
"history_end_time": "End time of historic"
}
}
}
@@ -15,7 +15,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of historic",
"history_end_time": "End time of historic"
}
}
}
@@ -35,7 +37,9 @@
"restore": "Restore state after simulation",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off",
"brightness": "Brightness to be used if light allows the feature"
"brightness": "Brightness to be used if light allows the feature",
"history_end_date": "End date of historic",
"history_end_time": "End time of historic"
}
}
}
@@ -15,7 +15,9 @@
"restore": "Restaurer l'état des entités à la fin de la simulation",
"random": "Ajout d'un délai (s) aléatoire pour les changements d'entités",
"unavailable_as_off": "Considérer le statut Indisponible comme éteint",
"brightness": "Luminosité à utiliser pour les lampes qui le permettent"
"brightness": "Luminosité à utiliser pour les lampes qui le permettent",
"history_end_date": "Date de fin de l'historique",
"history_end_time": "Heure de fin de l'historique"
}
}
}
@@ -35,7 +37,9 @@
"restore": "Restaurer l'état des entités à la fin de la simulation",
"random": "Ajout d'un délai (s) aléatoire pour les changements d'entités",
"unavailable_as_off": "Considérer le statut Indisponible comme éteint",
"brightness": "Luminosité à utiliser pour les lampes qui le permettent"
"brightness": "Luminosité à utiliser pour les lampes qui le permettent",
"history_end_date": "Date de fin de l'historique",
"history_end_time": "Heure de fin de l'historique"
}
}
}
@@ -14,7 +14,9 @@
"interval": "Verversingsinterval (in seconden)",
"restore": "Herstel toestand na simulatie",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off"
"unavailable_as_off": "Consider Unavailable status as Off",
"history_end_date": "Einddatum bronhistorie",
"history_end_time": "Eindtijd bronhistorie"
}
}
}
@@ -33,7 +35,9 @@
"interval": "Verversingsinterval (in seconden)",
"restore": "Herstel toestand na simulatie",
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
"unavailable_as_off": "Consider Unavailable status as Off"
"unavailable_as_off": "Consider Unavailable status as Off",
"history_end_date": "Einddatum bronhistorie",
"history_end_time": "Eindtijd bronhistorie"
}
}
}
@@ -12,5 +12,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/timmaurice/sea-temperatures/issues",
"requirements": [],
"version": "3.3.0"
"version": "3.3.1"
}
File diff suppressed because one or more lines are too long
@@ -10,5 +10,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/frenck/spook/issues",
"requirements": [],
"version": "5.5.0"
"version": "5.5.1"
}
+1 -1
View File
@@ -36,5 +36,5 @@
"requirements": [
"cronsim==2.7"
],
"version": "5.5.0"
"version": "5.5.1"
}
+25 -9
View File
@@ -62,8 +62,11 @@ class AbstractSpookServiceBase(ABC):
@abstractmethod
@callback
def async_register(self) -> None:
"""Handle the service call."""
def async_register(self) -> bool:
"""Register the service with Home Assistant.
Returns True when the service was actually registered.
"""
raise NotImplementedError
@final
@@ -92,7 +95,7 @@ class AbstractSpookService(AbstractSpookServiceBase):
@final
@callback
def async_register(self) -> None:
def async_register(self) -> bool:
"""Register the service with Home Assistant."""
# Only register the service if the domain is the spook integration
# or if the target integration is loaded.
@@ -103,7 +106,7 @@ class AbstractSpookService(AbstractSpookServiceBase):
self.service,
self.domain,
)
return
return False
LOGGER.debug(
"Registering Spook service: %s.%s",
@@ -119,6 +122,8 @@ class AbstractSpookService(AbstractSpookServiceBase):
supports_response=self.supports_response,
)
return True
@abstractmethod
async def async_handle_service(self, call: ServiceCall) -> ServiceResponse:
"""Handle the service call."""
@@ -130,7 +135,7 @@ class AbstractSpookAdminService(AbstractSpookServiceBase):
@final
@callback
def async_register(self) -> None:
def async_register(self) -> bool:
"""Register the service with Home Assistant."""
if self.domain != DOMAIN and self.domain not in self.hass.config.components:
LOGGER.debug(
@@ -139,7 +144,7 @@ class AbstractSpookAdminService(AbstractSpookServiceBase):
self.service,
self.domain,
)
return
return False
LOGGER.debug(
"Registering Spook admin service: %s.%s",
@@ -154,6 +159,8 @@ class AbstractSpookAdminService(AbstractSpookServiceBase):
schema=vol.Schema(self.schema) if self.schema else None,
)
return True
@abstractmethod
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
@@ -169,7 +176,7 @@ class AbstractSpookEntityService(AbstractSpookServiceBase, Generic[_EntityT]):
@final
@callback
def async_register(self) -> None:
def async_register(self) -> bool:
"""Register the service with Home Assistant."""
LOGGER.debug(
"Registering Spook entity service: %s.%s for platform %s",
@@ -205,6 +212,8 @@ class AbstractSpookEntityService(AbstractSpookServiceBase, Generic[_EntityT]):
supports_response=self.supports_response,
)
return True
@abstractmethod
async def async_handle_service(
self,
@@ -223,7 +232,7 @@ class AbstractSpookEntityComponentService(AbstractSpookServiceBase, Generic[_Ent
@final
@callback
def async_register(self) -> None:
def async_register(self) -> bool:
"""Register the service with Home Assistant."""
LOGGER.debug(
"Registering Spook entity component service: %s.%s",
@@ -248,6 +257,8 @@ class AbstractSpookEntityComponentService(AbstractSpookServiceBase, Generic[_Ent
supports_response=self.supports_response,
)
return True
@abstractmethod
async def async_handle_service(
self,
@@ -363,7 +374,12 @@ class SpookServiceManager:
@callback
def async_register_service(self, service: AbstractSpookService) -> None:
"""Register a Spook service."""
service.async_register()
# A service aimed at an integration that is not set up never lands in
# Home Assistant. Injecting a description for it would then describe
# an action that does not exist, which core refuses with a KeyError.
if not service.async_register():
return
self._services.add(service)
# Override service description with Spook's if the service is not
+59 -40
View File
@@ -1,13 +1,13 @@
{
"config": {
"abort": {
"already_spooked": "Spook já está configurado.",
"already_spooked": "O Spook já está configurado.",
"enable_failed": "A entrada atual do Spook não pode ser ativada.",
"enabled_existing": "A entrada atual do Spook foi ativada."
},
"step": {
"user": {
"description": "Leste o readme? Leste a licença? Tens mesmo a certeza?"
"description": "Leste o \"readme\"? Leste a licença? Tens mesmo a certeza?"
},
"choice_restart": {
"description": "Para que o Spook consiga terminar a configuração, o Home Assistant tem que ser reiniciado.\n\nPretendes reiniciar o Home assistant agora?",
@@ -17,7 +17,7 @@
}
},
"already_configured": {
"description": "Spook já está configurado, mas a entrada está desativada. Queres ativá-la?",
"description": "O Spook já está configurado, mas a entrada está desativada. Queres ativá-la?",
"menu_options": {
"enable_existing": "Ativar entrada existente"
}
@@ -72,7 +72,7 @@
"name": "Automations"
},
"homeassistant_binary_sensor": {
"name": "Binary sensors"
"name": "Sensores binários"
},
"homeassistant_button": {
"name": "Buttons"
@@ -168,7 +168,7 @@
"name": "Selects"
},
"homeassistant_sensor": {
"name": "Sensors"
"name": "Sensores"
},
"homeassistant_siren": {
"name": "Sirens"
@@ -198,10 +198,10 @@
"name": "Vacuums"
},
"homeassistant_water_heater": {
"name": "Water heaters"
"name": "Aquecedores de água"
},
"homeassistant_weather": {
"name": "Weather"
"name": "Metereologia"
},
"homeassistant_zone": {
"name": "Zonas"
@@ -269,7 +269,7 @@
"title": "Membros do grupo desconhecido em: {group}",
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre este grupo a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre este grupo a partir de agora.",
"manage": "Podes gerir este grupo tu próprio na [página de Assistentes](/config/helpers).",
"not_editable": "Este grupo está configurado em YAML, por isso o Spook não o pode editar. Atualiza o grupo na tua configuração."
},
@@ -287,19 +287,19 @@
}
},
"lovelace_unknown_entity_references": {
"description": "O Spook encontrou um fantasma nos teus dashboards 👻\n\nEnquanto flutuava por ai, o Spook cruzou-se com o seguinte dashboard:\n\n[{dashboard}]({edit})\n\nEste dashboard contém as seguintes entidades que são desconhecidas para o Home Assistant:\n\n{entities}\n\n\n\nPara corrigir este erro, [edita a dashboard]({edit}) e remove as entidades inexistentes.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus painéis👻\n\nEnquanto flutuava por ai, o Spook cruzou-se com o seguinte dashboard:\n\n[{dashboard}]({edit})\n\nEste dashboard contém as seguintes entidades que são desconhecidas para o Home Assistant:\n\n{entities}\n\n\n\nPara corrigir este erro, [edita a dashboard]({edit}) e remove as entidades inexistentes.\n\nSpook 👻 O teu amigo.",
"title": "Entidades desconhecidas utilizadas em: {dashboard}"
},
"proximity_unknown_ignored_zones": {
"description": "Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração ignora zonas que são desconhecidas para o Home Assistant:\n\n{zones}\n\nPara corrigir este erro, edita a configuração e remove esta ou estas zonas.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração ignora zonas que são desconhecidas para o Home Assistant:\n\n{zones}\n\nPara corrigir este erro, edita a configuração e remove esta ou estas zonas.\n\nSpook 👻 O teu amigo.",
"title": "Zonas ignoradas desconhecidas: {name}"
},
"proximity_unknown_tracked_entities": {
"description": "Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração segue pessoas ou rastreadores de dispositivo que são desconhecidos para o Home Assistant:\n\n{entities}\n\nPara corrigir este erro, edita a configuração e remove esta ou estas entidades.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração segue pessoas ou rastreadores de dispositivo que são desconhecidos para o Home Assistant:\n\n{entities}\n\nPara corrigir este erro, edita a configuração e remove esta ou estas entidades.\n\nSpook 👻 O teu amigo.",
"title": "Entidades seguidas desconhecidas: {name}"
},
"proximity_unknown_zone": {
"description": "Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração baseia-se numa zona que é desconhecida para o Home Assistant:\n\n`{zone}`\n\nPara corrigir este erro, e como não podes mudar a zona de uma configuração de proximidade, a única opção é remover esta instância da integração de proximidade.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma na tua configuração de proximidade 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com a seguinte configuração de proximidade:\n\n{name}\n\nEsta configuração baseia-se numa zona que é desconhecida para o Home Assistant:\n\n`{zone}`\n\nPara corrigir este erro, e como não podes mudar a zona de uma configuração de proximidade, a única opção é remover especificamente esta integração de proximidade.\n\nSpook 👻 O teu amigo.",
"title": "Zona desconhecida: {zone}"
},
"scene_unknown_entity_references": {
@@ -355,7 +355,7 @@
"empty_areas": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado relativamente a esta área a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado relativamente a esta área a partir de agora.",
"manage": "Podes gerir esta área tu próprio na [página de Áreas](/config/areas/dashboard)."
},
"step": {
@@ -375,7 +375,7 @@
"unused_blueprints": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre este blueprint a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre este blueprint a partir de agora.",
"manage": "Podes gerir este blueprint tu próprio na [página de Blueprints](/config/blueprint/dashboard)."
},
"step": {
@@ -395,7 +395,7 @@
"empty_floors": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre este piso a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre este piso a partir de agora.",
"manage": "Podes gerir este piso tu próprio na [página de Áreas](/config/areas/dashboard)."
},
"step": {
@@ -415,13 +415,13 @@
"min_max_unknown_sources": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre este auxiliar a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre este auxiliar a partir de agora.",
"manage": "Podes gerir este assistente tu próprio na [página de Assistentes](/config/helpers).",
"too_few_members": "Um assistente mín/máx precisa de pelo menos dois membros, por isso remover os que faltam deixaria poucos demais. Reconfigura ou remove o assistente tu próprio na [página de Assistentes](/config/helpers)."
},
"step": {
"init": {
"description": "Spook encontrou um fantasma nos teus assistentes 👻\n\nO assistente mín/máx {helper} combina os seguintes membros, que são desconhecidos para o Home Assistant:\n\n{sources}\n\nRemove-os para o assistente continuar com os membros que restam, ou fica com eles e o Spook não volta a falar deles.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus assistentes 👻\n\nO assistente mín/máx {helper} combina os seguintes membros, que são desconhecidos para o Home Assistant:\n\n{sources}\n\nRemove-os para o assistente continuar com os membros que restam, ou fica com eles e o Spook não volta a falar deles.\n\nSpook 👻 O teu amigo.",
"menu_options": {
"ignore": "Fica com ele e não me digas mais nada",
"manage": "Eu próprio resolvo",
@@ -436,7 +436,7 @@
"unknown_helper_source_references": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre este auxiliar a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre este auxiliar a partir de agora.",
"manage": "Podes gerir este assistente tu próprio na [página de Assistentes](/config/helpers)."
},
"step": {
@@ -456,7 +456,7 @@
"unused_labels": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre esta legenda a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre esta legenda a partir de agora.",
"manage": "Podes gerir esta etiqueta tu próprio na [página de Etiquetas](/config/labels)."
},
"step": {
@@ -476,13 +476,13 @@
"person_unknown_device_trackers": {
"fix_flow": {
"abort": {
"issue_ignored": "Spook vai manter-se calado sobre esta pessoa a partir de agora.",
"issue_ignored": "O Spook vai manter-se calado sobre esta pessoa a partir de agora.",
"manage": "Podes gerir esta pessoa tu próprio na [página de Pessoas](/config/person).",
"not_editable": "Esta pessoa está configurada em YAML, por isso o Spook não a pode editar. Atualiza a pessoa na tua configuração."
},
"step": {
"init": {
"description": "Spook encontrou um fantasma nas tuas pessoas 👻\n\n{person} segue os seguintes rastreadores de dispositivo, que são desconhecidos para o Home Assistant:\n\n{device_trackers}\n\nIsto acontece muitas vezes quando um rastreador de dispositivo foi renomeado ou a sua integração foi removida.\n\nRemove-os de {person}, ou fica com eles e o Spook não volta a falar deles.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nas tuas pessoas 👻\n\n{person} segue os seguintes rastreadores de dispositivo, que são desconhecidos para o Home Assistant:\n\n{device_trackers}\n\nIsto acontece muitas vezes quando um rastreador de dispositivo foi renomeado ou a sua integração foi removida.\n\nRemove-os de {person}, ou fica com eles e o Spook não volta a falar deles.\n\nSpook 👻 O teu amigo.",
"menu_options": {
"ignore": "Fica com ele e não me digas mais nada",
"manage": "Eu próprio resolvo",
@@ -507,7 +507,7 @@
"title": "Entidades desconhecidas nas tuas personalizações"
},
"lovelace_missing_resources": {
"description": "O Spook encontrou um fantasma nos seus painéis 👻\n\nOs seguintes recursos de dashboard apontam para arquivos que não existem:\n\n{resources}\n\n\n\nIsto geralmente acontece quando um cartão personalizado ou seus arquivos foram removidos, mas o recurso permaneceu. Para corrigir esse erro, vá em Configurações > Dashboards > Recursos e remova ou corrija esses recursos.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus painéis 👻\n\nOs seguintes recursos de dashboard apontam para arquivos que não existem:\n\n{resources}\n\n\n\nIsto geralmente acontece quando um cartão personalizado ou seus arquivos foram removidos, mas o recurso permaneceu. Para corrigir este erro, vai a Configurações > Dashboards > Recursos (/config/lovelace/resources) e remove ou corrije esses recursos. Não há uma aba própria: na página dos Painéis, encontra-se nos três pontos, no canto superior direito.\n\nSpook 👻 O teu amigo.",
"title": "Recursos do painel a apontar para ficheiros que faltam"
},
"lovelace_unknown_area_references": {
@@ -532,22 +532,22 @@
},
"orphaned_statistics": {
"title": "Encontradas estatísticas de longo prazo órfãs",
"description": "Spook encontrou um fantasma no teu gravador 👻\n\nO Home Assistant guarda estatísticas de longo prazo do seguinte, mas já não existe uma entidade que lhes corresponda:\n\n{statistics}\n\n\n\nIsto costuma acontecer quando um sensor foi apagado e as estatísticas dele ficaram. Para corrigir este erro, abre Definições > Ferramentas > Estatísticas e trata delas aí.\n\nSpook 👻 O teu amigo."
"description": "O Spook encontrou um fantasma no teu gravador 👻\n\nO Home Assistant guarda estatísticas de longo prazo do seguinte, mas já não existe uma entidade que lhes corresponda:\n\n{statistics}\n\n\n\nIsto costuma acontecer quando um sensor foi apagado e as estatísticas dele ficaram. Para corrigir este erro, abre Definições > Ferramentas > Estatísticas e trata delas aí.\n\nSpook 👻 O teu amigo."
},
"script_unknown_trigger_references": {
"description": "Spook encontrou um fantasma nos teus scripts 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte script:\n\n[{script}]({edit}) (`{entity_id}`)\n\nEste script usa os seguintes acionadores, que nenhuma integração disponível para o Home Assistant consegue fornecer:\n\n{triggers}\n\nIsto acontece muitas vezes quando a integração que fornecia um acionador foi removida. Para corrigir este erro, [edita o script]({edit}) e remove o uso destes acionadores indisponíveis, ou restaura a integração que os fornece.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus scripts 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte script:\n\n[{script}]({edit}) (`{entity_id}`)\n\nEste script usa os seguintes acionadores, que nenhuma integração disponível para o Home Assistant consegue fornecer:\n\n{triggers}\n\nIsto acontece muitas vezes quando a integração que fornecia um acionador foi removida. Para corrigir este erro, [edita o script]({edit}) e remove o uso destes acionadores indisponíveis, ou restaura a integração que os fornece.\n\nSpook 👻 O teu amigo.",
"title": "Acionadores desconhecidos usados em: {script}"
},
"template_unknown_entity_references": {
"description": "Spook encontrou um fantasma nos teus assistentes de template 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte assistente de template:\n\n[{helper}]({edit})\n\nA sua configuração refere as seguintes entidades, que são desconhecidas para o Home Assistant:\n\n{entities}\n\nIsto acontece muitas vezes quando uma entidade foi renomeada ou removida. Para corrigir este erro, reconfigura o assistente de template para apontar para entidades que existam.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus assistentes de template 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte assistente de template:\n\n[{helper}]({edit})\n\nA sua configuração refere as seguintes entidades, que são desconhecidas para o Home Assistant:\n\n{entities}\n\nIsto acontece muitas vezes quando uma entidade foi renomeada ou removida. Para corrigir este erro, reconfigura o assistente de template para apontar para entidades que existam.\n\nSpook 👻 O teu amigo.",
"title": "Entidades desconhecidas usadas no assistente de template: {helper}"
},
"template_unknown_service_references": {
"description": "Spook encontrou um fantasma nos teus assistentes de template 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte assistente de template:\n\n[{helper}]({edit})\n\nRefere as seguintes ações, que são desconhecidas para o Home Assistant:\n\n{services}\n\nIsto acontece muitas vezes quando a integração por trás de uma ação foi removida, ou quando um script foi renomeado ou eliminado. Para corrigir este erro, reconfigura o assistente de template para usar ações que existam.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus assistentes de template 👻\n\nEnquanto flutuava por aí, o Spook cruzou-se com o seguinte assistente de template:\n\n[{helper}]({edit})\n\nRefere as seguintes ações, que são desconhecidas para o Home Assistant:\n\n{services}\n\nIsto acontece muitas vezes quando a integração por trás de uma ação foi removida, ou quando um script foi renomeado ou eliminado. Para corrigir este erro, reconfigura o assistente de template para usar ações que existam.\n\nSpook 👻 O teu amigo.",
"title": "Ações desconhecidas usadas no assistente de template: {helper}"
},
"notify_unknown_group_members": {
"description": "Spook encontrou um fantasma nos teus grupos de notificação 👻\n\nO grupo de notificação `{group}` encaminha para as seguintes ações, que são desconhecidas para o Home Assistant:\n\n{members}\n\nO grupo continua a entregar aos restantes membros e continua a comunicar sucesso a quem o chamou, pelo que nada a montante se apercebe. A falha só aparece como um \"Task exception was never retrieved\" solto no registo, que não nomeia nenhum grupo e parece um erro de programação. Quem estava por trás desse membro deixa simplesmente de ser notificado.\n\nPara corrigir este erro, edita a lista `services:` deste grupo na tua configuração e remove ou corrige estas entradas.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus grupos de notificação 👻\n\nO grupo de notificação `{group}` encaminha para as seguintes ações, que são desconhecidas para o Home Assistant:\n\n{members}\n\nO grupo continua a entregar aos restantes membros e continua a comunicar sucesso a quem o chamou, pelo que nada a montante se apercebe. A falha só aparece como um \"Task exception was never retrieved\" solto no registo, que não nomeia nenhum grupo e parece um erro de programação. Quem estava por trás desse membro deixa simplesmente de ser notificado.\n\nPara corrigir este erro, edita a lista `services:` deste grupo na tua configuração e remove ou corrige estas entradas.\n\nSpook 👻 O teu amigo.",
"title": "Membros desconhecidos no grupo de notificação: {group}"
},
"lovelace_duplicate_resources": {
@@ -556,7 +556,7 @@
"step": {
"init": {
"title": "Recurso do painel presente mais do que uma vez: {resource}",
"description": "Spook encontrou um fantasma nos teus painéis 👻\n\nEste recurso do painel aparece {count} vezes:\n\n{resources}\n\nIsto costuma acontecer quando um cartão personalizado foi atualizado acrescentando um recurso para a versão nova em vez de editar o que já lá estava. O navegador carrega os dois, o cartão tenta registar-se duas vezes e a segunda tentativa falha, o que parece um cartão avariado em vez de um recurso repetido.\n\nO Spook pode limpar as cópias a mais e ficar com a que foi acrescentada mais recentemente.\n\nSpook 👻 O teu amigo.",
"description": "O Spook encontrou um fantasma nos teus painéis 👻\n\nEste recurso do painel aparece {count} vezes:\n\n{resources}\n\nIsto costuma acontecer quando um cartão personalizado foi atualizado acrescentando um recurso para a versão nova em vez de editar o que já lá estava. O navegador carrega os dois, o cartão tenta registar-se duas vezes e a segunda tentativa falha, o que parece um cartão avariado em vez de um recurso repetido.\n\nO Spook pode limpar as cópias a mais e ficar com a que foi acrescentada mais recentemente.\n\nSpook 👻 O teu amigo.",
"menu_options": {
"remove": "Limpar as cópias a mais e ficar com a mais recente",
"ignore": "Deixá-las estar, e não me dizer mais nada",
@@ -1420,7 +1420,7 @@
"name": "Definir os aliases de uma área"
},
"homeassistant_unexpose_entity": {
"description": "Deixa de expor uma entidade (ou entidades) aos assistentes de voz.",
"description": "Deixa de expor uma(s) entidade(s) aos assistentes de voz.",
"fields": {
"assistants": {
"description": "O assistente ou assistentes a que isto se aplica.",
@@ -1695,7 +1695,8 @@
"description": "As entidades a remover do grupo."
},
"group": {
"name": "Grupo"
"name": "Grupo",
"description": "O grupo do qual é para retirar."
}
},
"description": "Remove entidades de um grupo criado pela interface enquanto o Home Assistant está em execução. Renomear algo que não faz parte do grupo não faz nada, portanto essa também é a maneira de remover um membro que já não existe."
@@ -1710,7 +1711,9 @@
"name": "Membros",
"description": "As entidades que o grupo deve manter de agora em diante."
}
}
},
"name": "Define os membros de um grupo",
"description": "Substitui os membros de um grupo criado pela interface pelos membros fornecidos, enquanto o Home Assistant está em execução."
},
"group_add_members": {
"name": "Adiciona membros a um grupo",
@@ -1723,7 +1726,8 @@
"name": "Membros",
"description": "As entidades a adicionar ao grupo."
}
}
},
"description": "Adiciona entidades a um grupo criado pela interface, enquanto o Home Assistant está em execução. As entidades que já fazem parte do grupo permanecem onde estão."
}
},
"triggers": {
@@ -1760,20 +1764,20 @@
}
},
"watchdog": {
"name": "Cão de guarda",
"name": "Watchdog",
"description": "Dispara quando algo que esperavas não acontece a tempo.",
"fields": {
"arm": {
"name": "Armar",
"description": "O que põe a guarda a postos. A espera conta a partir daí, e armar outra vez enquanto já se está à espera reinicia a contagem."
"description": "O que inicia a contagem. A espera é começa a partir deste ponto, e reativar a função enquanto a espera já está em curso reinicia a contagem."
},
"expect": {
"name": "Esperado",
"description": "O que esperas que aconteça a seguir. Se chegar a tempo, a guarda é cancelada e nada dispara."
"description": "O que é esperado que aconteça a seguir. Se chegar a tempo, cancela e nada dispara."
},
"within": {
"name": "Dentro de",
"description": "Quanto tempo tem o que é esperado para chegar. Isto dispara quando esse tempo passa e nada chegou."
"description": "O tempo que é expectável esperar para chegar. Isto dispara quando esse tempo passa e nada chegou."
}
}
},
@@ -1862,23 +1866,38 @@
"debounce": {
"fields": {
"triggers": {
"name": "Gatilhos"
"name": "Gatilhos",
"description": "Os gatilhos a serem aguardados. O disparo de qualquer um deles inicia uma sequência de disparos, por isso o disparo subsequente de qualquer um deles mantém tudo a correr. Assim, vários gatilhos são agrupados, em vez de processados um de cada vez."
},
"for": {
"name": "Para",
"description": "Quanto tempo deve permanecer silenciado antes que isto dispare. Cada disparo reinicia essa contagem de tempo, portanto, algo que nunca para de disparar nunca chega a ser relatado."
}
},
"name": "Assim que ficar configurado"
"name": "Assim que ficar configurado",
"description": "Dispara após um gatilho parar de disparar por algum tempo, de modo que uma sequência de disparos chega como um único evento. Um sensor de movimento não relata o movimento apenas uma vez, reporta vinte vezes."
},
"all_of": {
"name": "Todos esses aconteceram",
"fields": {
"triggers": {
"name": "Gatilhos"
"name": "Gatilhos",
"description": "Todos os gatilhos que precisam ter sido disparados. A ordem não importa, e o disparo de um gatilho novamente substitui sua ocorrência anterior, em vez de contar duas vezes."
},
"within": {
"description": "O quão perto eles têm de estar um do outro. A janela desliza, e um gatilho que tenha disparado há mais tempo que isso é esquecido, enquanto os outros continuam a contar."
"description": "O quão perto eles têm de estar um do outro. A janela desliza, e um gatilho que tenha disparado há mais tempo que isso é esquecido, enquanto os outros continuam a contar.",
"name": "Dentro de"
}
},
"description": "É acionado quando todos os diversos gatilhos são disparados dentro do mesmo intervalo de tempo, em qualquer ordem. Os gatilhos nativos do Home Assistant funcionam como uma lista na qual basta que apenas um deles seja disparado; este é o outro tipo de gatilho."
},
"recovered": {
"name": "A entidade retornou",
"description": "É disparado quando uma entidade retorna após um período de indisponibilidade, evitando que a reinicialização de um router seja interpretada como a recuperação de todos os dispositivos da casa.",
"fields": {
"for": {
"name": "Para",
"description": "O tempo que a entidade tem de permanecer ausente antes de retornar é relevante. Qualquer período inferior a isso é apenas uma oscilação momentânea: ao recarregar uma integração, todas as suas entidades passam pelo estado de indisponibilidade e retornam em menos de um segundo."
}
}
}
+18 -12
View File
@@ -194,9 +194,8 @@ class ClaimRewardButton(TaskMateBaseButton):
Pool-mode pending claims are skipped because their cost was already
deducted from child.points at allocation time.
"""
pending_claims = self.coordinator.data.get("pending_reward_claims", [])
committed = 0
for c in pending_claims:
for c in self.coordinator.storage.get_pending_reward_claims():
if c.child_id == child_id and not self.coordinator.is_pool_mode_claim(c):
reward = self.coordinator.get_reward(c.reward_id)
if reward:
@@ -205,15 +204,19 @@ class ClaimRewardButton(TaskMateBaseButton):
@property
def available(self) -> bool:
"""Return if button is available (accounting for committed points)."""
child = self.coordinator.get_child(self.child_id)
reward = self.coordinator.get_reward(self.reward_id)
"""Offer the button exactly when claiming would be allowed.
if not child or not reward:
Deciding this on the wallet balance alone disagreed with the claim
itself in both directions: a jackpot or a filled savings jar is not
paid from the wallet, and a sold-out, expired, time-locked or
already-claimed reward cannot be claimed however many points a child
has. One rule, asked the same way by both.
"""
try:
self.coordinator.validate_reward_claim(self.reward_id, self.child_id)
except ValueError:
return False
available_points = child.points - self._get_committed_points(child.id)
return available_points >= reward.cost
return True
@property
def extra_state_attributes(self) -> dict:
@@ -226,7 +229,10 @@ class ClaimRewardButton(TaskMateBaseButton):
committed = self._get_committed_points(child.id)
available_points = child.points - committed
can_afford = available_points >= reward.cost
# A jackpot is paid from the shared pool and an ordinary reward may be
# paid from a filled savings jar, so affordability is not a question
# about the wallet.
funding = self.coordinator.reward_claim_funding(reward, child.id)
return {
"child_id": child.id,
@@ -237,8 +243,8 @@ class ClaimRewardButton(TaskMateBaseButton):
"child_points": child.points,
"committed_points": committed,
"available_points": available_points,
"can_afford": can_afford,
"points_needed": max(0, reward.cost - available_points),
"can_afford": funding >= reward.cost,
"points_needed": max(0, reward.cost - funding),
}
async def async_press(self) -> None:
+60 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from calendar import monthrange
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
@@ -833,6 +833,18 @@ class ChoresMixin:
)
return None
# Weekly target (#883): the same cap one level up. Parents completing on
# behalf are bound by it too — the quota is the point of the chore, and
# the daily limit above already treats them the same way.
if self.weekly_target_met(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: weekly target reached for '%s' (%d/%d this week)",
chore.name,
self.weekly_completion_count(chore_id, child_id),
getattr(chore, "weekly_target", 0),
)
return None
# A photo-required chore always goes through parent approval (unless a
# parent is completing on behalf), so the evidence gets reviewed.
requires_photo = bool(getattr(chore, "require_photo", False))
@@ -869,6 +881,7 @@ class ChoresMixin:
completed_at=now,
approved=auto_approve,
points_awarded=effective_points if auto_approve else 0,
submitted_points=effective_points,
photo_url=photo_url or "",
note=note,
suggested_points=suggested_points,
@@ -972,6 +985,7 @@ class ChoresMixin:
approved=True,
approved_at=now,
points_awarded=0,
submitted_points=0,
)
self.storage.add_completion(completion)
@@ -1058,6 +1072,7 @@ class ChoresMixin:
completed_at=now,
approved=not chore.requires_approval,
points_awarded=subtask.points if not chore.requires_approval else 0,
submitted_points=subtask.points,
bonus_subtask_id=bonus_subtask_id,
)
@@ -1093,8 +1108,9 @@ class ChoresMixin:
worth (#832) — needed for open-ended submissions, useful for any chore.
It replaces the chore's *base* points, so the streak and level
multipliers ``_award_points`` applies still ride on top exactly as they
would for an ordinary approval. ``None`` means "use the chore's value"
and leaves the existing behaviour untouched.
would for an ordinary approval. ``None`` means "pay what the submission
was worth when it was made", falling back to the chore's value for
completions recorded before that was stored.
"""
completions = self.storage.get_completions()
for completion in completions:
@@ -1111,7 +1127,15 @@ class ChoresMixin:
if chore and child:
comp_date = dt_util.as_local(completion.completed_at).date()
is_bonus = bool(completion.bonus_subtask_id)
if is_bonus:
if completion.submitted_points is not None:
# Pay what the child was promised when they submitted.
# Recalculating here reads the chore's *current* points
# — so an edit made while the work sat in the queue
# changed the price after the fact — and silently drops
# the speed bonus and roulette multiplier that were in
# the figure they saw.
pts = completion.submitted_points
elif is_bonus:
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
pts = subtask.points if subtask else 0
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
@@ -1601,6 +1625,34 @@ class ChoresMixin:
days_since = (today - last_dt).days
return days_since >= window_days
def weekly_completion_count(self, chore_id: str, child_id: str) -> int:
"""How many times ``child_id`` has completed ``chore_id`` this week.
Monday-anchored, matching the Challenges period convention. Bonus
sub-tasks don't count; pending completions do, so a chore can't be
submitted a fourth time just because the parent hasn't got round to
approving the third yet.
"""
today = dt_util.as_local(dt_util.now()).date()
week_start = today - timedelta(days=today.weekday())
count = 0
for comp in self._cached_completions_for_chore(chore_id):
if comp.child_id != child_id or getattr(comp, "bonus_subtask_id", ""):
continue
try:
if dt_util.as_local(comp.completed_at).date() >= week_start:
count += 1
except (AttributeError, TypeError, ValueError):
continue
return count
def weekly_target_met(self, chore, child_id: str) -> bool:
"""True when a weekly-target chore has had its quota filled this week."""
target = int(getattr(chore, "weekly_target", 0) or 0)
if target <= 0:
return False
return self.weekly_completion_count(chore.id, child_id) >= target
def _is_chore_completable_by_child(self, chore, child_id: str) -> bool:
"""Whether ``child_id`` may complete ``chore`` right now (card parity).
@@ -1657,6 +1709,10 @@ class ChoresMixin:
continue
if done >= limit:
continue
# Weekly target (#883): filled the week's quota, so nothing is
# owed until Monday even on a day the chore is scheduled.
if self.weekly_target_met(chore, child_id):
continue
out.append(chore)
return out
@@ -119,6 +119,11 @@ class MandatoryMixin:
continue
if self._child_completed_today(chore.id, child_id, day):
continue
# Weekly target (#883): the week's quota is already filled, so
# nothing is outstanding — even though the chore is still
# "scheduled" every day.
if self.weekly_target_met(chore, child_id):
continue
if (chore.id, child_id, day.isoformat()) in existing:
continue
miss = MandatoryMiss(
@@ -538,27 +538,42 @@ class NotificationCoordinator:
return
if action.startswith("TASKMATE_APPROVE_"):
entry_id = action[len("TASKMATE_APPROVE_") :]
try:
await coordinator.async_approve_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_approve_reward(entry_id)
except (ValueError, KeyError):
_LOGGER.info("Mobile action %s — entry not found", action)
await self._review_from_mobile(
action,
action[len("TASKMATE_APPROVE_") :],
coordinator.async_approve_chore,
coordinator.async_approve_reward,
)
elif action.startswith("TASKMATE_REJECT_"):
entry_id = action[len("TASKMATE_REJECT_") :]
try:
await coordinator.async_reject_chore(entry_id)
return
except (ValueError, KeyError):
pass
try:
await coordinator.async_reject_reward(entry_id)
except (ValueError, KeyError):
await self._review_from_mobile(
action,
action[len("TASKMATE_REJECT_") :],
coordinator.async_reject_chore,
coordinator.async_reject_reward,
)
async def _review_from_mobile(self, action: str, entry_id: str, review_chore, review_reward) -> None:
"""Send a mobile review to whichever record the id belongs to.
A chore completion id and a reward claim id look alike, so this used to
try the chore path and fall back to the reward path when it raised. It
never does: approving or rejecting an unknown completion is a no-op,
not an error, so the fallback was unreachable and every reward push
button did nothing at all. The id itself decides.
"""
if any(c.id == entry_id for c in self.storage.get_completions()):
review = review_chore
elif any(c.id == entry_id for c in self.storage.get_reward_claims()):
review = review_reward
else:
_LOGGER.info("Mobile action %s — entry not found", action)
return
try:
await review(entry_id)
except (ValueError, KeyError) as err:
# A stale push: the item was reviewed elsewhere, or the reward has
# since sold out. Nothing to do, but say why in the log.
_LOGGER.info("Mobile action %s could not be applied: %s", action, err)
# ------------------------------------------------------------------
# Scheduler — time-gated callbacks
+13 -8
View File
@@ -681,6 +681,11 @@ class PointsMixin:
continue
if child_id not in self._compute_active_children(chore, day):
continue
# Weekly target (#883): the child picks the days, so "every chore
# due today" can't include it — it's owed by Sunday, not by any one
# evening.
if int(getattr(chore, "weekly_target", 0) or 0) > 0:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
out.add(chore.id)
@@ -763,14 +768,14 @@ class PointsMixin:
weekend_bonus,
points,
)
# Log weekend bonus as a separate transaction for activity history
transaction = PointsTransaction(
child_id=child.id,
points=weekend_bonus,
reason=f"Weekend bonus (×{multiplier:.0f})",
created_at=now,
)
self.storage.add_points_transaction(transaction)
# Deliberately NOT written as its own transaction. The bonus is part
# of what `total_points` pays, and the completion records that in
# points_awarded — so an extra row here would be the same points
# counted twice by anything summing completions and transactions
# together (the graph, weekly and leaderboard cards all do).
# Households upgrading from an earlier version still have these
# rows in their history, so the "Weekend bonus" reason stays in
# _UNDO_DENY_PREFIXES and in the locales.
# ── Streak tracking ─────────────────────────────────────────────────
streak_reset_occurred = False
+174 -18
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
from .timewindow import has_window, is_within_window
if TYPE_CHECKING:
@@ -52,19 +52,37 @@ class RewardsMixin:
return self.storage.get_reward(reward_id)
def is_pool_mode_claim(self, claim: RewardClaim) -> bool:
"""True if the claim is covered by pool allocations (points already deducted).
"""True if the claim is funded from the pool rather than a wallet.
Pool-mode claims must NOT be counted against a child's spendable balance,
because their cost was already removed from child.points at allocation time.
Pool-funded claims must NOT be counted against a child's spendable
balance, because their cost was already removed from child.points at
allocation time. A jackpot is always pool-funded (#552), even if the
pool has since dipped below the cost — it is still not the claimer's
wallet that is on the hook for it.
"""
reward = self.get_reward(claim.reward_id)
if not reward:
return False
if getattr(reward, "is_jackpot", False):
return self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
return True
alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
return bool(alloc and alloc.allocated_points >= reward.cost)
def _require_funded_jackpot(self, reward: Reward, cost: int) -> None:
"""Raise unless the shared pool covers a jackpot's cost.
Jackpots are funded from one shared pool and never from a wallet
(#552). The pool can fall short of a pending claim — the cost is
raised, a contributor is unassigned, a child is deleted — and without
this guard the fallback path charged the full jackpot to whichever
child happened to be holding the claim.
"""
pool_total = self.storage.get_total_allocated_for_reward(reward.id)
if pool_total < cost:
raise ValueError(
f"'{reward.name}' is a shared jackpot and its pool is not full. Need {cost}, have {pool_total} saved up"
)
async def async_add_reward(
self,
name: str,
@@ -102,12 +120,22 @@ class RewardsMixin:
can't appear as e.g. 11/10. If the edit makes the reward unavailable
(quantity set to 0, or expires_at moved into the past) any pool
allocations on that reward are refunded in full.
An edit can also take the savings jar itself away: switching a reward
between jackpot and ordinary, turning pool mode off, or dropping a
child from ``assigned_to``. Allocations are locked with no withdraw
operation, so anything left behind by those edits is a child's points
stranded for good — they are refunded, and any pending claim the edit
has invalidated is cancelled with it.
"""
old = self.get_reward(reward.id)
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
if old and reward.cost != old.cost:
self._freeze_unrecorded_prices(old)
self.storage.update_reward(reward)
cancelled_claim_ids = self._settle_pool_after_edit(old, reward) if old else []
if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = (
@@ -121,6 +149,63 @@ class RewardsMixin:
await self.storage.async_save()
await self.async_refresh()
# Dismiss the approval pushes for claims this edit just cancelled.
if cancelled_claim_ids and getattr(self, "notifications", None):
for claim_id in cancelled_claim_ids:
await self.notifications.clear_approval("pending_reward_claim", claim_id)
def _settle_pool_after_edit(self, old: Reward, reward: Reward) -> list[str]:
"""Refund savings and cancel claims an edit has just invalidated.
Returns the ids of the cancelled claims so the caller can clear their
approval notifications once the change is saved.
"""
# Funding changed: the shared jar became per-child jars, or the other
# way round, or pool mode was switched off altogether. Whatever is in
# the pool was saved towards something that no longer exists.
funding_changed = reward.is_jackpot != old.is_jackpot or (old.pool_enabled and not reward.pool_enabled)
if funding_changed:
self._refund_all_pool_allocations(reward, "Pool refund (reward funding changed)")
else:
# Narrowed assignment: a child who can no longer be given this
# reward can no longer redeem what they saved towards it either.
for alloc in list(self.storage.get_pool_allocations()):
if alloc.reward_id == reward.id and not self._reward_is_for_child(reward, alloc.child_id):
self._apply_pool_refund(
alloc, alloc.allocated_points, reward, "Pool refund (reward assignment changed)"
)
cancelled: list[str] = []
for claim in self.storage.get_reward_claims():
if claim.reward_id != reward.id or claim.approved:
continue
# A pool-funded claim whose pool has just been refunded would fall
# through to the claimer's wallet on approval, and an unassigned
# child's claim should not be approvable at all.
if funding_changed or not self._reward_is_for_child(reward, claim.child_id):
self.storage.remove_reward_claim(claim.id)
cancelled.append(claim.id)
if cancelled:
_LOGGER.info(
"Cancelled %d pending claim(s) for '%s' — the edit changed how it is funded or who it is for",
len(cancelled),
reward.name,
)
return cancelled
def _freeze_unrecorded_prices(self, old: Reward) -> None:
"""Stamp the current price onto approved claims that never recorded one.
Claims approved before the price was stored alongside them have no
record of what was paid, so history reads the reward's live cost. This
is the last moment that cost is still the one those purchases were
approved at, so it is written down before the edit lands.
"""
for claim in self.storage.get_reward_claims():
if claim.reward_id == old.id and claim.approved and claim.approved_cost is None:
claim.approved_cost = old.cost
self.storage.update_reward_claim(claim)
async def async_remove_reward(self, reward_id: str) -> None:
"""Remove a reward and clean up any pending claims and pool allocations referencing it."""
self.storage.remove_reward_claims_for_reward(reward_id)
@@ -253,14 +338,34 @@ class RewardsMixin:
)
)
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
def reward_claim_funding(self, reward: Reward, child_id: str) -> int:
"""What is available to pay for this reward, from whichever purse applies.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
A jackpot is paid from the shared pool, an ordinary reward from either
a filled savings jar or the child's uncommitted balance — so "can they
afford it" cannot be answered from the wallet alone.
"""
if reward.is_jackpot:
return self.storage.get_total_allocated_for_reward(reward.id)
child = self.get_child(child_id)
if not child:
return 0
committed = 0
for claim in self.storage.get_pending_reward_claims():
if claim.child_id == child_id and not self.is_pool_mode_claim(claim):
pending_reward = self.get_reward(claim.reward_id)
if pending_reward:
committed += pending_reward.cost
wallet = child.points - committed
allocation = self.storage.get_pool_allocation(child_id, reward.id)
return max(wallet, allocation.allocated_points) if allocation else wallet
def validate_reward_claim(self, reward_id: str, child_id: str) -> tuple[Reward, Child]:
"""Check a claim is allowed, raising ValueError with the reason if not.
Every rule that decides whether a child may claim right now lives here,
so the claim itself and anything that offers it — the per-reward button
entity, for one — cannot drift apart. Mutates nothing.
"""
reward = self.get_reward(reward_id)
if not reward:
@@ -306,8 +411,9 @@ class RewardsMixin:
# or for jackpots the summed pool across all children reaches cost.
pool_filled = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(reward_id)
if pool_total >= effective_cost:
# A short pool is the end of it: a jackpot is never redeemed out of
# one child's wallet, however many points they happen to have.
self._require_funded_jackpot(reward, effective_cost)
pool_filled = True
else:
allocation = self.storage.get_pool_allocation(child_id, reward_id)
@@ -330,6 +436,19 @@ class RewardsMixin:
if available_points < effective_cost:
raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
return reward, child
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
"""
reward, child = self.validate_reward_claim(reward_id, child_id)
claim = RewardClaim(
reward_id=reward_id,
child_id=child_id,
@@ -378,7 +497,8 @@ class RewardsMixin:
continue
when = claim.approved_at or claim.claimed_at
if when and dt_util.as_local(when).date() >= start:
total += reward_cost.get(claim.reward_id, 0)
paid = claim.approved_cost
total += paid if paid is not None else reward_cost.get(claim.reward_id, 0)
return total
def _enforce_spend_cap(self, child_id: str, cost: int) -> None:
@@ -436,8 +556,11 @@ class RewardsMixin:
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
is_pool_mode = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(claim.reward_id)
if pool_total >= effective_cost:
# The pool can have drained since the claim was made, and
# the wallet path below is not an acceptable fallback for a
# shared reward — it would charge the whole jackpot to the
# one child holding the claim.
self._require_funded_jackpot(reward, effective_cost)
is_pool_mode = True
elif pool_alloc and pool_alloc.allocated_points >= effective_cost:
is_pool_mode = True
@@ -475,13 +598,34 @@ class RewardsMixin:
claim.approved = True
claim.approved_at = dt_util.now()
claim.approved_cost = effective_cost
self.storage.update_reward_claim(claim)
# One pool, one redemption. Claims stored before a jackpot was
# limited to a single pending claim (#873) can still be sitting
# in the queue; the pool that would have paid for them has just
# been spent, so approving one of those later would fall
# through to its claimer's wallet. Retire them with the pool.
superseded = []
if reward.is_jackpot:
superseded = [c for c in claims if c.id != claim.id and c.reward_id == reward.id and not c.approved]
for other in superseded:
self.storage.remove_reward_claim(other.id)
if superseded:
_LOGGER.info(
"Retired %d duplicate pending claim(s) for jackpot '%s' on redemption",
len(superseded),
reward.name,
)
await self.storage.async_save()
await self.async_refresh()
# Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None):
await self.notifications.clear_approval("pending_reward_claim", claim_id)
for other in superseded:
await self.notifications.clear_approval("pending_reward_claim", other.id)
# Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child)
@@ -506,8 +650,20 @@ class RewardsMixin:
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
"""Reject a reward claim — no refund needed as points were never deducted."""
"""Reject a *pending* reward claim — no refund needed as points were never deducted.
That "no refund needed" only holds while the claim is pending. Two
parents can review the same claim at once — one in the panel, one from
a mobile approval push — and the second one is acting on a list that no
longer matches storage. Deleting an approved claim there would erase
the purchase from history while its points, its stock and any timed
unlock stayed spent, so the stale review is refused instead.
"""
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
if claim is not None and claim.approved:
reward = self.get_reward(claim.reward_id)
name = reward.name if reward else "This reward"
raise ValueError(f"'{name}' has already been approved and can no longer be rejected")
self.storage.remove_reward_claim(claim_id)
await self.storage.async_save()
await self.async_refresh()
@@ -128,6 +128,7 @@ class TimedMixin:
completed_at=now,
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
submitted_points=pts,
timed_duration_seconds=total_seconds,
)
@@ -251,6 +252,7 @@ class TimedMixin:
completed_at=dt_util.now(),
approved=not chore.requires_approval,
points_awarded=pts if not chore.requires_approval else 0,
submitted_points=pts,
timed_duration_seconds=total_seconds,
)
if not chore.requires_approval:
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.7.0"
"version": "5.8.0"
}
+37
View File
@@ -54,6 +54,24 @@ def parse_datetime(value: str | datetime | None) -> datetime | None:
return None
def parse_optional_points(value: Any) -> int | None:
"""Parse a stored points figure, or None for "not recorded".
Used for fields that snapshot a value at the moment it applied. ``None``
marks a record written before the field existed, so callers can fall back
to recalculating; anything unusable is treated the same way rather than
being trusted as a number.
"""
if value is None or isinstance(value, bool):
return None
try:
points = int(value)
except (ValueError, TypeError, OverflowError):
_LOGGER.warning("Ignoring unusable stored points value %r", value)
return None
return None if points < 0 else points
def format_datetime(dt: datetime | None) -> str | None:
"""Format a datetime as ISO string with timezone info.
@@ -260,6 +278,12 @@ class Chore:
time_category: str = "anytime" # morning, afternoon, evening, night, anytime
claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight.
daily_limit: int = 1
# Weekly target (#883): "do this N times this week, on whichever days you
# like" — instrument practice, reading, exercise. 0 = off. Monday-anchored
# to match Challenges. It caps the week the way daily_limit caps the day,
# and counts pending completions too, so a slow approval never hands the
# child a spare go.
weekly_target: int = 0
completion_sound: str = "coin" # Sound to play on completion
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
# one per chore; everything else falls back to the time-of-day icon.
@@ -375,6 +399,7 @@ class Chore:
time_category=data.get("time_category", "anytime"),
claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)),
daily_limit=data.get("daily_limit", 1),
weekly_target=int(data.get("weekly_target", 0) or 0),
completion_sound=data.get("completion_sound", "coin"),
icon=str(data.get("icon", "") or ""),
image_url=str(data.get("image_url", "") or ""),
@@ -442,6 +467,7 @@ class Chore:
"time_category": self.time_category,
"claim_allowance_minutes": self.claim_allowance_minutes,
"daily_limit": self.daily_limit,
"weekly_target": self.weekly_target,
"completion_sound": self.completion_sound,
"icon": self.icon,
"image_url": self.image_url,
@@ -721,6 +747,10 @@ class ChoreCompletion:
note: str = "" # child's own description of the work (open-ended chores, #832)
suggested_points: int = 0 # what the child reckons it was worth (#832)
id: str = field(default_factory=generate_id)
# The award this submission was worth when it was made, before the streak
# and weekend multipliers that _award_points adds on top. None on records
# written before the field existed.
submitted_points: int | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion:
@@ -741,6 +771,7 @@ class ChoreCompletion:
note=data.get("note", ""),
suggested_points=int(data.get("suggested_points", 0) or 0),
id=data.get("id") or generate_id(),
submitted_points=parse_optional_points(data.get("submitted_points")),
)
def to_dict(self) -> dict[str, Any]:
@@ -758,6 +789,7 @@ class ChoreCompletion:
"note": self.note,
"suggested_points": self.suggested_points,
"id": self.id,
"submitted_points": self.submitted_points,
}
@@ -820,6 +852,9 @@ class RewardClaim:
approved: bool = False
approved_at: datetime | None = None
id: str = field(default_factory=generate_id)
# What this purchase actually cost, recorded when it was approved. None on
# a pending claim, and on claims approved before the field existed.
approved_cost: int | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RewardClaim:
@@ -834,6 +869,7 @@ class RewardClaim:
approved=data.get("approved", False),
approved_at=approved_at,
id=data.get("id") or generate_id(),
approved_cost=parse_optional_points(data.get("approved_cost")),
)
def to_dict(self) -> dict[str, Any]:
@@ -845,6 +881,7 @@ class RewardClaim:
"approved": self.approved,
"approved_at": format_datetime(self.approved_at),
"id": self.id,
"approved_cost": self.approved_cost,
}
+1 -1
View File
@@ -21,7 +21,7 @@ _SELECTS = [
(
"card_design",
"card_design",
["classic", "playroom", "console", "cleanpro", "accessible"],
["classic", "playroom", "console", "cleanpro", "accessible", "graphite"],
"classic",
"mdi:palette",
),
+41 -4
View File
@@ -146,6 +146,14 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
for c in children:
committed_amount = committed.get(c.id, 0)
lvl = coordinator.level_info(c)
# Weekly targets (#883): {chore_id: done so far this week} for the
# chores that have one, so a card can render "2/3 this week" without
# its own pass over completions. Empty — and so absent — for most.
weekly_progress = {
ch.id: coordinator.weekly_completion_count(ch.id, c.id)
for ch in common["chores"]
if int(getattr(ch, "weekly_target", 0) or 0) > 0
}
summary.append(
{
"level": lvl["level"],
@@ -195,6 +203,7 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"quests": coordinator.quest_progress_for_child(c.id),
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
**({"weekly_chore_progress": weekly_progress} if weekly_progress else {}),
}
)
return summary
@@ -243,6 +252,11 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
daily_limit = getattr(c, "daily_limit", 1)
if daily_limit != 1:
record["daily_limit"] = daily_limit
# Weekly target (#883) — off for almost every chore, so it only goes
# into the record when a family actually uses one.
weekly_target = int(getattr(c, "weekly_target", 0) or 0)
if weekly_target:
record["weekly_target"] = weekly_target
claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
if claim_allowance_minutes:
record["claim_allowance_minutes"] = claim_allowance_minutes
@@ -355,6 +369,27 @@ def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) ->
return availability
def _completion_award(completion, recalculated: int) -> int:
"""What a completion is worth, recorded rather than re-derived.
Approved: what the child was actually paid, weekend multiplier included.
Re-deriving it from the chore reported the chore's *current* points, so
re-pricing a chore rewrote every past completion of it.
Pending: what the child was promised at submission, including a speed
bonus or roulette multiplier that may no longer apply — the same figure
approval will pay.
Neither recorded (a completion from before those fields existed): fall
back to the caller's calculation.
"""
if completion.approved:
return completion.points_awarded
if completion.submitted_points is not None:
return completion.submitted_points
return recalculated
def _build_todays_completions(common: dict) -> list[dict]:
"""Build today's completions (both approved and pending)."""
now = dt_util.now()
@@ -391,7 +426,7 @@ def _build_todays_completions(common: dict) -> list[dict]:
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": display_name,
"points": display_points,
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -573,7 +608,7 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": matched_chore.name if matched_chore else "",
"points": display_points,
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -657,7 +692,9 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
"reward_id": rc.reward_id,
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
"points": -reward.cost,
# An approved purchase is worth what it cost that day, not what
# the reward has been re-priced to since.
"points": -(rc.approved_cost if rc.approved and rc.approved_cost is not None else reward.cost),
"approved": rc.approved,
"created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
}
@@ -1378,7 +1415,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
"child_id": child.id,
"chore_name": chore_name,
"chore_id": chore.id,
"points": pts,
"points": _completion_award(comp, pts),
"time_category": chore.time_category,
"completed_at": comp.completed_at.isoformat(),
"bonus_subtask_id": bonus_subtask_id,
+1 -1
View File
@@ -1277,7 +1277,7 @@ class TaskMateStorage:
from .photos import is_taskmate_photo_url
_STREAK_MODES = ("reset", "pause")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible", "graphite")
# Per-record numeric fields. The WebSocket schemas coerce these on every
# normal write; import bypasses the schemas entirely, so a crafted
# backup could leave a string (or inf/NaN) where the rest of the code —
+5 -1
View File
@@ -575,6 +575,7 @@ _CHORE_EDITABLE_FIELDS = {
"time_category",
"claim_allowance_minutes",
"daily_limit",
"weekly_target",
"completion_sound",
"icon",
"image_url",
@@ -629,6 +630,9 @@ def _chore_payload_schema(*, require_name: bool):
vol.Optional("time_category"): str,
vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)),
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
# Weekly target (#883): 0 = no quota, which is the case for almost
# every chore, so it has to be a valid value rather than a floor of 1.
vol.Optional("weekly_target"): vol.All(int, vol.Range(min=0)),
vol.Optional("completion_sound"): _completion_sound,
vol.Optional("icon"): str,
vol.Optional("image_url"): _image_url_or_blank,
@@ -1640,7 +1644,7 @@ async def _ws_remove_custom_sound(hass, connection, msg, coordinator):
# Top-level fields stored at storage._data root
_TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
# Allowed values for the global default card-design style (per-card design styles).
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible", "graphite"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"require_linked_child",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphit",
"panel.settings_card_design_label": "Standard-Kartendesign",
"panel.settings_card_design_hint": "Der Stil neuer Karten, sofern nicht pro Karte überschrieben.",
"activity.a_chore": "eine lästige Pflicht",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Strafe: {name}",
"activity.reason_perfect_week": "Perfekter Wochenbonus!",
"activity.reason_pool_refund_assignment_changed": "Poolrückerstattung (Zuweisung der Prämie geändert): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool-Rückerstattung (Prämienkosten reduziert): {name}",
"activity.reason_pool_refund_deleted": "Poolrückerstattung (Prämie gelöscht): {name}",
"activity.reason_pool_refund_expired": "Poolrückerstattung (Prämie abgelaufen): {name}",
"activity.reason_pool_refund_funding_changed": "Poolrückerstattung (Finanzierung der Prämie geändert): {name}",
"activity.reason_pool_refund_sold_out": "Poolrückerstattung (Prämie ausverkauft): {name}",
"activity.reason_streak_milestone": "Serie-Meilensteinbonus ({days} Tage Serie!)",
"activity.reason_weekend_bonus": "Wochenendbonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} nicht gefunden",
"badges.label": "Abzeichen",
"badges.next_up": "Als Nächstes",
"badges.one_more": "Nur noch eins – dann gehört es dir!",
"badges.title_with_name": "Abzeichen von {name}",
"bonuses.add_bonus": "Bonus hinzufügen",
"bonuses.applying_to": "Beantragung bei {childName} – aktuelles Guthaben: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Senden",
"child.extra_needs_desc": "Schreib zuerst, was du gemacht hast.",
"child.open_ended_tag": "Beschreiben",
"child.weekly_target_done": "Für diese Woche erledigt",
"child.weekly_target_progress": "{done}/{target} diese Woche",
"child.photo_take": "Foto aufnehmen",
"child.photo_hint": "Zeig deine erledigte Aufgabe",
"child.photo_retake": "Neu aufnehmen",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Tageslimit (Minuten, 0 = unbegrenzt)",
"panel.chore_daily_limit_label": "Tageslimit",
"panel.chore_days_label": "Wochentage (leer = jeden Tag)",
"panel.chore_weekly_target_pill": "{count}×/Woche",
"panel.chore_weekly_target_hint": "Wie oft pro Woche die Aufgabe erledigt werden muss, an beliebigen der gewählten Tage. Ist die Anzahl erreicht, wird sie nicht mehr angezeigt; montags beginnt die Zählung neu.",
"panel.chore_weekly_target_label": "Wochenziel (0 = aus)",
"panel.chore_description_label": "Beschreibung (optional)",
"panel.chore_enabled_label": "Aktiviert",
"panel.chore_first_occurrence_label": "Erstes Vorkommen",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Default card design",
"panel.settings_card_design_hint": "The style new cards use unless overridden per card.",
"activity.a_chore": "a chore",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Penalty: {name}",
"activity.reason_perfect_week": "Perfect week bonus!",
"activity.reason_pool_refund_assignment_changed": "Pool refund (reward assignment changed): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool refund (reward cost reduced): {name}",
"activity.reason_pool_refund_deleted": "Pool refund (reward deleted): {name}",
"activity.reason_pool_refund_expired": "Pool refund (reward expired): {name}",
"activity.reason_pool_refund_funding_changed": "Pool refund (reward funding changed): {name}",
"activity.reason_pool_refund_sold_out": "Pool refund (reward sold out): {name}",
"activity.reason_streak_milestone": "Streak milestone bonus ({days} day streak!)",
"activity.reason_weekend_bonus": "Weekend bonus (×{multiplier})",
@@ -159,6 +162,7 @@
"badges.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.one_more": "One more and it's yours!",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -231,6 +235,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Write what you did first.",
"child.open_ended_tag": "Describe it",
"child.weekly_target_done": "Done for this week",
"child.weekly_target_progress": "{done}/{target} this week",
"child.photo_take": "Take a photo",
"child.photo_hint": "Show your finished chore",
"child.photo_retake": "Retake",
@@ -654,6 +660,9 @@
"panel.chore_daily_cap_label": "Daily cap (minutes, 0 = unlimited)",
"panel.chore_daily_limit_label": "Daily limit",
"panel.chore_days_label": "Days of week (leave empty = every day)",
"panel.chore_weekly_target_pill": "{count}×/week",
"panel.chore_weekly_target_hint": "How many times a week this has to be done, on any of the chosen days. It stops being offered once the count is reached, and resets on Monday.",
"panel.chore_weekly_target_label": "Weekly target (0 = off)",
"panel.chore_description_label": "Description (optional)",
"panel.chore_enabled_label": "Enabled",
"panel.chore_first_occurrence_label": "First occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Default card design",
"panel.settings_card_design_hint": "The style new cards use unless overridden per card.",
"activity.a_chore": "a chore",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Penalty: {name}",
"activity.reason_perfect_week": "Perfect week bonus!",
"activity.reason_pool_refund_assignment_changed": "Pool refund (reward assignment changed): {name}",
"activity.reason_pool_refund_cost_reduced": "Pool refund (reward cost reduced): {name}",
"activity.reason_pool_refund_deleted": "Pool refund (reward deleted): {name}",
"activity.reason_pool_refund_expired": "Pool refund (reward expired): {name}",
"activity.reason_pool_refund_funding_changed": "Pool refund (reward funding changed): {name}",
"activity.reason_pool_refund_sold_out": "Pool refund (reward sold out): {name}",
"activity.reason_streak_milestone": "Streak milestone bonus ({days} day streak!)",
"activity.reason_weekend_bonus": "Weekend bonus (×{multiplier})",
@@ -159,6 +162,7 @@
"badges.entity_not_found": "{entity} not found",
"badges.label": "Badges",
"badges.next_up": "Next up",
"badges.one_more": "One more and it's yours!",
"badges.title_with_name": "{name}'s Badges",
"bonuses.add_bonus": "Add Bonus",
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
@@ -231,6 +235,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Write what you did first.",
"child.open_ended_tag": "Describe it",
"child.weekly_target_done": "Done for this week",
"child.weekly_target_progress": "{done}/{target} this week",
"child.photo_take": "Take a photo",
"child.photo_hint": "Show your finished chore",
"child.photo_retake": "Retake",
@@ -654,6 +660,9 @@
"panel.chore_daily_cap_label": "Daily cap (minutes, 0 = unlimited)",
"panel.chore_daily_limit_label": "Daily limit",
"panel.chore_days_label": "Days of week (leave empty = every day)",
"panel.chore_weekly_target_pill": "{count}×/week",
"panel.chore_weekly_target_hint": "How many times a week this has to be done, on any of the chosen days. It stops being offered once the count is reached, and resets on Monday.",
"panel.chore_weekly_target_label": "Weekly target (0 = off)",
"panel.chore_description_label": "Description (optional)",
"panel.chore_enabled_label": "Enabled",
"panel.chore_first_occurrence_label": "First occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Graphite",
"panel.settings_card_design_label": "Design de carte par défaut",
"panel.settings_card_design_hint": "Le style des nouvelles cartes, sauf remplacement par carte.",
"activity.a_chore": "une tâche",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus : {name}",
"activity.reason_penalty": "Pénalité : {name}",
"activity.reason_perfect_week": "Bonus de semaine parfaite !",
"activity.reason_pool_refund_assignment_changed": "Remboursement de cagnotte (attribution de la récompense modifiée) : {name}",
"activity.reason_pool_refund_cost_reduced": "Remboursement de cagnotte (coût réduit) : {name}",
"activity.reason_pool_refund_deleted": "Remboursement de cagnotte (récompense supprimée) : {name}",
"activity.reason_pool_refund_expired": "Remboursement de cagnotte (récompense expirée) : {name}",
"activity.reason_pool_refund_funding_changed": "Remboursement de cagnotte (financement de la récompense modifié) : {name}",
"activity.reason_pool_refund_sold_out": "Remboursement de cagnotte (récompense épuisée) : {name}",
"activity.reason_streak_milestone": "Bonus de palier de série ({days} jours de suite !)",
"activity.reason_weekend_bonus": "Bonus de week-end (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} introuvable",
"badges.label": "Badges",
"badges.next_up": "Prochain",
"badges.one_more": "Encore un et il est à toi !",
"badges.title_with_name": "Badges de {name}",
"bonuses.add_bonus": "Ajouter un bonus",
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Envoyer",
"child.extra_needs_desc": "Écris d'abord ce que tu as fait.",
"child.open_ended_tag": "À décrire",
"child.weekly_target_done": "Terminé pour cette semaine",
"child.weekly_target_progress": "{done}/{target} cette semaine",
"child.photo_take": "Prendre une photo",
"child.photo_hint": "Montre ta tâche terminée",
"child.photo_retake": "Reprendre",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Plafond journalier (minutes, 0 = illimité)",
"panel.chore_daily_limit_label": "Limite quotidienne",
"panel.chore_days_label": "Jours de la semaine (vide = tous les jours)",
"panel.chore_weekly_target_pill": "{count}×/semaine",
"panel.chore_weekly_target_hint": "Combien de fois par semaine la tâche doit être faite, n'importe lesquels des jours choisis. Elle n'est plus proposée une fois le compte atteint et repart le lundi.",
"panel.chore_weekly_target_label": "Objectif hebdomadaire (0 = désactivé)",
"panel.chore_description_label": "Description (optionnel)",
"panel.chore_enabled_label": "Activé",
"panel.chore_first_occurrence_label": "Première occurrence",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafitt",
"panel.settings_card_design_label": "Standard kortdesign",
"panel.settings_card_design_hint": "Stilen nye kort bruker med mindre den overstyres per kort.",
"activity.a_chore": "en oppgave",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Straff: {name}",
"activity.reason_perfect_week": "Perfekt uke-bonus!",
"activity.reason_pool_refund_assignment_changed": "Refusjon fra sparegris (belønningens tildeling endret): {name}",
"activity.reason_pool_refund_cost_reduced": "Refusjon fra sparegris (kostnad redusert): {name}",
"activity.reason_pool_refund_deleted": "Refusjon fra sparegris (belønning slettet): {name}",
"activity.reason_pool_refund_expired": "Refusjon fra sparegris (belønning utløpt): {name}",
"activity.reason_pool_refund_funding_changed": "Refusjon fra sparegris (belønningens finansiering endret): {name}",
"activity.reason_pool_refund_sold_out": "Refusjon fra sparegris (belønning utsolgt): {name}",
"activity.reason_streak_milestone": "Milepælsbonus ({days} dagers strek!)",
"activity.reason_weekend_bonus": "Helgebonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} ikke funnet",
"badges.label": "Merker",
"badges.next_up": "Neste",
"badges.one_more": "Bare én til, så er det ditt!",
"badges.title_with_name": "{name}s merker",
"bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Skriv først hva du gjorde.",
"child.open_ended_tag": "Beskriv",
"child.weekly_target_done": "Ferdig for denne uka",
"child.weekly_target_progress": "{done}/{target} denne uka",
"child.photo_take": "Ta et bilde",
"child.photo_hint": "Vis den ferdige oppgaven",
"child.photo_retake": "Ta på nytt",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Daglig tak (minutter, 0 = ubegrenset)",
"panel.chore_daily_limit_label": "Daglig grense",
"panel.chore_days_label": "Ukedager (la stå tomt = hver dag)",
"panel.chore_weekly_target_pill": "{count}×/uke",
"panel.chore_weekly_target_hint": "Hvor mange ganger i uka oppgaven må gjøres, på hvilke som helst av de valgte dagene. Den vises ikke lenger når antallet er nådd, og nullstilles på mandag.",
"panel.chore_weekly_target_label": "Ukesmål (0 = av)",
"panel.chore_description_label": "Beskrivelse (valgfritt)",
"panel.chore_enabled_label": "Aktivert",
"panel.chore_first_occurrence_label": "Første forekomst",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafitt",
"panel.settings_card_design_label": "Standard kortdesign",
"panel.settings_card_design_hint": "Stilen nye kort brukar med mindre den blir overstyrt per kort.",
"activity.a_chore": "ei oppgåve",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bonus: {name}",
"activity.reason_penalty": "Straff: {name}",
"activity.reason_perfect_week": "Perfekt veke-bonus!",
"activity.reason_pool_refund_assignment_changed": "Refusjon frå sparegris (løninga si tildeling endra): {name}",
"activity.reason_pool_refund_cost_reduced": "Refusjon frå sparegris (kostnad redusert): {name}",
"activity.reason_pool_refund_deleted": "Refusjon frå sparegris (løning sletta): {name}",
"activity.reason_pool_refund_expired": "Refusjon frå sparegris (løning utløpt): {name}",
"activity.reason_pool_refund_funding_changed": "Refusjon frå sparegris (løninga si finansiering endra): {name}",
"activity.reason_pool_refund_sold_out": "Refusjon frå sparegris (løning utseld): {name}",
"activity.reason_streak_milestone": "Milepælsbonus ({days} dagar på rad!)",
"activity.reason_weekend_bonus": "Helgebonus (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} ikkje funne",
"badges.label": "Merke",
"badges.next_up": "Neste",
"badges.one_more": "Berre éin til, så er det ditt!",
"badges.title_with_name": "Merka til {name}",
"bonuses.add_bonus": "Legg til bonus",
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Send",
"child.extra_needs_desc": "Skriv først kva du gjorde.",
"child.open_ended_tag": "Beskriv",
"child.weekly_target_done": "Ferdig for denne veka",
"child.weekly_target_progress": "{done}/{target} denne veka",
"child.photo_take": "Ta eit bilete",
"child.photo_hint": "Vis den ferdige oppgåva",
"child.photo_retake": "Ta på nytt",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Dagleg tak (minutt, 0 = uavgrensa)",
"panel.chore_daily_limit_label": "Dagleg grense",
"panel.chore_days_label": "Vekedagar (la stå tomt = kvar dag)",
"panel.chore_weekly_target_pill": "{count}×/veke",
"panel.chore_weekly_target_hint": "Kor mange gonger i veka oppgåva må gjerast, på kva som helst av dei valde dagane. Ho blir ikkje vist meir når talet er nådd, og blir nullstilt på måndag.",
"panel.chore_weekly_target_label": "Vekemål (0 = av)",
"panel.chore_description_label": "Skildring (valfri)",
"panel.chore_enabled_label": "Aktivert",
"panel.chore_first_occurrence_label": "Fyrste førekomst",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafite",
"panel.settings_card_design_label": "Design de card padrão",
"panel.settings_card_design_hint": "O estilo que novos cards usam, a menos que substituído por card.",
"activity.a_chore": "uma tarefa",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bônus: {name}",
"activity.reason_penalty": "Penalidade: {name}",
"activity.reason_perfect_week": "Bônus de semana perfeita!",
"activity.reason_pool_refund_assignment_changed": "Reembolso do cofrinho (atribuição da recompensa alterada): {name}",
"activity.reason_pool_refund_cost_reduced": "Reembolso do cofrinho (custo reduzido): {name}",
"activity.reason_pool_refund_deleted": "Reembolso do cofrinho (recompensa excluída): {name}",
"activity.reason_pool_refund_expired": "Reembolso do cofrinho (recompensa expirada): {name}",
"activity.reason_pool_refund_funding_changed": "Reembolso do cofrinho (financiamento da recompensa alterado): {name}",
"activity.reason_pool_refund_sold_out": "Reembolso do cofrinho (recompensa esgotada): {name}",
"activity.reason_streak_milestone": "Bônus de marco de sequência ({days} dias seguidos!)",
"activity.reason_weekend_bonus": "Bônus de fim de semana (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.one_more": "Só mais um e é seu!",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Enviar",
"child.extra_needs_desc": "Escreva primeiro o que você fez.",
"child.open_ended_tag": "Descrever",
"child.weekly_target_done": "Concluída esta semana",
"child.weekly_target_progress": "{done}/{target} esta semana",
"child.photo_take": "Tirar uma foto",
"child.photo_hint": "Mostre sua tarefa concluída",
"child.photo_retake": "Repetir",
@@ -616,6 +622,9 @@
"panel.chore_daily_cap_label": "Limite diário (minutos, 0 = ilimitado)",
"panel.chore_daily_limit_label": "Limite diário",
"panel.chore_days_label": "Dias da semana (vazio = todos os dias)",
"panel.chore_weekly_target_pill": "{count}×/semana",
"panel.chore_weekly_target_hint": "Quantas vezes por semana a tarefa precisa ser feita, em qualquer um dos dias escolhidos. Ela deixa de aparecer quando a contagem é atingida e reinicia na segunda-feira.",
"panel.chore_weekly_target_label": "Meta semanal (0 = desligado)",
"panel.chore_description_label": "Descrição (opcional)",
"panel.chore_enabled_label": "Ativada",
"panel.chore_first_occurrence_label": "Primeira ocorrência",
@@ -6,6 +6,7 @@
"common.design.playroom": "Playroom",
"common.design.console": "Console",
"common.design.cleanpro": "Clean Pro",
"common.design.graphite": "Grafite",
"panel.settings_card_design_label": "Design de cartão predefinido",
"panel.settings_card_design_hint": "O estilo que os novos cartões usam, salvo substituição por cartão.",
"activity.a_chore": "uma tarefa",
@@ -55,9 +56,11 @@
"activity.reason_bonus": "Bónus: {name}",
"activity.reason_penalty": "Penalidade: {name}",
"activity.reason_perfect_week": "Bónus de semana perfeita!",
"activity.reason_pool_refund_assignment_changed": "Reembolso do mealheiro (atribuição da recompensa alterada): {name}",
"activity.reason_pool_refund_cost_reduced": "Reembolso do mealheiro (custo reduzido): {name}",
"activity.reason_pool_refund_deleted": "Reembolso do mealheiro (recompensa eliminada): {name}",
"activity.reason_pool_refund_expired": "Reembolso do mealheiro (recompensa expirada): {name}",
"activity.reason_pool_refund_funding_changed": "Reembolso do mealheiro (financiamento da recompensa alterado): {name}",
"activity.reason_pool_refund_sold_out": "Reembolso do mealheiro (recompensa esgotada): {name}",
"activity.reason_streak_milestone": "Bónus de marco de sequência ({days} dias seguidos!)",
"activity.reason_weekend_bonus": "Bónus de fim de semana (×{multiplier})",
@@ -158,6 +161,7 @@
"badges.entity_not_found": "{entity} não encontrado",
"badges.label": "Conquistas",
"badges.next_up": "A seguir",
"badges.one_more": "Só mais um e é teu!",
"badges.title_with_name": "Conquistas de {name}",
"bonuses.add_bonus": "Adicionar Bónus",
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
@@ -229,6 +233,8 @@
"child.extra_submit": "Enviar",
"child.extra_needs_desc": "Escreve primeiro o que fizeste.",
"child.open_ended_tag": "Descrever",
"child.weekly_target_done": "Concluída esta semana",
"child.weekly_target_progress": "{done}/{target} esta semana",
"child.photo_take": "Tirar uma foto",
"child.photo_hint": "Mostra a tua tarefa terminada",
"child.photo_retake": "Repetir",
@@ -621,6 +627,9 @@
"panel.chore_daily_cap_label": "Limite diário (minutos, 0 = ilimitado)",
"panel.chore_daily_limit_label": "Limite diário",
"panel.chore_days_label": "Dias da semana (deixe vazio = todos os dias)",
"panel.chore_weekly_target_pill": "{count}×/semana",
"panel.chore_weekly_target_hint": "Quantas vezes por semana a tarefa tem de ser feita, em qualquer um dos dias escolhidos. Deixa de ser apresentada quando a contagem é atingida e reinicia à segunda-feira.",
"panel.chore_weekly_target_label": "Meta semanal (0 = desligado)",
"panel.chore_description_label": "Descrição (opcional)",
"panel.chore_enabled_label": "Ativada",
"panel.chore_first_occurrence_label": "Primeira ocorrência",
@@ -73,6 +73,8 @@ class TaskMateActivityCard extends LitElement {
['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'],
['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'],
['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'],
['Pool refund (reward funding changed):', 'activity.reason_pool_refund_funding_changed'],
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
];
@@ -603,7 +603,9 @@ class TaskMateApprovalsCard extends LitElement {
.ap-cn-photo img { width: 100%; height: 100%; object-fit: cover; }
/* Clean Pro — divided list */
.ap-cp-item { padding: 10px 0; }
.ap-cp-item { padding: 10px 0; flex-wrap: wrap; row-gap: 8px; }
.ap-cp-mid { flex: 1 1 130px; min-width: 130px; }
.ap-cp-actions { display: flex; gap: 8px; flex: 0 0 auto; margin-left: auto; }
.ap-cp-title { font-weight: 600; }
.ap-cp-sub { font-size: 12px; }
.ap-cp-photo { width: 32px; height: 32px; border-radius: 8px; overflow: hidden;
@@ -1048,13 +1050,13 @@ class TaskMateApprovalsCard extends LitElement {
return html`
<div class="row ap-cp-item" style="--ac:${tone}">
${this._av(it.childName, this._childAvatar(it.childId), tone, 38)}
<div style="flex:1;min-width:0">
<div class="ap-cp-mid">
<div class="ap-cp-title">${it.title}</div>
<div class="muted ap-cp-sub">${it.childName}${time ? html` · ${time}` : ""}</div>
</div>
${this._apPhotoDesigned(it, "ap-cp-photo")}
<span class="chip soft ap-gold-soft">+${it.points}</span>
${this._designActions(it, "ghost")}
<div class="ap-cp-actions">${this._designActions(it, "ghost")}</div>
</div>`;
}
@@ -1716,6 +1716,12 @@ class TaskMateChildCard extends LitElement {
border-radius: 3px;
transition: width 0.4s ease;
}
.next-badge-nudge {
margin-top: 5px;
font-size: 11px;
font-weight: 700;
color: var(--primary-color);
}
/* ══════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro)
@@ -1886,6 +1892,9 @@ class TaskMateChildCard extends LitElement {
.tmd-next-badge .bar i {
display: block; height: 100%; border-radius: 999px; transition: width .4s ease;
}
.tmd-next-badge .nudge {
margin-top: 5px; font-size: 11px; font-weight: 800; color: var(--tmd-accent);
}
/* Playroom is chunkier, console squares everything off, accessible needs
a solid ring rather than a dashed one to stay legible. */
:host([data-tm-design="playroom"]) .tmd-next-badge .bar { height: 8px; }
@@ -1895,6 +1904,7 @@ class TaskMateChildCard extends LitElement {
:host([data-tm-design="accessible"]) .tmd-next-badge .bar { height: 10px; }
:host([data-tm-design="accessible"]) .tmd-next-badge .nm,
:host([data-tm-design="accessible"]) .tmd-next-badge .cnt { font-size: 14px; }
:host([data-tm-design="accessible"]) .tmd-next-badge .nudge { font-size: 13px; }
/* Designed: vacation banner + swappable section */
.tmd-vacation {
@@ -1928,6 +1938,7 @@ class TaskMateChildCard extends LitElement {
show_due_days_only: true, // Whether to apply due_days filtering at all
show_badges: true, // Show badge strip between points and chores
show_next_badge: true, // Show progress toward the closest unearned badge
show_badge_nudge: true, // Encouraging line when one away from that badge
header_color: '#9b59b6',
...config,
};
@@ -2131,6 +2142,9 @@ class TaskMateChildCard extends LitElement {
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width: ${nextBadge.pct}%; background: ${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
${this._badgeNudgeText(nextBadge)
? html`<div class="next-badge-nudge">${this._badgeNudgeText(nextBadge)}</div>`
: ''}
</div>
</div>
` : ''}
@@ -2249,6 +2263,21 @@ class TaskMateChildCard extends LitElement {
return best;
}
/* "Nearly there" (#890) — the line of encouragement under the next-badge bar.
It fires on the last remaining unit and nothing else, measured in the
criterion's own units rather than percent: 90% of a ten-week badge is one
week away, 90% of a hundred-point badge is ten points away. Rare on
purpose — a line that shows for half the climb has stopped meaning
anything by the time it matters. Manual-award badges carry no criterion
and so never nudge. */
_badgeNudgeText(nextBadge) {
if (this.config.show_badge_nudge === false) return "";
const c = nextBadge?.badge?.closest_criterion;
if (!c) return "";
return Number(c.target) - Number(c.current) === 1 ? this._t("badges.one_more") : "";
}
// Built-in badge names arrive from the sensor in English; the localised
// name lives under badge.name_<suffix> (same scheme as the panel).
_badgeName(b) {
@@ -2307,6 +2336,23 @@ class TaskMateChildCard extends LitElement {
}
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
// How many times this chore can still be ticked TODAY.
//
// Normally the chore's own daily_limit, clamped to 1 for a first_come race.
// A weekly target (#883) clamps it again: once the week's quota is full there
// is no allowance left, so a chore with daily_limit 5 and 2/2 done reads as
// finished rather than staying tappable — the tap would only be one
// complete_chore refuses (#805). Only applied once the child has ticked it
// today; a quota filled on earlier days leaves the row locked instead, so a
// green "done" row never appears with no completion behind it to undo.
_effectiveDailyLimit(chore, child, countToday, isFirstCome) {
const limit = isFirstCome ? 1 : (chore.daily_limit || 1);
const target = Number(chore.weekly_target) || 0;
if (target <= 0 || countToday <= 0) return limit;
const doneThisWeek = Number((child?.weekly_chore_progress || {})[chore.id] || 0);
return Math.min(limit, countToday + Math.max(0, target - doneThisWeek));
}
_isChoreDone(chore, child, todaysCompletions) {
// async_parent_complete_chore writes child_id="__parent__" to dismiss a
// chore for the WHOLE pool with zero points — it never credits a specific
@@ -2336,7 +2382,7 @@ class TaskMateChildCard extends LitElement {
// the filter and the backend both clamp it to 1 (coord_assignments). Reading
// the raw daily_limit here would leave the winner's own row looking incomplete
// and tappable, and complete_chore would then silently no-op the tap (#805).
const done = count >= (isFirstCome ? 1 : (chore.daily_limit || 1));
const done = count >= this._effectiveDailyLimit(chore, child, count, isFirstCome);
return { done, completions: childCompletionsToday };
}
@@ -2395,8 +2441,11 @@ class TaskMateChildCard extends LitElement {
// dim/show only — hide is filtered upstream same as recurrence). Never true
// for the winner's own row: _isFirstComeLocked is only set on siblings.
const firstComeLocked = !!chore._isFirstComeLocked && !done;
// Weekly quota filled — nothing owed until Monday, so the tap is refused
// the same way a recurrence window refuses one.
const weeklyDone = !!chore._weeklyTargetMet && !done;
const onAct = () => {
if (loading || blocked || recLocked || firstComeLocked) return;
if (loading || blocked || recLocked || firstComeLocked || weeklyDone) return;
if (done) this._handleUndo(chore, child, completions);
else this._handleComplete(chore, child);
};
@@ -2409,10 +2458,11 @@ class TaskMateChildCard extends LitElement {
(blocked && dependencyMode === "dim") ||
(chore._isRecurrenceLocked && recurrenceDoneMode === "dim") ||
(chore._isFirstComeLocked && firstComeClaimedMode === "dim") ||
weeklyDone ||
chore._isLockedPreview === true
);
return {
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked,
chore, child, done, loading, onAct, index: i, dimmed, blocked, recLocked, firstComeLocked, weeklyDone,
tone: this._designTone(i),
glyph: this._choreGlyph(chore),
points: chore.effective_points ?? chore.points,
@@ -2494,6 +2544,9 @@ class TaskMateChildCard extends LitElement {
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
<i style="width:${nextBadge.pct}%;background:${this._tierColor(nextBadge.badge.tier)}"></i>
</div>
${this._badgeNudgeText(nextBadge)
? html`<div class="nudge">${this._badgeNudgeText(nextBadge)}</div>`
: ""}
</div>
</div>` : ""}
${sectionLine}
@@ -2559,8 +2612,13 @@ class TaskMateChildCard extends LitElement {
? this._t("child.claimed_by_another", { name: r.chore._firstComeCompletedByName })
: this._t("child.claimed_by_another_generic"))
: "";
// Weekly target (#883): shown from the first tick so the child can see
// where they are, not only once the quota is full.
const weeklyLabel = r.chore._weeklyTarget > 0
? this._t("child.weekly_target_progress", { done: r.chore._weeklyProgress, target: r.chore._weeklyTarget })
: "";
return html`
${r.mandatory || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel ? html`
${r.mandatory || r.photo || r.openEnded || depNames.length || recLabel || firstComeLabel || weeklyLabel ? html`
<div class="tmd-meta">
${r.mandatory ? html`<span class="tmd-tag mandatory">⚠ ${this._t("child.mandatory")}</span>` : ""}
${r.photo ? html`<span class="tmd-tag photo">📷 ${this._t("child.photo_needed")}</span>` : ""}
@@ -2568,6 +2626,7 @@ class TaskMateChildCard extends LitElement {
${depNames.length ? html`<span class="tmd-tag">🔒 ${this._t("child.blocked_by_dependency", { chores: depNames.join(", ") })}</span>` : ""}
${recLabel ? html`<span class="tmd-tag">🕒 ${recLabel}</span>` : ""}
${firstComeLabel ? html`<span class="tmd-tag">✅ ${firstComeLabel}</span>` : ""}
${weeklyLabel ? html`<span class="tmd-tag">📅 ${weeklyLabel}</span>` : ""}
</div>` : ""}
${showDesc ? html`<div class="tmd-desc">${r.chore.description}</div>` : ""}`;
}
@@ -2583,7 +2642,7 @@ class TaskMateChildCard extends LitElement {
_designDoneBtn(r, label, cls) {
return html`<button class="btn ${cls || ""}"
?disabled=${r.loading || r.blocked || r.recLocked || r.firstComeLocked}
?disabled=${r.loading || r.blocked || r.recLocked || r.firstComeLocked || r.weeklyDone}
@click=${(e) => { e.stopPropagation(); r.onAct(); }}>${label}</button>`;
}
@@ -2990,6 +3049,20 @@ class TaskMateChildCard extends LitElement {
chore._isRecurrenceLocked = chore._isRecurring && !availableNow && !completedToday;
if (chore._isRecurrenceLocked && recurrenceDoneMode === 'hide') return false;
// Weekly target (#883): the child picks which days, so the chore keeps
// being offered until the week's quota is filled. The count comes from
// the per-child map on the overview sensor — the card only ever sees
// today's completions, so it can't total a week by itself. Excluding
// completedToday keeps the row live for an undo when today's tick was
// the one that filled it, exactly as the recurrence lock does.
chore._weeklyTarget = Number(chore.weekly_target) || 0;
chore._weeklyProgress = chore._weeklyTarget > 0
? Number((child.weekly_chore_progress || {})[chore.id] || 0)
: 0;
chore._weeklyTargetMet = chore._weeklyTarget > 0
&& chore._weeklyProgress >= chore._weeklyTarget
&& !completedToday;
// Mark whether this chore's time period has elapsed (grace-aware).
chore._isTimeElapsed = this._isTimePeriodElapsed(chore);
@@ -3494,7 +3567,7 @@ class TaskMateChildCard extends LitElement {
// Clamped for first_come the same way the filter and the backend clamp it: a
// stored daily_limit > 1 must not make the winner's finished row read as still
// outstanding, or it invites a tap that complete_chore silently swallows (#805).
const dailyLimit = isFirstCome ? 1 : (chore.daily_limit || 1);
const dailyLimit = this._effectiveDailyLimit(chore, child, completionsToday, isFirstCome);
// Check for optimistic completions (chores just completed but not yet confirmed by HA)
const optimisticKey = `${chore.id}_${child.id}`;
@@ -3547,6 +3620,9 @@ class TaskMateChildCard extends LitElement {
const firstComeClaimedMode = this.config.first_come_claimed_mode || 'hide';
const firstComeLocked = !!chore._isFirstComeLocked && !isCompletedForToday;
const notAvailableFirstCome = firstComeLocked && firstComeClaimedMode === 'dim';
// Weekly quota filled (#883). Dimmed and non-interactive: the backend
// would refuse the completion, so a live row would just swallow the tap.
const weeklyDone = !!chore._weeklyTargetMet && !isCompletedForToday;
// Elapsed: only dim incomplete chores — completed ones keep their green "done" style
const elapsedTimeMode = this.config.elapsed_time_mode || 'dim';
const timeElapsed = chore._isTimeElapsed && !isCompletedForToday && elapsedTimeMode === 'dim';
@@ -3570,13 +3646,14 @@ class TaskMateChildCard extends LitElement {
if (depBlocked) return; // Prerequisite chore not approved yet
if (timeElapsed) return; // Time period passed — not interactive
if (firstComeLocked) return; // Claimed by another child today — not interactive
if (weeklyDone) return; // Weekly target already met — nothing owed until Monday
if (isCompletedForToday) {
this._handleUndo(chore, child, childCompletionsToday);
} else {
this._handleComplete(chore, child);
}
};
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed || firstComeLocked);
const isInteractive = !(isLoading || notDueToday || recurrenceLocked || isLockedPreview || depBlocked || timeElapsed || firstComeLocked || weeklyDone);
const handleRowKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -3596,13 +3673,15 @@ class TaskMateChildCard extends LitElement {
? (chore._firstComeCompletedByName
? this._t('child.claimed_by_another', { name: chore._firstComeCompletedByName })
: this._t('child.claimed_by_another_generic'))
: weeklyDone
? this._t('child.weekly_target_done')
: isCompletedForToday
? this._t('child.click_to_undo')
: this._t('child.click_to_complete');
return html`
<div
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence ? "recurrence-unavailable" : ""} ${notAvailableFirstCome ? "first-come-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""} ${depDimmed ? "dependency-blocked" : ""}"
class="chore-card ${chore.mandatory ? "mandatory" : ""} ${isLoading ? "loading" : ""} ${isCelebrating ? "celebrating" : ""} ${isCompletedForToday ? "completed" : ""} ${notDueToday ? "not-due-today" : ""} ${notAvailableRecurrence || weeklyDone ? "recurrence-unavailable" : ""} ${notAvailableFirstCome ? "first-come-unavailable" : ""} ${timeElapsed ? "time-elapsed" : ""} ${isLockedPreview ? "chore-locked" : ""} ${depDimmed ? "dependency-blocked" : ""}"
role="button"
tabindex="${isInteractive ? '0' : '-1'}"
aria-disabled="${isInteractive ? 'false' : 'true'}"
@@ -3646,6 +3725,7 @@ class TaskMateChildCard extends LitElement {
<ha-icon icon="${pointsIcon}"></ha-icon>
+${chore.effective_points ?? chore.points}
${dailyLimit > 1 ? html`<span style="font-size: 0.8em; opacity: 0.7;">(${completionsToday}/${dailyLimit})</span>` : ''}
${chore._weeklyTarget > 0 ? html`<span style="font-size: 0.8em; opacity: 0.7;">${this._t('child.weekly_target_progress', { done: chore._weeklyProgress, target: chore._weeklyTarget })}</span>` : ''}
</div>
${chore.require_photo && !isCompletedForToday ? html`
<div class="recurrence-label">
@@ -13,11 +13,21 @@
* console — dark gamified HUD
* cleanpro — calm productivity / SaaS
* accessible — high contrast, colour-blind safe, dyslexia-friendly type
* graphite — corporate/system look; reuses the cleanpro LAYOUT (see
* LAYOUT_ALIAS) and restyles it entirely through tokens + kit
*
* Mirrors window.__taskmate_localize: exposed globally, no ES module imports.
*/
(function () {
const IDS = ["classic", "playroom", "console", "cleanpro", "accessible"];
const IDS = ["classic", "playroom", "console", "cleanpro", "accessible", "graphite"];
// A design whose markup is another design's. apply() stamps the real id on
// the host (so the style's CSS applies) but hands cards the layout id, so
// every existing `design === "cleanpro"` branch picks graphite up too. Without
// this a new id falls down each card's final `else`, which is playroom in
// some cards and cleanpro in others — the classic "works on one style,
// invisible on the rest" bug.
const LAYOUT_ALIAS = { graphite: "cleanpro" };
// Fonts must be loaded at the document level (an @import inside a shadow
// stylesheet is honoured, but loading once globally avoids N duplicate fetches).
@@ -56,6 +66,23 @@
--tmd-c1:#4F6BED;--tmd-c2:#0FB5A8;--tmd-c3:#E0A100;--tmd-c4:#7A5AF0;--tmd-c5:#E0567A;--tmd-c6:#2BA84A;
}
:host([data-tm-design="graphite"]),[data-tm-design="graphite"]{
/* Neutrals carry the card; the only saturated element is the header band
(per-card header_color). Actions are near-black rather than a coloured
accent, and colour is reserved for meaning: good / warn / bad. */
--tmd-bg:#EFEFF2;--tmd-surface:#FFFFFF;--tmd-surface-2:#F5F5F7;--tmd-border:#D2D2D7;
--tmd-text:#1D1D1F;--tmd-dim:#6E6E73;
--tmd-accent:#1D1D1F;--tmd-accent2:#6E6E73;--tmd-good:#1D7A4C;--tmd-warn:#B25E00;--tmd-bad:#B3261E;--tmd-gold:#B25E00;
--tmd-radius:18px;--tmd-radius-sm:12px;--tmd-shadow:none;--tmd-hd-text:#fff;
/* The system stack resolves to real SF Pro on Apple hardware; Inter (already
in FONT_IMPORT) is the fallback everywhere else. Figures are kept aligned
with font-variant-numeric rather than a separate monospace face. */
--tmd-font-display:-apple-system,BlinkMacSystemFont,"SF Pro Display","Inter",system-ui,sans-serif;
--tmd-font-body:-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
--tmd-font-mono:-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
--tmd-c1:#1F5F8B;--tmd-c2:#146356;--tmd-c3:#8A4B12;--tmd-c4:#3E4B8B;--tmd-c5:#6B3D6B;--tmd-c6:#2F4858;
}
/* ── Dark-mode variants ──────────────────────────────────────────────────
Cards stamp data-tm-dark on the host when HA is in dark mode. Each design has
a light base (above) and a dark override here, so all three follow HA's
@@ -93,6 +120,16 @@
--tmd-text:#E6EAF1;--tmd-dim:#97A1B0;--tmd-accent:#6E86F5;--tmd-accent2:#1FC7B8;
--tmd-shadow:0 1px 2px rgba(0,0,0,.4),0 8px 18px rgba(0,0,0,.35);
}
:host([data-tm-design="graphite"][data-tm-dark]),[data-tm-design="graphite"][data-tm-dark]{
/* True black behind elevated sheets. --tmd-accent stays a mid neutral so a
card with no header_color still has a legible band with white text; the
button colour is derived from --tmd-text instead, so it inverts properly. */
--tmd-bg:#000000;--tmd-surface:#1C1C1E;--tmd-surface-2:#2C2C2E;--tmd-border:#38383A;
--tmd-text:#F5F5F7;--tmd-dim:#98989F;
--tmd-accent:#3A3A3C;--tmd-accent2:#98989F;--tmd-good:#30A46C;--tmd-warn:#D98324;--tmd-bad:#E5484D;--tmd-gold:#D98324;
--tmd-shadow:none;
--tmd-c1:#2C7CB0;--tmd-c2:#1E8C77;--tmd-c3:#B06A26;--tmd-c4:#5B6AC0;--tmd-c5:#945993;--tmd-c6:#4A6B80;
}
:host([data-tm-design="accessible"][data-tm-dark]),[data-tm-design="accessible"][data-tm-dark]{
/* Pure black would bloom on OLED and is harsh for astigmatism; #0A0A0A with
near-white text still clears AAA. Hues are the Okabe-Ito light variants,
@@ -145,7 +182,142 @@
.stat{background:var(--tmd-surface-2);border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);padding:10px 11px}
.stat .k{font-size:11px;font-weight:700;color:var(--tmd-dim);text-transform:uppercase;letter-spacing:.04em}
.stat .v{font-family:var(--tmd-font-display);font-weight:800;font-size:20px;margin-top:2px}
.grid{display:grid;gap:10px}`;
.grid{display:grid;gap:10px}
/* ── Graphite ─────────────────────────────────────────────────────────────
Graphite has no render path of its own: it borrows the Clean Pro markup
(LAYOUT_ALIAS) and is defined entirely by the rules below, so all cards
adopt it at once. Selectors are :host([...]) + class, which out-specifies
the '.tmd-check .c-num' pattern the cards use for their own design CSS. */
:host([data-tm-design="graphite"]) .tmd{border:1px solid var(--tmd-border);box-shadow:none}
/* The header band stays full colour — it is the one saturated element. */
:host([data-tm-design="graphite"]) .tmd-hd{padding:13px 15px 12px}
:host([data-tm-design="graphite"]) .tmd-hd .tt{font-weight:600;font-size:17px;letter-spacing:-.42px}
:host([data-tm-design="graphite"]) .tmd-hd .tt small{font-weight:400;font-size:13px;opacity:.78;letter-spacing:-.06px;margin-top:2px}
:host([data-tm-design="graphite"]) .tmd-hd .ic{border-radius:50%;background:rgba(255,255,255,.2)}
:host([data-tm-design="graphite"]) .tmd-hd .pill{background:transparent;padding:0;font-weight:500;font-size:15px;letter-spacing:-.2px;text-transform:none}
:host([data-tm-design="graphite"]) .tmd-hd .cnt{background:rgba(255,255,255,.22);font-weight:500;font-variant-numeric:tabular-nums}
/* Actions are near-black on light and near-white on dark. Deriving them from
--tmd-text (not --tmd-accent) is what makes both directions invert. */
:host([data-tm-design="graphite"]) .btn{background:var(--tmd-text);color:var(--tmd-surface);
font-family:var(--tmd-font-body);font-weight:500;letter-spacing:-.2px;
border-radius:9px;box-shadow:none}
:host([data-tm-design="graphite"]) .btn.ghost{background:transparent;color:var(--tmd-text);border:1px solid var(--tmd-border)}
/* "good" marks the primary confirm (Approve / Dismiss). Graphite draws the
primary action in near-black like every other one; green is reserved for
reporting a completed state, not for a button. */
:host([data-tm-design="graphite"]) .btn.good{background:var(--tmd-text);color:var(--tmd-surface)}
:host([data-tm-design="graphite"]) .btn.bad{background:transparent;color:var(--tmd-bad)}
:host([data-tm-design="graphite"]) .btn.round{border-radius:50%}
/* Figures hold their column as they tick over. */
:host([data-tm-design="graphite"]) .num,
:host([data-tm-design="graphite"]) .big,
:host([data-tm-design="graphite"]) .stat .v{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
/* A chip reports a value here; it is not a highlight. */
:host([data-tm-design="graphite"]) .chip{background:transparent;border-color:transparent;color:var(--tmd-dim);
font-weight:500;font-variant-numeric:tabular-nums}
:host([data-tm-design="graphite"]) .chip.soft{color:var(--tmd-dim)}
/* 4px track rather than a fat rounded pill. */
:host([data-tm-design="graphite"]) .bar{height:4px;border:0;border-radius:2px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .bar>i{box-shadow:none}
:host([data-tm-design="graphite"]) .av{box-shadow:none}
:host([data-tm-design="graphite"]) .divide{background:var(--tmd-border)}
/* Stat cells are separated by hairlines, not drawn as nested boxes. */
:host([data-tm-design="graphite"]) .stat{background:transparent;border:0;border-radius:0;padding:0 12px}
:host([data-tm-design="graphite"]) .stat .k{text-transform:none;letter-spacing:-.04px;font-weight:400;font-size:12.5px}
/* Grouped inset list: one hairline container, separators starting past the
leading control, and the leading badge reduced to a completion mark. */
:host([data-tm-design="graphite"]) .tmd-checklist{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .tmd-check{position:relative;padding:9px 12px;min-height:46px;box-sizing:border-box}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check{border-top:0}
:host([data-tm-design="graphite"]) .tmd-check+.tmd-check::before{content:"";position:absolute;top:0;left:46px;right:0;height:1px;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-check .c-num{width:22px;height:22px;border-radius:50%;
background:transparent;border:1.5px solid var(--tmd-border);color:transparent;font-size:0}
:host([data-tm-design="graphite"]) .tmd-check.done .c-num{background:var(--tmd-good);border-color:var(--tmd-good);color:#fff;font-size:13px}
:host([data-tm-design="graphite"]) .tmd-check .c-name{font-weight:450;font-size:15px;letter-spacing:-.18px}
/* Dimming already says "done"; the strike-through is redundant noise. */
:host([data-tm-design="graphite"]) .tmd-check.done .c-name{text-decoration:none}
:host([data-tm-design="graphite"]) .tmd-check.done{opacity:.52}
/* Meta reads as quiet text, not as a row of coloured pills. */
:host([data-tm-design="graphite"]) .tmd-tag{background:transparent;border-color:transparent;
color:var(--tmd-dim);font-weight:400;font-size:12.5px;letter-spacing:-.02px;padding:0;text-transform:none}
:host([data-tm-design="graphite"]) .tmd-tag.mandatory{color:var(--tmd-bad)}
:host([data-tm-design="graphite"]) .tmd-tag.photo{color:var(--tmd-dim)}
/* The decorative emoji tile in a card header is the loudest "kids app" tell
left in the designed markup, and it is chrome rather than user data — the
child card puts a real avatar here instead, which is kept. */
:host([data-tm-design="graphite"]) .tmd-hd .ic{display:none}
/* Sentence case throughout: tracked-out uppercase labels are not this style. */
:host([data-tm-design="graphite"]) .lbl,
:host([data-tm-design="graphite"]) .ap-group-label,
:host([data-tm-design="graphite"]) .rw-jackpot-label,
:host([data-tm-design="graphite"]) .tmd-badges .lbl{text-transform:none;letter-spacing:-.04px;font-weight:500;font-size:13px;color:var(--tmd-dim)}
/* Badge strip + next-badge: outlines rather than filled colour discs. */
:host([data-tm-design="graphite"]) .tmd-badges,
:host([data-tm-design="graphite"]) .tmd-next-badge{background:transparent;border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .tmd-badge-mini{background:transparent;border:1px solid var(--tmd-border);box-shadow:none}
/* The glyphs inside these discs are drawn white for a filled tier colour;
once the fill is gone they have to take the text colour or they vanish. */
:host([data-tm-design="graphite"]) .tmd-badge-mini,
:host([data-tm-design="graphite"]) .tmd-badge-mini ha-icon,
:host([data-tm-design="graphite"]) .tmd-next-badge .ic,
:host([data-tm-design="graphite"]) .tmd-next-badge .ic ha-icon{color:var(--tmd-text)}
:host([data-tm-design="graphite"]) .tmd-next-badge .ic{background:transparent;box-shadow:none}
:host([data-tm-design="graphite"]) .tmd-next-badge .bar{height:4px;border:0;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .tmd-next-badge .nm{font-weight:500}
/* Points display: tiles become a plain figure block. */
:host([data-tm-design="graphite"]) .cp-tile{background:transparent;border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);box-shadow:none}
:host([data-tm-design="graphite"]) .cp-pts,
:host([data-tm-design="graphite"]) .cp-total{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-1.2px}
:host([data-tm-design="graphite"]) .cp-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .cp-rank,
:host([data-tm-design="graphite"]) .cp-chips .chip{background:transparent;border-color:transparent;color:var(--tmd-dim);font-weight:500}
/* Rewards: a grouped list of fixed costs, not a deck of tinted cards. */
:host([data-tm-design="graphite"]) .rw-list{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm);gap:0}
:host([data-tm-design="graphite"]) .rw-card{background:transparent;border:0;border-radius:0;box-shadow:none}
:host([data-tm-design="graphite"]) .rw-card+.rw-card{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .rw-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .rw-cost{font-variant-numeric:tabular-nums;font-weight:500;color:var(--tmd-dim)}
:host([data-tm-design="graphite"]) .rw-emoji{font-size:0}
:host([data-tm-design="graphite"]) .rw-pool-bar{height:4px;border:0;background:var(--tmd-border)}
:host([data-tm-design="graphite"]) .rw-jackpot{background:transparent;border:1px solid var(--tmd-border)}
/* Approvals: one grouped queue; the destructive action is text, not a slab. */
:host([data-tm-design="graphite"]) .ap-cp-item{background:transparent;border:0;border-radius:0;box-shadow:none}
:host([data-tm-design="graphite"]) .ap-cp-item+.ap-cp-item{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ap-cp-title{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .ap-cp-sub{color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .ap-cp-photo{border-radius:8px;border:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ap-gold-soft{background:transparent;color:var(--tmd-dim)}
:host([data-tm-design="graphite"]) .approve-all-btn{background:transparent;color:#fff;border:1px solid rgba(255,255,255,.45);font-weight:500}
/* Leaderboard: rank, name, figure — right-aligned and tabular. */
:host([data-tm-design="graphite"]) .lb-cp-row{background:transparent;border:0;border-radius:0}
:host([data-tm-design="graphite"]) .lb-cp-row+.lb-cp-row{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .lb-cp-rank{background:transparent;border:0;color:var(--tmd-dim);font-variant-numeric:tabular-nums;font-weight:500}
:host([data-tm-design="graphite"]) .lb-cp-name{font-weight:500;letter-spacing:-.18px}
:host([data-tm-design="graphite"]) .lb-cp-score{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .lb-cp-unit{color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .lb-cp-row.win{background:transparent}
:host([data-tm-design="graphite"]) .lb-cp-meta ha-icon{--mdc-icon-size:14px;vertical-align:-2px;margin-right:3px;color:var(--tmd-dim)}
/* Overview: hairline-separated stats, then a grouped per-child list. */
:host([data-tm-design="graphite"]) .ov-kids{border:1px solid var(--tmd-border);border-radius:var(--tmd-radius-sm)}
:host([data-tm-design="graphite"]) .ov-kid{background:transparent;border:0;border-radius:0}
:host([data-tm-design="graphite"]) .ov-kid+.ov-kid{border-top:1px solid var(--tmd-border)}
:host([data-tm-design="graphite"]) .ov-kid-flag{background:transparent;border:0;color:var(--tmd-dim);font-weight:400}
:host([data-tm-design="graphite"]) .ov-alert{background:transparent;border:1px solid var(--tmd-border);color:var(--tmd-text)}
:host([data-tm-design="graphite"]) .ov-today .val,
:host([data-tm-design="graphite"]) .pts{font-variant-numeric:tabular-nums;font-weight:600;letter-spacing:-.6px}
:host([data-tm-design="graphite"]) .nm,
:host([data-tm-design="graphite"]) .ttl{font-weight:500;letter-spacing:-.18px}`;
if (!document.getElementById("taskmate-design-fonts")) {
const styleEl = document.createElement("style");
@@ -249,7 +421,14 @@
el.setAttribute("data-tm-design", design);
el.toggleAttribute("data-tm-dark", design !== "classic" && isDark(hass, el));
}
return design;
// The host keeps the real id (its CSS keys off it); the card gets the
// layout id so aliased styles reuse an existing render path.
return LAYOUT_ALIAS[design] || design;
}
/** The real design id stamped on a card host (not the layout alias). */
function active(el) {
return (el && el.getAttribute("data-tm-design")) || "classic";
}
/** ha-form select options for a per-card design override (includes "use global"). */
@@ -262,6 +441,7 @@
{ value: "console", label: label("console", "Console") },
{ value: "cleanpro", label: label("cleanpro", "Clean Pro") },
{ value: "accessible", label: label("accessible", "Accessible") },
{ value: "graphite", label: label("graphite", "Graphite") },
];
}
@@ -307,7 +487,7 @@
`;
}
window.__taskmate_design = { IDS, resolve, isDark, apply, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
window.__taskmate_design = { IDS, resolve, isDark, apply, active, editorOptions, styles, cssText, tokensCSS: TOKENS, colourPicker };
/**
* The single place that decides what a chore looks like (#750).
@@ -659,10 +659,17 @@ class TaskMateLeaderboardCard extends LitElement {
_lbMetaChips(r, ctx, cls) {
const sortBy = ctx.sortBy;
const chips = [];
if (this.config.show_streak !== false && sortBy !== "streak") chips.push(html`<span>🔥 ${r.streak}</span>`);
if (this.config.show_weekly !== false && sortBy !== "weekly") chips.push(html`<span>📅 ${r.weekly}</span>`);
if (sortBy !== "points") chips.push(html`<span>⭐ ${r.points}</span>`);
if (this.config.show_career !== false && sortBy !== "career") chips.push(html`<span>🏆 ${r.career}</span>`);
// Graphite carries no emoji chrome, so the same figures get monoline
// icons there. active() returns the real design id (apply() hands the
// render path the layout alias, which for graphite is "cleanpro").
const mono = window.__taskmate_design?.active?.(this) === "graphite";
const stat = (icon, emoji, value) => (mono
? html`<span><ha-icon icon="${icon}"></ha-icon>${value}</span>`
: html`<span>${emoji} ${value}</span>`);
if (this.config.show_streak !== false && sortBy !== "streak") chips.push(stat("mdi:fire", "🔥", r.streak));
if (this.config.show_weekly !== false && sortBy !== "weekly") chips.push(stat("mdi:calendar-blank-outline", "📅", r.weekly));
if (sortBy !== "points") chips.push(stat("mdi:star-outline", "⭐", r.points));
if (this.config.show_career !== false && sortBy !== "career") chips.push(stat("mdi:trophy-outline", "🏆", r.career));
if (!chips.length) return "";
return html`<div class="row muted ${cls}">${chips}</div>`;
}
@@ -581,6 +581,12 @@ class TaskMateOverviewCard extends LitElement {
const perChild = availability[c.id];
if (perChild && perChild[child.id] === false) return false;
}
// Weekly target (#883): the week's quota is filled, so the chore isn't
// outstanding today. Filtering here rather than at the two call sites
// keeps it out of the progress ring as well — otherwise the ring could
// never reach 100% once a weekly chore was finished for the week.
const weeklyTarget = Number(c.weekly_target) || 0;
if (weeklyTarget > 0 && Number((child.weekly_chore_progress || {})[c.id] || 0) >= weeklyTarget) return false;
return true;
});
}
@@ -345,6 +345,8 @@ class TaskMatePanel extends HTMLElement {
['Pool refund (reward sold out):', 'activity.reason_pool_refund_sold_out'],
['Pool refund (reward cost reduced):', 'activity.reason_pool_refund_cost_reduced'],
['Pool refund (reward deleted):', 'activity.reason_pool_refund_deleted'],
['Pool refund (reward funding changed):', 'activity.reason_pool_refund_funding_changed'],
['Pool refund (reward assignment changed):', 'activity.reason_pool_refund_assignment_changed'],
['Penalty:', 'activity.reason_penalty'],
['Bonus:', 'activity.reason_bonus'],
];
@@ -1417,6 +1419,7 @@ class TaskMatePanel extends HTMLElement {
timed_rate_points: 10, timed_rate_minutes: 5, timed_max_daily_minutes: 0,
assigned_to: [], requires_approval: true,
time_category: "anytime", completion_sound: "coin", daily_limit: 1,
weekly_target: 0,
difficulty: "medium",
claim_allowance_minutes: 0,
schedule_mode: "specific_days",
@@ -1657,6 +1660,7 @@ class TaskMatePanel extends HTMLElement {
completion_sound: d.completion_sound || "coin",
difficulty: d.difficulty || "medium",
daily_limit: Number(d.daily_limit) || 1,
weekly_target: Math.max(0, Number(d.weekly_target) || 0),
schedule_mode: d.schedule_mode || "specific_days",
due_days: d.due_days || [],
recurrence: d.recurrence || "weekly",
@@ -3235,7 +3239,7 @@ class TaskMatePanel extends HTMLElement {
ts: c.approved_at || c.claimed_at, kind: "claim",
child: (childById[c.child_id] || {}).name || "?",
label: `${this._t("panel.activity_claimed_reward", {name: (rewardById[c.reward_id] || {}).name || this._t("panel.activity_deleted_reward")})}`,
points: -((rewardById[c.reward_id] || {}).cost || 0),
points: -(c.approved_cost ?? (rewardById[c.reward_id] || {}).cost ?? 0),
}));
transactions.forEach(t => events.push({
ts: t.created_at, kind: "manual",
@@ -3283,6 +3287,10 @@ class TaskMatePanel extends HTMLElement {
chorePoints = Math.floor(c.timed_duration_seconds / rateSeconds) * (chore.timed_rate_points || 0);
}
}
// What the child was promised when they submitted beats anything
// recalculated here: a chore edited while the work sat in the
// queue, or a speed/roulette bonus that no longer applies.
chorePoints = c.submitted_points ?? chorePoints;
const photoCap = [choreName, (child && child.name) || "", c.completed_at ? new Date(c.completed_at).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""].filter(Boolean).join(" · ");
return `
<div class="tm-approval-item">
@@ -3464,6 +3472,11 @@ class TaskMatePanel extends HTMLElement {
? this._t("panel.common_one_shot")
: ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · "));
const schedClass = c.schedule_mode === "recurring" ? "tm-pill-accent" : c.schedule_mode === "one_shot" ? "tm-pill-warn" : "tm-pill-success";
// A weekly quota doesn't replace the day list — the chore is still only
// offered on those days, it just stops once the week's count is filled.
const weeklyPill = Number(c.weekly_target) > 0
? ` <span class="tm-pill tm-pill-accent">${this._t("panel.chore_weekly_target_pill", { count: Number(c.weekly_target) })}</span>`
: "";
const modeBadge = c.assignment_mode && c.assignment_mode !== "everyone"
? `<span class="tm-pill tm-pill-${this._esc(c.assignment_mode)}">${this._t(`panel.assign_${c.assignment_mode}_short`)}</span>` : "";
const nameCell = renaming
@@ -3485,7 +3498,7 @@ class TaskMatePanel extends HTMLElement {
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
<td>${assignedNames} ${modeBadge}</td>
<td>${currentName}</td>
<td><span class="tm-pill ${schedClass} tm-pill-dot">${this._esc(schedLabel)}</span></td>
<td><span class="tm-pill ${schedClass} tm-pill-dot">${this._esc(schedLabel)}</span>${weeklyPill}</td>
<td>${c.requires_approval ? `<span class='tm-yes'>${this._t("panel.common_yes")}</span>` : `<span class='tm-no'>${this._t("panel.common_no")}</span>`}</td>
</tr>
`;
@@ -4539,7 +4552,7 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.settings_card_design_label")}<small>${this._t("panel.settings_card_design_hint")}</small></div>
<select class="tm-select" data-setting="card_design">
${["classic","playroom","console","cleanpro"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
${["classic","playroom","console","cleanpro","accessible","graphite"].map(v => `<option value="${v}" ${v === (s.card_design || "classic") ? "selected" : ""}>${this._esc(this._t("common.design." + v))}</option>`).join("")}
</select>
</div>
<div class="tm-setting-row">
@@ -5433,6 +5446,8 @@ class TaskMatePanel extends HTMLElement {
`).join("")}
</div>
</div>
${this._field(this._t("panel.chore_weekly_target_label"), "weekly_target", d.weekly_target || 0, "number",
this._t("panel.chore_weekly_target_hint"))}
` : "",
showRecurring ? `
<div class="tm-field-row">
@@ -1042,6 +1042,10 @@ class TaskMateParentDashboardCard extends LitElement {
if (c.schedule_mode === 'recurring') {
if (perChild && perChild[child.id] === false) return false;
}
// Weekly target (#883): quota filled for the week, so it is neither
// outstanding nor part of today's total.
const weeklyTarget = Number(c.weekly_target) || 0;
if (weeklyTarget > 0 && Number((child.weekly_chore_progress || {})[c.id] || 0) >= weeklyTarget) return false;
return true;
});
const childChoreIds = new Set(childChores.map(c => c.id));
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.