849 lines
37 KiB
JavaScript
849 lines
37 KiB
JavaScript
/**
|
|
* 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`<span class="ac-av" style="--ac-ac:${colour};--ac-s:${size}px" title="${name}">${name.charAt(0).toUpperCase()}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 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 <style> in the template is
|
|
* ordered before adoptedStyleSheets and would lose to the :host default.
|
|
*/
|
|
_applyDesign() {
|
|
const design = window.__taskmate_design
|
|
? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity)
|
|
: "classic";
|
|
const configured = this.config.header_color;
|
|
if (typeof configured === "string" && /^#[0-9a-fA-F]{3,8}$/.test(configured)) {
|
|
this.style.setProperty("--ac-accent", _safeColor(configured, DEFAULT_ACCENT));
|
|
} else {
|
|
this.style.removeProperty("--ac-accent");
|
|
}
|
|
return design;
|
|
}
|
|
|
|
// ── data ────────────────────────────────────────────────────────────────
|
|
|
|
async _fetch() {
|
|
const conn = this.hass?.connection;
|
|
if (!conn || !this.config?.child_id) return;
|
|
try {
|
|
const res = await conn.sendMessagePromise({ type: "taskmate/auctions/list", child_id: this.config.child_id });
|
|
this._auctions = Array.isArray(res?.auctions) ? res.auctions : [];
|
|
this._error = "";
|
|
} catch (err) {
|
|
this._error = String(err?.message || err?.code || err);
|
|
}
|
|
}
|
|
|
|
async _send(msg) {
|
|
if (this._busy) return false;
|
|
this._busy = true;
|
|
try {
|
|
await this.hass.connection.sendMessagePromise(msg);
|
|
await this._fetch();
|
|
return true;
|
|
} catch (err) {
|
|
this.dispatchEvent(new CustomEvent("hass-notification", {
|
|
detail: { message: String(err?.message || err) }, bubbles: true, composed: true,
|
|
}));
|
|
return false;
|
|
} finally {
|
|
this._busy = false;
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
// ── bidding ─────────────────────────────────────────────────────────────
|
|
|
|
_openSheet(a) {
|
|
const start = a.my_bid ?? Math.round((a.min_points + a.max_points) / 2);
|
|
this._sheet = { id: a.id, value: Math.max(a.min_points, Math.min(a.max_points, start)), fresh: true };
|
|
}
|
|
|
|
_setBid(value, a) {
|
|
const v = Math.max(0, Math.min(a.max_points, Number(value) || 0));
|
|
this._sheet = { ...this._sheet, value: v, fresh: false };
|
|
}
|
|
|
|
_key(k, a) {
|
|
const cur = String(this._sheet.value || "");
|
|
if (k === "c") return this._setBid(0, a);
|
|
if (k === "bs") return this._setBid(Number(cur.slice(0, -1)) || 0, a);
|
|
return this._setBid(Number(this._sheet.fresh ? k : cur + k), a);
|
|
}
|
|
|
|
async _seal(a) {
|
|
const ok = await this._send({
|
|
type: "taskmate/auctions/bid", auction_id: a.id, child_id: this.config.child_id, points: this._sheet.value,
|
|
});
|
|
if (ok) this._sheet = null;
|
|
}
|
|
|
|
async _withdraw(a) {
|
|
await this._send({ type: "taskmate/auctions/withdraw", auction_id: a.id, child_id: this.config.child_id });
|
|
}
|
|
|
|
// ── render ──────────────────────────────────────────────────────────────
|
|
|
|
render() {
|
|
if (!this.hass || !this.config) return html``;
|
|
this._applyDesign();
|
|
const title = this.config.title || this._t("auction.title");
|
|
const child = this._child();
|
|
if (!child) {
|
|
return html`<ha-card>${this._header(title, null)}<div class="ac-empty">${this._t("auction.no_child")}</div></ha-card>`;
|
|
}
|
|
const all = this._auctions || [];
|
|
const open = all.filter(a => a.status === "open");
|
|
const done = all.filter(a => a.status !== "open");
|
|
const sheetAuction = this._sheet && open.find(a => a.id === this._sheet.id);
|
|
return html`
|
|
<ha-card class="${sheetAuction ? "ac-sheet-open" : ""}">
|
|
${this._header(title, this._auctions ? open.length : null)}
|
|
<div class="ac-content">
|
|
${this._error ? html`<div class="ac-empty ac-err">${this._t("auction.load_failed", { error: this._error })}</div>` : ""}
|
|
${open.length
|
|
? open.map(a => this._lot(a))
|
|
: (this._auctions && !this._error ? html`<div class="ac-empty">${this._t("auction.empty")}</div>` : "")}
|
|
${done.length ? html`
|
|
<div class="ac-sec">${this._t("auction.results")}</div>
|
|
${done.map(a => this._lot(a))}
|
|
` : ""}
|
|
</div>
|
|
${sheetAuction ? this._renderSheet(sheetAuction) : ""}
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
_header(title, openCount) {
|
|
return html`
|
|
<div class="ac-hd">
|
|
<span class="ac-hd-gavel"><ha-icon icon="mdi:gavel"></ha-icon></span>
|
|
<div class="ac-hd-text">
|
|
<div class="ac-hd-title">${title}</div>
|
|
<div class="ac-hd-sub">${this._t("auction.subtitle")}</div>
|
|
</div>
|
|
${openCount !== null ? html`<span class="ac-hd-pill">${this._t("auction.open_count", { count: openCount })}</span>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_otherBids(a) {
|
|
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
|
|
if (!others) return this._t("auction.no_other_bids");
|
|
return others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others });
|
|
}
|
|
|
|
_gauge(a) {
|
|
return html`
|
|
<div class="ac-gauge">
|
|
<div class="ac-gauge-row">
|
|
<span>${a.min_points} ★</span><span class="ac-caps">${this._t("auction.lowest_wins")}</span><span>${this._t("auction.gauge_max", { points: a.max_points })}</span>
|
|
</div>
|
|
<div class="ac-gauge-bar"></div>
|
|
<div class="ac-gauge-hint">${this._t("auction.gauge_hint", { points: a.normal_points })}</div>
|
|
</div>`;
|
|
}
|
|
|
|
_lot(a) {
|
|
const me = String(this.config.child_id);
|
|
const head = html`
|
|
<div class="ac-lot-top">
|
|
<span class="ac-lot-ic"><ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon></span>
|
|
<div class="ac-grow">
|
|
<div class="ac-lot-t">${a.chore_name}</div>
|
|
<div class="ac-lot-when"><ha-icon icon="mdi:calendar-blank-outline"></ha-icon>${this._date(a.occurrence)}</div>
|
|
</div>
|
|
${a.status === "open" ? html`<span class="ac-tag ac-tag-max">${this._t("auction.up_to", { points: a.max_points })}</span>` : ""}
|
|
</div>`;
|
|
|
|
if (a.status === "open") {
|
|
const mine = a.my_bid != null;
|
|
return html`
|
|
<div class="ac-lot">
|
|
${head}
|
|
${mine ? html`
|
|
<div class="ac-sealed">
|
|
<span class="ac-env"><ha-icon icon="mdi:email-lock"></ha-icon></span>
|
|
<div class="ac-grow"><small>${this._t("auction.your_bid")}</small><b>${a.my_bid} ★</b></div>
|
|
<button class="ac-btn ac-ghost ac-sm" ?disabled=${this._busy} @click=${() => this._openSheet(a)}>${this._t("auction.change")}</button>
|
|
</div>` : this._gauge(a)}
|
|
<div class="ac-lot-foot">
|
|
<span class="ac-cdown"><ha-icon icon="mdi:timer-sand"></ha-icon>${this._t("auction.closes_in", { time: this._fmtDur(this._left(a)) })}</span>
|
|
<span class="ac-bidcount"><ha-icon icon="mdi:email-outline"></ha-icon>${this._otherBids(a)}</span>
|
|
<span class="ac-grow"></span>
|
|
${mine
|
|
? html`<button class="ac-btn ac-text ac-sm" ?disabled=${this._busy} @click=${() => this._withdraw(a)}>${this._t("auction.withdraw")}</button>`
|
|
: html`<button class="ac-btn ac-bid" ?disabled=${this._busy} @click=${() => this._openSheet(a)}><ha-icon icon="mdi:gavel"></ha-icon>${this._t("auction.place_bid")}</button>`}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const closed = html`<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.closed_at", { when: this._when(a.closed_at) })}</span></div>`;
|
|
if (!a.winner_id) {
|
|
return html`
|
|
<div class="ac-lot ac-nobid">
|
|
${head}
|
|
<div class="ac-result ac-nob">
|
|
<span class="ac-ri"><ha-icon icon="mdi:email-outline"></ha-icon></span>
|
|
<div>${this._t("auction.no_bids")}<small>${this._t("auction.no_bids_sub", { chore: a.chore_name, date: this._date(a.occurrence, false) })}</small></div>
|
|
</div>
|
|
${closed}
|
|
</div>`;
|
|
}
|
|
if (String(a.winner_id) === me) {
|
|
return html`
|
|
<div class="ac-lot ac-won">
|
|
${head}
|
|
<div class="ac-result ac-win">
|
|
<span class="ac-ri"><ha-icon icon="mdi:trophy"></ha-icon></span>
|
|
<div>${this._t("auction.you_won", { points: a.price })}<small>${this._t("auction.you_won_sub", { date: this._date(a.occurrence, false) })}</small></div>
|
|
</div>
|
|
<div class="ac-won-chore">
|
|
<ha-icon icon="${a.icon || "mdi:clipboard-check-outline"}"></ha-icon>
|
|
<span class="ac-grow ac-won-nm">${a.chore_name}</span>
|
|
<span class="ac-tag ac-tag-won"><ha-icon icon="mdi:gavel"></ha-icon>${this._t("child.won_at_auction")}</span>
|
|
<span class="ac-pts"><ha-icon icon="mdi:star"></ha-icon>${a.price}</span>
|
|
</div>
|
|
<div class="ac-lot-foot"><span class="ac-faint">${this._t("auction.in_your_chores", { date: this._date(a.occurrence, false) })}</span></div>
|
|
</div>`;
|
|
}
|
|
// Lowest bid wins and the earliest breaks a tie, so a losing bid equal to
|
|
// the price lost on timing, not on the amount.
|
|
const winner = this._childName(a.winner_id);
|
|
const sub = a.my_bid == null ? this._t("auction.sibling_won_sub", { points: a.price })
|
|
: Number(a.my_bid) === Number(a.price) ? this._t("auction.tied_sub", { points: a.price, name: winner })
|
|
: this._t("auction.sibling_won_sub_bid", { points: a.price, bid: a.my_bid });
|
|
return html`
|
|
<div class="ac-lot ac-lost">
|
|
${head}
|
|
<div class="ac-result ac-lose">
|
|
<span class="ac-ri ac-ri-av">${this._avatar(a.winner_id)}</span>
|
|
<div>${this._t("auction.sibling_won", { name: winner })}<small>${sub}</small></div>
|
|
</div>
|
|
${closed}
|
|
</div>`;
|
|
}
|
|
|
|
_renderSheet(a) {
|
|
const v = this._sheet.value;
|
|
const valid = v >= a.min_points && v <= a.max_points;
|
|
const others = Math.max(0, (a.bid_count || 0) - (a.my_bid != null ? 1 : 0));
|
|
const close = () => { this._sheet = null; };
|
|
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
|
return html`
|
|
<div class="ac-scrim" @click=${e => { if (e.target === e.currentTarget) close(); }}>
|
|
<div class="ac-sheet" role="dialog" aria-modal="true" aria-label="${a.chore_name}">
|
|
<div class="ac-grab"></div>
|
|
<h3>${a.chore_name}</h3>
|
|
<div class="ac-sheet-sub">${this._t("auction.sheet_sub", { date: this._date(a.occurrence), time: this._fmtDur(this._left(a)) })}</div>
|
|
<div class="ac-bigbid">${v} <ha-icon icon="mdi:star"></ha-icon></div>
|
|
<div class="ac-bidlbl">${this._t("auction.bid_label")}</div>
|
|
<input type="range" class="ac-range" min="${a.min_points}" max="${a.max_points}" .value=${String(Math.max(a.min_points, v))}
|
|
aria-label="${this._t("auction.bid_label")}"
|
|
@input=${e => this._setBid(e.target.value, a)}>
|
|
<div class="ac-rng-l"><span>${this._t("auction.range_low", { points: a.min_points })}</span><span>${this._t("auction.range_high", { points: a.max_points })}</span></div>
|
|
<div class="ac-keypad">
|
|
${keys.map(k => html`<button type="button" @click=${() => this._key(k, a)}>${k}</button>`)}
|
|
<button type="button" class="ac-key-clear" @click=${() => this._key("c", a)}>${this._t("auction.clear")}</button>
|
|
<button type="button" @click=${() => this._key("0", a)}>0</button>
|
|
<button type="button" aria-label="${this._t("auction.backspace")}" @click=${() => this._key("bs", a)}><ha-icon class="tm-rtl-flip" icon="mdi:backspace-outline"></ha-icon></button>
|
|
</div>
|
|
<div class="ac-rules">
|
|
<div><ha-icon icon="mdi:lock-outline"></ha-icon><span>${this._t("auction.rule_secret")}${others ? ` ${others === 1 ? this._t("auction.other_bids_one") : this._t("auction.other_bids", { count: others })}.` : ""}</span></div>
|
|
<div><ha-icon icon="mdi:trophy-outline"></ha-icon><span>${this._t("auction.rule_lowest")}</span></div>
|
|
${a.min_points > 1 ? html`<div><ha-icon icon="mdi:arrow-collapse-down"></ha-icon><span>${this._t("auction.rule_min", { points: a.min_points })}</span></div>` : ""}
|
|
<div><ha-icon icon="mdi:check"></ha-icon><span>${this._t("auction.rule_win", { date: this._date(a.occurrence, false) })}</span></div>
|
|
</div>
|
|
<div class="ac-sheet-acts">
|
|
<button type="button" class="ac-btn ac-ghost" @click=${close}>${this._t("auction.cancel")}</button>
|
|
<button type="button" class="ac-btn ac-bid ac-seal" ?disabled=${!valid || this._busy} @click=${() => this._seal(a)}>
|
|
<ha-icon icon="mdi:email-lock"></ha-icon>${this._t("auction.seal", { points: v })}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
static get styles() {
|
|
const base = css`
|
|
:host { display: block; --ac-accent: var(--tmd-accent, #5e35b1); --ac-auc: #7e57c2; }
|
|
ha-card {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px));
|
|
font-family: var(--tmd-font-body, inherit);
|
|
}
|
|
ha-card.ac-sheet-open { min-height: 700px; }
|
|
.ac-hd {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 14px 18px;
|
|
background: var(--ac-accent);
|
|
color: var(--tmd-hd-text, #fff);
|
|
}
|
|
:host([data-tm-design="playroom"]) .ac-hd {
|
|
background: linear-gradient(135deg, var(--ac-accent), color-mix(in srgb, var(--ac-accent) 72%, #fff));
|
|
}
|
|
.ac-hd-gavel {
|
|
width: 48px; height: 48px; flex: none; border-radius: 14px;
|
|
display: grid; place-items: center;
|
|
background: rgba(255, 255, 255, .18);
|
|
}
|
|
.ac-hd-gavel ha-icon { --mdc-icon-size: 28px; }
|
|
.ac-hd-text { flex: 1; min-width: 0; }
|
|
.ac-hd-title {
|
|
font-family: var(--tmd-font-display, inherit);
|
|
font-size: 1.3rem; font-weight: 600;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.ac-hd-sub { font-size: .8rem; opacity: .85; margin-top: 1px; }
|
|
.ac-hd-pill {
|
|
flex: none; white-space: nowrap;
|
|
background: rgba(255, 255, 255, .2);
|
|
padding: 4px 12px; border-radius: 16px;
|
|
font-size: .9rem; font-weight: 500;
|
|
}
|
|
.ac-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
.ac-empty {
|
|
text-align: center; padding: 30px 12px;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-err { color: var(--tmd-bad, var(--error-color, #c62828)); padding: 12px; }
|
|
.ac-sec {
|
|
font-size: .8rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
margin: 4px 2px -4px;
|
|
}
|
|
.ac-grow { flex: 1; min-width: 0; }
|
|
.ac-faint { font-size: 12px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
|
|
.ac-lot {
|
|
border-radius: var(--tmd-radius-sm, 18px);
|
|
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
|
border: 2px solid color-mix(in srgb, var(--ac-auc) 45%, var(--tmd-border, var(--divider-color, #e0e0e0)));
|
|
overflow: hidden;
|
|
}
|
|
.ac-lot.ac-won { border-color: var(--tmd-gold, #f1c40f); }
|
|
.ac-lot.ac-lost, .ac-lot.ac-nobid { border-color: var(--tmd-border, var(--divider-color, #e0e0e0)); opacity: .85; }
|
|
.ac-lot-top { display: flex; gap: 12px; padding: 14px 14px 10px; align-items: flex-start; }
|
|
.ac-lot-ic {
|
|
width: 46px; height: 46px; flex: none; border-radius: 14px;
|
|
display: grid; place-items: center; color: #fff;
|
|
background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent));
|
|
box-shadow: 0 3px 10px color-mix(in srgb, var(--ac-accent) 40%, transparent);
|
|
}
|
|
.ac-lot-ic ha-icon { --mdc-icon-size: 24px; }
|
|
:host([data-tm-design="accessible"]) .ac-lot-ic { box-shadow: none; border: 2px solid var(--tmd-border, #111); }
|
|
.ac-lot-t {
|
|
font-family: var(--tmd-font-display, inherit);
|
|
font-size: 1.12rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere;
|
|
}
|
|
.ac-lot-when {
|
|
display: flex; align-items: center; gap: 6px; margin-top: 2px;
|
|
font-size: .85rem; color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-lot-when ha-icon { --mdc-icon-size: 14px; }
|
|
.ac-tag {
|
|
display: inline-flex; align-items: center; gap: 4px; flex: none;
|
|
font-size: .72rem; font-weight: 700;
|
|
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
|
background: var(--tmd-surface-2, rgba(127, 127, 127, .12));
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
}
|
|
.ac-tag ha-icon { --mdc-icon-size: 12px; }
|
|
.ac-tag-max, .ac-tag-won {
|
|
background: color-mix(in srgb, var(--ac-auc) 16%, transparent);
|
|
color: color-mix(in srgb, var(--ac-auc) 70%, var(--tmd-text, var(--primary-text-color)));
|
|
}
|
|
|
|
.ac-gauge {
|
|
margin: 0 14px; padding: 10px 12px; border-radius: 12px;
|
|
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
}
|
|
.ac-gauge-row {
|
|
display: flex; justify-content: space-between; gap: 8px;
|
|
font-size: .75rem; font-weight: 600; letter-spacing: .02em;
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-caps { text-transform: uppercase; }
|
|
.ac-gauge-bar {
|
|
height: 10px; border-radius: 5px; margin: 6px 0;
|
|
background: linear-gradient(90deg, var(--tmd-good, #2ecc71), var(--tmd-gold, #f1c40f) 60%, var(--tmd-warn, #e67e22));
|
|
}
|
|
/* Low (green) sits at the reading start, beside the minimum label. */
|
|
:host([dir="rtl"]) .ac-gauge-bar { transform: scaleX(-1); }
|
|
.ac-gauge-hint { font-size: .78rem; text-align: center; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
|
|
.ac-sealed {
|
|
margin: 0 14px; padding: 12px;
|
|
display: flex; gap: 12px; align-items: center;
|
|
border-radius: 14px;
|
|
background: color-mix(in srgb, var(--ac-auc) 14%, transparent);
|
|
border: 1px dashed color-mix(in srgb, var(--ac-auc) 60%, transparent);
|
|
}
|
|
.ac-env { width: 44px; height: 40px; flex: none; display: grid; place-items: center; color: var(--ac-auc); }
|
|
.ac-env ha-icon { --mdc-icon-size: 36px; }
|
|
.ac-sealed small { display: block; font-size: .78rem; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-sealed b { font-size: 1.4rem; font-family: var(--tmd-font-display, inherit); }
|
|
|
|
.ac-lot-foot { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-wrap: wrap; }
|
|
.ac-cdown {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
|
|
color: color-mix(in srgb, var(--ac-auc) 75%, var(--tmd-text, var(--primary-text-color)));
|
|
}
|
|
.ac-cdown ha-icon { --mdc-icon-size: 16px; }
|
|
.ac-bidcount {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
font-size: .8rem; color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-bidcount ha-icon { --mdc-icon-size: 14px; }
|
|
|
|
.ac-result {
|
|
margin: 0 14px; padding: 12px 14px; border-radius: 14px;
|
|
display: flex; gap: 12px; align-items: center; font-weight: 600;
|
|
}
|
|
.ac-result small { display: block; font-weight: 500; font-size: .82rem; opacity: .85; margin-top: 2px; }
|
|
.ac-win { background: linear-gradient(135deg, var(--tmd-gold, #f1c40f), #f39c12); color: #3b2a00; }
|
|
.ac-lose, .ac-nob {
|
|
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
}
|
|
.ac-nob { color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-ri {
|
|
width: 42px; height: 42px; flex: none; border-radius: 50%;
|
|
display: grid; place-items: center; background: rgba(255, 255, 255, .35);
|
|
}
|
|
.ac-ri-av { background: transparent; }
|
|
.ac-won-chore {
|
|
margin: 10px 14px 0; padding: 10px 12px;
|
|
display: flex; align-items: center; gap: 10px;
|
|
border-radius: 16px;
|
|
border: 3px solid var(--tmd-gold, #f1c40f);
|
|
background: color-mix(in srgb, var(--tmd-gold, #f1c40f) 8%, transparent);
|
|
}
|
|
.ac-won-nm { font-weight: 700; }
|
|
.ac-pts {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-weight: 700; color: var(--tmd-warn, #e67e22);
|
|
}
|
|
.ac-pts ha-icon { --mdc-icon-size: 16px; }
|
|
.ac-av {
|
|
width: var(--ac-s, 30px); height: var(--ac-s, 30px); flex: none;
|
|
border-radius: 50%;
|
|
background: var(--ac-ac, #9b59b6); color: #fff;
|
|
display: inline-grid; place-items: center;
|
|
font-weight: 700; font-size: calc(var(--ac-s, 30px) * .42); line-height: 1;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.ac-btn {
|
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
border: 0; cursor: pointer;
|
|
border-radius: var(--tmd-radius-sm, 10px);
|
|
padding: 9px 16px; min-height: 36px;
|
|
font: inherit; font-family: var(--tmd-font-display, inherit);
|
|
font-size: .92rem; font-weight: 600; color: #fff; white-space: nowrap;
|
|
}
|
|
.ac-btn ha-icon { --mdc-icon-size: 17px; }
|
|
.ac-btn[disabled] { opacity: .45; cursor: default; }
|
|
.ac-sm { padding: 6px 12px; min-height: 30px; font-size: .82rem; }
|
|
.ac-bid { background: linear-gradient(135deg, var(--ac-auc), var(--ac-accent)); }
|
|
.ac-text { background: transparent; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-ghost {
|
|
background: transparent; color: var(--tmd-text, var(--primary-text-color));
|
|
border: 1px solid var(--tmd-border, var(--divider-color, #ccc));
|
|
}
|
|
|
|
.ac-scrim {
|
|
position: absolute; inset: 0; z-index: 5;
|
|
display: flex; align-items: flex-end;
|
|
background: rgba(0, 0, 0, .6);
|
|
border-radius: inherit;
|
|
}
|
|
.ac-sheet {
|
|
width: 100%; box-sizing: border-box;
|
|
padding: 10px 18px 20px;
|
|
background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff)));
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
border-radius: 26px 26px 0 0;
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
box-shadow: 0 -10px 40px rgba(0, 0, 0, .45);
|
|
}
|
|
.ac-grab {
|
|
width: 40px; height: 5px; border-radius: 3px; margin: 0 auto 10px;
|
|
background: var(--tmd-border, var(--divider-color, #9aa3ad));
|
|
}
|
|
.ac-sheet h3 { margin: 0; font-size: 1.1rem; text-align: center; font-family: var(--tmd-font-display, inherit); }
|
|
.ac-sheet-sub { text-align: center; font-size: .85rem; margin: 2px 0 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-bigbid {
|
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
font-size: 3.2rem; font-weight: 800; line-height: 1;
|
|
margin: 8px 0 2px; font-variant-numeric: tabular-nums;
|
|
font-family: var(--tmd-font-display, inherit);
|
|
}
|
|
.ac-bigbid ha-icon { --mdc-icon-size: 38px; color: var(--tmd-gold, #f1c40f); }
|
|
.ac-bidlbl { text-align: center; font-size: .8rem; margin-bottom: 8px; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-range { width: 100%; height: 28px; accent-color: var(--ac-auc); margin: 0; }
|
|
.ac-rng-l {
|
|
display: flex; justify-content: space-between;
|
|
font-size: .72rem; color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 12px 0; }
|
|
.ac-keypad button {
|
|
height: 44px; border-radius: 12px; cursor: pointer;
|
|
display: grid; place-items: center;
|
|
font: inherit; font-size: 1.2rem; font-weight: 600;
|
|
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .12)));
|
|
color: var(--tmd-text, var(--primary-text-color));
|
|
border: 1px solid var(--tmd-border, transparent);
|
|
}
|
|
.ac-keypad .ac-key-clear { font-size: .9rem; color: var(--tmd-dim, var(--secondary-text-color)); }
|
|
.ac-rules {
|
|
font-size: .8rem; line-height: 1.5;
|
|
padding: 10px 12px; margin-bottom: 12px; border-radius: 12px;
|
|
background: var(--tmd-surface-2, var(--secondary-background-color, rgba(127, 127, 127, .1)));
|
|
color: var(--tmd-dim, var(--secondary-text-color));
|
|
}
|
|
.ac-rules div { display: flex; gap: 8px; align-items: flex-start; }
|
|
.ac-rules ha-icon { --mdc-icon-size: 14px; margin-top: 2px; flex: none; color: var(--ac-auc); }
|
|
.ac-sheet-acts { display: flex; gap: 10px; }
|
|
.ac-sheet-acts .ac-ghost { flex: 1; }
|
|
.ac-sheet-acts .ac-seal { flex: 2; }
|
|
`;
|
|
// The token block is :host-scoped, so it only takes effect when included
|
|
// in THIS card's styles — a document-level rule cannot cross into the
|
|
// shadow root.
|
|
const tokens = window.__taskmate_design && window.__taskmate_design.styles
|
|
? window.__taskmate_design.styles() : null;
|
|
return tokens ? [tokens, base] : base;
|
|
}
|
|
}
|
|
|
|
class TaskMateAuctionCardEditor extends LitElement {
|
|
static get properties() {
|
|
return { hass: { type: Object }, config: { type: Object } };
|
|
}
|
|
|
|
setConfig(config) { this.config = config; }
|
|
|
|
_t(key, params) {
|
|
const fn = window.__taskmate_localize;
|
|
return fn ? fn(this.hass, key, params) : key;
|
|
}
|
|
|
|
_buildSchema() {
|
|
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
|
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
|
const children = attrs.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: "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) => ({
|
|
entity: this._t("common.editor.overview_entity"),
|
|
child_id: this._t("auction.editor.child"),
|
|
title: this._t("common.editor.card_title"),
|
|
card_design: this._t("common.design.field_label"),
|
|
}[entry.name] ?? entry.name);
|
|
|
|
_computeHelper = (entry) => ({
|
|
entity: this._t("common.editor.overview_entity_helper"),
|
|
child_id: this._t("auction.editor.child_helper"),
|
|
}[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 || "",
|
|
card_design: this.config.card_design || "global",
|
|
};
|
|
return html`
|
|
<ha-form
|
|
.hass=${this.hass}
|
|
.data=${data}
|
|
.schema=${this._buildSchema()}
|
|
.computeLabel=${this._computeLabel}
|
|
.computeHelper=${this._computeHelper}
|
|
@value-changed=${this._formChanged}
|
|
></ha-form>
|
|
${this._renderColourPicker("header_color", DEFAULT_ACCENT)}
|
|
`;
|
|
}
|
|
|
|
_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)) {
|
|
// "global" is the absence of a per-card override, not a value to store.
|
|
if (value === "" || value === null || value === undefined || (key === "card_design" && value === "global")) {
|
|
delete newConfig[key];
|
|
} else {
|
|
newConfig[key] = value;
|
|
}
|
|
}
|
|
this._fire(newConfig);
|
|
}
|
|
|
|
_updateConfig(key, value) {
|
|
const newConfig = { ...this.config, [key]: value };
|
|
if (value === null || value === "" || value === undefined) delete newConfig[key];
|
|
this._fire(newConfig);
|
|
}
|
|
|
|
_fire(config) {
|
|
this.dispatchEvent(new CustomEvent("config-changed", {
|
|
detail: { config }, bubbles: true, composed: true,
|
|
}));
|
|
}
|
|
|
|
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; }
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define("taskmate-auction-card", TaskMateAuctionCard);
|
|
customElements.define("taskmate-auction-card-editor", TaskMateAuctionCardEditor);
|
|
|
|
window.customCards = window.customCards || [];
|
|
window.customCards.push({
|
|
type: "taskmate-auction-card",
|
|
name: "TaskMate Chore Auction",
|
|
description: "Children bid the fewest points they'd do an unpopular chore for — lowest bid wins",
|
|
preview: true,
|
|
getEntitySuggestion: (hass, entityId) =>
|
|
window.__taskmate_suggest(hass, entityId, "taskmate-auction-card", "overview"),
|
|
});
|