/** * TaskMate Recap Card (#929) * A "Wrapped"-style story of a child's finished period: chores done, points * earned, top chore, longest streak, best day, badges and rewards, and how it * compared with the period before. Tap the right of a slide to go on, the * left to go back. "Older" lists every kept recap; the finale saves a * shareable summary as a PNG, drawn in the browser. * * Recaps are fetched over the websocket (taskmate/recaps/*) rather than read * from a sensor attribute — a year of them would blow the 16 KB cap. */ const LitElement = customElements.get("hui-masonry-view") ? Object.getPrototypeOf(customElements.get("hui-masonry-view")) : Object.getPrototypeOf(customElements.get("hui-view")); const html = LitElement.prototype.html; const css = LitElement.prototype.css; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); // Keeps number runs like "3 / 1" reading left to right in RTL text (#995); // identity until the design layer has loaded (it is also what stamps dir). const _ltrNums = (s) => (window.__taskmate_design && window.__taskmate_design.ltrNums ? window.__taskmate_design.ltrNums(s) : s); // "a / b" for a template: the same text nodes as a literal `${a} / ${b}` in // LTR (so it renders pixel-for-pixel as before), the isolated string in RTL. const _ltrRatio = (a, b) => { const s = `${a} / ${b}`; const r = _ltrNums(s); return r === s ? html`${a} / ${b}` : r; }; const RECAP_FREQUENCIES = ["weekly", "monthly", "every_3_months", "every_6_months", "every_9_months", "yearly"]; // Longest first: several recaps on one day open with the longest period. const FREQ_RANK = { yearly: 0, every_9_months: 1, every_6_months: 2, every_3_months: 3, monthly: 4, weekly: 5, preview: 6 }; const SEEN_KEY = "taskmate_recap_seen"; // A recap older than this isn't flagged NEW on a device that never opened it. const NEW_WINDOW_DAYS = 30; const REFETCH_MS = 5 * 60 * 1000; const AUTOPLAY_MS = 6000; // Slide gradients (classic). Other designs re-map --s1/--s2 in CSS. const SLIDE_COLOURS = { intro: ["#ff6b9d", "#9b59b6"], chores: ["#3498db", "#1abc9c"], points: ["#f5b700", "#e67e22"], top: ["#2ecc71", "#16a085"], streak: ["#e74c3c", "#e67e22"], best: ["#8e44ad", "#3498db"], badges: ["#16a085", "#8e44ad"], cmp: ["#34495e", "#1f2b38"], fin: ["#ff6b9d", "#f39c12"], }; /** "2026-09-01" → a Date at UTC midnight, so formatting never shifts a day. */ function _day(iso) { const [y, m, d] = String(iso || "").split("-").map(Number); return new Date(Date.UTC(y || 1970, (m || 1) - 1, d || 1)); } function _loadSeen() { try { return JSON.parse(window.localStorage.getItem(SEEN_KEY) || "{}") || {}; } catch (e) { return {}; } } class TaskMateRecapCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _list: { state: true }, _recap: { state: true }, _idx: { state: true }, _sheet: { state: true }, _filter: { state: true }, _error: { state: true }, _toast: { state: true }, }; } constructor() { super(); this._list = null; this._recap = null; this._idx = 0; this._sheet = false; this._filter = "all"; this._error = ""; this._toast = ""; this._cache = new Map(); this._lastFetch = 0; this._loadedFor = null; } shouldUpdate(changedProps) { if (changedProps.size === 1 && changedProps.has("hass")) { this._maybeRefetch(); return window.__taskmate_hasChanged ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) : true; } return true; } connectedCallback() { super.connectedCallback(); this._armAutoplay(); if (this.hass && this.config) this._maybeRefetch(true); } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._autoplay); this._autoplay = null; clearTimeout(this._toastTimer); } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } // Count strings have a `_singular` twin. Which form a number takes follows // the language of the strings (French and Brazilian Portuguese also say "0 tâche"), // not a bare `=== 1`. Intl's "pt" is Brazilian; HA's "pt" is European. _tn(key, count, params) { const lang = this.hass?.language === "pt" ? "pt-PT" : this.hass?.language || "en"; let one; try { one = new Intl.PluralRules(lang).select(Number(count)) === "one"; } catch { one = Number(count) === 1; } return this._t(one ? `${key}_singular` : key, params); } setConfig(config) { this.config = { entity: "sensor.taskmate_overview", title: null, child_id: null, header_color: "#9b59b6", default_frequency: null, autoplay: false, ...config, }; this._loadedFor = null; this._armAutoplay(); } getCardSize() { return 9; } static getConfigElement() { return document.createElement("taskmate-recap-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview" }; } // ── data ──────────────────────────────────────────────────────────────── _maybeRefetch(force = false) { const childId = this.config?.child_id; if (!this.hass || !childId) return; const stale = Date.now() - this._lastFetch > REFETCH_MS; if (force || this._loadedFor !== childId || stale) this._load(); } async _load() { const childId = this.config.child_id; const fresh = this._loadedFor !== childId; this._loadedFor = childId; this._lastFetch = Date.now(); try { const res = await this.hass.callWS({ type: "taskmate/recaps/list", child_id: childId }); this._list = res; this._error = ""; if (fresh || !this._recap || !(res.recaps || []).some((r) => r.id === this._recap.id)) { const first = this._pickFirst(res.recaps || []); if (first) await this._open(first.id); else this._recap = null; } } catch (err) { this._error = (err && (err.message || err.code)) || String(err); } } _pickFirst(recaps) { const unseen = recaps.filter((r) => this._isNew(r)); if (unseen.length) { // Newest period first; several on the same day → the longest period. return [...unseen].sort((a, b) => (b.end || "").localeCompare(a.end || "") || (FREQ_RANK[a.frequency] ?? 9) - (FREQ_RANK[b.frequency] ?? 9))[0]; } const want = this.config.default_frequency; if (want) { const match = recaps.find((r) => r.frequency === want); if (match) return match; } return recaps.find((r) => !r.preview) || recaps[0] || null; } async _open(id) { let recap = this._cache.get(id); if (!recap) { try { const res = await this.hass.callWS({ type: "taskmate/recaps/get", child_id: this.config.child_id, recap_id: id }); recap = res.recap; this._cache.set(id, recap); } catch (err) { this._error = (err && (err.message || err.code)) || String(err); return; } } this._recap = recap; this._idx = 0; this._markSeen(id); } _seenIds() { const all = _loadSeen(); return new Set(all[this.config?.child_id] || []); } _markSeen(id) { const all = _loadSeen(); const key = this.config.child_id; const ids = (all[key] || []).filter((x) => x !== id); ids.push(id); all[key] = ids.slice(-200); try { window.localStorage.setItem(SEEN_KEY, JSON.stringify(all)); } catch (e) { /* private mode */ } } _isNew(summary) { if (!summary || this._seenIds().has(summary.id)) return false; const created = Date.parse(summary.created_at || ""); return !Number.isNaN(created) && Date.now() - created < NEW_WINDOW_DAYS * 86400000; } // ── labels ────────────────────────────────────────────────────────────── _lang() { return this.hass?.locale?.language || this.hass?.language || undefined; } _fmt(iso, opts) { try { return new Intl.DateTimeFormat(this._lang(), { timeZone: "UTC", ...opts }).format(_day(iso)); } catch (e) { return iso; } } _range(startIso, endIso, opts) { try { const f = new Intl.DateTimeFormat(this._lang(), { timeZone: "UTC", ...opts }); return f.formatRange ? f.formatRange(_day(startIso), _day(endIso)) : `${f.format(_day(startIso))} – ${f.format(_day(endIso))}`; } catch (e) { return `${startIso} – ${endIso}`; } } _num(n) { try { return Number(n || 0).toLocaleString(this._lang()); } catch (e) { return String(n || 0); } } /** "August 2026", "15 – 21 Sep 2026", "Apr – Jun 2026", "2026". */ _periodLabel(r) { if (!r) return ""; switch (r.frequency) { case "monthly": return this._fmt(r.start, { month: "long", year: "numeric" }); case "yearly": return this._fmt(r.start, { year: "numeric" }); case "weekly": case "preview": return this._range(r.start, r.end, { day: "numeric", month: "short", year: "numeric" }); default: return this._range(r.start, r.end, { month: "short", year: "numeric" }); } } /** The short name used inside sentences: "week", "August", "Apr – Jun", "2026". */ _periodShort(r) { switch (r.frequency) { case "weekly": return this._t("recap.word.weekly"); case "preview": return this._t("recap.last_30_days"); case "monthly": return this._fmt(r.start, { month: "long" }); case "yearly": return this._fmt(r.start, { year: "numeric" }); default: return this._range(r.start, r.end, { month: "short" }); } } _freqName(freq) { return this._t(`recap.freq.${freq}`); } _child() { const entity = this.hass?.states?.[this.config.entity]; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity?.attributes || {}; return (attrs.children || []).find((c) => c.id === this.config.child_id) || null; } _pointsName() { const entity = this.hass?.states?.[this.config.entity]; return entity?.attributes?.points_name || this._t("common.stars"); } _pointsIcon() { const entity = this.hass?.states?.[this.config.entity]; return entity?.attributes?.points_icon || "mdi:star"; } // ── render ────────────────────────────────────────────────────────────── render() { if (!this.hass || !this.config) return html``; const design = window.__taskmate_design ? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity) : "classic"; const hd = _safeColor(this.config.header_color, "#9b59b6"); // Every design shares one body: only the header shell differs, so a // feature added to the story can't go missing from some styles. const body = this._renderBody(); if (design === "classic") { return html`${this._classicHeader()}${body}`; } return html`${this._designHeader()}${body}`; } _title() { const child = this._child(); return this.config.title || this._t("recap.default_title", { name: child?.name || "" }); } _subtitle() { const r = this._recap; if (!r) return ""; return `${this._periodLabel(r)} · ${this._freqName(r.frequency)}`; } _classicHeader() { return html`
${this._title()}
${this._recap ? html`
${this._subtitle()}
` : ""}
${this._olderPill("header-pill")}
`; } _designHeader() { const sub = this._subtitle(); return html`
${this._title()}${sub ? html`${sub}` : ""} ${this._olderPill("pill rc-older")}
`; } _olderPill(cls) { const recaps = this._list?.recaps || []; if (!recaps.length) return ""; const unseen = recaps.filter((r) => this._isNew(r) && r.id !== this._recap?.id).length; return html` `; } _renderBody() { if (!this.config.child_id) { return html`
${this._t("recap.choose_child")}
`; } if (this._error && !this._recap) { return html`
${this._t("recap.load_failed", { error: this._error })}
`; } if (!this._list) { return html`
${this._t("recap.loading")}
`; } if (!this._recap) { return html`
${this._renderEmpty()}
`; } return html`
${this._renderStory(this._recap)} ${this._sheet ? this._renderSheet() : ""} ${this._toast ? html`
${this._toast}
` : ""}
`; } _renderEmpty() { const upcoming = (this._list?.upcoming || [])[0]; const child = this._child(); const text = upcoming ? this._t("recap.empty", { date: this._fmt(upcoming.ready_on, { day: "numeric", month: "long" }) }) : this._t("recap.empty_off", { name: child?.name || "" }); return html`
${text}
`; } _slides(r) { const s = ["intro", "chores"]; if (r.points) s.push("points"); if (r.top_chore) s.push("top"); if (r.streak && r.streak.days) s.push("streak"); if (r.best_day) s.push("best"); if ((r.badges || []).length || (r.rewards || []).length) s.push("badges"); if (r.previous) s.push("cmp"); s.push("fin"); return s; } _renderStory(r) { const slides = this._slides(r); const idx = Math.min(this._idx, slides.length - 1); const kind = slides[idx]; const [s1, s2] = SLIDE_COLOURS[kind]; const child = this._child(); const last = idx === slides.length - 1; return html`
this._onKey(e)}>
${slides.map((_, j) => html``)}
${this._avatar(child)} ${child?.name || ""} · ${this._periodLabel(r)}${r.preview ? html` · ${this._t("recap.preview_tag")}` : ""}
${this._slide(kind, r)}
${idx > 0 ? html`` : ""} ${!last ? html`` : ""}
${_ltrRatio(idx + 1, slides.length)}${!last ? html` · ${this._t("recap.tap_to_continue")}` : ""}
`; } _avatar(child) { const a = child?.avatar || ""; const inner = a.startsWith("mdi:") ? html`` : /^(https?:|\/)/.test(a) ? html`` : (child?.name || "?").slice(0, 1).toUpperCase(); return html`${inner}`; } _slide(kind, r) { // "week", "month", "three months", "year": the word a sentence needs. const word = this._t(`recap.word.${r.frequency}`); switch (kind) { case "intro": return this._slideIntro(r, word); case "chores": return this._slideChores(r, word); case "points": return this._slidePoints(r); case "top": return this._slideTop(r); case "streak": return this._slideStreak(r); case "best": return this._slideBest(r); case "badges": return this._slideBadges(r); case "cmp": return this._slideCompare(r, word); default: return this._slideFinale(r, word); } } _slideIntro(r, word) { const child = this._child(); return html`
${this._periodLabel(r)}
${this._t("recap.title", { name: child?.name || "", period: this._periodShort(r) })}
${this._t("recap.slide.intro_say", { period: word })}
${this._t("recap.slide.tap_to_start")}
`; } _slideChores(r, word) { const bars = r.bars || []; const max = Math.max(1, ...bars.map((b) => b[1])); const perDay = r.days ? r.chores / r.days : 0; let say = ""; if (!r.chores) say = this._t("recap.slide.no_chores", { period: word }); else if (perDay >= 1) say = this._t("recap.slide.per_day", { count: this._num(Math.round(perDay)) }); else if (r.days >= 14) say = this._t("recap.slide.per_week", { count: this._num(Math.max(1, Math.round(r.chores / (r.days / 7)))) }); return html`
${this._t("recap.slide.you_finished")}
${this._num(r.chores)}
${this._tn("recap.slide.chores_unit", r.chores)}
${say ? html`
${say}
` : ""} ${bars.length > 1 ? html`
${bars.map((b, i) => html`
${this._barLabel(r, b, i)}
`)}
` : ""}`; } _barLabel(r, bar, i) { if (r.frequency === "weekly") return this._fmt(bar[0], { weekday: "narrow" }); if (r.frequency === "monthly" || r.frequency === "preview") return this._t("recap.week_short", { n: i + 1 }); if (r.frequency === "yearly" || r.frequency === "every_9_months") return this._fmt(bar[0], { month: "narrow" }); return this._fmt(bar[0], { month: "short" }); } _slidePoints(r) { return html`
${this._t("recap.slide.you_earned")}
${this._num(r.points)}
${this._pointsName()}
${this._t("recap.slide.from_chores", { count: this._num(r.chore_points) })} ${r.bonus_points ? html`${this._t("recap.slide.bonus", { count: this._num(r.bonus_points) })}` : ""}
`; } _slideTop(r) { const top = r.top_chore; const icon = top.icon && top.icon.startsWith("mdi:") ? top.icon : "mdi:broom"; return html`
${this._t("recap.slide.top_chore")}
${top.name}
${this._tn("recap.slide.times", top.count, { count: this._num(top.count) })}
`; } _slideStreak(r) { const st = r.streak; const range = st.start === st.end ? this._fmt(st.start, { day: "numeric", month: "long" }) : this._range(st.start, st.end, { day: "numeric", month: "long" }); return html`
${this._t("recap.slide.longest_streak")}
${this._num(st.days)}
${this._tn("recap.slide.days_in_a_row", st.days)}
${range}
`; } _slideBest(r) { const best = r.best_day; const ws = Number.isInteger(r.week_start) ? r.week_start : 0; const dow = r.dow || [0, 0, 0, 0, 0, 0, 0]; const order = [0, 1, 2, 3, 4, 5, 6].map((i) => (i + ws) % 7); const dmax = Math.max(1, ...dow); const top = dow.indexOf(Math.max(...dow)); // 2024-01-01 was a Monday: offset it to name any weekday index (0 = Monday). const dayIso = (i) => `2024-01-0${i + 1}`; return html`
${this._t("recap.slide.best_day")}
${this._fmt(best.date, { weekday: "long", day: "numeric", month: "long" })}
${this._tn("recap.slide.best_day_say", best.chores, { chores: this._num(best.chores), points: this._num(best.points), points_name: this._pointsName() })}
${order.map((i) => html`
${this._fmt(dayIso(i), { weekday: "narrow" })}
`)}
${this._t("recap.slide.best_weekday", { weekday: this._fmt(dayIso(top), { weekday: "long" }) })}
`; } _slideBadges(r) { const badges = r.badges || []; const rewards = r.rewards || []; return html` ${badges.length ? html`
${this._t("recap.slide.unlocked")}
${badges.slice(0, 3).map((b) => html`
${b.name}
`)}
${badges.length > 3 ? html`
${this._t("recap.slide.more", { count: badges.length - 3 })}
` : ""}` : ""} ${rewards.slice(0, 3).map((w) => html`
${w.name}
${this._t("recap.slide.treated")}
${this._num(w.cost)}
`)}`; } _slideCompare(r, word) { const p = r.previous; const streak = r.streak?.days || 0; const rows = [ [this._t("recap.cmp.chores"), r.chores, p.chores], [this._t("recap.cmp.points", { points_name: this._pointsName() }), r.points, p.points], [this._t("recap.cmp.streak"), streak, p.streak], [this._t("recap.cmp.days_active"), r.days_active, p.days_active], ]; const prevLabel = r.frequency === "weekly" ? this._t("recap.prev_week") : this._periodShort({ ...r, start: p.start, end: p.end }); const up = r.chores >= p.chores; return html`
${this._t("recap.slide.compared", { period: prevLabel })}
${up ? this._t("recap.slide.levelled_up") : this._t("recap.slide.quieter", { period: word })}
${rows.map(([label, now, was]) => { const d = now - was; const pct = was ? Math.round((Math.abs(d) / was) * 100) : null; return html`
${label}${this._t("recap.cmp.was", { value: this._num(was) })}
${this._num(now)}
${pct === null ? this._num(Math.abs(d)) : `${pct}%`}
`; })}
`; } _shareStats(r) { return [ [this._num(r.chores), this._tn("recap.share.chores", r.chores)], [this._num(r.points), this._pointsName()], [this._num(r.streak?.days || 0), this._tn("recap.share.streak", r.streak?.days || 0)], [r.top_chore?.name || "—", this._t("recap.share.top")], ]; } _slideFinale(r, word) { const child = this._child(); const stats = this._shareStats(r); return html`
${this._t("recap.slide.finale", { period: word, name: child?.name || "" })}
${stats.map(([v, k], i) => html`
${v}${k}
`)}
TaskMate
`; } _renderSheet() { const recaps = this._list?.recaps || []; const freqs = ["all", ...new Set(recaps.map((r) => r.frequency))]; const list = recaps.filter((r) => this._filter === "all" || r.frequency === this._filter); const child = this._child(); const upcoming = (this._list?.upcoming || [])[0]; return html`
this._closeSheet(e)}>
`; } // ── interaction ───────────────────────────────────────────────────────── _slideCount() { return this._recap ? this._slides(this._recap).length : 0; } _next(e) { if (e) e.stopPropagation(); if (this._idx < this._slideCount() - 1) this._idx += 1; this._armAutoplay(); } _prev(e) { if (e) e.stopPropagation(); if (this._idx > 0) this._idx -= 1; this._armAutoplay(); } _replay(e) { if (e) e.stopPropagation(); this._idx = 0; this._armAutoplay(); } _onKey(e) { // The story reads in the layout's direction, so in RTL left is forward (#979). const rtl = this.getAttribute("dir") === "rtl"; if (e.key === (rtl ? "ArrowLeft" : "ArrowRight")) { e.preventDefault(); this._next(); } else if (e.key === (rtl ? "ArrowRight" : "ArrowLeft")) { e.preventDefault(); this._prev(); } } _openSheet(e) { if (e) e.stopPropagation(); this._sheet = true; } _closeSheet(e) { if (e) e.stopPropagation(); this._sheet = false; } async _pick(id) { this._sheet = false; await this._open(id); this._armAutoplay(); } _armAutoplay() { clearInterval(this._autoplay); this._autoplay = null; if (!this.config?.autoplay || !this.isConnected) return; this._autoplay = setInterval(() => { if (this._sheet || !this._recap) return; if (this._idx < this._slideCount() - 1) this._idx += 1; }, AUTOPLAY_MS); } _showToast(msg) { this._toast = msg; clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { this._toast = ""; }, 2400); } /** Draw the finale's summary tile onto a canvas and hand it over as a PNG. */ async _saveImage(e) { if (e) e.stopPropagation(); const r = this._recap; if (!r) return; const child = this._child(); const name = child?.name || ""; const W = 1080, H = 1350; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); if (!ctx) { this._showToast(this._t("recap.save_failed")); return; } const face = getComputedStyle(this).fontFamily || "system-ui, sans-serif"; const grad = ctx.createLinearGradient(0, 0, W * 0.6, H); grad.addColorStop(0, SLIDE_COLOURS.fin[0]); grad.addColorStop(1, SLIDE_COLOURS.fin[1]); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); ctx.fillStyle = "rgba(255,255,255,.12)"; ctx.beginPath(); ctx.arc(W - 60, 80, 300, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(80, H - 60, 260, 0, Math.PI * 2); ctx.fill(); const fit = (text, max, size, weight) => { let s = size; do { ctx.font = `${weight} ${s}px ${face}`; s -= 2; } while (ctx.measureText(text).width > max && s > 18); }; ctx.fillStyle = "#fff"; ctx.textAlign = "center"; const word = this._periodShort(r); const title = this._t("recap.title", { name, period: word }); fit(title, W - 140, 84, 800); ctx.fillText(title, W / 2, 250); fit(this._periodLabel(r), W - 140, 44, 600); ctx.globalAlpha = 0.9; ctx.fillText(this._periodLabel(r), W / 2, 320); ctx.globalAlpha = 1; const stats = this._shareStats(r); const gx = 110, gy = 420, gw = (W - 2 * gx - 40) / 2, gh = 300; stats.forEach(([v, k], i) => { const x = gx + (i % 2) * (gw + 40), y = gy + Math.floor(i / 2) * (gh + 40); ctx.fillStyle = "rgba(255,255,255,.18)"; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, gw, gh, 36); ctx.fill(); } else ctx.fillRect(x, y, gw, gh); ctx.fillStyle = "#fff"; ctx.textAlign = "left"; fit(String(v), gw - 80, i === 3 ? 64 : 120, 800); ctx.fillText(String(v), x + 40, y + (i === 3 ? 150 : 170)); fit(k, gw - 80, 40, 600); ctx.globalAlpha = 0.9; ctx.fillText(k, x + 40, y + 240); ctx.globalAlpha = 1; }); ctx.textAlign = "center"; ctx.font = `700 40px ${face}`; ctx.globalAlpha = 0.85; ctx.fillText("✓ TaskMate", W / 2, H - 90); ctx.globalAlpha = 1; const slug = (name || "recap").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "recap"; const file = `${slug}-recap-${r.start}.png`; const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); if (!blob) { this._showToast(this._t("recap.save_failed")); return; } try { const shareFile = typeof File === "function" ? new File([blob], file, { type: "image/png" }) : null; // The companion app has no downloads folder: offer the share sheet there. if (shareFile && navigator.canShare && navigator.canShare({ files: [shareFile] })) { await navigator.share({ files: [shareFile], title }); return; } } catch (err) { if (err && err.name === "AbortError") return; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = file; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); this._showToast(this._t("recap.saved", { file })); } static get styles() { const base = css` :host { display: block; } ha-card { overflow: hidden; position: relative; } /* Classic header — full-colour banner */ .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; background: var(--hd, #9b59b6); color: #fff; min-width: 0; } .header-content { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; } .header-icon { --mdc-icon-size: 28px; opacity: 0.95; flex: none; } .header-text { min-width: 0; } .header-title { font-size: 1.25rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .header-sub { font-size: 0.8rem; color: rgba(255, 255, 255, 0.85); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .header-pill, .rc-older { display: inline-flex; align-items: center; gap: 5px; flex: none; cursor: pointer; background: rgba(255, 255, 255, 0.2); color: #fff; border: 0; border-radius: 16px; padding-block: 4px; padding-inline: 12px 10px; font: inherit; font-size: 0.88rem; font-weight: 600; } .header-pill ha-icon, .rc-older ha-icon { --mdc-icon-size: 16px; } .tmd-hd .ic ha-icon { --mdc-icon-size: 18px; } .newdot { font-size: 10px; font-weight: 800; color: #fff; background: var(--tmd-bad, #e74c3c); border-radius: 999px; padding: 1px 7px; letter-spacing: 0.04em; line-height: 1.5; } .rc-body { padding: 12px; position: relative; } .rc-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; padding: 36px 16px; color: var(--tmd-dim, var(--secondary-text-color)); } .rc-empty ha-icon { --mdc-icon-size: 40px; opacity: 0.6; } /* The story */ .story { position: relative; aspect-ratio: 2 / 3; max-height: 560px; min-height: 440px; width: 100%; border-radius: var(--tmd-radius, 16px); overflow: hidden; color: #fff; outline: none; background: linear-gradient(160deg, var(--s1), var(--s2)); user-select: none; -webkit-user-select: none; font-family: var(--tmd-font-body, inherit); transition: background 0.3s ease; } .story:focus-visible { box-shadow: 0 0 0 3px var(--primary-color, #03a9f4); } .blob { position: absolute; border-radius: 50%; background: rgba(255, 255, 255, 0.1); pointer-events: none; } .b1 { width: 240px; height: 240px; top: -80px; inset-inline-end: -80px; } .b2 { width: 210px; height: 210px; bottom: -70px; inset-inline-start: -70px; } .segs { position: absolute; top: 10px; inset-inline-start: 12px; inset-inline-end: 12px; display: flex; gap: 4px; z-index: 3; } .segs i { flex: 1; height: 3px; border-radius: 2px; background: rgba(255, 255, 255, 0.35); display: block; } .segs i.done { background: #fff; } .story-who { position: absolute; top: 22px; inset-inline-start: 12px; inset-inline-end: 12px; display: flex; align-items: center; gap: 8px; z-index: 3; font-size: 13px; font-weight: 600; min-width: 0; } .story-who .p { opacity: 0.85; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .who-av { width: 26px; height: 26px; flex: none; border-radius: 50%; display: grid; place-items: center; overflow: hidden; background: rgba(255, 255, 255, 0.25); border: 2px solid rgba(255, 255, 255, 0.6); font-weight: 800; font-size: 12px; } .who-av ha-icon { --mdc-icon-size: 16px; } .who-av img { width: 100%; height: 100%; object-fit: cover; } .tapzone { position: absolute; top: 0; bottom: 0; z-index: 2; cursor: pointer; } .tapzone.prev { inset-inline-start: 0; width: 33%; } .tapzone.next { inset-inline-end: 0; width: 67%; } .story-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 4; width: 34px; height: 34px; border-radius: 50%; border: 0; background: rgba(0, 0, 0, 0.22); color: #fff; display: grid; place-items: center; cursor: pointer; padding: 0; } .story-nav ha-icon { --mdc-icon-size: 20px; } .story-nav.l { inset-inline-start: 8px; } .story-nav.r { inset-inline-end: 8px; } .story-foot { position: absolute; bottom: 12px; inset-inline-start: 0; inset-inline-end: 0; text-align: center; font-size: 12px; opacity: 0.8; z-index: 3; } .slide { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 70px 44px 44px; z-index: 1; box-sizing: border-box; } .kick { font-size: 15px; font-weight: 600; opacity: 0.9; letter-spacing: 0.02em; } .huge { font-family: var(--tmd-font-display, inherit); font-size: clamp(64px, 20vw, 92px); font-weight: 800; line-height: 1; letter-spacing: -3px; margin: 6px 0 2px; display: flex; align-items: center; gap: 8px; } .huge .pts-ic { --mdc-icon-size: 56px; } .big { font-family: var(--tmd-font-display, inherit); font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -0.5px; margin: 6px 0; overflow-wrap: anywhere; } .big.title { font-size: 38px; } .big.mid { font-size: 25px; } .unit { font-size: 22px; font-weight: 700; } .say { font-size: 15px; opacity: 0.92; margin-top: 10px; max-width: 280px; line-height: 1.4; } .say.small { font-size: 13px; } .say.tap { margin-top: 30px; display: flex; align-items: center; gap: 6px; font-weight: 600; } .times { font-size: 22px; } .glyph { width: 96px; height: 96px; border-radius: 28px; background: rgba(255, 255, 255, 0.2); display: grid; place-items: center; margin: 12px 0; transform: rotate(-6deg); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); } .glyph.round { border-radius: 50%; } .glyph ha-icon { --mdc-icon-size: 54px; } .minibars { display: flex; align-items: flex-end; justify-content: center; gap: 6px; height: 80px; margin-top: 22px; width: 100%; } .minibars div { flex: 0 1 22px; min-width: 0; } .minibars div, .dow div { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 10px; opacity: 0.95; } .minibars b { display: block; width: 100%; border-radius: 5px 5px 2px 2px; background: rgba(255, 255, 255, 0.55); } .minibars .hi b, .dow .hi b { background: #fff; } .dow { display: flex; gap: 5px; margin-top: 18px; align-items: flex-end; height: 70px; } .dow b { display: block; width: 24px; border-radius: 5px; background: rgba(255, 255, 255, 0.35); } .pchips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 16px; } .pchips span { background: rgba(255, 255, 255, 0.2); border-radius: 999px; padding: 4px 11px; font-size: 12.5px; font-weight: 600; } .medals { display: flex; gap: 14px; justify-content: center; margin: 10px 0 4px; flex-wrap: wrap; } .medal { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 86px; font-size: 12px; font-weight: 600; } .medal .m { width: 62px; height: 62px; border-radius: 50%; background: rgba(255, 255, 255, 0.22); display: grid; place-items: center; border: 3px solid rgba(255, 255, 255, 0.55); } .medal .m ha-icon { --mdc-icon-size: 30px; } .rc-rw { margin-top: 12px; background: rgba(255, 255, 255, 0.16); border-radius: 14px; padding: 10px 14px; display: flex; align-items: center; gap: 10px; text-align: start; font-size: 13px; width: 100%; max-width: 280px; box-sizing: border-box; } .rc-rw .grow { flex: 1; min-width: 0; } .rc-rw-cost { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; } .rc-rw-cost ha-icon { --mdc-icon-size: 14px; } .muted-w { opacity: 0.85; } .cmp { width: 100%; max-width: 300px; margin-top: 14px; display: flex; flex-direction: column; gap: 8px; } .cmp-row { display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: center; background: rgba(255, 255, 255, 0.08); border-radius: 12px; padding: 10px 12px; text-align: start; font-size: 13px; } .cmp-row .v { font-weight: 800; font-size: 17px; } .was { font-size: 11px; opacity: 0.7; display: block; font-weight: 400; } .delta { display: inline-flex; align-items: center; gap: 2px; font-weight: 800; font-size: 12px; padding: 3px 8px; border-radius: 999px; } .delta ha-icon { --mdc-icon-size: 12px; } .delta.up { background: rgba(46, 204, 113, 0.25); color: #7dffb0; } .delta.dn { background: rgba(255, 120, 110, 0.22); color: #ffb3ab; } .share-tile { width: 100%; max-width: 280px; background: rgba(0, 0, 0, 0.18); border-radius: 18px; padding: 14px; margin: 8px 0 14px; box-sizing: border-box; } .share-hd { font-size: 12px; font-weight: 700; margin-bottom: 8px; opacity: 0.9; } .share-tile .g { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .share-tile .g div { background: rgba(255, 255, 255, 0.14); border-radius: 12px; padding: 8px; font-size: 11px; opacity: 0.95; min-width: 0; } .share-tile .g b { display: block; font-size: 22px; font-weight: 800; } .share-tile .g b.txt { font-size: 14px; line-height: 1.9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .brand { display: flex; align-items: center; gap: 6px; justify-content: center; font-size: 11px; opacity: 0.8; margin-top: 10px; } .brand ha-icon { --mdc-icon-size: 12px; } .fin-acts { display: flex; gap: 8px; position: relative; z-index: 5; flex-wrap: wrap; justify-content: center; } .rc-btn { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: var(--tmd-radius-sm, 10px); padding: 9px 16px; font: inherit; font-size: 0.92rem; font-weight: 700; cursor: pointer; background: #fff; color: #8e44ad; } .rc-btn ha-icon { --mdc-icon-size: 18px; } .rc-btn.ghost { background: rgba(255, 255, 255, 0.15); color: #fff; border: 1px solid rgba(255, 255, 255, 0.4); } /* Older ▾ sheet */ .sheet-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: 10; display: flex; align-items: flex-end; border-radius: inherit; } .sheet { background: var(--tmd-surface, var(--card-background-color, #fff)); color: var(--tmd-text, var(--primary-text-color)); width: 100%; border-radius: 18px 18px 0 0; max-height: 82%; overflow-y: auto; padding: 8px 0 12px; box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.35); font-family: var(--tmd-font-body, inherit); } .grab { width: 40px; height: 4px; border-radius: 2px; background: var(--tmd-border, var(--divider-color, #999)); margin: 4px auto 8px; } .sheet h4 { margin: 0; padding-block: 6px 10px; padding-inline: 18px 12px; font-size: 15px; display: flex; align-items: center; gap: 8px; } .sheet h4 ha-icon { --mdc-icon-size: 18px; } .x-btn { margin-inline-start: auto; background: none; border: 0; color: inherit; cursor: pointer; padding: 4px; display: grid; place-items: center; } .freq-tabs { display: flex; gap: 6px; padding: 0 16px 10px; flex-wrap: wrap; } .rc-chip { font: inherit; font-size: 12.5px; font-weight: 600; padding: 5px 12px; border-radius: 999px; cursor: pointer; background: var(--tmd-surface-2, color-mix(in srgb, var(--primary-text-color, #212121) 7%, transparent)); color: var(--tmd-dim, var(--secondary-text-color)); border: 1px solid var(--tmd-border, var(--divider-color, #ddd)); } .rc-chip.on { background: color-mix(in srgb, var(--hd) 16%, transparent); color: var(--tmd-text, var(--primary-text-color)); border-color: var(--hd); } .per { display: flex; align-items: center; gap: 12px; padding: 11px 18px; cursor: pointer; width: 100%; text-align: start; border: 0; border-top: 1px solid var(--tmd-border, var(--divider-color, #e0e0e0)); background: none; color: inherit; font: inherit; box-sizing: border-box; } .per:hover { background: var(--tmd-surface-2, var(--secondary-background-color, rgba(0, 0, 0, 0.04))); } .per.cur { background: color-mix(in srgb, var(--hd) 10%, transparent); } .per.locked { cursor: default; opacity: 0.7; } .per .ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--hd) 22%, transparent); color: var(--hd); } .per .ic ha-icon { --mdc-icon-size: 18px; } .per .grow { flex: 1; min-width: 0; display: flex; flex-direction: column; } .per .t { font-weight: 600; } .per .s { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); } .per > ha-icon { --mdc-icon-size: 16px; opacity: 0.6; } .rc-toast { position: absolute; left: 50%; bottom: 24px; transform: translateX(-50%); background: #323232; color: #fff; /* rtl-ok: centred with translate(-50%), symmetric */ padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 20; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); max-width: 90%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ── Per-design treatments of the story (the body is shared) ───────── */ :host([data-tm-design="playroom"]) .story { border-radius: 26px; } :host([data-tm-design="playroom"]) .glyph { border-radius: 32px; } :host([data-tm-design="console"]) .story { background: radial-gradient(120% 80% at 100% 0%, color-mix(in srgb, var(--s1) 55%, transparent), transparent 60%), linear-gradient(160deg, #0e1320, color-mix(in srgb, var(--s2) 45%, #0e1320)); border: 1px solid color-mix(in srgb, var(--s1) 55%, #2a3550); } :host([data-tm-design="console"]) .huge { text-shadow: 0 0 24px color-mix(in srgb, var(--s1) 70%, transparent); } :host([data-tm-design="console"]) .rc-btn:not(.ghost) { background: var(--s1); color: #06101c; } :host([data-tm-design="cleanpro"]) .story { background: linear-gradient(160deg, color-mix(in srgb, var(--s1) 82%, #1a2230), color-mix(in srgb, var(--s2) 82%, #1a2230)); } :host([data-tm-design="graphite"]) .story { background: linear-gradient(160deg, color-mix(in srgb, var(--hd) 88%, #000), color-mix(in srgb, var(--hd) 55%, #000)); } :host([data-tm-design="graphite"]) .blob { display: none; } :host([data-tm-design="graphite"]) .glyph { transform: none; border-radius: 50%; box-shadow: none; } :host([data-tm-design="graphite"]) .huge, :host([data-tm-design="graphite"]) .big { font-weight: 600; letter-spacing: -1px; font-variant-numeric: tabular-nums; } :host([data-tm-design="graphite"]) .rc-btn:not(.ghost) { color: #1d1d1f; } :host([data-tm-design="graphite"]) .rc-chip.on { background: transparent; } /* Accessible: solid near-black, heavy border, no decorative motion. */ :host([data-tm-design="accessible"]) .story { background: #0a0a0a; border: 3px solid var(--s1); transition: none; } :host([data-tm-design="accessible"]) .blob { display: none; } :host([data-tm-design="accessible"]) .glyph { transform: none; background: #1c1c1c; border: 2px solid #f5f5f5; } :host([data-tm-design="accessible"]) .say, :host([data-tm-design="accessible"]) .kick, :host([data-tm-design="accessible"]) .story-foot, :host([data-tm-design="accessible"]) .was { opacity: 1; } :host([data-tm-design="accessible"]) .segs i { background: #6b6b6b; } :host([data-tm-design="accessible"]) .segs i.done { background: #f5f5f5; } :host([data-tm-design="accessible"]) .minibars b, :host([data-tm-design="accessible"]) .dow b { background: #8a8a8a; } :host([data-tm-design="accessible"]) .minibars .hi b, :host([data-tm-design="accessible"]) .dow .hi b { background: #f0e442; } :host([data-tm-design="accessible"]) .delta.up { background: #009e73; color: #fff; } :host([data-tm-design="accessible"]) .delta.dn { background: #d55e00; color: #fff; } :host([data-tm-design="accessible"]) .rc-btn:not(.ghost) { background: #f5f5f5; color: #0a0a0a; } :host([data-tm-design="accessible"]) .story-nav { background: #f5f5f5; color: #0a0a0a; } @media (prefers-reduced-motion: reduce) { .story { transition: none; } } `; const tokens = window.__taskmate_design && window.__taskmate_design.styles ? window.__taskmate_design.styles() : null; return tokens ? [tokens, base] : base; } } // Card Editor class TaskMateRecapCardEditor 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; } .colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); } .colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; } .colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; } .colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; } .colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; } .colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; } .colour-presets { display: flex; gap: 6px; flex-wrap: wrap; } .preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; } .preset-swatch:hover { transform: scale(1.15); } .preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); } .colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-inline-start: auto; } .colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; } `; } setConfig(config) { this.config = config; } _buildSchema() { const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null; const children = entity?.attributes?.children || []; return [ { name: "entity", selector: { entity: { domain: "sensor" } } }, { name: "child_id", selector: { select: { options: children.map((c) => ({ value: c.id, label: c.name })), mode: "dropdown", }, }, }, { name: "title", selector: { text: {} } }, { name: "default_frequency", selector: { select: { options: [ { value: "__newest__", label: this._t("recap.editor.newest") }, ...RECAP_FREQUENCIES.map((f) => ({ value: f, label: this._t(`recap.freq.${f}`) })), ], mode: "dropdown", }, }, }, { name: "autoplay", 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 = { entity: this._t("common.editor.overview_entity"), child_id: this._t("recap.editor.child"), title: this._t("common.editor.card_title"), default_frequency: this._t("recap.editor.default_frequency"), autoplay: this._t("recap.editor.autoplay"), card_design: this._t("common.design.field_label"), }; return labels[entry.name] ?? entry.name; }; _computeHelper = (entry) => { const helpers = { entity: this._t("common.editor.overview_entity_helper"), child_id: this._t("recap.editor.child_helper"), default_frequency: this._t("recap.editor.default_frequency_helper"), autoplay: this._t("recap.editor.autoplay_helper"), }; return helpers[entry.name] ?? ""; }; render() { if (!this.hass || !this.config) return html``; const data = { entity: this.config.entity || "", child_id: this.config.child_id || "", title: this.config.title || "", default_frequency: this.config.default_frequency || "__newest__", autoplay: this.config.autoplay === true, card_design: this.config.card_design || "global", }; return html` ${this._renderColourPicker("header_color", "#9b59b6")} `; } _renderColourPicker(key, defaultValue) { const d = window.__taskmate_design; const current = this.config[key] || defaultValue; if (!d || !d.colourPicker) return html``; return d.colourPicker({ defaultValue, current, label: this._t("common.editor.header_colour"), helper: this._t("common.editor.header_colour_helper"), resetLabel: this._t("common.reset"), onInput: (v) => this._updateConfig(key, v), onPreset: (v) => this._updateConfig(key, v), onReset: () => this._updateConfig(key, defaultValue), }); } _formChanged(e) { const newValues = e.detail.value || {}; const newConfig = { ...this.config }; for (const [key, value] of Object.entries(newValues)) { if ( value === "" || value === null || value === undefined || (key === "default_frequency" && value === "__newest__") || (key === "card_design" && value === "global") || (key === "autoplay" && value === false) ) { delete newConfig[key]; } else { newConfig[key] = value; } } this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, })); } _updateConfig(key, value) { const newConfig = { ...this.config, [key]: value }; if (value === null || value === "" || value === undefined) delete newConfig[key]; this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, })); } } customElements.define("taskmate-recap-card", TaskMateRecapCard); customElements.define("taskmate-recap-card-editor", TaskMateRecapCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-recap-card", name: "TaskMate Recap", description: "A Wrapped-style story of a child's week, month or year", preview: false, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-recap-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-recap-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE RECAP CARD %c v" + _tmVersion + " ", "background:#9b59b6;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;" );