1273 lines
54 KiB
JavaScript
1273 lines
54 KiB
JavaScript
/**
|
|
* TaskMate Sticker Chart Card
|
|
*
|
|
* The fridge-door sticker chart as a whole dashboard view, driven entirely by
|
|
* TaskMate. Parents keep managing chores, points and rewards in the TaskMate
|
|
* panel; this card is only the child's side of it:
|
|
*
|
|
* summary "Today: 3 / 7 jobs done", a sticker per point earned, the
|
|
* child's photo, and blocks filling towards the chosen reward
|
|
* tiles one sticker per chore due today — tap to complete (through the
|
|
* same button entity / service TaskMate's child card uses), hold
|
|
* to undo (TaskMate only lets a parent do that)
|
|
* goal the reward being saved for, and a claim button once it's earned;
|
|
* with `goal: week` the stickers and blocks count this week's
|
|
* jobs instead (stars since Monday out of all the week offers)
|
|
*
|
|
* One column per child, side by side, or a single child with `child_id`.
|
|
*
|
|
* Data comes from the TaskMate sensors through window.__taskmate_attrs, so
|
|
* nothing here needs keeping in step by hand:
|
|
* points / photo → overview sensor children[] (photo = the child's picture entity)
|
|
* chores → chores sensor, filtered to today for each child
|
|
* done / pending → today's completions (approved vs awaiting approval)
|
|
* goal → a reward's cost (per-child cost when one is set)
|
|
*/
|
|
|
|
// HA's Lit base class, found through an element built on it: the class that
|
|
// owns the html tag helper is LitElement itself. Dashboards always define hui-masonry-view;
|
|
// the TaskMate admin panel also shows this card (to log past days) and has no
|
|
// dashboard views, so fall back to elements every HA page defines.
|
|
const LIT_HOST_TAGS = ["hui-masonry-view", "hui-view", "home-assistant-main", "ha-card"];
|
|
|
|
const findLitBase = () => {
|
|
for (const tag of LIT_HOST_TAGS) {
|
|
let C = customElements.get(tag);
|
|
while (C && C.prototype && !Object.prototype.hasOwnProperty.call(C.prototype, "html")) {
|
|
C = Object.getPrototypeOf(C);
|
|
}
|
|
if (C && C.prototype) return C;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// A slow tablet can evaluate this module before HA has registered any element
|
|
// of its own. Failing here would abort the module, so the card would never be
|
|
// defined for that page load ("Custom element doesn't exist") — a race a fast
|
|
// desktop wins and a Fire tablet can lose. Wait for one to appear instead.
|
|
let litBase = findLitBase();
|
|
if (!litBase) {
|
|
await Promise.race([
|
|
...LIT_HOST_TAGS.map(tag => customElements.whenDefined(tag)),
|
|
new Promise(resolve => setTimeout(resolve, 15000)),
|
|
]);
|
|
litBase = findLitBase();
|
|
}
|
|
if (!litBase) throw new Error("TaskMate Sticker Chart: Home Assistant's Lit base class was not found");
|
|
const LitElement = litBase;
|
|
|
|
const html = LitElement.prototype.html;
|
|
const css = LitElement.prototype.css;
|
|
|
|
// Keeps number runs like "3 / 10" reading left to right in RTL text (#995);
|
|
// identity until the design layer has loaded.
|
|
const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s);
|
|
|
|
// The sticker alphabet, cycled as points accumulate so a long row stays
|
|
// interesting instead of being forty identical stars.
|
|
const STICKERS = [
|
|
"⭐", "🌟", "🦄", "🌈", "🐶", "🍭", "🚀", "🦖",
|
|
"🐱", "🍓", "🎈", "🦋", "🐬", "🍦", "🎨", "🐸",
|
|
];
|
|
|
|
// Tile colours, assigned by position so a chart keeps its look day to day.
|
|
const GRADIENTS = [
|
|
"linear-gradient(135deg,#4facfe,#00f2fe)",
|
|
"linear-gradient(135deg,#a18cd1,#fbc2eb)",
|
|
"linear-gradient(135deg,#f6d365,#fda085)",
|
|
"linear-gradient(135deg,#84fab0,#8fd3f4)",
|
|
"linear-gradient(135deg,#a8e063,#56ab2f)",
|
|
"linear-gradient(135deg,#ff9a9e,#fad0c4)",
|
|
"linear-gradient(135deg,#e0c3fc,#8ec5fc)",
|
|
"linear-gradient(135deg,#fccb90,#d57eeb)",
|
|
"linear-gradient(135deg,#96e6a1,#d4fc79)",
|
|
"linear-gradient(135deg,#fbc2eb,#a6c1ee)",
|
|
];
|
|
|
|
// Keyword → sticker for chores whose name carries no emoji of its own.
|
|
// Starting a chore's name with an emoji in TaskMate always wins over this.
|
|
const CHORE_EMOJI = [
|
|
[/(teeth|tooth|brush)/, "🦷"], [/(toilet|potty|wee|poo)/, "🚽"],
|
|
[/(pants|knickers)/, "🩲"], [/(pyjama|pajama|jammies)/, "🌙"],
|
|
[/(shoe|trainer)/, "👟"], [/(coat|jacket)/, "🧥"],
|
|
[/(dress|clothes|uniform)/, "👚"], [/(bed)/, "🛏️"],
|
|
[/(breakfast|lunch|dinner|eat)/, "🥣"], [/(bag)/, "🎒"],
|
|
[/(story)/, "📖"], [/(read|book|homework|study|spelling)/, "📚"],
|
|
[/(hand)/, "🧼"], [/(shower|bath)/, "🛁"],
|
|
[/(kind|shar|nice|love)/, "💖"], [/(please|thank|manner)/, "🙏"],
|
|
[/(hoover|vacuum)/, "🧹"], [/(tidy|clean|sweep)/, "🧸"],
|
|
[/(laundry|washing|fold)/, "🧺"], [/(bin|rubbish|trash|recycl)/, "🗑️"],
|
|
[/(dish|plate|table)/, "🍽️"], [/(dog|cat|pet|feed)/, "🐶"],
|
|
[/(plant|water|garden)/, "🪴"], [/(music|piano|practi)/, "🎵"],
|
|
[/(run|exercise|sport|swim)/, "🏃"],
|
|
];
|
|
|
|
// Emoji at the very start of a name: "🦷 Brush teeth" → ["🦷", "Brush teeth"].
|
|
const LEADING_EMOJI = /^(\p{Extended_Pictographic}(?:\u{FE0F}|\u{200D}\p{Extended_Pictographic}|[\u{1F3FB}-\u{1F3FF}])*)\s*/u;
|
|
|
|
const PERIOD_ORDER = { morning: 0, afternoon: 1, evening: 2, anytime: 3 };
|
|
|
|
// A 500-point reward would otherwise paint 500 emoji into the page; past
|
|
// these the row is summarised and each block stands for several stars. 40 stickers
|
|
// is about three rows, which keeps the jobs on screen on a tablet.
|
|
const MAX_STICKERS = 40;
|
|
const MAX_BLOCKS = 60;
|
|
|
|
const HOLD_MS = 600; // press-and-hold that means "undo"
|
|
const OPTIMISTIC_MS = 20000;
|
|
const CONFIRM_MS = 4000; // second tap on the claim button must land in this window
|
|
|
|
class TaskMateStickerChartCard extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
hass: { type: Object },
|
|
config: { type: Object },
|
|
// A past day's completions, when `config.date` is set (the admin
|
|
// panel's "Log a past job"); today's come from the sensors instead.
|
|
dayCompletions: { type: Array },
|
|
// That day's week, per child and weekly-target chore (count so far).
|
|
dayWeekProgress: { type: Object },
|
|
// The chores TaskMate would let be logged that day, per child: the
|
|
// backend's own rules, so every schedule (recurrences, calendar moves,
|
|
// holidays) is followed without re-implementing it here.
|
|
dayLoggable: { type: Object },
|
|
_optimistic: { state: true },
|
|
_confirming: { state: true },
|
|
_celebrating: { state: true },
|
|
_confetti: { state: true },
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._optimistic = {}; // "child:chore" -> { childId, choreId, base, count, at }
|
|
this._confirming = ""; // child id whose claim button is armed
|
|
this._celebrating = null; // { day: bool, name, points } while the pop-up shows
|
|
this._confetti = [];
|
|
this._busy = new Set();
|
|
this._holdTimer = null;
|
|
this._held = false;
|
|
}
|
|
|
|
shouldUpdate(changedProps) {
|
|
if (changedProps.has("hass")) {
|
|
return window.__taskmate_hasChanged
|
|
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
|
|
: true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Drop an optimistic tick once Home Assistant has caught up with it.
|
|
*
|
|
* It must go the moment the real completion lands, not merely be ignored:
|
|
* a stale entry would resurrect the "done" look as soon as the count dropped
|
|
* again — after an undo, or a parent rejecting it in the panel.
|
|
*/
|
|
willUpdate(changedProps) {
|
|
if (!(changedProps.has("hass") || changedProps.has("dayCompletions"))) return;
|
|
if (!Object.keys(this._optimistic).length) return;
|
|
const attrs = this._attrs();
|
|
if (!attrs) return;
|
|
const completions = this._completions(attrs);
|
|
const chores = new Map((attrs.chores || []).map(c => [c.id, c]));
|
|
const next = {};
|
|
for (const [key, opt] of Object.entries(this._optimistic)) {
|
|
const chore = chores.get(opt.choreId);
|
|
const landed = chore ? this._mine(opt.childId, chore, completions).length : Infinity;
|
|
if (landed < opt.base + opt.count && Date.now() - opt.at < OPTIMISTIC_MS) next[key] = opt;
|
|
}
|
|
this._optimistic = next;
|
|
}
|
|
|
|
/**
|
|
* This child's completions of a chore today. "__parent__" is a parent
|
|
* dismissing it for the whole pool, so it counts for whoever is looking —
|
|
* except first-come, which is a race the child didn't win.
|
|
*/
|
|
_mine(childId, chore, completions) {
|
|
const firstCome = (chore.assignment_mode || "everyone") === "first_come";
|
|
return completions.filter(c => c.chore_id === chore.id
|
|
&& (String(c.child_id) === String(childId) || (c.child_id === "__parent__" && !firstCome)));
|
|
}
|
|
|
|
_t(key, params) {
|
|
const fn = window.__taskmate_localize;
|
|
return fn ? fn(this.hass, key, params) : key;
|
|
}
|
|
|
|
setConfig(config) {
|
|
if (!config.entity) throw new Error(this._t("sticker_chart.error.entity_required"));
|
|
this.config = { ...config };
|
|
}
|
|
|
|
static getStubConfig() {
|
|
return { entity: "sensor.taskmate_overview" };
|
|
}
|
|
|
|
static getConfigElement() { return document.createElement("taskmate-sticker-chart-card-editor"); }
|
|
|
|
getCardSize() { return 12; }
|
|
|
|
static get styles() {
|
|
const base = css`
|
|
:host { display: block; }
|
|
ha-card {
|
|
background: none; box-shadow: none; border: none; overflow: visible;
|
|
--panel-bg: var(--ha-card-background, var(--card-background-color, rgba(0,0,0,0.35)));
|
|
--panel-radius: var(--ha-card-border-radius, 22px);
|
|
}
|
|
|
|
.cols {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
.col { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
|
|
.panel {
|
|
background: var(--panel-bg);
|
|
border-radius: var(--panel-radius);
|
|
box-shadow: var(--ha-card-box-shadow, none);
|
|
border: var(--ha-card-border-width, 0) solid var(--ha-card-border-color, transparent);
|
|
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
|
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
|
|
}
|
|
|
|
/* -- Heading -- */
|
|
.heading {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 2px 4px 0;
|
|
color: var(--primary-text-color);
|
|
font-size: 1.05rem; font-weight: 500;
|
|
}
|
|
.heading ha-icon { --mdc-icon-size: 20px; }
|
|
.heading .title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.score {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 0.9rem; font-weight: 600; color: var(--secondary-text-color);
|
|
}
|
|
.score ha-icon { --mdc-icon-size: 16px; color: var(--tone, #3498db); }
|
|
|
|
/* -- Summary -- */
|
|
.summary { padding: 16px; }
|
|
.top-row { display: flex; align-items: flex-start; gap: 14px; }
|
|
.top-main { flex: 1; min-width: 0; }
|
|
.today {
|
|
font-size: 1rem; font-weight: 700;
|
|
color: var(--primary-text-color); margin-bottom: 10px;
|
|
}
|
|
.face {
|
|
width: 112px; height: 112px; min-width: 112px;
|
|
border-radius: 50%; overflow: hidden;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: rgba(127,127,127,0.18);
|
|
flex: none;
|
|
}
|
|
.face ha-icon { --mdc-icon-size: 60px; color: var(--primary-color); }
|
|
.stickers {
|
|
font-size: 26px; line-height: 1.3;
|
|
word-break: break-all;
|
|
}
|
|
.stickers-none, .muted {
|
|
font-size: 0.92rem; font-weight: 600; color: var(--secondary-text-color);
|
|
}
|
|
.overflow {
|
|
display: inline-block; white-space: nowrap; word-break: normal;
|
|
font-size: 0.85rem; font-weight: 800; color: var(--secondary-text-color); margin-inline-start: 4px;
|
|
}
|
|
.goal-line {
|
|
margin-top: 14px;
|
|
font-size: 0.92rem; font-weight: 700; color: var(--primary-text-color);
|
|
}
|
|
.blocks { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 6px; }
|
|
.blocks i {
|
|
width: 16px; height: 16px; border-radius: 3px;
|
|
background: #d9d9d9; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12);
|
|
}
|
|
.blocks i.on { background: #36b43a; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.22); }
|
|
.blocks-key { margin-top: 4px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-text-color); }
|
|
.reached {
|
|
margin-top: 12px; font-size: 1.1rem; font-weight: 800;
|
|
color: var(--primary-text-color);
|
|
}
|
|
|
|
/* -- Tiles -- */
|
|
.tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
.tile {
|
|
position: relative;
|
|
aspect-ratio: 1 / 1.05;
|
|
border-radius: 22px;
|
|
border: 3px dashed rgba(255,255,255,0.28);
|
|
background: rgba(255,255,255,0.07);
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
gap: 6px; padding: 8px 4px;
|
|
color: var(--secondary-text-color);
|
|
font: inherit; cursor: pointer;
|
|
transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
|
|
-webkit-tap-highlight-color: transparent;
|
|
user-select: none; -webkit-user-select: none;
|
|
touch-action: manipulation;
|
|
min-width: 0;
|
|
}
|
|
/* Older tablet browsers (some Fire OS builds) have no aspect-ratio, which
|
|
would leave a tile only as tall as its label. Keep it tappable. */
|
|
@supports not (aspect-ratio: 1 / 1) {
|
|
.tile { min-height: 96px; }
|
|
}
|
|
.tile:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 2px; }
|
|
.tile:active { transform: scale(0.96); }
|
|
.glyph {
|
|
font-size: 36px; line-height: 1;
|
|
filter: grayscale(1) opacity(0.5);
|
|
}
|
|
.glyph img {
|
|
width: 58px; height: 58px; min-width: 0; min-height: 0;
|
|
object-fit: cover; border-radius: 12px; display: block;
|
|
}
|
|
.label {
|
|
font-size: 12px; font-weight: 700; line-height: 1.2;
|
|
text-align: center; padding: 0 2px;
|
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
|
overflow: hidden; overflow-wrap: anywhere;
|
|
}
|
|
.tile.done {
|
|
background: var(--grad);
|
|
border: 3px solid rgba(255,255,255,0.95);
|
|
transform: rotate(-2deg) scale(1.03);
|
|
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
|
|
color: #fff;
|
|
}
|
|
.tile.done:active { transform: rotate(-2deg) scale(0.99); }
|
|
.tile.done .glyph { filter: none; animation: pop 0.45s cubic-bezier(.2,1.6,.4,1); }
|
|
.tile.done .label { text-shadow: 0 1px 2px rgba(0,0,0,0.45); }
|
|
.tile.partial {
|
|
border-color: rgba(255,193,7,0.85);
|
|
background: linear-gradient(90deg, rgba(255,193,7,0.32) var(--pct, 0%), rgba(255,255,255,0.07) var(--pct, 0%));
|
|
}
|
|
.tile.partial .glyph { filter: grayscale(0.3) opacity(0.9); }
|
|
.tile.pending { border-style: solid; border-color: rgba(255,193,7,0.85); background: rgba(255,193,7,0.14); }
|
|
.tile.pending .glyph { filter: grayscale(0.2) opacity(0.85); }
|
|
.count, .flag {
|
|
position: absolute; top: 6px; inset-inline-end: 8px;
|
|
font-size: 11px; font-weight: 800; padding: 2px 7px; border-radius: 999px;
|
|
background: rgba(0,0,0,0.35); color: #fff;
|
|
}
|
|
.flag { inset-inline-end: auto; inset-inline-start: 8px; }
|
|
.tile.done .count { background: rgba(255,255,255,0.9); color: #111; }
|
|
.none-today { padding: 22px 16px; text-align: center; }
|
|
|
|
/* -- Goal + claim -- */
|
|
.goal-tile {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 16px 18px; width: 50%; box-sizing: border-box;
|
|
}
|
|
.goal-tile ha-icon { --mdc-icon-size: 26px; color: #f1c40f; }
|
|
.goal-tile .gt-title { font-weight: 600; color: var(--primary-text-color); }
|
|
.goal-tile .gt-sub { font-size: 0.8rem; color: var(--secondary-text-color); }
|
|
.claim {
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
gap: 8px; padding: 18px 16px; width: 100%; box-sizing: border-box;
|
|
font: inherit; font-weight: 600; color: var(--primary-text-color);
|
|
cursor: pointer; border: none;
|
|
}
|
|
.claim ha-icon { --mdc-icon-size: 44px; }
|
|
.claim[disabled] { cursor: default; opacity: 0.6; }
|
|
.claim.armed { outline: 3px solid #f1c40f; outline-offset: -3px; }
|
|
|
|
.error {
|
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
padding: 28px 16px; text-align: center; color: var(--error-color, #f44336);
|
|
}
|
|
.error ha-icon { --mdc-icon-size: 40px; opacity: 0.6; }
|
|
|
|
/* -- Celebration: the TaskMate child card's own pop-up and confetti -- */
|
|
.celebration-overlay {
|
|
position: fixed; inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex; align-items: center; justify-content: center;
|
|
z-index: 9999;
|
|
animation: fade-in 0.3s ease;
|
|
}
|
|
.celebration-content {
|
|
background: var(--card-background-color, #fff);
|
|
border-radius: 30px;
|
|
padding: 40px 50px;
|
|
text-align: center;
|
|
animation: pop-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
position: relative; overflow: hidden;
|
|
max-width: calc(100vw - 40px); box-sizing: border-box;
|
|
}
|
|
.celebration-content.day { padding: 48px 56px; }
|
|
.celebration-stars {
|
|
font-size: 4rem; margin-bottom: 16px;
|
|
animation: star-bounce 0.6s ease infinite;
|
|
}
|
|
.celebration-title { font-size: 2.5rem; font-weight: bold; color: #9b59b6; margin-bottom: 8px; }
|
|
.celebration-content.day .celebration-title { font-size: 2.8rem; }
|
|
.celebration-message { font-size: 1.3rem; color: var(--secondary-text-color); margin-bottom: 16px; }
|
|
.celebration-points {
|
|
font-size: 1.8rem; font-weight: bold; color: #e67e22;
|
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
}
|
|
.celebration-points ha-icon { --mdc-icon-size: 28px; color: #f1c40f; }
|
|
.confetti-container {
|
|
position: fixed; inset: 0;
|
|
pointer-events: none; z-index: 10000; overflow: hidden;
|
|
}
|
|
.confetti { position: absolute; top: 0; width: 10px; height: 10px; animation: confetti-fall 3s linear forwards; }
|
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes pop-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
|
@keyframes star-bounce {
|
|
0%, 100% { transform: scale(1) rotate(0deg); }
|
|
50% { transform: scale(1.2) rotate(10deg); }
|
|
}
|
|
@keyframes confetti-fall {
|
|
0% { transform: translateY(-100px) rotate(0deg); opacity: 1; }
|
|
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
|
}
|
|
@media (max-width: 420px) {
|
|
.celebration-content { padding: 28px 24px; }
|
|
.celebration-title { font-size: 1.9rem; }
|
|
}
|
|
|
|
@keyframes pop {
|
|
0% { transform: scale(.4) rotate(-20deg); }
|
|
70% { transform: scale(1.35) rotate(6deg); }
|
|
100% { transform: scale(1) rotate(0); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.tile, .tile.done .glyph { animation: none; transition: none; }
|
|
/* The pop-up still says well done; it just doesn't bounce or rain. */
|
|
.celebration-overlay, .celebration-content, .celebration-stars { animation: none; }
|
|
.confetti-container { display: none; }
|
|
}
|
|
@media (max-width: 420px) {
|
|
.face { width: 76px; height: 76px; min-width: 76px; }
|
|
.face ha-icon { --mdc-icon-size: 42px; }
|
|
.stickers { font-size: 21px; }
|
|
.glyph { font-size: 30px; }
|
|
.goal-tile { width: 100%; }
|
|
}
|
|
`;
|
|
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
|
? window.__taskmate_design.styles() : null;
|
|
return tokens ? [tokens, base] : base;
|
|
}
|
|
|
|
// -- Data --------------------------------------------------------------------
|
|
|
|
_attrs() {
|
|
const entity = this.hass && this.hass.states[this.config.entity];
|
|
if (!entity) return null;
|
|
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity))
|
|
|| entity.attributes || {};
|
|
}
|
|
|
|
_children(attrs) {
|
|
const all = (window.__taskmate_family_children ? window.__taskmate_family_children(attrs.children || [], this.config) : attrs.children || []);
|
|
const wanted = this.config.child_id;
|
|
if (!wanted) return all;
|
|
return all.filter(c => c.id === wanted || c.name === wanted);
|
|
}
|
|
|
|
/** Per-child cost when the reward has one, else its list price. */
|
|
_cost(reward, child) {
|
|
const perChild = reward.calculated_costs || {};
|
|
const value = perChild[child.id] !== undefined ? perChild[child.id] : reward.cost;
|
|
return Math.max(0, Number(value) || 0);
|
|
}
|
|
|
|
/**
|
|
* The reward being saved up for.
|
|
*
|
|
* A configured reward wins. Otherwise the cheapest reward the child cannot
|
|
* afford yet, so there is always something to aim at; if they can afford
|
|
* everything, the dearest, so the chart reads full.
|
|
*/
|
|
_reward(rewards, child) {
|
|
const list = (rewards || []).filter(r => {
|
|
const assigned = r.assigned_to || [];
|
|
return assigned.length === 0 || assigned.includes(child.id);
|
|
});
|
|
if (this.config.reward_id) return list.find(r => r.id === this.config.reward_id) || null;
|
|
const claimable = list.filter(r => r.is_available !== false);
|
|
if (!claimable.length) return null;
|
|
const points = Math.max(0, Number(child.points) || 0);
|
|
const ahead = claimable.filter(r => this._cost(r, child) > points);
|
|
return ahead.length
|
|
? ahead.reduce((a, b) => (this._cost(b, child) < this._cost(a, child) ? b : a))
|
|
: claimable.reduce((a, b) => (this._cost(b, child) > this._cost(a, child) ? b : a));
|
|
}
|
|
|
|
/**
|
|
* Today's chores for one child, each with its completion state.
|
|
*
|
|
* Mirrors the TaskMate child card with every period shown at once — the
|
|
* sticker chart is the whole day on one screen:
|
|
* - enabled, not disabled for this child, assigned to them (or everyone)
|
|
* - rotation chores only for today's active child; first-come for the pool
|
|
* - specific-days chores only on their days (the backend leaves that to cards)
|
|
* - the backend's availability matrix for everything else (vacation,
|
|
* recurrence windows, visibility and weather gates)
|
|
* A chore already done today stays on the chart even when the matrix has
|
|
* since switched it off, so a finished sticker never vanishes.
|
|
*/
|
|
// -- Past-day mode (`config.date`, used by the admin panel) --------------------
|
|
|
|
/** The past day being shown, as a local Date at midday, or null for today. */
|
|
_pastDay() {
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(this.config?.date || "");
|
|
return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12) : null;
|
|
}
|
|
|
|
/** The completions the tiles are drawn from: the chosen day's, or today's. */
|
|
_completions(attrs) {
|
|
const list = this._pastDay() ? (this.dayCompletions || []) : (attrs.todays_completions || []);
|
|
return list.filter(c => !c.bonus_subtask_id);
|
|
}
|
|
|
|
_todaysChores(child, attrs) {
|
|
const childId = String(child.id);
|
|
const past = this._pastDay();
|
|
const todayDow = past
|
|
? past.toLocaleDateString("en-US", { weekday: "long" }).toLowerCase()
|
|
: attrs.today_day_of_week || new Date().toLocaleDateString("en-US", { weekday: "long" }).toLowerCase();
|
|
// The availability matrix describes today only, so a past day skips it.
|
|
const availability = past ? {} : (attrs.chore_availability || {});
|
|
const completions = this._completions(attrs);
|
|
const order = (child.chore_order || []).map(String);
|
|
|
|
// A past day the backend has described: only what it would accept, plus
|
|
// anything already ticked that day so it can still be undone.
|
|
const loggable = past && this.dayLoggable ? (this.dayLoggable[childId] || []).map(String) : null;
|
|
|
|
const rows = [];
|
|
for (const chore of attrs.chores || []) {
|
|
const mine = this._mine(childId, chore, completions);
|
|
if (loggable && !loggable.includes(String(chore.id)) && mine.length === 0) continue;
|
|
if (chore.enabled === false) continue;
|
|
// A one-off job is switched off for the child once it's done; its tile
|
|
// stays while it has a tick on this day, so it can still be undone.
|
|
if ((chore.disabled_for || []).map(String).includes(childId) && mine.length === 0) continue;
|
|
const assigned = (Array.isArray(chore.assigned_to) ? chore.assigned_to : []).map(String);
|
|
if (assigned.length && !assigned.includes(childId)) continue;
|
|
|
|
// An open-ended chore is a placeholder for unlisted work: the child's note
|
|
// is the job, and a tile has nowhere to take it, so it stays on the child
|
|
// card. (A completion made there still counts in the totals.)
|
|
if (chore.open_ended) continue;
|
|
|
|
const mode = chore.assignment_mode || "everyone";
|
|
// Whose turn a rotation chore was, or who made up a team, can't be
|
|
// reconstructed for a past day, and TaskMate refuses to log one, so
|
|
// it isn't offered.
|
|
if (past && (mode !== "everyone" || chore.team)) continue;
|
|
if (mode !== "everyone" && mode !== "first_come") {
|
|
const active = chore.assignment_current_child_id ? String(chore.assignment_current_child_id) : "";
|
|
if (active !== childId) continue;
|
|
}
|
|
|
|
if (!loggable && chore.schedule_mode === "specific_days") {
|
|
const days = Array.isArray(chore.due_days) ? chore.due_days : [];
|
|
if (days.length && !days.includes(todayDow) && mine.length === 0) continue;
|
|
}
|
|
|
|
const perChild = availability[chore.id];
|
|
if (perChild && perChild[childId] === false && mine.length === 0) continue;
|
|
|
|
let limit = mode === "first_come" ? 1 : Math.max(1, Number(chore.daily_limit) || 1);
|
|
// Weekly target (#883): the quota caps it a level above the day. Once the
|
|
// other days have filled it there is nothing left to tick, so the row
|
|
// goes; a tick made on this day that filled it stays, so it can still be
|
|
// undone. A past day reads the progress of that day's week instead.
|
|
const weeklyTarget = Number(chore.weekly_target) || 0;
|
|
if (weeklyTarget > 0) {
|
|
const weekProgress = past ? (this.dayWeekProgress || {})[childId] : child.weekly_chore_progress;
|
|
const left = Math.max(0, weeklyTarget - Number((weekProgress || {})[chore.id] || 0));
|
|
if (mine.length === 0 && left === 0) continue;
|
|
limit = Math.min(limit, mine.length + left);
|
|
}
|
|
const approved = mine.filter(c => c.approved).length;
|
|
|
|
const key = `${childId}:${chore.id}`;
|
|
const opt = this._optimistic[key];
|
|
const extra = opt && mine.length < opt.base + opt.count && Date.now() - opt.at < OPTIMISTIC_MS
|
|
? opt.base + opt.count - mine.length : 0;
|
|
const total = Math.min(limit, mine.length + extra);
|
|
|
|
let state = "todo";
|
|
if (approved >= limit) state = "done";
|
|
else if (total >= limit) {
|
|
// Optimistic ticks only count as done where no grown-up has to approve;
|
|
// TaskMate omits requires_approval from the payload when it is true.
|
|
state = extra > 0 && approved + extra >= limit && (past || chore.requires_approval === false) ? "done" : "pending";
|
|
}
|
|
else if (total > 0) state = "partial";
|
|
|
|
rows.push({ chore, key, limit, total, approved, state, mine });
|
|
}
|
|
|
|
const pos = (r) => {
|
|
const i = order.indexOf(String(r.chore.id));
|
|
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
|
};
|
|
rows.sort((a, b) =>
|
|
pos(a) - pos(b)
|
|
|| (PERIOD_ORDER[a.chore.time_category] ?? 9) - (PERIOD_ORDER[b.chore.time_category] ?? 9)
|
|
|| String(a.chore.name || "").localeCompare(String(b.chore.name || "")));
|
|
return rows;
|
|
}
|
|
|
|
_glyph(chore) {
|
|
const name = String(chore.name || "").trim();
|
|
const lead = name.match(LEADING_EMOJI);
|
|
const label = lead ? name.slice(lead[0].length) || name : name;
|
|
if (chore.image_url) {
|
|
return { label, glyph: html`<img src="${chore.image_url}" alt="" loading="lazy">` };
|
|
}
|
|
if (lead) return { label, glyph: lead[1] };
|
|
const n = name.toLowerCase();
|
|
for (const [re, e] of CHORE_EMOJI) if (re.test(n)) return { label, glyph: e };
|
|
return { label, glyph: "⭐" };
|
|
}
|
|
|
|
// -- Actions -----------------------------------------------------------------
|
|
|
|
_toast(message) {
|
|
this.dispatchEvent(new CustomEvent("hass-notification", {
|
|
detail: { message }, bubbles: true, composed: true,
|
|
}));
|
|
}
|
|
|
|
async _complete(child, row) {
|
|
if (row.total >= row.limit || this._busy.has(row.key)) return;
|
|
const pastDate = this._pastDay() ? this.config.date : "";
|
|
// A grown-up logging a past day is the reviewer, so no photo is needed.
|
|
if (row.chore.require_photo && !pastDate) {
|
|
this._toast(this._t("sticker_chart.needs_photo"));
|
|
return;
|
|
}
|
|
this._busy.add(row.key);
|
|
const lastOfDay = this._finishesDay(child, row);
|
|
const prev = this._optimistic[row.key];
|
|
this._optimistic = {
|
|
...this._optimistic,
|
|
[row.key]: {
|
|
childId: child.id, choreId: row.chore.id, base: row.mine.length,
|
|
count: (prev && Date.now() - prev.at < OPTIMISTIC_MS ? prev.count : 0) + 1, at: Date.now(),
|
|
},
|
|
};
|
|
try {
|
|
const button = !pastDate && window.__taskmate_find_button
|
|
&& window.__taskmate_find_button(this.hass, child.id, "complete", row.chore.id);
|
|
if (pastDate) {
|
|
await this.hass.callService("taskmate", "complete_chore", {
|
|
chore_id: row.chore.id, child_id: child.id, as_parent: true, completed_date: pastDate,
|
|
});
|
|
this._dayChanged();
|
|
} else if (button) {
|
|
await this.hass.callService("button", "press", { entity_id: button });
|
|
} else {
|
|
await this.hass.callService("taskmate", "complete_chore", {
|
|
chore_id: row.chore.id, child_id: child.id,
|
|
});
|
|
}
|
|
// Logging a past day happens in the admin panel's dialog, where a coin
|
|
// chime and a full-screen pop-up would land on the grown-up, not the child.
|
|
if (!pastDate) {
|
|
this._playSound(row.chore.completion_sound || this.config.default_sound || "coin");
|
|
this._celebrate(child, row, lastOfDay);
|
|
}
|
|
} catch (err) {
|
|
const next = { ...this._optimistic };
|
|
delete next[row.key];
|
|
this._optimistic = next;
|
|
this._failed(err, child);
|
|
} finally {
|
|
this._busy.delete(row.key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Explain a refusal rather than showing HA's raw error.
|
|
*
|
|
* A child in TaskMate can be linked to one HA user, and then only that user
|
|
* (or a grown-up) may tick their jobs. A shared family tablet signed in as
|
|
* somebody else gets "Unauthorized" from the button entity / service, which
|
|
* means nothing to whoever is standing at the fridge.
|
|
*/
|
|
_failed(err, child) {
|
|
const msg = String(err?.message || err || "");
|
|
this._toast(/unauthori|not authorized|admin|permission|forbidden/i.test(msg)
|
|
? this._t("sticker_chart.not_allowed", { name: child?.name || "" })
|
|
: this._t("sticker_chart.complete_failed", { error: msg }));
|
|
}
|
|
|
|
async _undo(row) {
|
|
if (this.config.allow_undo === false) return;
|
|
const own = row.mine.filter(c => c.child_id !== "__parent__" && c.completion_id);
|
|
const latest = own[own.length - 1];
|
|
if (!latest) return;
|
|
if (this._optimistic[row.key]) {
|
|
const next = { ...this._optimistic };
|
|
delete next[row.key];
|
|
this._optimistic = next;
|
|
}
|
|
try {
|
|
await this.hass.callService("taskmate", "reject_chore", { completion_id: latest.completion_id });
|
|
if (this._pastDay()) this._dayChanged();
|
|
else this._playSound(this.config.undo_sound || "undo");
|
|
this._toast(this._t("sticker_chart.undone"));
|
|
} catch (err) {
|
|
const msg = String(err?.message || err || "");
|
|
this._toast(/admin|parent|unauthori|permission/i.test(msg)
|
|
? this._t("sticker_chart.undo_denied")
|
|
: this._t("sticker_chart.complete_failed", { error: msg }));
|
|
}
|
|
}
|
|
|
|
async _claim(child, reward) {
|
|
if (this._confirming !== child.id) {
|
|
this._confirming = child.id;
|
|
clearTimeout(this._confirmTimer);
|
|
this._confirmTimer = setTimeout(() => { this._confirming = ""; }, CONFIRM_MS);
|
|
return;
|
|
}
|
|
this._confirming = "";
|
|
clearTimeout(this._confirmTimer);
|
|
try {
|
|
const button = window.__taskmate_find_button
|
|
&& window.__taskmate_find_button(this.hass, child.id, "claim", reward.id);
|
|
if (button) {
|
|
await this.hass.callService("button", "press", { entity_id: button });
|
|
} else {
|
|
await this.hass.callService("taskmate", "claim_reward", { reward_id: reward.id, child_id: child.id });
|
|
}
|
|
this._toast(this._t("sticker_chart.claimed", { reward: reward.name, name: child.name }));
|
|
} catch (err) {
|
|
this._failed(err, child);
|
|
}
|
|
}
|
|
|
|
// Press-and-hold means undo; a short press means complete. Pointer events
|
|
// cover mouse and touch alike, and the context menu a long touch would
|
|
// otherwise raise is suppressed so the hold reads as one gesture.
|
|
_down(row) {
|
|
this._held = false;
|
|
clearTimeout(this._holdTimer);
|
|
this._holdTimer = setTimeout(() => {
|
|
this._held = true;
|
|
this._undo(row);
|
|
}, HOLD_MS);
|
|
}
|
|
|
|
_up(child, row) {
|
|
clearTimeout(this._holdTimer);
|
|
if (this._held) { this._held = false; return; }
|
|
this._complete(child, row);
|
|
}
|
|
|
|
_cancel() { clearTimeout(this._holdTimer); }
|
|
|
|
/** A past day's completions changed: the host re-fetches `dayCompletions`. */
|
|
_dayChanged() {
|
|
this.dispatchEvent(new CustomEvent("taskmate-day-changed", {
|
|
detail: { date: this.config.date }, bubbles: true, composed: true,
|
|
}));
|
|
}
|
|
|
|
/** TaskMate's shared sound engine, the same one the child card plays through. */
|
|
_playSound(name) {
|
|
const engine = window.__taskmate_sounds;
|
|
if (!engine || this.config.sound === false) return;
|
|
engine.play(name, (this._attrs() || {}).custom_sounds || []);
|
|
}
|
|
|
|
/**
|
|
* Will this tap finish every job on today's chart?
|
|
*
|
|
* Only when it completes this chore outright — no approval to wait for —
|
|
* and every other job is already done. Judged before the tap lands, from
|
|
* the same rows the tiles are drawn from.
|
|
*/
|
|
_finishesDay(child, row) {
|
|
if ((!this._pastDay() && row.chore.requires_approval !== false) || row.total + 1 < row.limit) return false;
|
|
const rows = this._todaysChores(child, this._attrs() || {});
|
|
return rows.length > 0 && rows.every(r => r.key === row.key || r.state === "done");
|
|
}
|
|
|
|
/**
|
|
* The child card's completion moment: a dimmed-screen pop-up with the
|
|
* points just earned and falling confetti. Finishing the whole day gets a
|
|
* bigger version, the way the old sticker chart's "all done" pop-up did.
|
|
*/
|
|
_celebrate(child, row, day) {
|
|
if (this.config.celebrate === false) return;
|
|
const latest = row.mine[row.mine.length - 1];
|
|
const points = latest && latest.points != null ? latest.points : (row.chore.points || 0);
|
|
this._celebrating = { day, name: child.name, points };
|
|
this._confetti = Array.from({ length: day ? 140 : 50 }, () => ({
|
|
x: Math.random() * 100,
|
|
delay: Math.random() * (day ? 1.2 : 0.5),
|
|
size: Math.random() * 8 + 6,
|
|
round: Math.random() > 0.5,
|
|
}));
|
|
clearTimeout(this._celebrateTimer);
|
|
clearTimeout(this._confettiTimer);
|
|
this._celebrateTimer = setTimeout(() => { this._celebrating = null; }, day ? 6000 : 2500);
|
|
this._confettiTimer = setTimeout(() => { this._confetti = []; }, day ? 5000 : 3500);
|
|
}
|
|
|
|
_renderCelebration(attrs) {
|
|
const c = this._celebrating;
|
|
const close = () => { this._celebrating = null; };
|
|
return html`
|
|
<div class="celebration-overlay" @click=${close}>
|
|
<div class="celebration-content ${c.day ? "day" : ""}" @click=${(e) => e.stopPropagation()}>
|
|
${c.day ? html`
|
|
<div class="celebration-stars">🏆🎉⭐🎉🏆</div>
|
|
<div class="celebration-title">${this._t("sticker_chart.all_done", { name: c.name })}</div>
|
|
<div class="celebration-message">${this._t("sticker_chart.day_done_message")}</div>
|
|
` : html`
|
|
<div class="celebration-stars">🌟🌟🌟</div>
|
|
<div class="celebration-title">${this._t("child.celebration_title")}</div>
|
|
<div class="celebration-message">${this._t("child.celebration_message")}</div>
|
|
`}
|
|
<div class="celebration-points">
|
|
<ha-icon icon="${attrs.points_icon || "mdi:star"}"></ha-icon>
|
|
+${c.points}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
_renderConfetti() {
|
|
const colors = ["#ff6b9d", "#9b59b6", "#3498db", "#2ecc71", "#f1c40f", "#e67e22"];
|
|
return html`
|
|
<div class="confetti-container">
|
|
${this._confetti.map((p, i) => html`
|
|
<div class="confetti"
|
|
style="inset-inline-start:${p.x}%;animation-delay:${p.delay}s;background:${colors[i % colors.length]};border-radius:${p.round ? "50%" : "0"};width:${p.size}px;height:${p.size}px"></div>`)}
|
|
</div>`;
|
|
}
|
|
|
|
// Keyboard twin of the pointer gestures: Enter/Space completes, and
|
|
// Delete/Backspace stands in for press-and-hold, which a keyboard can't do.
|
|
_key(e, child, row) {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
this._complete(child, row);
|
|
} else if ((e.key === "Delete" || e.key === "Backspace") && this._undoable(row)) {
|
|
e.preventDefault();
|
|
this._undo(row);
|
|
}
|
|
}
|
|
|
|
/** Whether the tile has a tick of the child's own that undo would take back. */
|
|
_undoable(row) {
|
|
return this.config.allow_undo !== false
|
|
&& row.mine.some(c => c.child_id !== "__parent__" && c.completion_id);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
clearTimeout(this._holdTimer);
|
|
clearTimeout(this._confirmTimer);
|
|
clearTimeout(this._celebrateTimer);
|
|
clearTimeout(this._confettiTimer);
|
|
}
|
|
|
|
// -- Render ------------------------------------------------------------------
|
|
|
|
_face(child) {
|
|
const face = window.__taskmate_child_visual(child);
|
|
if (face.kind === "image") return html`<img class="tm-face-img" src="${face.url}" alt="" loading="lazy">`;
|
|
return html`<ha-icon icon="${face.kind === "icon" ? face.icon : "mdi:account-circle"}"></ha-icon>`;
|
|
}
|
|
|
|
_renderTile(child, row, index) {
|
|
const { label, glyph } = this._glyph(row.chore);
|
|
const grad = GRADIENTS[index % GRADIENTS.length];
|
|
const pct = Math.round((100 * Math.min(row.total, row.limit)) / row.limit);
|
|
const stateLabel = this._t(`sticker_chart.state.${row.state}`);
|
|
return html`
|
|
<button class="tile ${row.state}"
|
|
style="--grad:${grad};--pct:${pct}%"
|
|
aria-label="${label}: ${stateLabel}"
|
|
aria-keyshortcuts="${this._undoable(row) ? "Delete Backspace" : ""}"
|
|
@pointerdown=${() => this._down(row)}
|
|
@pointerup=${() => this._up(child, row)}
|
|
@pointerleave=${() => this._cancel()}
|
|
@pointercancel=${() => this._cancel()}
|
|
@contextmenu=${(e) => e.preventDefault()}
|
|
@keydown=${(e) => this._key(e, child, row)}>
|
|
${row.limit > 1 ? html`<span class="count">${Math.min(row.total, row.limit)}/${row.limit}</span>` : ""}
|
|
${row.chore.require_photo && row.state === "todo" ? html`<span class="flag">📷</span>` : ""}
|
|
${row.state === "pending" ? html`<span class="flag">⏳</span>` : ""}
|
|
<span class="glyph">${glyph}</span>
|
|
<span class="label">${label}</span>
|
|
</button>`;
|
|
}
|
|
|
|
// One block per star up to MAX_BLOCKS. Past that each block stands for
|
|
// several stars, so a big week looks the same as a small one (a separate
|
|
// plain bar made two children's charts look unlike each other). A block
|
|
// lights only once all its stars are earned, and the last one when the
|
|
// goal is reached, so the row is never fuller than the count above it.
|
|
_renderBlocks(points, goal, unit) {
|
|
const per = Math.max(1, Math.ceil(goal / MAX_BLOCKS));
|
|
const total = Math.ceil(goal / per);
|
|
const on = points >= goal ? total : Math.floor(Math.max(0, points) / per);
|
|
return html`<div class="blocks" role="img" aria-label="${_ltrNums(`${Math.min(points, goal)} / ${goal}`)}">${
|
|
Array.from({ length: total }, (_, i) => html`<i class="${i < on ? "on" : ""}"></i>`)}</div>
|
|
${per > 1 ? html`<div class="blocks-key">${this._t("sticker_chart.block_key", { count: per, unit })}</div>` : ""}`;
|
|
}
|
|
|
|
_renderChild(child, index, attrs) {
|
|
const unit = attrs.points_name || this._t("common.stars");
|
|
const points = Math.max(0, Number(child.points) || 0);
|
|
const reward = this._reward(attrs.rewards, child);
|
|
const goal = reward ? this._cost(reward, child) : 0;
|
|
const reached = goal > 0 && points >= goal;
|
|
|
|
// What the stickers and blocks count towards: the reward's cost against
|
|
// the child's balance, or this week's jobs — every star on offer since
|
|
// Monday against the stars earned since then, so extra stars can't pile
|
|
// up unseen past the goal.
|
|
const weekly = this.config.goal === "week";
|
|
const target = weekly ? Math.max(0, Number(child.week_points_available) || 0) : goal;
|
|
const progress = weekly ? Math.max(0, Number(child.week_points_earned) || 0) : points;
|
|
const earned = target > 0 ? Math.min(progress, target) : progress;
|
|
const full = target > 0 && progress >= target;
|
|
const shown = Math.min(earned, MAX_STICKERS);
|
|
|
|
const rows = this._todaysChores(child, attrs);
|
|
const done = rows.filter(r => r.state === "done").length;
|
|
const allDone = rows.length > 0 && done === rows.length;
|
|
|
|
const tone = `var(--tmd-c${(index % 6) + 1}, ${index % 2 ? "#e84393" : "#3498db"})`;
|
|
const title = this.config.title || this._t("sticker_chart.default_title", { name: child.name });
|
|
|
|
const pendingClaim = reward && (attrs.pending_reward_claims || [])
|
|
.some(c => c.child_id === child.id && c.reward_id === reward.id);
|
|
|
|
// A past day (the admin panel's "Log a past job"): just that day's jobs,
|
|
// done and not done, ticked the same way. The running totals and the
|
|
// reward belong to today, so they are left off.
|
|
const past = this._pastDay();
|
|
if (past) {
|
|
const day = past.toLocaleDateString(this.hass?.locale?.language || this.hass?.language || undefined,
|
|
{ weekday: "long", day: "numeric", month: "short" });
|
|
return html`
|
|
<div class="col" style="--tone:${tone}">
|
|
<div class="heading">
|
|
<ha-icon icon="mdi:calendar-check"></ha-icon>
|
|
<span class="title">${child.name}</span>
|
|
<span class="score"><ha-icon icon="${attrs.points_icon || "mdi:star"}"></ha-icon>${points}</span>
|
|
</div>
|
|
<div class="panel summary">
|
|
<div class="today">
|
|
${_ltrNums(this._t("sticker_chart.day_count", { day, done, total: rows.length }))}
|
|
${allDone ? html` 🏆` : ""}
|
|
</div>
|
|
</div>
|
|
${rows.length ? html`
|
|
<div class="tiles" style="--cols:${Math.max(2, Math.min(6, Number(this.config.columns) || 4))}">
|
|
${rows.map((row, i) => this._renderTile(child, row, i))}
|
|
</div>` : html`
|
|
<div class="panel none-today muted">${this._t("sticker_chart.no_jobs_day", { day })}</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<div class="col" style="--tone:${tone}">
|
|
<div class="heading">
|
|
<ha-icon icon="mdi:star-face"></ha-icon>
|
|
<span class="title">${title}</span>
|
|
<span class="score"><ha-icon icon="${attrs.points_icon || "mdi:star"}"></ha-icon>${points}</span>
|
|
</div>
|
|
|
|
<div class="panel summary">
|
|
<div class="top-row">
|
|
<div class="top-main">
|
|
${this.config.show_today === false ? "" : html`
|
|
<div class="today">
|
|
${_ltrNums(this._t("sticker_chart.today", { done, total: rows.length }))}
|
|
${allDone ? html` 🏆 ${this._t("sticker_chart.all_done", { name: child.name })}` : ""}
|
|
</div>`}
|
|
<div class="stickers" role="img"
|
|
aria-label="${this._t("sticker_chart.stickers_alt", { count: earned, points: unit })}">
|
|
${earned === 0
|
|
? html`<span class="stickers-none">${this._t("sticker_chart.no_stickers")}</span>`
|
|
: html`${Array.from({ length: shown }, (_, i) => STICKERS[i % STICKERS.length]).join("")}${
|
|
earned > shown ? html`<span class="overflow">+${earned - shown}</span>` : ""}`}
|
|
</div>
|
|
</div>
|
|
<div class="face">${this._face(child)}</div>
|
|
</div>
|
|
|
|
${weekly ? (target > 0 ? html`
|
|
<div class="goal-line">${_ltrNums(this._t("sticker_chart.week_goal", { points: earned, goal: target, unit }))}</div>
|
|
${this._renderBlocks(progress, target, unit)}
|
|
${full ? html`<div class="reached">🌟 ${this._t("sticker_chart.week_perfect")} 🌟</div>` : ""}
|
|
` : "") : goal > 0 ? html`
|
|
<div class="goal-line">${_ltrNums(this._t("sticker_chart.goal", { points: earned, goal, unit }))}</div>
|
|
${this._renderBlocks(points, goal, unit)}
|
|
${reached ? html`<div class="reached">🎉 ${this._t("sticker_chart.goal_reached")} 🎉</div>` : ""}
|
|
` : html`<div class="goal-line muted">${this._t("sticker_chart.no_reward")}</div>`}
|
|
</div>
|
|
|
|
${rows.length ? html`
|
|
<div class="tiles" style="--cols:${Math.max(2, Math.min(6, Number(this.config.columns) || 4))}">
|
|
${rows.map((row, i) => this._renderTile(child, row, i))}
|
|
</div>` : html`
|
|
<div class="panel none-today muted">${this._t("sticker_chart.no_jobs_today")}</div>`}
|
|
|
|
${reward ? html`
|
|
<div class="panel goal-tile">
|
|
<ha-icon icon="mdi:trophy"></ha-icon>
|
|
<div>
|
|
<div class="gt-title">${this._t("sticker_chart.goal_tile")}</div>
|
|
<div class="gt-sub">${reward.name} · ${goal} ${unit}</div>
|
|
</div>
|
|
</div>
|
|
|
|
${this.config.show_claim === false ? "" : html`
|
|
<button class="panel claim ${this._confirming === child.id ? "armed" : ""}"
|
|
?disabled=${!reached || pendingClaim}
|
|
@click=${() => this._claim(child, reward)}>
|
|
<ha-icon icon="mdi:gift"></ha-icon>
|
|
<span>${pendingClaim
|
|
? this._t("sticker_chart.claim_pending")
|
|
: !reached
|
|
? this._t("sticker_chart.claim_short", { count: goal - points, unit })
|
|
: this._confirming === child.id
|
|
? this._t("sticker_chart.claim_confirm", { reward: reward.name })
|
|
: html`🎁 ${this._t("sticker_chart.claim", { reward: reward.name })}`}</span>
|
|
</button>`}
|
|
` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
render() {
|
|
if (!this.hass || !this.config) return html``;
|
|
if (window.__taskmate_design) {
|
|
window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity);
|
|
}
|
|
const attrs = this._attrs();
|
|
if (!attrs) {
|
|
return html`<ha-card><div class="panel error">
|
|
<ha-icon icon="mdi:alert-circle"></ha-icon>
|
|
<div>${this._t("common.entity_not_found", { entity: this.config.entity })}</div>
|
|
</div></ha-card>`;
|
|
}
|
|
const children = this._children(attrs);
|
|
if (!children.length) {
|
|
return html`<ha-card><div class="panel error">
|
|
<ha-icon icon="mdi:account-question"></ha-icon>
|
|
<div>${this._t("sticker_chart.no_child")}</div>
|
|
</div></ha-card>`;
|
|
}
|
|
return html`
|
|
<ha-card>
|
|
<div class="cols">
|
|
${children.map((child, i) => this._renderChild(child, i, attrs))}
|
|
</div>
|
|
${this._celebrating ? this._renderCelebration(attrs) : ""}
|
|
${this._confetti.length ? this._renderConfetti() : ""}
|
|
</ha-card>`;
|
|
}
|
|
}
|
|
|
|
class TaskMateStickerChartCardEditor extends LitElement {
|
|
static get properties() {
|
|
return { hass: { type: Object }, config: { type: Object } };
|
|
}
|
|
|
|
_t(key, params) {
|
|
const fn = window.__taskmate_localize;
|
|
return fn ? fn(this.hass, key, params) : key;
|
|
}
|
|
|
|
static get styles() {
|
|
return css`
|
|
:host { display: block; }
|
|
ha-form { display: block; margin-bottom: 16px; }
|
|
`;
|
|
}
|
|
|
|
setConfig(config) { this.config = config; }
|
|
|
|
_attrs() {
|
|
return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
|
|| (this.config?.entity ? this.hass?.states?.[this.config.entity]?.attributes : null)
|
|
|| {};
|
|
}
|
|
|
|
_buildSchema() {
|
|
const attrs = this._attrs();
|
|
const children = attrs.children || [];
|
|
const rewards = attrs.rewards || [];
|
|
return [
|
|
{ name: "entity", selector: { entity: { domain: "sensor" } } },
|
|
{
|
|
name: "child_id",
|
|
selector: {
|
|
select: {
|
|
options: [
|
|
{ value: "", label: this._t("sticker_chart.editor.child_all") },
|
|
...children.map(c => ({ value: c.id, label: c.name })),
|
|
],
|
|
mode: "dropdown",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "reward_id",
|
|
selector: {
|
|
select: {
|
|
options: [
|
|
{ value: "", label: this._t("sticker_chart.editor.reward_auto") },
|
|
...rewards.map(r => ({ value: r.id, label: `${r.name} (${r.cost})` })),
|
|
],
|
|
mode: "dropdown",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "goal",
|
|
selector: {
|
|
select: {
|
|
options: [
|
|
{ value: "reward", label: this._t("sticker_chart.editor.goal_reward") },
|
|
{ value: "week", label: this._t("sticker_chart.editor.goal_week") },
|
|
],
|
|
mode: "dropdown",
|
|
},
|
|
},
|
|
},
|
|
{ name: "title", selector: { text: {} } },
|
|
{ name: "columns", selector: { number: { min: 2, max: 6, mode: "slider" } } },
|
|
{ name: "show_today", selector: { boolean: {} } },
|
|
{ name: "show_claim", selector: { boolean: {} } },
|
|
{ name: "allow_undo", selector: { boolean: {} } },
|
|
{ name: "sound", selector: { boolean: {} } },
|
|
{
|
|
name: "card_design",
|
|
selector: {
|
|
select: {
|
|
options: window.__taskmate_design
|
|
? window.__taskmate_design.editorOptions(this._t.bind(this))
|
|
: [{ value: "global", label: "Use global default" }],
|
|
mode: "dropdown",
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
_computeLabel = (entry) => {
|
|
const labels = {
|
|
show_adults: this._t("common.editor.show_adults"),
|
|
entity: this._t("common.editor.overview_entity"),
|
|
child_id: this._t("sticker_chart.editor.child"),
|
|
reward_id: this._t("sticker_chart.editor.reward"),
|
|
goal: this._t("sticker_chart.editor.goal"),
|
|
title: this._t("sticker_chart.editor.title"),
|
|
columns: this._t("sticker_chart.editor.columns"),
|
|
show_today: this._t("sticker_chart.editor.show_today"),
|
|
show_claim: this._t("sticker_chart.editor.show_claim"),
|
|
allow_undo: this._t("sticker_chart.editor.allow_undo"),
|
|
sound: this._t("sticker_chart.editor.sound"),
|
|
card_design: this._t("common.design.field_label"),
|
|
};
|
|
return labels[entry.name] ?? entry.name;
|
|
};
|
|
|
|
_computeHelper = (entry) => {
|
|
const helpers = {
|
|
show_adults: this._t("common.editor.show_adults_helper"),
|
|
entity: this._t("common.editor.overview_entity_helper"),
|
|
reward_id: this._t("sticker_chart.editor.reward_helper"),
|
|
goal: this._t("sticker_chart.editor.goal_helper"),
|
|
title: this._t("sticker_chart.editor.title_helper"),
|
|
show_today: this._t("sticker_chart.editor.show_today_helper"),
|
|
show_claim: this._t("sticker_chart.editor.show_claim_helper"),
|
|
allow_undo: this._t("sticker_chart.editor.allow_undo_helper"),
|
|
};
|
|
return helpers[entry.name] ?? "";
|
|
};
|
|
|
|
// Booleans that default to true are only written when switched off, and the
|
|
// default column count is omitted, so a card's YAML stays minimal.
|
|
_valueChanged(e) {
|
|
e.stopPropagation();
|
|
const defaultsOn = new Set(["show_today", "show_claim", "allow_undo", "sound"]);
|
|
const newConfig = {};
|
|
for (const [key, value] of Object.entries(e.detail.value)) {
|
|
if (value === "" || value === null || value === undefined) continue;
|
|
if (key === "card_design" && value === "global") continue;
|
|
if (key === "goal" && value === "reward") continue;
|
|
if (defaultsOn.has(key) && value === true) continue;
|
|
if (key === "columns" && Number(value) === 4) continue;
|
|
newConfig[key] = value;
|
|
}
|
|
this.dispatchEvent(new CustomEvent("config-changed", {
|
|
detail: { config: newConfig }, bubbles: true, composed: true,
|
|
}));
|
|
}
|
|
|
|
render() {
|
|
if (!this.hass || !this.config) return html``;
|
|
const data = { goal: "reward", show_today: true, show_claim: true, allow_undo: true, sound: true, columns: 4, ...this.config };
|
|
return html`
|
|
<ha-form
|
|
.hass=${this.hass}
|
|
.data=${data}
|
|
.schema=${this._buildSchema().concat(window.__taskmate_adults_field ? window.__taskmate_adults_field(this.hass, this.config) : [])}
|
|
.computeLabel=${this._computeLabel}
|
|
.computeHelper=${this._computeHelper}
|
|
@value-changed=${this._valueChanged}
|
|
></ha-form>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// A version bump can briefly leave two copies of this module in one page (the
|
|
// old ?v= URL and the new one). Defining a name twice throws, which would take
|
|
// the editor down with the card, so each define is guarded.
|
|
if (!customElements.get("taskmate-sticker-chart-card")) {
|
|
customElements.define("taskmate-sticker-chart-card", TaskMateStickerChartCard);
|
|
}
|
|
if (!customElements.get("taskmate-sticker-chart-card-editor")) {
|
|
customElements.define("taskmate-sticker-chart-card-editor", TaskMateStickerChartCardEditor);
|
|
}
|
|
|
|
window.customCards = window.customCards || [];
|
|
if (!window.customCards.some(c => c.type === "taskmate-sticker-chart-card")) window.customCards.push({
|
|
type: "taskmate-sticker-chart-card",
|
|
name: "TaskMate Sticker Chart",
|
|
description: "The kids' sticker chart: today's chores as stickers to tap, stars filling towards a reward",
|
|
preview: true,
|
|
getEntitySuggestion: (hass, entityId) =>
|
|
window.__taskmate_suggest(hass, entityId, "taskmate-sticker-chart-card", "overview"),
|
|
});
|
|
|
|
// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM
|
|
const _tmVersion = new URLSearchParams(
|
|
Array.from(document.querySelectorAll('script[src*="/taskmate-sticker-chart-card.js"]'))
|
|
.map(s => s.src.split("?")[1]).find(Boolean) || ""
|
|
).get("v") || "?";
|
|
console.info(
|
|
"%c TASKMATE STICKER CHART CARD %c v" + _tmVersion + " ",
|
|
"background:#8e44ad;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
|
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
|
);
|