59 files
This commit is contained in:
+1
-1
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"requirements": [
|
||||
"cronsim==2.7"
|
||||
],
|
||||
"version": "5.5.0"
|
||||
"version": "5.5.1"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.7.0"
|
||||
"version": "5.8.0"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ _SELECTS = [
|
||||
(
|
||||
"card_design",
|
||||
"card_design",
|
||||
["classic", "playroom", "console", "cleanpro", "accessible"],
|
||||
["classic", "playroom", "console", "cleanpro", "accessible", "graphite"],
|
||||
"classic",
|
||||
"mdi:palette",
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Reference in New Issue
Block a user