/** * TaskMate Chore Auction Card (#982) * * A reverse auction on one occurrence of an unpopular chore: the child this * card belongs to bids the fewest points they would do it for, lowest bid * wins, and the winner gets that occurrence at their price. Bids are sealed — * the card shows how many bids are in, the child's own bid, and after closing * the winner and the winning price. Never anyone else's amount. * * The auctions are fetched over the `taskmate/auctions/list` WebSocket * command, which applies the linked-child rule and only ever returns this * child's own bid. `sensor.taskmate_auctions` carries a public digest (bid * counts, results); the card re-fetches whenever that digest moves. * * Single layout, so it consumes the design tokens directly (like the bounty * card) rather than carrying a second _renderDesigned() template. Every * --tmd-* reference has the card's original value as its fallback, so classic * — which defines no tokens — keeps the wireframe look. Layout classes carry * an ac- prefix: the shared design kit defines its own .btn / .bar / .chip. */ 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); const DEFAULT_ACCENT = "#5e35b1"; const AUCTIONS_ENTITY = "sensor.taskmate_auctions"; // Child avatar colours by position; a design's own palette wins when it has one. const CHILD_COLOURS = ["#ff6b9d", "#3498db", "#1abc9c", "#e67e22", "#9b59b6", "#2ecc71"]; class TaskMateAuctionCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _auctions: { type: Array }, _error: { type: String }, _sheet: { type: Object }, _busy: { type: Boolean }, }; } constructor() { super(); this._auctions = null; this._error = ""; this._sheet = null; this._busy = false; this._tick = null; this._fetchedKey = null; } setConfig(config) { if (!config || !config.entity) throw new Error("You need to define an entity"); if (!config.child_id) throw new Error("You need to define a child_id"); this.config = config; this._fetchedKey = null; } static getStubConfig() { return { entity: "sensor.taskmate_overview", child_id: "" }; } static getConfigElement() { return document.createElement("taskmate-auction-card-editor"); } getCardSize() { return 6; } connectedCallback() { super.connectedCallback(); // Countdowns tick client-side from the server's closing times; re-render // once a second only while an auction is actually open. this._tick = setInterval(() => { if ((this._auctions || []).some(a => a.status === "open")) this.requestUpdate(); }, 1000); this._fetchedKey = null; } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._tick); this._tick = null; } shouldUpdate(changedProps) { if (changedProps.has("hass") && changedProps.size === 1) { return window.__taskmate_hasChanged ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) : true; } return true; } updated() { // Re-fetch whenever the public digest moves (a bid came in, one closed). const key = this._digestKey(); if (this.hass && key !== this._fetchedKey) { this._fetchedKey = key; this._fetch(); } } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } _attrs() { return (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {}; } _digestKey() { const digest = this._attrs().auctions || this.hass?.states?.[AUCTIONS_ENTITY]?.attributes?.auctions || []; return `${this.config?.child_id}|${JSON.stringify(digest)}`; } _children() { return this._attrs().children || []; } _child() { return this._children().find(c => String(c.id) === String(this.config.child_id)); } _childName(id) { const c = this._children().find(ch => String(ch.id) === String(id)); return c ? c.name : "?"; } _lang() { return this.hass?.locale?.language || this.hass?.language || undefined; } /** "Saturday 4 October" (long) or "Sat 4 Oct" for an ISO date. */ _date(iso, long = true) { const d = new Date(`${iso}T00:00:00`); if (Number.isNaN(d.getTime())) return iso || ""; return d.toLocaleDateString(this._lang(), long ? { weekday: "long", day: "numeric", month: "long" } : { weekday: "short", day: "numeric", month: "short" }); } _when(isoInstant) { const d = new Date(isoInstant || ""); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleString(this._lang(), { weekday: "short", hour: "2-digit", minute: "2-digit" }); } _fmtDur(ms) { const sec = Math.max(0, Math.round(ms / 1000)); const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (d) return this._t("bounty.dur_days", { d, h }); if (h) return this._t("bounty.dur_hours", { h, m: String(m).padStart(2, "0") }); return this._t("bounty.dur_minutes", { m, s: String(s).padStart(2, "0") }); } _left(a) { const t = Date.parse(a.closes_at || ""); return Number.isFinite(t) ? t - Date.now() : 0; } _avatar(childId, size = 30) { const children = this._children(); const idx = Math.max(0, children.findIndex(c => String(c.id) === String(childId))); const name = this._childName(childId); const colour = `var(--tmd-c${(idx % 6) + 1}, ${CHILD_COLOURS[idx % CHILD_COLOURS.length]})`; return html`${name.charAt(0).toUpperCase()}`; } /** * Resolve the design, stamp data-tm-design on the host (so the :host-scoped * token block applies inside this shadow root) and settle the header colour. * The colour is an inline host property: a