-
-
Display debug information at the bottom of the card
+
+
+
+ Applies to chores with due_days set when today isn't scheduled
+
+
+
+
+
+
`;
}
diff --git a/custom_components/choremander/www/choremander-graph-card.js b/custom_components/choremander/www/choremander-graph-card.js
new file mode 100644
index 0000000..f4d2301
--- /dev/null
+++ b/custom_components/choremander/www/choremander-graph-card.js
@@ -0,0 +1,729 @@
+/**
+ * Choremander Points Graph Card
+ * Line graph tracking points earned per day or cumulative total.
+ * Configurable time range, per-child or combined view, toggle between modes.
+ *
+ * Version: 1.0.0
+ * Last Updated: 2026-03-18
+ */
+
+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;
+
+// Child colors — matches the streak/jackpot palette
+const CHILD_COLORS = [
+ { line: "#9b59b6", fill: "rgba(155,89,182,0.15)" },
+ { line: "#3498db", fill: "rgba(52,152,219,0.15)" },
+ { line: "#2ecc71", fill: "rgba(46,204,113,0.15)" },
+ { line: "#e67e22", fill: "rgba(230,126,34,0.15)" },
+ { line: "#e74c3c", fill: "rgba(231,76,60,0.15)" },
+ { line: "#1abc9c", fill: "rgba(26,188,156,0.15)" },
+];
+
+class ChoremanderGraphCard extends LitElement {
+ static get properties() {
+ return {
+ hass: { type: Object },
+ config: { type: Object },
+ _mode: { type: String }, // "daily" | "cumulative"
+ };
+ }
+
+ constructor() {
+ super();
+ this._mode = "daily";
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --gr-purple: #9b59b6;
+ --gr-purple-light: #a569bd;
+ --gr-gold: #f1c40f;
+ --gr-green: #2ecc71;
+ --gr-blue: #3498db;
+ }
+
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%);
+ color: white;
+ gap: 12px;
+ }
+
+ .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
+ .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
+ .header-title { font-size: 1.15rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+ .mode-toggle {
+ display: flex;
+ background: rgba(255,255,255,0.12);
+ border-radius: 20px;
+ padding: 3px;
+ gap: 2px;
+ flex-shrink: 0;
+ }
+
+ .mode-btn {
+ background: none;
+ border: none;
+ color: rgba(255,255,255,0.6);
+ padding: 4px 10px;
+ border-radius: 16px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ }
+
+ .mode-btn.active {
+ background: rgba(255,255,255,0.2);
+ color: white;
+ }
+
+ .mode-btn:hover:not(.active) {
+ color: rgba(255,255,255,0.85);
+ }
+
+ .card-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ /* Legend */
+ .legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ padding: 0 4px;
+ }
+
+ .legend-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8rem;
+ color: var(--secondary-text-color);
+ font-weight: 500;
+ }
+
+ .legend-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ /* SVG chart container */
+ .chart-wrap {
+ position: relative;
+ width: 100%;
+ min-height: 180px;
+ }
+
+ /* Tooltip */
+ .tooltip {
+ position: absolute;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-size: 0.8rem;
+ color: var(--primary-text-color);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+ display: none;
+ }
+
+ .tooltip.visible { display: block; }
+
+ .tooltip-date {
+ font-weight: 700;
+ margin-bottom: 4px;
+ color: var(--secondary-text-color);
+ font-size: 0.75rem;
+ }
+
+ .tooltip-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 2px 0;
+ }
+
+ .tooltip-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ /* Empty / error */
+ .error-state, .empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--secondary-text-color); text-align: center;
+ }
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+ .empty-state .message { font-size: 1rem; color: var(--primary-text-color); }
+ .empty-state .submessage { font-size: 0.85rem; margin-top: 4px; }
+
+ @media (max-width: 480px) {
+ .card-header { padding: 12px 14px; }
+ .header-title { font-size: 1rem; }
+ .mode-btn { padding: 3px 8px; font-size: 0.7rem; }
+ .card-content { padding: 12px; }
+ }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Points Graph",
+ child_id: null,
+ days: 14,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 4; }
+ static getConfigElement() { return document.createElement("choremander-graph-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Points Graph", days: 14 };
+ }
+
+ render() {
+ try {
+ return this._render();
+ } catch(e) {
+ console.error("[ChoremanderGraph] Render error:", e);
+ return html`
Graph error: ${e.message}
`;
+ }
+ }
+
+ _render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) {
+ return html`
Entity not found: ${this.config.entity}
`;
+ }
+ if (entity.state === "unavailable" || entity.state === "unknown") {
+ return html`
Choremander is unavailable
`;
+ }
+
+ const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ let children = entity.attributes.children || [];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Points";
+ const days = Math.max(3, Math.min(90, this.config.days || 14));
+
+ // Filter to specific child if configured
+ if (this.config.child_id) {
+ children = children.filter(c => c.id === this.config.child_id);
+ }
+
+ // Get all completions (approved only for points)
+ const allCompletions = [
+ ...(entity.attributes.recent_completions || entity.attributes.todays_completions || []),
+ ].filter(c => c.approved);
+
+ // Get manual transactions
+ const allTransactions = entity.attributes.recent_transactions || [];
+
+ // Build chore points map
+ const chorePointsMap = {};
+ (entity.attributes.chores || []).forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
+
+ // Build date range
+ const dateRange = this._buildDateRange(days, tz);
+
+ // Build per-child data series
+ const series = children.map((child, idx) => {
+ const color = CHILD_COLORS[idx % CHILD_COLORS.length];
+ const dailyPoints = this._buildDailyPoints(
+ child.id, dateRange, allCompletions, allTransactions, chorePointsMap, tz
+ );
+ const cumulativePoints = this._buildCumulative(dailyPoints);
+ return { child, color, dailyPoints, cumulativePoints };
+ });
+
+ if (series.length === 0) {
+ return html`
`;
+ }
+
+ const dataKey = this._mode === "daily" ? "dailyPoints" : "cumulativePoints";
+ const hasData = series.some(s => s[dataKey].some(v => v > 0));
+
+ return html`
+
+
+
+
+ ${series.length > 1 ? html`
+
+ ${series.map(s => html`
+
+ `)}
+
+ ` : ''}
+
+ ${hasData
+ ? this._renderChart(series, dateRange, dataKey, pointsName)
+ : html`
+
+
+
No data yet
+
Complete and approve chores to see the graph
+
+ `}
+
+
+
+ `;
+ }
+
+ get _tooltipId() {
+ if (!this.__tid) this.__tid = Math.random().toString(36).slice(2, 8);
+ return this.__tid;
+ }
+
+ _renderChart(series, dateRange, dataKey, pointsName) {
+ // Store for canvas drawing after render
+ this._chartData = { series, dateRange, dataKey, pointsName };
+
+ return html`
+
this._onChartInteract(e, false)}"
+ @mouseleave="${() => this._hideTooltip()}"
+ @touchmove="${(e) => this._onChartInteract(e, true)}"
+ @touchend="${() => this._hideTooltip()}"
+ >
+
+
+
+ `;
+ }
+
+ updated() {
+ this._drawCanvas();
+ }
+
+ _drawCanvas() {
+ if (!this._chartData) return;
+ const canvas = this.shadowRoot?.querySelector(`#chart-canvas-${this._tooltipId}`);
+ if (!canvas) return;
+
+ const { series, dateRange, dataKey } = this._chartData;
+ const DPR = window.devicePixelRatio || 1;
+ const W = canvas.offsetWidth || 300;
+ const H = 180;
+
+ canvas.width = W * DPR;
+ canvas.height = H * DPR;
+
+ const ctx = canvas.getContext('2d');
+ ctx.scale(DPR, DPR);
+
+ const PAD = { top: 16, right: 16, bottom: 28, left: 36 };
+ const innerW = W - PAD.left - PAD.right;
+ const innerH = H - PAD.top - PAD.bottom;
+ const n = dateRange.length;
+
+ const allValues = series.flatMap(s => s[dataKey]);
+ const maxVal = Math.max(1, ...allValues);
+ const niceMax = this._niceMax(maxVal);
+ const yTicks = this._yTicks(niceMax);
+
+ const xPos = (i) => PAD.left + (i / Math.max(n - 1, 1)) * innerW;
+ const yPos = (v) => PAD.top + innerH - (v / niceMax) * innerH;
+
+ // Store for tooltip use
+ this._xPos = xPos;
+ this._PAD = PAD;
+ this._innerH = innerH;
+ this._canvasW = W;
+
+ // Get computed colours from CSS
+ const style = getComputedStyle(this);
+ const gridColor = style.getPropertyValue('--divider-color').trim() || '#e0e0e0';
+ const textColor = style.getPropertyValue('--secondary-text-color').trim() || '#888';
+
+ ctx.clearRect(0, 0, W, H);
+
+ // Y grid lines and labels
+ ctx.font = '10px sans-serif';
+ ctx.textBaseline = 'middle';
+ for (const tick of yTicks) {
+ const y = yPos(tick);
+ ctx.strokeStyle = gridColor;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([2, 3]);
+ ctx.beginPath();
+ ctx.moveTo(PAD.left, y);
+ ctx.lineTo(W - PAD.right, y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.fillStyle = textColor;
+ ctx.textAlign = 'right';
+ ctx.fillText(tick, PAD.left - 4, y);
+ }
+
+ // Zero line (solid)
+ ctx.strokeStyle = gridColor;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([]);
+ ctx.beginPath();
+ ctx.moveTo(PAD.left, yPos(0));
+ ctx.lineTo(W - PAD.right, yPos(0));
+ ctx.stroke();
+
+ // Area fills
+ for (const s of series) {
+ const data = s[dataKey];
+ ctx.beginPath();
+ ctx.moveTo(xPos(0), yPos(data[0]));
+ for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(data[i]));
+ ctx.lineTo(xPos(n - 1), yPos(0));
+ ctx.lineTo(xPos(0), yPos(0));
+ ctx.closePath();
+ ctx.fillStyle = s.color.fill;
+ ctx.fill();
+ }
+
+ // Lines
+ for (const s of series) {
+ const data = s[dataKey];
+ ctx.strokeStyle = s.color.line;
+ ctx.lineWidth = 2.5;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.setLineDash([]);
+ ctx.beginPath();
+ ctx.moveTo(xPos(0), yPos(data[0]));
+ for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(data[i]));
+ ctx.stroke();
+ }
+
+ // Dots
+ for (const s of series) {
+ const data = s[dataKey];
+ for (let i = 0; i < n; i++) {
+ if (data[i] > 0) {
+ ctx.beginPath();
+ ctx.arc(xPos(i), yPos(data[i]), 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = s.color.line;
+ ctx.fill();
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+ }
+ }
+
+ // X axis labels
+ const labelEvery = n <= 7 ? 1 : n <= 14 ? 2 : n <= 31 ? 4 : 7;
+ ctx.fillStyle = textColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'alphabetic';
+ ctx.font = '10px sans-serif';
+ dateRange.forEach((d, i) => {
+ if (i % labelEvery === 0 || i === n - 1) {
+ ctx.fillText(this._shortDate(d), xPos(i), H - 4);
+ }
+ });
+ }
+
+ _onChartInteract(e, isTouch) {
+ if (!this._chartData || !this._xPos) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const clientX = isTouch ? e.touches[0]?.clientX : e.clientX;
+ if (clientX === undefined) return;
+ if (isTouch) e.preventDefault();
+ const xPx = clientX - rect.left;
+
+ const { series, dateRange, dataKey, pointsName } = this._chartData;
+ const n = dateRange.length;
+ const xPos = this._xPos;
+
+ // Scale pixel position to canvas coordinate space
+ const scaledX = (xPx / rect.width) * this._canvasW;
+
+ let nearest = 0;
+ let minDist = Infinity;
+ for (let i = 0; i < n; i++) {
+ const dist = Math.abs(xPos(i) - scaledX);
+ if (dist < minDist) { minDist = dist; nearest = i; }
+ }
+
+ const date = dateRange[nearest];
+ const tooltip = this.shadowRoot?.querySelector(`#graph-tooltip-${this._tooltipId}`);
+ const hoverLine = this.shadowRoot?.querySelector(`#hover-line-${this._tooltipId}`);
+
+ if (hoverLine) {
+ const lineX = (xPos(nearest) / this._canvasW) * rect.width;
+ hoverLine.style.left = `${lineX}px`;
+ hoverLine.style.display = 'block';
+ }
+
+ if (!tooltip) return;
+ const dateLabel = this._formatTooltipDate(date);
+ let html_content = `
${dateLabel}
`;
+ series.forEach(s => {
+ const val = s[dataKey][nearest] || 0;
+ html_content += `
`;
+ });
+ tooltip.innerHTML = html_content;
+ tooltip.classList.add('visible');
+
+ const tipW = 150;
+ let left = (xPos(nearest) / this._canvasW) * rect.width - tipW / 2;
+ left = Math.max(4, Math.min(left, rect.width - tipW - 4));
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${this._PAD.top}px`;
+ }
+
+ _hideTooltip() {
+ const tooltip = this.shadowRoot?.querySelector(`#graph-tooltip-${this._tooltipId}`);
+ const hoverLine = this.shadowRoot?.querySelector(`#hover-line-${this._tooltipId}`);
+ if (tooltip) tooltip.classList.remove('visible');
+ if (hoverLine) hoverLine.style.display = 'none';
+ }
+
+ // ── Data helpers ─────────────────────────────────────────────
+
+ _buildDateRange(days, tz) {
+ const range = [];
+ const today = new Date();
+ for (let i = days - 1; i >= 0; i--) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ range.push(d.toLocaleDateString("en-CA", { timeZone: tz }));
+ }
+ return range;
+ }
+
+ _buildDailyPoints(childId, dateRange, completions, transactions, chorePointsMap, tz) {
+ const byDay = {};
+ dateRange.forEach(d => { byDay[d] = 0; });
+
+ // Approved chore completions
+ completions
+ .filter(c => c.child_id === childId)
+ .forEach(c => {
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ if (day in byDay) {
+ byDay[day] += c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0);
+ }
+ });
+
+ // Manual transactions (positive only for points earned; include removes too if desired)
+ transactions
+ .filter(t => t.child_id === childId)
+ .forEach(t => {
+ const day = new Date(t.created_at).toLocaleDateString("en-CA", { timeZone: tz });
+ if (day in byDay) {
+ byDay[day] += t.points || 0; // negative for removals
+ }
+ });
+
+ return dateRange.map(d => Math.max(0, byDay[d]));
+ }
+
+ _buildCumulative(dailyPoints) {
+ let running = 0;
+ return dailyPoints.map(v => { running += v; return running; });
+ }
+
+ _niceMax(val) {
+ if (val <= 10) return 10;
+ if (val <= 20) return 20;
+ if (val <= 50) return 50;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(val)));
+ return Math.ceil(val / magnitude) * magnitude;
+ }
+
+ _yTicks(max) {
+ const count = 4;
+ const step = max / count;
+ return Array.from({ length: count + 1 }, (_, i) => Math.round(i * step));
+ }
+
+ _shortDate(dateStr) {
+ const d = new Date(dateStr + "T12:00:00");
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ }
+
+ _formatTooltipDate(dateStr) {
+ const d = new Date(dateStr + "T12:00:00");
+ const today = new Date().toLocaleDateString("en-CA");
+ const yesterday = new Date(Date.now() - 86400000).toLocaleDateString("en-CA");
+ if (dateStr === today) return "Today";
+ if (dateStr === yesterday) return "Yesterday";
+ return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
+ }
+}
+
+// ── Card Editor ──────────────────────────────────────────────
+class ChoremanderGraphCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-textfield { width: 100%; margin-bottom: 16px; }
+ .form-row { margin-bottom: 16px; }
+ .form-label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--primary-text-color);
+ margin-bottom: 6px;
+ padding: 0 2px;
+ }
+ .form-select {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 4px;
+ background: var(--card-background-color, #fff);
+ color: var(--primary-text-color);
+ font-size: 1rem;
+ box-sizing: border-box;
+ cursor: pointer;
+ appearance: auto;
+ }
+ .form-select:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ }
+ .form-helper {
+ display: block;
+ font-size: 0.78rem;
+ color: var(--secondary-text-color);
+ margin-top: 4px;
+ padding: 0 2px;
+ }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ const entity = this.config.entity ? this.hass.states[this.config.entity] : null;
+ const children = entity?.attributes?.children || [];
+
+ return html`
+
this._updateConfig('entity', e.target.value)}"
+ helper="The Choremander overview sensor entity"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
+
this._updateConfig('title', e.target.value)}"
+ placeholder="Points Graph"
+ >
+
+
this._updateConfig('days', parseInt(e.target.value) || 14)}"
+ helper="Number of days to display (3–90)"
+ helperPersistent
+ >
+
+
+
+
+ Show one child's line or all children together
+
+ `;
+ }
+
+ _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("choremander-graph-card", ChoremanderGraphCard);
+customElements.define("choremander-graph-card-editor", ChoremanderGraphCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-graph-card",
+ name: "Choremander Points Graph",
+ description: "Line graph tracking daily or cumulative points over time",
+ preview: true,
+});
+
+console.info(
+ "%c CHOREMANDER-GRAPH-CARD %c v1.0.0 ",
+ "background: #2c3e50; color: white; font-weight: bold; border-radius: 4px 0 0 4px;",
+ "background: #9b59b6; color: white; font-weight: bold; border-radius: 0 4px 4px 0;"
+);
\ No newline at end of file
diff --git a/custom_components/choremander/www/choremander-leaderboard-card.js b/custom_components/choremander/www/choremander-leaderboard-card.js
new file mode 100644
index 0000000..7e77ed1
--- /dev/null
+++ b/custom_components/choremander/www/choremander-leaderboard-card.js
@@ -0,0 +1,533 @@
+/**
+ * Choremander Leaderboard Card
+ * Competitive multi-child ranking showing points, streaks, and weekly activity.
+ * Adapts gracefully for single-child households (shows personal bests instead).
+ *
+ * Version: 1.0.0
+ */
+
+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 RANK_COLOURS = ["#f1c40f", "#bdc3c7", "#cd7f32", "#9b59b6", "#3498db"];
+const RANK_LABELS = ["🥇", "🥈", "🥉", "4th", "5th"];
+
+class ChoremanderLeaderboardCard extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%);
+ color: white; gap: 12px;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
+ .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
+ .header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+ .period-badge {
+ background: rgba(255,255,255,0.15);
+ border-radius: 10px;
+ padding: 3px 10px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ flex-shrink: 0;
+ }
+
+ .card-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
+
+ /* Rank row */
+ .rank-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 14px;
+ transition: box-shadow 0.2s;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .rank-row:hover { box-shadow: 0 3px 12px rgba(0,0,0,0.08); }
+
+ .rank-row.first {
+ border-color: #f1c40f;
+ background: linear-gradient(135deg, rgba(241,196,15,0.06) 0%, var(--card-background-color, #fff) 100%);
+ }
+
+ .rank-row.second {
+ border-color: #bdc3c7;
+ background: linear-gradient(135deg, rgba(189,195,199,0.06) 0%, var(--card-background-color, #fff) 100%);
+ }
+
+ .rank-row.third {
+ border-color: #cd7f32;
+ background: linear-gradient(135deg, rgba(205,127,50,0.06) 0%, var(--card-background-color, #fff) 100%);
+ }
+
+ .rank-badge {
+ font-size: 1.6rem;
+ line-height: 1;
+ flex-shrink: 0;
+ width: 36px;
+ text-align: center;
+ }
+
+ .rank-number {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--secondary-text-color);
+ text-align: center;
+ width: 36px;
+ flex-shrink: 0;
+ }
+
+ .child-avatar {
+ width: 44px; height: 44px; min-width: 44px;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
+
+ .rank-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
+
+ .rank-name {
+ font-size: 1rem; font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .rank-stats {
+ display: flex; align-items: center; flex-wrap: wrap;
+ gap: 8px; font-size: 0.78rem; color: var(--secondary-text-color);
+ }
+
+ .stat-chip {
+ display: flex; align-items: center; gap: 3px;
+ font-size: 0.75rem; color: var(--secondary-text-color);
+ }
+
+ .stat-chip ha-icon { --mdc-icon-size: 13px; }
+
+ .rank-score {
+ text-align: right; flex-shrink: 0;
+ display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
+ }
+
+ .score-value {
+ font-size: 1.4rem; font-weight: 800;
+ line-height: 1;
+ }
+
+ .score-label {
+ font-size: 0.65rem; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--secondary-text-color);
+ }
+
+ /* Tie indicator */
+ .tie-line {
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--divider-color, #e0e0e0), transparent);
+ margin: -4px 0;
+ position: relative;
+ }
+
+ .tie-line::after {
+ content: 'TIE';
+ position: absolute; top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--secondary-background-color, #f5f5f5);
+ padding: 0 6px;
+ font-size: 0.65rem; font-weight: 700;
+ color: var(--secondary-text-color);
+ letter-spacing: 0.1em;
+ }
+
+ /* Solo mode (1 child) */
+ .solo-header {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 0 4px;
+ margin-bottom: 4px;
+ }
+
+ .personal-best-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px 14px;
+ background: var(--secondary-background-color, #f5f5f5);
+ border-radius: 10px;
+ }
+
+ .pb-icon { --mdc-icon-size: 20px; }
+ .pb-label { flex: 1; font-size: 0.88rem; color: var(--primary-text-color); }
+ .pb-value { font-size: 0.95rem; font-weight: 700; color: var(--primary-text-color); }
+
+ /* Footer */
+ .card-footer {
+ padding: 10px 18px;
+ background: var(--secondary-background-color, #f5f5f5);
+ border-top: 1px solid var(--divider-color, #e0e0e0);
+ display: flex; justify-content: center;
+ font-size: 0.78rem; color: var(--secondary-text-color);
+ }
+
+ /* Error / empty */
+ .error-state, .empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--secondary-text-color); text-align: center;
+ }
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+
+ @media (max-width: 480px) {
+ .card-content { padding: 10px; gap: 8px; }
+ .rank-row { padding: 10px 12px; gap: 10px; }
+ .rank-badge { font-size: 1.3rem; width: 28px; }
+ .child-avatar { width: 38px; height: 38px; min-width: 38px; }
+ .child-avatar ha-icon { --mdc-icon-size: 22px; }
+ .rank-name { font-size: 0.95rem; }
+ .score-value { font-size: 1.2rem; }
+ }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Leaderboard",
+ sort_by: "points", // "points" | "streak" | "weekly"
+ show_streak: true,
+ show_weekly: true,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 4; }
+ static getConfigElement() { return document.createElement("choremander-leaderboard-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Leaderboard" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) return html`
Entity not found: ${this.config.entity}
`;
+ if (entity.state === "unavailable" || entity.state === "unknown") return html`
`;
+
+ const children = [...(entity.attributes.children || [])];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Points";
+
+ if (children.length === 0) return html`
`;
+
+ // Build weekly points from recent_completions
+ const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const weeklyPoints = this._buildWeeklyPoints(entity, tz);
+
+ // Sort children
+ const sortBy = this.config.sort_by || "points";
+ const sorted = [...children].sort((a, b) => {
+ if (sortBy === "streak") return (b.current_streak || 0) - (a.current_streak || 0);
+ if (sortBy === "weekly") return (weeklyPoints[b.id] || 0) - (weeklyPoints[a.id] || 0);
+ return (b.points || 0) - (a.points || 0);
+ });
+
+ const sortLabels = { points: "All-time Points", streak: "Current Streak", weekly: "This Week" };
+ const periodLabel = sortLabels[sortBy] || sortLabels.points;
+
+ // Solo mode
+ if (children.length === 1) {
+ return this._renderSolo(sorted[0], weeklyPoints, pointsIcon, pointsName, periodLabel);
+ }
+
+ return html`
+
+
+
+ ${sorted.map((child, idx) => {
+ const prevChild = idx > 0 ? sorted[idx - 1] : null;
+ const isTie = prevChild && this._getScore(child, prevChild, sortBy, weeklyPoints);
+ return html`
+ ${isTie ? html`
` : ''}
+ ${this._renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName)}
+ `;
+ })}
+
+
+
+ `;
+ }
+
+ _getScore(a, b, sortBy, weeklyPoints) {
+ if (sortBy === "streak") return (a.current_streak || 0) === (b.current_streak || 0);
+ if (sortBy === "weekly") return (weeklyPoints[a.id] || 0) === (weeklyPoints[b.id] || 0);
+ return (a.points || 0) === (b.points || 0);
+ }
+
+ _renderRankRow(child, idx, sortBy, weeklyPoints, pointsIcon, pointsName) {
+ const rankClass = idx === 0 ? "first" : idx === 1 ? "second" : idx === 2 ? "third" : "";
+ const avatarColour = RANK_COLOURS[idx % RANK_COLOURS.length];
+ const rankEmoji = idx < 3 ? RANK_LABELS[idx] : null;
+ const rankNum = idx + 1;
+
+ let scoreValue, scoreLabel;
+ if (sortBy === "streak") {
+ scoreValue = child.current_streak || 0;
+ scoreLabel = "day streak";
+ } else if (sortBy === "weekly") {
+ scoreValue = weeklyPoints[child.id] || 0;
+ scoreLabel = "this week";
+ } else {
+ scoreValue = child.points || 0;
+ scoreLabel = pointsName;
+ }
+
+ return html`
+
+ ${rankEmoji
+ ? html`
${rankEmoji}
`
+ : html`
${rankNum}
`}
+
+
+
+
+
+
+
${child.name}
+
+ ${this.config.show_streak !== false && sortBy !== "streak" ? html`
+
+
+ ${child.current_streak || 0}d streak
+
+ ` : ''}
+ ${this.config.show_weekly !== false && sortBy !== "weekly" ? html`
+
+
+ ${weeklyPoints[child.id] || 0} this week
+
+ ` : ''}
+ ${sortBy !== "points" ? html`
+
+
+ ${child.points || 0} total
+
+ ` : ''}
+
+
+
+
+
${scoreValue}
+
${scoreLabel}
+
+
+ `;
+ }
+
+ _renderSolo(child, weeklyPoints, pointsIcon, pointsName, periodLabel) {
+ const entity = this.hass.states[this.config.entity];
+ const totalChores = child.total_chores_completed || 0;
+ const bestStreak = child.best_streak || 0;
+ const weekly = weeklyPoints[child.id] || 0;
+
+ return html`
+
+
+
+
+
🥇
+
+
+
+
+
${child.name}
+
+
+
+ ${child.current_streak || 0}d streak
+
+
+
+
+
${child.points || 0}
+
${pointsName}
+
+
+
+
+
+
+ Best streak
+ ${bestStreak} days
+
+
+
+ Total chores completed
+ ${totalChores}
+
+
+
+ Points this week
+ ${weekly}
+
+
+
+ Total points earned
+ ${child.total_points_earned || child.points || 0}
+
+
+
+ `;
+ }
+
+ _buildWeeklyPoints(entity, tz) {
+ const result = {};
+ const completions = entity.attributes.recent_completions || entity.attributes.todays_completions || [];
+ const choreMap = {};
+ (entity.attributes.chores || []).forEach(ch => { choreMap[ch.id] = ch.points || 0; });
+
+ const today = new Date();
+ const weekDays = new Set();
+ for (let i = 6; i >= 0; i--) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ weekDays.add(d.toLocaleDateString("en-CA", { timeZone: tz }));
+ }
+
+ completions
+ .filter(c => c.approved)
+ .forEach(c => {
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ if (weekDays.has(day)) {
+ result[c.child_id] = (result[c.child_id] || 0) +
+ (c.points !== undefined ? c.points : (choreMap[c.chore_id] || 0));
+ }
+ });
+
+ return result;
+ }
+}
+
+class ChoremanderLeaderboardCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; padding: 4px 0; }
+ ha-textfield { width: 100%; margin-bottom: 8px; }
+ .field-row { margin-bottom: 16px; }
+ .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; }
+ .field-select { display: block; width: 100%; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; box-sizing: border-box; cursor: pointer; appearance: auto; }
+ .field-select:focus { outline: none; border-color: var(--primary-color); border-width: 2px; }
+ .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; }
+ .check-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); cursor: pointer; user-select: none; margin-bottom: 8px; }
+ .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; accent-color: var(--primary-color); margin: 0; }
+ .check-label { font-size: 14px; color: var(--primary-text-color); flex: 1; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ return html`
+
this._update('entity', e.target.value)}"
+ helper="The Choremander overview sensor"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
+
this._update('title', e.target.value)}"
+ placeholder="Leaderboard"
+ >
+
+
+
+
+ What to rank children by
+
+
+
+
+
+ `;
+ }
+
+ _update(key, value) {
+ const cfg = { ...this.config, [key]: value };
+ this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true }));
+ }
+}
+
+customElements.define("choremander-leaderboard-card", ChoremanderLeaderboardCard);
+customElements.define("choremander-leaderboard-card-editor", ChoremanderLeaderboardCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-leaderboard-card",
+ name: "Choremander Leaderboard",
+ description: "Multi-child competitive ranking by points, streak, or weekly activity",
+ preview: true,
+});
+
+console.info("%c CHOREMANDER-LEADERBOARD-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#f1c40f;color:#333;font-weight:bold;border-radius:0 4px 4px 0;");
diff --git a/custom_components/choremander/www/choremander-overview-card.js b/custom_components/choremander/www/choremander-overview-card.js
new file mode 100644
index 0000000..16e9e51
--- /dev/null
+++ b/custom_components/choremander/www/choremander-overview-card.js
@@ -0,0 +1,504 @@
+/**
+ * Choremander Overview Card
+ * At-a-glance parent dashboard showing all children's points,
+ * today's chore completion progress, and pending approvals.
+ *
+ * Version: 1.0.0
+ * Last Updated: 2026-03-18
+ */
+
+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;
+
+class ChoremanderOverviewCard extends LitElement {
+ static get properties() {
+ return {
+ hass: { type: Object },
+ config: { type: Object },
+ };
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --ov-purple: #9b59b6;
+ --ov-purple-light: #a569bd;
+ --ov-gold: #f1c40f;
+ --ov-green: #2ecc71;
+ --ov-orange: #e67e22;
+ --ov-red: #e74c3c;
+ --ov-blue: #3498db;
+ }
+
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, var(--ov-purple) 0%, var(--ov-purple-light) 100%);
+ color: white;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; }
+ .header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
+ .header-title { font-size: 1.2rem; font-weight: 600; }
+
+ .pending-badge {
+ background: var(--ov-red);
+ color: white;
+ border-radius: 12px;
+ padding: 3px 10px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ animation: badge-pulse 2s ease-in-out infinite;
+ }
+
+ @keyframes badge-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); }
+ 50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); }
+ }
+
+ .pending-badge ha-icon { --mdc-icon-size: 14px; }
+
+ .card-content {
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ /* Child tile */
+ .child-tile {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 16px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 14px;
+ transition: box-shadow 0.2s ease;
+ }
+
+ .child-tile:hover {
+ box-shadow: 0 3px 10px rgba(0,0,0,0.08);
+ }
+
+ .child-avatar {
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--ov-purple) 0%, var(--ov-purple-light) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 28px; color: white; }
+
+ .child-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+
+ .child-name-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ .child-name {
+ font-weight: 600;
+ font-size: 1.05rem;
+ color: var(--primary-text-color);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .points-pill {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(241,196,15,0.15);
+ color: var(--ov-orange);
+ border-radius: 10px;
+ padding: 3px 8px;
+ font-size: 0.85rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ }
+
+ .points-pill ha-icon { --mdc-icon-size: 14px; color: var(--ov-gold); }
+
+ .pending-points-pill {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ background: rgba(230,126,34,0.12);
+ color: var(--ov-orange);
+ border-radius: 10px;
+ padding: 2px 7px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ flex-shrink: 0;
+ opacity: 0.85;
+ }
+
+ .pending-points-pill ha-icon { --mdc-icon-size: 12px; }
+
+ /* Chore progress bar */
+ .progress-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .progress-bar-bg {
+ flex: 1;
+ height: 8px;
+ background: var(--divider-color, #e0e0e0);
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .progress-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.4s ease;
+ }
+
+ .progress-bar-fill.complete {
+ background: linear-gradient(90deg, var(--ov-green), #27ae60);
+ }
+
+ .progress-bar-fill.partial {
+ background: linear-gradient(90deg, var(--ov-blue), #2980b9);
+ }
+
+ .progress-bar-fill.none {
+ background: var(--divider-color, #e0e0e0);
+ width: 0 !important;
+ }
+
+ .progress-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ white-space: nowrap;
+ min-width: 36px;
+ text-align: right;
+ }
+
+ .progress-label.complete { color: var(--ov-green); }
+
+ /* Approval item in tile */
+ .approvals-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(231,76,60,0.12);
+ color: var(--ov-red);
+ border-radius: 10px;
+ padding: 2px 8px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ }
+
+ .approvals-chip ha-icon { --mdc-icon-size: 13px; }
+
+ /* Footer summary row */
+ .summary-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ padding: 10px 16px;
+ background: var(--secondary-background-color, #f5f5f5);
+ border-top: 1px solid var(--divider-color, #e0e0e0);
+ gap: 8px;
+ }
+
+ .summary-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ }
+
+ .summary-stat-value {
+ font-size: 1.3rem;
+ font-weight: 700;
+ color: var(--primary-text-color);
+ }
+
+ .summary-stat-label {
+ font-size: 0.7rem;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .summary-divider {
+ width: 1px;
+ height: 32px;
+ background: var(--divider-color, #e0e0e0);
+ }
+
+ /* States */
+ .error-state, .empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ color: var(--secondary-text-color);
+ text-align: center;
+ }
+
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Choremander",
+ approvals_entity: null,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 3; }
+ static getConfigElement() { return document.createElement("choremander-overview-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Choremander" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) {
+ return html`
Entity not found: ${this.config.entity}
`;
+ }
+ if (entity.state === "unavailable" || entity.state === "unknown") {
+ return html`
Choremander is unavailable
`;
+ }
+
+ const children = entity.attributes.children || [];
+ const chores = entity.attributes.chores || [];
+ const completions = [...(entity.attributes.todays_completions || [])];
+ const chorePointsMap = {};
+ chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Stars";
+
+ // Pending approvals — from approvals entity if configured, else from completions
+ let pendingApprovals = 0;
+ if (this.config.approvals_entity) {
+ const appEntity = this.hass.states[this.config.approvals_entity];
+ pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0;
+ } else {
+ pendingApprovals = completions.filter(c => !c.approved).length;
+ }
+
+ // Total points across all children
+ const totalPoints = children.reduce((sum, c) => sum + (c.points || 0), 0);
+ // Only count approved completions
+ const totalCompletedToday = completions.filter(c => c.approved).length;
+
+ if (children.length === 0) {
+ return html`
`;
+ }
+
+ return html`
+
+
+
+
+ ${children.map(child => this._renderChildTile(child, chores, completions, pointsIcon, pointsName))}
+
+
+
+
+ `;
+ }
+
+ _renderChildTile(child, chores, completions, pointsIcon, pointsName) {
+ // Avatar now included directly in children array from the overview sensor
+ const avatar = child.avatar || "mdi:account-circle";
+
+ // Chores assigned to this child
+ const childChores = chores.filter(c => {
+ const at = Array.isArray(c.assigned_to) ? c.assigned_to.map(String) : [];
+ return at.length === 0 || at.includes(String(child.id));
+ });
+
+ // All completions today for this child
+ const childCompletions = completions.filter(c => c.child_id === child.id);
+ // Only approved completions count toward progress
+ const childApprovedCompletions = childCompletions.filter(c => c.approved);
+ const completedCount = childApprovedCompletions.length;
+ const totalChores = childChores.length;
+ const percentage = totalChores > 0 ? Math.min((completedCount / totalChores) * 100, 100) : 0;
+ const isComplete = totalChores > 0 && completedCount >= totalChores;
+
+ // Pending approvals for this child
+ const childPending = childCompletions.filter(c => !c.approved).length;
+
+ return html`
+
+
+
+
+
+
+
${child.name}
+
+ ${child.pending_points > 0 ? html`
+
+ +${child.pending_points}
+
+ ` : ''}
+
+
+ ${child.points}
+
+ ${childPending > 0 ? html`
+
+ ${childPending}
+
+ ` : ''}
+
+
+ ${totalChores > 0 ? html`
+
+
+
+ ${completedCount}/${totalChores}
+
+
+ ` : html`
+
No chores today
+ `}
+
+
+ `;
+ }
+}
+
+// Card Editor
+class ChoremanderOverviewCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-textfield { width: 100%; margin-bottom: 16px; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ return html`
+
this._updateConfig('entity', e.target.value)}"
+ helper="The Choremander overview sensor entity"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
this._updateConfig('title', e.target.value)}"
+ placeholder="Choremander"
+ >
+
this._updateConfig('approvals_entity', e.target.value)}"
+ helper="sensor.pending_approvals — for accurate pending count"
+ helperPersistent
+ placeholder="sensor.pending_approvals"
+ >
+ `;
+ }
+
+ _updateConfig(key, value) {
+ const newConfig = { ...this.config, [key]: value };
+ if (!value) delete newConfig[key];
+ this.dispatchEvent(new CustomEvent("config-changed", {
+ detail: { config: newConfig }, bubbles: true, composed: true,
+ }));
+ }
+}
+
+customElements.define("choremander-overview-card", ChoremanderOverviewCard);
+customElements.define("choremander-overview-card-editor", ChoremanderOverviewCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-overview-card",
+ name: "Choremander Overview",
+ description: "At-a-glance parent dashboard for all children",
+ preview: true,
+});
+
+console.info(
+ "%c CHOREMANDER-OVERVIEW-CARD %c v1.0.0 ",
+ "background: #9b59b6; color: white; font-weight: bold; border-radius: 4px 0 0 4px;",
+ "background: #2ecc71; color: white; font-weight: bold; border-radius: 0 4px 4px 0;"
+);
\ No newline at end of file
diff --git a/custom_components/choremander/www/choremander-parent-dashboard-card.js b/custom_components/choremander/www/choremander-parent-dashboard-card.js
new file mode 100644
index 0000000..d9059eb
--- /dev/null
+++ b/custom_components/choremander/www/choremander-parent-dashboard-card.js
@@ -0,0 +1,732 @@
+/**
+ * Choremander Parent Dashboard Card
+ * Unified parent view: all children's today progress, pending approvals
+ * with inline approve/reject, pending reward claims, and quick point adjustments.
+ *
+ * Version: 1.0.0
+ */
+
+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;
+
+class ChoremanderParentDashboardCard extends LitElement {
+ static get properties() {
+ return {
+ hass: { type: Object },
+ config: { type: Object },
+ _loading: { type: Object },
+ _activeSection: { type: String },
+ };
+ }
+
+ constructor() {
+ super();
+ this._loading = {};
+ this._activeSection = "overview";
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-card { overflow: hidden; }
+
+ /* ── Header ── */
+ .card-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%);
+ color: white; gap: 12px;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
+ .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
+ .header-title { font-size: 1.1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+ .pending-badge {
+ background: #e74c3c; color: white;
+ border-radius: 12px; padding: 3px 10px;
+ font-size: 0.82rem; font-weight: 700;
+ display: flex; align-items: center; gap: 4px;
+ flex-shrink: 0;
+ animation: badge-pulse 2s ease-in-out infinite;
+ }
+
+ .pending-badge ha-icon { --mdc-icon-size: 14px; }
+
+ @keyframes badge-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.4); }
+ 50% { box-shadow: 0 0 0 5px rgba(231,76,60,0); }
+ }
+
+ /* ── Tab nav ── */
+ .tab-nav {
+ display: flex;
+ border-bottom: 1px solid var(--divider-color, #e0e0e0);
+ background: var(--secondary-background-color, #f5f5f5);
+ }
+
+ .tab-btn {
+ flex: 1; padding: 10px 8px;
+ background: none; border: none;
+ font-size: 0.78rem; font-weight: 600;
+ color: var(--secondary-text-color);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, border-color 0.15s;
+ display: flex; align-items: center; justify-content: center; gap: 5px;
+ position: relative;
+ }
+
+ .tab-btn ha-icon { --mdc-icon-size: 16px; }
+
+ .tab-btn.active {
+ color: var(--primary-color, #3498db);
+ border-bottom-color: var(--primary-color, #3498db);
+ }
+
+ .tab-badge {
+ background: #e74c3c; color: white;
+ border-radius: 8px; padding: 1px 5px;
+ font-size: 0.65rem; font-weight: 700;
+ line-height: 1.4;
+ }
+
+ /* ── Content ── */
+ .tab-content { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
+
+ /* ── Child overview tiles ── */
+ .child-tile {
+ display: flex; align-items: center; gap: 12px;
+ padding: 12px 14px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 12px;
+ }
+
+ .child-avatar {
+ width: 42px; height: 42px; min-width: 42px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
+
+ .child-tile-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+
+ .child-tile-header {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ }
+
+ .child-tile-name {
+ font-size: 0.95rem; font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .points-pill {
+ display: flex; align-items: center; gap: 3px;
+ background: rgba(241,196,15,0.15);
+ color: #e67e22; border-radius: 10px;
+ padding: 2px 8px; font-size: 0.8rem; font-weight: 700;
+ flex-shrink: 0;
+ }
+
+ .points-pill ha-icon { --mdc-icon-size: 13px; color: #f1c40f; }
+
+ .progress-row { display: flex; align-items: center; gap: 8px; }
+
+ .progress-bar {
+ flex: 1; height: 7px;
+ background: var(--divider-color, #e0e0e0);
+ border-radius: 4px; overflow: hidden;
+ }
+
+ .progress-fill {
+ height: 100%; border-radius: 4px;
+ transition: width 0.4s ease;
+ }
+
+ .progress-fill.complete { background: linear-gradient(90deg, #27ae60, #2ecc71); }
+ .progress-fill.partial { background: linear-gradient(90deg, #3498db, #2980b9); }
+ .progress-fill.none { width: 0 !important; }
+
+ .progress-label {
+ font-size: 0.75rem; font-weight: 600;
+ color: var(--secondary-text-color);
+ white-space: nowrap; min-width: 32px; text-align: right;
+ }
+
+ /* ── Approval items ── */
+ .approval-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 12px 14px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 12px;
+ transition: opacity 0.2s;
+ }
+
+ .approval-item.loading { opacity: 0.5; pointer-events: none; }
+
+ .approval-child-avatar {
+ width: 38px; height: 38px; min-width: 38px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
+ display: flex; align-items: center; justify-content: center;
+ }
+
+ .approval-child-avatar ha-icon { --mdc-icon-size: 22px; color: white; }
+
+ .approval-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+
+ .approval-chore {
+ font-size: 0.9rem; font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .approval-meta {
+ font-size: 0.75rem; color: var(--secondary-text-color);
+ display: flex; align-items: center; gap: 6px;
+ }
+
+ .approval-points {
+ display: flex; align-items: center; gap: 2px;
+ font-weight: 600; color: #e67e22;
+ }
+
+ .approval-points ha-icon { --mdc-icon-size: 12px; color: #f1c40f; }
+
+ .approval-actions { display: flex; gap: 6px; flex-shrink: 0; }
+
+ .btn-approve, .btn-reject {
+ width: 34px; height: 34px;
+ border-radius: 50%; border: none; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ transition: transform 0.1s, box-shadow 0.1s;
+ flex-shrink: 0;
+ }
+
+ .btn-approve {
+ background: linear-gradient(135deg, #27ae60, #2ecc71);
+ color: white; box-shadow: 0 2px 8px rgba(46,204,113,0.3);
+ }
+
+ .btn-reject {
+ background: linear-gradient(135deg, #c0392b, #e74c3c);
+ color: white; box-shadow: 0 2px 8px rgba(231,76,60,0.3);
+ }
+
+ .btn-approve:hover { transform: scale(1.1); }
+ .btn-reject:hover { transform: scale(1.1); }
+ .btn-approve ha-icon, .btn-reject ha-icon { --mdc-icon-size: 18px; }
+
+ /* ── Reward claim items ── */
+ .claim-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 12px 14px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid rgba(155,89,182,0.3);
+ border-radius: 12px;
+ background: rgba(155,89,182,0.04);
+ transition: opacity 0.2s;
+ }
+
+ .claim-item.loading { opacity: 0.5; pointer-events: none; }
+
+ .claim-icon-wrap {
+ width: 38px; height: 38px; min-width: 38px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6, #8e44ad);
+ display: flex; align-items: center; justify-content: center;
+ }
+
+ .claim-icon-wrap ha-icon { --mdc-icon-size: 22px; color: white; }
+
+ .claim-info { flex: 1; min-width: 0; }
+
+ .claim-reward-name {
+ font-size: 0.9rem; font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .claim-meta {
+ font-size: 0.75rem; color: var(--secondary-text-color); margin-top: 2px;
+ }
+
+ /* ── Quick points ── */
+ .quick-points-row {
+ display: flex; align-items: center; gap: 10px;
+ padding: 12px 14px;
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 12px;
+ }
+
+ .qp-avatar {
+ width: 38px; height: 38px; min-width: 38px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
+ display: flex; align-items: center; justify-content: center;
+ }
+
+ .qp-avatar ha-icon { --mdc-icon-size: 22px; color: white; }
+
+ .qp-name {
+ flex: 1; font-size: 0.9rem; font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .qp-points {
+ font-size: 1rem; font-weight: 700;
+ color: #9b59b6; white-space: nowrap;
+ display: flex; align-items: center; gap: 3px;
+ }
+
+ .qp-points ha-icon { --mdc-icon-size: 14px; color: #f1c40f; }
+
+ .qp-actions { display: flex; gap: 6px; }
+
+ .btn-add, .btn-remove {
+ width: 32px; height: 32px;
+ border-radius: 50%; border: none; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ transition: transform 0.1s;
+ flex-shrink: 0;
+ }
+
+ .btn-add {
+ background: linear-gradient(135deg, #27ae60, #2ecc71);
+ color: white; box-shadow: 0 2px 6px rgba(46,204,113,0.3);
+ }
+
+ .btn-remove {
+ background: linear-gradient(135deg, #c0392b, #e74c3c);
+ color: white; box-shadow: 0 2px 6px rgba(231,76,60,0.3);
+ }
+
+ .btn-add:hover, .btn-remove:hover { transform: scale(1.1); }
+ .btn-add ha-icon, .btn-remove ha-icon { --mdc-icon-size: 16px; }
+
+ /* ── Empty state ── */
+ .empty-section {
+ display: flex; flex-direction: column; align-items: center;
+ padding: 24px 16px; text-align: center; gap: 8px;
+ color: var(--secondary-text-color);
+ }
+
+ .empty-section ha-icon { --mdc-icon-size: 40px; opacity: 0.35; }
+ .empty-section span { font-size: 0.9rem; }
+
+ .error-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--error-color, #f44336); text-align: center;
+ }
+
+ .error-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+
+ @media (max-width: 480px) {
+ .card-header { padding: 12px 14px; }
+ .tab-btn { font-size: 0.72rem; padding: 8px 6px; }
+ .tab-content { padding: 10px; gap: 8px; }
+ .approval-item, .claim-item, .quick-points-row, .child-tile { padding: 10px 12px; }
+ }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Parent Dashboard",
+ quick_points_amount: 5,
+ show_claims: true,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 6; }
+ static getConfigElement() { return document.createElement("choremander-parent-dashboard-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Parent Dashboard" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) return html`
Entity not found: ${this.config.entity}
`;
+ if (entity.state === "unavailable" || entity.state === "unknown") return html`
`;
+
+ const children = entity.attributes.children || [];
+ const chores = entity.attributes.chores || [];
+ const completions = entity.attributes.todays_completions || [];
+ const pendingCompletions = completions.filter(c => !c.approved);
+ const pendingRewardClaims = entity.attributes.pending_reward_claims || [];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Points";
+ const totalPending = pendingCompletions.length + pendingRewardClaims.length;
+
+ const tabs = [
+ { id: "overview", label: "Overview", icon: "mdi:view-dashboard" },
+ { id: "approvals", label: "Approvals", icon: "mdi:check-circle", count: pendingCompletions.length },
+ { id: "points", label: "Points", icon: "mdi:star-plus" },
+ ];
+
+ if (this.config.show_claims) {
+ tabs.splice(2, 0, { id: "claims", label: "Claims", icon: "mdi:gift", count: pendingRewardClaims.length });
+ }
+
+ return html`
+
+
+
+
+ ${tabs.map(tab => html`
+
+ `)}
+
+
+
+ ${this._activeSection === "overview" ? this._renderOverview(children, chores, completions, pointsIcon, pointsName) : ''}
+ ${this._activeSection === "approvals" ? this._renderApprovals(pendingCompletions, children, chores, pointsIcon) : ''}
+ ${this._activeSection === "claims" ? this._renderClaims(pendingRewardClaims, pointsIcon) : ''}
+ ${this._activeSection === "points" ? this._renderPoints(children, pointsIcon, pointsName) : ''}
+
+
+ `;
+ }
+
+ _renderOverview(children, chores, completions, pointsIcon, pointsName) {
+ if (!children.length) return html`
No children found
`;
+
+ return html`
+ ${children.map(child => {
+ const childChores = chores.filter(c => {
+ const at = c.assigned_to || [];
+ return at.length === 0 || at.includes(child.id);
+ });
+ const approved = completions.filter(c => c.child_id === child.id && c.approved).length;
+ const total = childChores.length;
+ const pct = total > 0 ? Math.min(100, (approved / total) * 100) : 0;
+ const isComplete = total > 0 && approved >= total;
+ const cls = isComplete ? "complete" : pct > 0 ? "partial" : "none";
+
+ return html`
+
+
+
+
+
+
+
+
+
${approved}/${total}
+
+
+
+ `;
+ })}
+ `;
+ }
+
+ _renderApprovals(pending, children, chores, pointsIcon) {
+ if (!pending.length) return html`
+
+
+ All caught up! No pending approvals.
+
+ `;
+
+ const childMap = {};
+ children.forEach(c => { childMap[c.id] = c; });
+ const choreMap = {};
+ chores.forEach(c => { choreMap[c.id] = c; });
+
+ return html`
+ ${pending.map(comp => {
+ const child = childMap[comp.child_id];
+ const chore = choreMap[comp.chore_id];
+ const isLoading = this._loading[comp.completion_id];
+ const time = new Date(comp.completed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+
+ return html`
+
+
+
+
+
+
${comp.chore_name || chore?.name || 'Unknown chore'}
+
+ ${child?.name || 'Unknown'}
+ •
+ ${time}
+
+
+ +${comp.points || chore?.points || 0}
+
+
+
+
+
+
+
+
+ `;
+ })}
+ `;
+ }
+
+ _renderClaims(claims, pointsIcon) {
+ if (!claims.length) return html`
+
+
+ No pending reward claims.
+
+ `;
+
+ return html`
+ ${claims.map(claim => {
+ const isLoading = this._loading[claim.claim_id];
+ const time = new Date(claim.claimed_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ return html`
+
+
+
+
+
+
${claim.reward_name}
+
+ ${claim.child_name} • ${time} •
+
+
+ ${claim.cost}
+
+
+
+
+
+
+
+
+ `;
+ })}
+ `;
+ }
+
+ _renderPoints(children, pointsIcon, pointsName) {
+ const amount = this.config.quick_points_amount || 5;
+
+ return html`
+ ${children.map(child => html`
+
+
+
+
+
${child.name}
+
+
+ ${child.points}
+
+
+
+
+
+
+ `)}
+ `;
+ }
+
+ async _handleApprove(completionId) {
+ this._loading = { ...this._loading, [completionId]: true };
+ this.requestUpdate();
+ try {
+ await this.hass.callService("choremander", "approve_chore", { completion_id: completionId });
+ } catch (e) {
+ console.error("Failed to approve chore:", e);
+ } finally {
+ this._loading = { ...this._loading, [completionId]: false };
+ this.requestUpdate();
+ }
+ }
+
+ async _handleReject(completionId) {
+ this._loading = { ...this._loading, [completionId]: true };
+ this.requestUpdate();
+ try {
+ await this.hass.callService("choremander", "reject_chore", { completion_id: completionId });
+ } catch (e) {
+ console.error("Failed to reject chore:", e);
+ } finally {
+ this._loading = { ...this._loading, [completionId]: false };
+ this.requestUpdate();
+ }
+ }
+
+ async _handleApproveReward(claimId) {
+ this._loading = { ...this._loading, [claimId]: true };
+ this.requestUpdate();
+ try {
+ await this.hass.callService("choremander", "approve_reward", { claim_id: claimId });
+ } catch (e) {
+ console.error("Failed to approve reward:", e);
+ } finally {
+ this._loading = { ...this._loading, [claimId]: false };
+ this.requestUpdate();
+ }
+ }
+
+ async _handleRejectReward(claimId) {
+ this._loading = { ...this._loading, [claimId]: true };
+ this.requestUpdate();
+ try {
+ await this.hass.callService("choremander", "reject_reward", { claim_id: claimId });
+ } catch (e) {
+ console.error("Failed to reject reward:", e);
+ } finally {
+ this._loading = { ...this._loading, [claimId]: false };
+ this.requestUpdate();
+ }
+ }
+
+ async _handlePoints(childId, delta) {
+ const key = `${childId}_${delta}`;
+ this._loading = { ...this._loading, [key]: true };
+ this.requestUpdate();
+ try {
+ const service = delta > 0 ? "add_points" : "remove_points";
+ await this.hass.callService("choremander", service, {
+ child_id: childId,
+ points: Math.abs(delta),
+ });
+ } catch (e) {
+ console.error("Failed to adjust points:", e);
+ } finally {
+ this._loading = { ...this._loading, [key]: false };
+ this.requestUpdate();
+ }
+ }
+}
+
+class ChoremanderParentDashboardCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; padding: 4px 0; }
+ ha-textfield { width: 100%; margin-bottom: 8px; }
+ .field-row { margin-bottom: 16px; }
+ .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; }
+ .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; }
+ .check-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); cursor: pointer; user-select: none; margin-bottom: 8px; }
+ .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; accent-color: var(--primary-color); margin: 0; }
+ .check-label { font-size: 14px; color: var(--primary-text-color); flex: 1; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ return html`
+
this._update('entity', e.target.value)}"
+ helper="The Choremander overview sensor"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
+
this._update('title', e.target.value)}"
+ placeholder="Parent Dashboard"
+ >
+
+
this._update('quick_points_amount', parseInt(e.target.value) || 5)}"
+ helper="How many points the +/- buttons add or remove"
+ helperPersistent
+ >
+
+
+ `;
+ }
+
+ _update(key, value) {
+ const cfg = { ...this.config, [key]: value };
+ this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true }));
+ }
+}
+
+customElements.define("choremander-parent-dashboard-card", ChoremanderParentDashboardCard);
+customElements.define("choremander-parent-dashboard-card-editor", ChoremanderParentDashboardCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-parent-dashboard-card",
+ name: "Choremander Parent Dashboard",
+ description: "Unified parent view with approvals, child progress, and quick point controls",
+ preview: true,
+});
+
+console.info("%c CHOREMANDER-PARENT-DASHBOARD-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#e74c3c;color:white;font-weight:bold;border-radius:0 4px 4px 0;");
\ No newline at end of file
diff --git a/custom_components/choremander/www/choremander-reward-progress-card.js b/custom_components/choremander/www/choremander-reward-progress-card.js
new file mode 100644
index 0000000..c696742
--- /dev/null
+++ b/custom_components/choremander/www/choremander-reward-progress-card.js
@@ -0,0 +1,632 @@
+/**
+ * Choremander Reward Progress Card
+ * Full-screen motivational display showing a single reward's progress.
+ * Designed for wall tablets as a persistent motivation display.
+ *
+ * Version: 1.0.0
+ */
+
+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;
+
+class ChoremanderRewardProgressCard extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, #2c3e50 0%, #3d5166 100%);
+ color: white;
+ gap: 12px;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
+ .header-icon { --mdc-icon-size: 26px; opacity: 0.9; flex-shrink: 0; }
+ .header-title {
+ font-size: 1.1rem; font-weight: 600;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+
+ .card-content { padding: 20px 18px; }
+
+ /* Reward hero section */
+ .reward-hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 12px 0 20px;
+ gap: 10px;
+ }
+
+ .reward-icon-wrap {
+ width: 90px;
+ height: 90px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 6px 24px rgba(155,89,182,0.35);
+ animation: hero-float 3s ease-in-out infinite;
+ }
+
+ @keyframes hero-float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-6px); }
+ }
+
+ .reward-icon-wrap ha-icon { --mdc-icon-size: 52px; color: white; }
+
+ .reward-name {
+ font-size: 1.6rem;
+ font-weight: 700;
+ color: var(--primary-text-color);
+ line-height: 1.2;
+ }
+
+ .reward-description {
+ font-size: 0.9rem;
+ color: var(--secondary-text-color);
+ max-width: 280px;
+ }
+
+ /* Children progress blocks */
+ .children-section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ .child-progress-block {
+ background: var(--secondary-background-color, #f8f8f8);
+ border-radius: 16px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .child-progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ }
+
+ .child-progress-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .child-avatar {
+ width: 40px;
+ height: 40px;
+ min-width: 40px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 24px; color: white; }
+
+ .child-progress-name {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--primary-text-color);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .child-points-label {
+ font-size: 0.8rem;
+ color: var(--secondary-text-color);
+ }
+
+ .child-progress-cost {
+ text-align: right;
+ flex-shrink: 0;
+ }
+
+ .cost-label {
+ font-size: 0.75rem;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+
+ .cost-value {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: #9b59b6;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ justify-content: flex-end;
+ }
+
+ .cost-value ha-icon { --mdc-icon-size: 16px; color: #f1c40f; }
+
+ /* Big animated progress bar */
+ .big-progress-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .big-progress-bar {
+ height: 22px;
+ background: var(--divider-color, #e0e0e0);
+ border-radius: 11px;
+ overflow: hidden;
+ position: relative;
+ }
+
+ .big-progress-fill {
+ height: 100%;
+ border-radius: 11px;
+ transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
+ position: relative;
+ overflow: hidden;
+ }
+
+ .big-progress-fill::after {
+ content: '';
+ position: absolute;
+ top: 0; left: -100%;
+ width: 60%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
+ animation: shimmer 2s infinite;
+ }
+
+ @keyframes shimmer {
+ 0% { left: -100%; }
+ 100% { left: 200%; }
+ }
+
+ .big-progress-fill.affordable {
+ background: linear-gradient(90deg, #27ae60, #2ecc71);
+ }
+
+ .big-progress-fill.close {
+ background: linear-gradient(90deg, #e67e22, #f39c12);
+ }
+
+ .big-progress-fill.far {
+ background: linear-gradient(90deg, #9b59b6, #a569bd);
+ }
+
+ .big-progress-fill.complete {
+ background: linear-gradient(90deg, #27ae60, #1abc9c);
+ }
+
+ .progress-stat-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.82rem;
+ }
+
+ .progress-have {
+ font-weight: 600;
+ color: var(--primary-text-color);
+ }
+
+ .progress-need {
+ color: var(--secondary-text-color);
+ }
+
+ .progress-pct {
+ font-weight: 700;
+ font-size: 0.95rem;
+ }
+
+ .progress-pct.affordable { color: #27ae60; }
+ .progress-pct.close { color: #e67e22; }
+ .progress-pct.far { color: #9b59b6; }
+
+ /* Can afford badge */
+ .can-afford-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: rgba(46,204,113,0.15);
+ color: #27ae60;
+ border-radius: 20px;
+ padding: 5px 12px;
+ font-size: 0.85rem;
+ font-weight: 700;
+ animation: pulse-green 2s ease-in-out infinite;
+ align-self: center;
+ }
+
+ .can-afford-badge ha-icon { --mdc-icon-size: 16px; }
+
+ @keyframes pulse-green {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(46,204,113,0.3); }
+ 50% { box-shadow: 0 0 0 6px rgba(46,204,113,0); }
+ }
+
+ /* Jackpot section */
+ .jackpot-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: linear-gradient(135deg, #f39c12, #f1c40f);
+ color: white;
+ border-radius: 20px;
+ padding: 4px 12px;
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ align-self: flex-start;
+ }
+
+ .jackpot-badge ha-icon { --mdc-icon-size: 14px; }
+
+ .jackpot-pool {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(241,196,15,0.08);
+ border: 1px solid rgba(241,196,15,0.25);
+ border-radius: 12px;
+ padding: 12px;
+ }
+
+ .jackpot-pool-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+
+ .jackpot-contributors {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .jackpot-contributor {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--card-background-color, #fff);
+ border-radius: 20px;
+ padding: 4px 10px 4px 6px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--primary-text-color);
+ }
+
+ .jackpot-contributor .mini-avatar {
+ width: 22px; height: 22px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #f39c12, #f1c40f);
+ display: flex; align-items: center; justify-content: center;
+ }
+
+ .jackpot-contributor .mini-avatar ha-icon { --mdc-icon-size: 13px; color: white; }
+
+ /* Error / empty */
+ .error-state, .empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--secondary-text-color); text-align: center;
+ }
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+
+ /* Responsive */
+ @media (max-width: 480px) {
+ .card-content { padding: 14px 12px; }
+ .reward-icon-wrap { width: 70px; height: 70px; }
+ .reward-icon-wrap ha-icon { --mdc-icon-size: 40px; }
+ .reward-name { font-size: 1.3rem; }
+ .big-progress-bar { height: 18px; border-radius: 9px; }
+ .child-progress-block { padding: 12px; }
+ }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Reward Goal",
+ reward_id: null,
+ child_id: null,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 5; }
+ static getConfigElement() { return document.createElement("choremander-reward-progress-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Reward Goal" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) return html`
Entity not found: ${this.config.entity}
`;
+ if (entity.state === "unavailable" || entity.state === "unknown") return html`
`;
+
+ const rewards = entity.attributes.rewards || [];
+ const children = entity.attributes.children || [];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Points";
+
+ // Pick reward
+ let reward = this.config.reward_id
+ ? rewards.find(r => r.id === this.config.reward_id)
+ : rewards[0];
+
+ if (!reward) return html`
`;
+
+ // Which children to show
+ let showChildren = children;
+ if (this.config.child_id) showChildren = children.filter(c => c.id === this.config.child_id);
+ if (reward.assigned_to?.length) showChildren = showChildren.filter(c => reward.assigned_to.includes(c.id));
+ if (!showChildren.length) showChildren = children;
+
+ const isJackpot = reward.is_jackpot;
+
+ return html`
+
+
+
+
+
+
+
+
${reward.name}
+ ${reward.description ? html`
${reward.description}
` : ''}
+ ${isJackpot ? html`
+
+
+ Jackpot Reward
+
+ ` : ''}
+
+
+ ${isJackpot
+ ? this._renderJackpot(reward, showChildren, pointsIcon, pointsName)
+ : html`
+
+ ${showChildren.map(child => this._renderChildProgress(child, reward, pointsIcon, pointsName))}
+
+ `}
+
+
+ `;
+ }
+
+ _renderChildProgress(child, reward, pointsIcon, pointsName) {
+ const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
+ const have = child.points || 0;
+ const pct = Math.min(100, Math.round((have / cost) * 100));
+ const canAfford = have >= cost;
+ const close = pct >= 70;
+ const cls = canAfford ? "complete" : close ? "close" : "far";
+ const pctCls = canAfford ? "affordable" : close ? "close" : "far";
+
+ return html`
+
+
+
+
+
+
+ ${have} / ${cost} ${pointsName}
+ ${canAfford
+ ? html`🎉 Ready to claim!`
+ : html`${cost - have} more needed`}
+ ${pct}%
+
+
+
+ ${canAfford ? html`
+
+
+ Ready to claim!
+
+ ` : ''}
+
+ `;
+ }
+
+ _renderJackpot(reward, children, pointsIcon, pointsName) {
+ const cost = reward.calculated_costs
+ ? Object.values(reward.calculated_costs)[0] ?? reward.cost
+ : reward.cost;
+
+ const totalHave = children.reduce((s, c) => s + (c.points || 0), 0);
+ const pct = Math.min(100, Math.round((totalHave / cost) * 100));
+ const canAfford = totalHave >= cost;
+ const close = pct >= 70;
+ const cls = canAfford ? "complete" : close ? "close" : "far";
+ const pctCls = canAfford ? "affordable" : close ? "close" : "far";
+
+ return html`
+
+
+
+
Combined Points Pool
+
+ ${children.map(child => html`
+
+
+
+
+
${child.name}: ${child.points}
+
+ `)}
+
+
+
+
+
+
+
+
+ ${totalHave} / ${cost} ${pointsName}
+ ${canAfford
+ ? html`🎉 Ready!`
+ : html`${cost - totalHave} more needed`}
+ ${pct}%
+
+
+
+ ${canAfford ? html`
+
+
+ Ready to claim!
+
+ ` : ''}
+
+
+ `;
+ }
+}
+
+class ChoremanderRewardProgressCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; padding: 4px 0; }
+ ha-textfield { width: 100%; margin-bottom: 8px; }
+ .field-row { margin-bottom: 16px; }
+ .field-label { display: block; font-size: 12px; font-weight: 500; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; padding: 0 4px; }
+ .field-select { display: block; width: 100%; padding: 10px 12px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; box-sizing: border-box; cursor: pointer; appearance: auto; }
+ .field-select:focus { outline: none; border-color: var(--primary-color); border-width: 2px; }
+ .field-helper { display: block; font-size: 11px; color: var(--secondary-text-color); margin-top: 5px; padding: 0 4px; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ const entity = this.config.entity ? this.hass.states[this.config.entity] : null;
+ const rewards = entity?.attributes?.rewards || [];
+ const children = entity?.attributes?.children || [];
+
+ return html`
+
this._update('entity', e.target.value)}"
+ helper="The Choremander overview sensor"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
+
this._update('title', e.target.value)}"
+ placeholder="Reward Goal"
+ >
+
+
+
+
+ Which reward to show progress for
+
+
+
+
+
+ Show only this child's progress
+
+ `;
+ }
+
+ _update(key, value) {
+ const cfg = { ...this.config, [key]: value };
+ if (!value) delete cfg[key];
+ this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: cfg }, bubbles: true, composed: true }));
+ }
+}
+
+customElements.define("choremander-reward-progress-card", ChoremanderRewardProgressCard);
+customElements.define("choremander-reward-progress-card-editor", ChoremanderRewardProgressCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-reward-progress-card",
+ name: "Choremander Reward Progress",
+ description: "Full-screen motivational reward progress display",
+ preview: true,
+});
+
+console.info("%c CHOREMANDER-REWARD-PROGRESS-CARD %c v1.0.0 ", "background:#2c3e50;color:white;font-weight:bold;border-radius:4px 0 0 4px;", "background:#9b59b6;color:white;font-weight:bold;border-radius:0 4px 4px 0;");
diff --git a/custom_components/choremander/www/choremander-rewards-card.js b/custom_components/choremander/www/choremander-rewards-card.js
index 52d6ed6..bfc27c5 100644
--- a/custom_components/choremander/www/choremander-rewards-card.js
+++ b/custom_components/choremander/www/choremander-rewards-card.js
@@ -20,9 +20,15 @@ class ChoremanderRewardsCard extends LitElement {
return {
hass: { type: Object },
config: { type: Object },
+ _loading: { type: Object },
};
}
+ constructor() {
+ super();
+ this._loading = {};
+ }
+
static get styles() {
return css`
:host {
@@ -453,6 +459,50 @@ class ChoremanderRewardsCard extends LitElement {
color: white;
}
+ /* Pending approval state */
+ .reward-row.pending-approval {
+ opacity: 0.6;
+ border-left: 3px solid #e67e22;
+ }
+
+ .pending-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(230,126,34,0.12);
+ color: #e67e22;
+ border-radius: 8px;
+ padding: 3px 8px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-top: 4px;
+ }
+
+ .pending-label ha-icon { --mdc-icon-size: 12px; }
+
+ /* Claim button */
+ .claim-btn {
+ width: 42px; height: 42px;
+ border-radius: 50%; border: none; cursor: pointer;
+ background: linear-gradient(135deg, #9b59b6, #8e44ad);
+ color: white;
+ display: flex; align-items: center; justify-content: center;
+ box-shadow: 0 3px 10px rgba(155,89,182,0.35);
+ transition: transform 0.15s, box-shadow 0.15s;
+ flex-shrink: 0;
+ }
+
+ .claim-btn:hover { transform: scale(1.08); box-shadow: 0 4px 14px rgba(155,89,182,0.45); }
+ .claim-btn:active { transform: scale(0.96); }
+ .claim-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
+ .claim-btn ha-icon { --mdc-icon-size: 22px; }
+
+ .claim-btn.cant-afford {
+ background: linear-gradient(135deg, #bdc3c7, #95a5a6);
+ box-shadow: none;
+ cursor: not-allowed;
+ }
+
/* Empty state */
.empty-state {
display: flex;
@@ -728,8 +778,25 @@ class ChoremanderRewardsCard extends LitElement {
const percentage = Math.min((currentStars / displayCost) * 100, 100);
+ // Check if this reward has a pending claim
+ const entity = this.hass?.states?.[this.config?.entity];
+ const pendingClaims = entity?.attributes?.pending_reward_claims || [];
+ const childId = this.config?.child_id;
+ const hasPendingClaim = pendingClaims.some(c =>
+ c.reward_id === reward.id && (!childId || c.child_id === childId)
+ );
+
+ // Can the current child afford it? Account for points committed to other pending claims
+ const relevantChild = childId
+ ? children.find(c => c.id === childId)
+ : relevantChildren[0];
+ const committedPoints = relevantChild?.committed_points || 0;
+ const availablePoints = (relevantChild?.points || 0) - committedPoints;
+ const canAfford = relevantChild && availablePoints >= displayCost;
+ const isLoading = this._loading[reward.id];
+
return html`
-
+
${displayCost}
@@ -747,6 +814,13 @@ class ChoremanderRewardsCard extends LitElement {
? this._renderJackpotProgress(reward, childContributions, currentStars, pointsIcon, displayCost)
: this._renderRegularProgress(currentStars, displayCost, percentage, pointsIcon)}
+ ${hasPendingClaim ? html`
+
+
+ Awaiting parent approval
+
+ ` : ''}
+
${showChildBadges && !isJackpot
? html`
@@ -760,13 +834,40 @@ class ChoremanderRewardsCard extends LitElement {
`
: ""}
-
+
+ ${!hasPendingClaim && childId ? html`
+
+ ` : ''}
`;
}
+ async _handleClaim(reward, child) {
+ if (!child || !reward) return;
+ this._loading = { ...this._loading, [reward.id]: true };
+ this.requestUpdate();
+ try {
+ await this.hass.callService("choremander", "claim_reward", {
+ reward_id: reward.id,
+ child_id: child.id,
+ });
+ } catch (e) {
+ console.error("Failed to claim reward:", e);
+ } finally {
+ this._loading = { ...this._loading, [reward.id]: false };
+ this.requestUpdate();
+ }
+ }
+
_renderRegularProgress(currentStars, cost, percentage, pointsIcon) {
return html`
@@ -1017,7 +1118,7 @@ class ChoremanderRewardsCardEditor extends LitElement {
}
_childIdChanged(e) {
- const value = e.target.value;
+ const value = e.detail?.value ?? e.target?.value;
this._updateConfig("child_id", value || null);
}
diff --git a/custom_components/choremander/www/choremander-streak-card.js b/custom_components/choremander/www/choremander-streak-card.js
new file mode 100644
index 0000000..0eaed2f
--- /dev/null
+++ b/custom_components/choremander/www/choremander-streak-card.js
@@ -0,0 +1,488 @@
+/**
+ * Choremander Streak & Achievement Card
+ * Shows each child's consecutive day streak and milestone badges.
+ * Streaks are calculated client-side from completion data.
+ *
+ * Version: 1.0.0
+ * Last Updated: 2026-03-18
+ */
+
+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;
+
+class ChoremanderStreakCard extends LitElement {
+ static get properties() {
+ return {
+ hass: { type: Object },
+ config: { type: Object },
+ };
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --str-purple: #9b59b6;
+ --str-gold: #f1c40f;
+ --str-orange: #e67e22;
+ --str-green: #2ecc71;
+ --str-red: #e74c3c;
+ --str-fire: #ff6b35;
+ }
+
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, var(--str-orange) 0%, var(--str-fire) 100%);
+ color: white;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; }
+ .header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
+ .header-title { font-size: 1.2rem; font-weight: 600; }
+
+ .card-content {
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ /* Child streak tile */
+ .streak-tile {
+ background: var(--card-background-color, #fff);
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 14px;
+ overflow: hidden;
+ }
+
+ .streak-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px 10px;
+ }
+
+ .child-avatar {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--str-purple) 0%, #a569bd 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 26px; color: white; }
+
+ .streak-info { flex: 1; }
+ .child-name {
+ font-weight: 600;
+ font-size: 1rem;
+ color: var(--primary-text-color);
+ }
+
+ .streak-count-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 2px;
+ }
+
+ .streak-number {
+ font-size: 1.5rem;
+ font-weight: 800;
+ line-height: 1;
+ }
+
+ .streak-number.hot { color: var(--str-fire); }
+ .streak-number.warm { color: var(--str-orange); }
+ .streak-number.cold { color: var(--secondary-text-color); }
+
+ .streak-label {
+ font-size: 0.8rem;
+ color: var(--secondary-text-color);
+ font-weight: 500;
+ }
+
+ .streak-emoji { font-size: 1.4rem; }
+
+ /* Streak bar - visual days */
+ .streak-days {
+ display: flex;
+ gap: 4px;
+ padding: 0 16px 14px;
+ flex-wrap: wrap;
+ }
+
+ .day-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ transition: transform 0.2s ease;
+ }
+
+ .day-dot.active { background: var(--str-fire); }
+ .day-dot.today-active { background: var(--str-green); transform: scale(1.3); }
+ .day-dot.inactive { background: var(--divider-color, #e0e0e0); }
+
+ /* Achievements */
+ .achievements-section {
+ padding: 0 16px 14px;
+ border-top: 1px solid var(--divider-color, #f0f0f0);
+ padding-top: 10px;
+ }
+
+ .achievements-label {
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ }
+
+ .badges {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .badge {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: var(--secondary-background-color, #f5f5f5);
+ min-width: 56px;
+ transition: transform 0.15s ease;
+ }
+
+ .badge:hover { transform: scale(1.05); }
+
+ .badge.earned {
+ background: linear-gradient(135deg, rgba(241,196,15,0.2), rgba(230,126,34,0.2));
+ border: 1px solid rgba(241,196,15,0.4);
+ }
+
+ .badge.locked { opacity: 0.35; filter: grayscale(1); }
+
+ .badge-emoji { font-size: 1.5rem; }
+ .badge-name {
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ text-align: center;
+ line-height: 1.2;
+ }
+
+ .badge.earned .badge-name { color: var(--str-orange); }
+
+ /* Empty / error */
+ .error-state, .empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--secondary-text-color); text-align: center;
+ }
+
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "Streaks & Achievements",
+ child_id: null,
+ streak_days_shown: 14,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 4; }
+ static getConfigElement() { return document.createElement("choremander-streak-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "Streaks & Achievements" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) {
+ return html`
Entity not found: ${this.config.entity}
`;
+ }
+ if (entity.state === "unavailable" || entity.state === "unknown") {
+ return html`
Choremander is unavailable
`;
+ }
+
+ let children = entity.attributes.children || [];
+ // Use recent_completions (all-time history, last 50) for streak/achievement calculation
+ const completions = [...(entity.attributes.recent_completions || entity.attributes.todays_completions || [])];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const chores = entity.attributes.chores || [];
+
+ if (this.config.child_id) {
+ children = children.filter(c => c.id === this.config.child_id);
+ }
+
+ if (children.length === 0) {
+ return html`
`;
+ }
+
+ return html`
+
+
+
+ ${children.map(child => this._renderStreakTile(child, completions, chores, pointsIcon, entity))}
+
+
+ `;
+ }
+
+ _renderStreakTile(child, completions, chores, pointsIcon, entity_ref) {
+ const childCompletions = completions.filter(c => c.child_id === child.id);
+ // Use backend-calculated streak if available, fall back to client calculation
+ const streak = child.current_streak !== undefined
+ ? child.current_streak
+ : this._calculateStreak(childCompletions, chores, child.id);
+ const daysShown = this.config.streak_days_shown || 14;
+ const dayDots = this._buildDayDots(childCompletions, chores, child.id, daysShown);
+ const achievements = this._getAchievements(child, childCompletions, streak, entity_ref);
+
+ // Avatar now included directly in children array from the overview sensor
+ const avatar = child.avatar || "mdi:account-circle";
+
+ const streakClass = streak >= 7 ? "hot" : streak >= 3 ? "warm" : "cold";
+ const streakEmoji = streak >= 14 ? "🔥🔥" : streak >= 7 ? "🔥" : streak >= 3 ? "⚡" : streak >= 1 ? "✨" : "💤";
+
+ return html`
+
+
+
+
+ ${dayDots.map(dot => html`
+
+ `)}
+
+
+ ${achievements.length > 0 ? html`
+
+
Achievements
+
+ ${achievements.map(a => html`
+
+ ${a.emoji}
+ ${a.name}
+
+ `)}
+
+
+ ` : ''}
+
+ `;
+ }
+
+ _calculateStreak(completions, chores, childId) {
+ const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ // Build set of unique days with at least one completion
+ const daysWithCompletion = new Set();
+ completions.forEach(c => {
+ if (!c.completed_at) return;
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ daysWithCompletion.add(day);
+ });
+
+ // Walk backwards from today counting consecutive days
+ let streak = 0;
+ const today = new Date();
+ for (let i = 0; i < 365; i++) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ const key = d.toLocaleDateString("en-CA", { timeZone: tz });
+ if (daysWithCompletion.has(key)) {
+ streak++;
+ } else {
+ break;
+ }
+ }
+ return streak;
+ }
+
+ _buildDayDots(completions, chores, childId, days) {
+ const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const daysWithCompletion = new Set();
+ completions.forEach(c => {
+ if (!c.completed_at) return;
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ daysWithCompletion.add(day);
+ });
+
+ const dots = [];
+ const today = new Date();
+ const todayKey = today.toLocaleDateString("en-CA", { timeZone: tz });
+
+ for (let i = days - 1; i >= 0; i--) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ const key = d.toLocaleDateString("en-CA", { timeZone: tz });
+ const active = daysWithCompletion.has(key);
+ const isToday = key === todayKey;
+ dots.push({
+ cssClass: active ? (isToday ? "today-active" : "active") : "inactive",
+ label: d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }),
+ });
+ }
+ return dots;
+ }
+
+ _getAchievements(child, completions, streak, entity_ref) {
+ // Prefer backend-tracked totals over client-visible completions
+ const totalCompletions = child.total_chores_completed !== undefined
+ ? child.total_chores_completed
+ : (entity_ref?.attributes?.total_completions_all_time || completions.filter(comp => comp.child_id === child.id).length);
+ const totalPoints = (child.total_points_earned !== undefined ? child.total_points_earned : child.points) || 0;
+ const bestStreak = child.best_streak || streak || 0;
+
+ const milestones = [
+ { id: "first", name: "First!", emoji: "🌟", description: "Complete your first chore", earned: totalCompletions >= 1 },
+ { id: "ten", name: "10 Done", emoji: "🏅", description: "Complete 10 chores", earned: totalCompletions >= 10 },
+ { id: "fifty", name: "50 Done", emoji: "🥈", description: "Complete 50 chores", earned: totalCompletions >= 50 },
+ { id: "hundred", name: "100 Done", emoji: "🥇", description: "Complete 100 chores", earned: totalCompletions >= 100 },
+ { id: "streak3", name: "3 Days", emoji: "⚡", description: "3 day streak", earned: bestStreak >= 3 },
+ { id: "streak7", name: "Week!", emoji: "🔥", description: "7 day streak", earned: bestStreak >= 7 },
+ { id: "streak14", name: "2 Weeks", emoji: "🔥🔥", description: "14 day streak", earned: bestStreak >= 14 },
+ { id: "streak30", name: "Month!", emoji: "💎", description: "30 day streak", earned: bestStreak >= 30 },
+ { id: "points50", name: "50 ⭐", emoji: "🎯", description: "Earn 50 points total", earned: totalPoints >= 50 },
+ { id: "points100", name: "100 ⭐", emoji: "💰", description: "Earn 100 points total", earned: totalPoints >= 100 },
+ ];
+
+ // Show earned ones + next locked milestone
+ const earned = milestones.filter(m => m.earned);
+ const nextLocked = milestones.find(m => !m.earned);
+ return nextLocked ? [...earned, nextLocked] : earned;
+ }
+}
+
+// Card Editor
+class ChoremanderStreakCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-textfield { width: 100%; margin-bottom: 16px; }
+ .form-row { margin-bottom: 16px; }
+ .form-label {
+ display: block; font-size: 0.85rem; font-weight: 500;
+ color: var(--primary-text-color); margin-bottom: 6px; padding: 0 2px;
+ }
+ .form-select {
+ width: 100%; padding: 10px 12px;
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 4px;
+ background: var(--card-background-color, #fff);
+ color: var(--primary-text-color);
+ font-size: 1rem; box-sizing: border-box; cursor: pointer; appearance: auto;
+ }
+ .form-select:focus { outline: none; border-color: var(--primary-color); }
+ .form-helper { display: block; font-size: 0.78rem; color: var(--secondary-text-color); margin-top: 4px; padding: 0 2px; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ const entity = this.config.entity ? this.hass.states[this.config.entity] : null;
+ const children = entity?.attributes?.children || [];
+
+ return html`
+
this._updateConfig('entity', e.target.value)}"
+ helper="The Choremander overview sensor entity"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
this._updateConfig('title', e.target.value)}"
+ placeholder="Streaks & Achievements"
+ >
+
+
+
+ Show streak for a specific child only
+
+
this._updateConfig('streak_days_shown', parseInt(e.target.value) || 14)}"
+ helper="How many days of dots to show (default: 14)"
+ helperPersistent
+ >
+ `;
+ }
+
+ _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("choremander-streak-card", ChoremanderStreakCard);
+customElements.define("choremander-streak-card-editor", ChoremanderStreakCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-streak-card",
+ name: "Choremander Streaks & Achievements",
+ description: "Consecutive day streaks and milestone badges for each child",
+ preview: true,
+});
+
+console.info(
+ "%c CHOREMANDER-STREAK-CARD %c v1.0.0 ",
+ "background: #e67e22; color: white; font-weight: bold; border-radius: 4px 0 0 4px;",
+ "background: #f1c40f; color: #333; font-weight: bold; border-radius: 0 4px 4px 0;"
+);
diff --git a/custom_components/choremander/www/choremander-weekly-card.js b/custom_components/choremander/www/choremander-weekly-card.js
new file mode 100644
index 0000000..76a7844
--- /dev/null
+++ b/custom_components/choremander/www/choremander-weekly-card.js
@@ -0,0 +1,547 @@
+/**
+ * Choremander Weekly Summary Card
+ * Current week at a glance: days completed, points per day as a bar chart,
+ * rewards claimed this week, and a per-child breakdown.
+ *
+ * Version: 1.0.0
+ * Last Updated: 2026-03-18
+ */
+
+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;
+
+class ChoremanderWeeklyCard extends LitElement {
+ static get properties() {
+ return {
+ hass: { type: Object },
+ config: { type: Object },
+ };
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --wk-purple: #9b59b6;
+ --wk-green: #2ecc71;
+ --wk-orange: #e67e22;
+ --wk-blue: #3498db;
+ --wk-gold: #f1c40f;
+ --wk-red: #e74c3c;
+ }
+
+ ha-card { overflow: hidden; }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, var(--wk-green) 0%, #27ae60 100%);
+ color: white;
+ }
+
+ .header-content { display: flex; align-items: center; gap: 10px; }
+ .header-icon { --mdc-icon-size: 28px; opacity: 0.9; }
+ .header-title { font-size: 1.2rem; font-weight: 600; }
+ .week-label {
+ background: rgba(255,255,255,0.2);
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ }
+
+ .card-content {
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ /* Summary stats row */
+ .stats-row {
+ display: flex;
+ gap: 10px;
+ }
+
+ .stat-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 12px 8px;
+ background: var(--secondary-background-color, #f5f5f5);
+ border-radius: 12px;
+ }
+
+ .stat-value {
+ font-size: 1.6rem;
+ font-weight: 800;
+ color: var(--primary-text-color);
+ line-height: 1;
+ }
+
+ .stat-value.green { color: var(--wk-green); }
+ .stat-value.orange { color: var(--wk-orange); }
+ .stat-value.purple { color: var(--wk-purple); }
+
+ .stat-label {
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ text-align: center;
+ }
+
+ /* Daily bar chart */
+ .chart-section { }
+
+ .section-label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 10px;
+ }
+
+ .bar-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ height: 80px;
+ }
+
+ .bar-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ height: 100%;
+ justify-content: flex-end;
+ }
+
+ .bar-value {
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ min-height: 14px;
+ }
+
+ .bar-fill {
+ width: 100%;
+ border-radius: 4px 4px 0 0;
+ min-height: 3px;
+ transition: height 0.4s ease;
+ }
+
+ .bar-fill.today {
+ background: linear-gradient(180deg, var(--wk-green) 0%, #27ae60 100%);
+ }
+
+ .bar-fill.past {
+ background: linear-gradient(180deg, var(--wk-blue) 0%, #2980b9 100%);
+ }
+
+ .bar-fill.future {
+ background: var(--divider-color, #e8e8e8);
+ }
+
+ .bar-fill.zero {
+ background: var(--divider-color, #e8e8e8);
+ min-height: 3px !important;
+ height: 3px !important;
+ }
+
+ .bar-day {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--secondary-text-color);
+ }
+
+ .bar-day.today {
+ color: var(--wk-green);
+ font-weight: 800;
+ }
+
+ /* Per-child breakdown */
+ .children-section { }
+
+ .child-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--divider-color, #f0f0f0);
+ }
+
+ .child-row:last-child { border-bottom: none; }
+
+ .child-avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--wk-purple), #a569bd);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .child-avatar ha-icon { --mdc-icon-size: 20px; color: white; }
+
+ .child-info { flex: 1; min-width: 0; }
+
+ .child-name {
+ font-weight: 600;
+ font-size: 0.9rem;
+ color: var(--primary-text-color);
+ }
+
+ .child-week-stats {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 2px;
+ font-size: 0.78rem;
+ color: var(--secondary-text-color);
+ flex-wrap: wrap;
+ }
+
+ .child-week-stats span { display: flex; align-items: center; gap: 3px; }
+ .child-week-stats ha-icon { --mdc-icon-size: 13px; }
+
+ .week-progress-bar {
+ flex: 1;
+ height: 6px;
+ background: var(--divider-color, #e0e0e0);
+ border-radius: 3px;
+ overflow: hidden;
+ min-width: 40px;
+ }
+
+ .week-progress-fill {
+ height: 100%;
+ border-radius: 3px;
+ background: linear-gradient(90deg, var(--wk-green), #27ae60);
+ }
+
+ /* Error / empty */
+ .error-state, .empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; padding: 40px 20px;
+ color: var(--secondary-text-color); text-align: center;
+ }
+
+ .error-state { color: var(--error-color, #f44336); }
+ .error-state ha-icon, .empty-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 12px; opacity: 0.5; }
+ `;
+ }
+
+ setConfig(config) {
+ if (!config.entity) throw new Error("Please define an entity");
+ this.config = {
+ title: "This Week",
+ child_id: null,
+ ...config,
+ };
+ }
+
+ getCardSize() { return 4; }
+ static getConfigElement() { return document.createElement("choremander-weekly-card-editor"); }
+ static getStubConfig() {
+ return { entity: "sensor.choremander_overview", title: "This Week" };
+ }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+
+ const entity = this.hass.states[this.config.entity];
+ if (!entity) {
+ return html`
Entity not found: ${this.config.entity}
`;
+ }
+ if (entity.state === "unavailable" || entity.state === "unknown") {
+ return html`
Choremander is unavailable
`;
+ }
+
+ const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ let children = entity.attributes.children || [];
+ const chores = entity.attributes.chores || [];
+ const pointsIcon = entity.attributes.points_icon || "mdi:star";
+ const pointsName = entity.attributes.points_name || "Stars";
+
+ // Use recent_completions (last 50 all-time) for full week view
+ let allCompletions = [...(entity.attributes.recent_completions || entity.attributes.todays_completions || [])];
+ const seen = new Set();
+ allCompletions = allCompletions.filter(comp => {
+ if (seen.has(comp.completion_id)) return false;
+ seen.add(comp.completion_id); return true;
+ });
+
+ if (this.config.child_id) {
+ children = children.filter(c => c.id === this.config.child_id);
+ allCompletions = allCompletions.filter(c => c.child_id === this.config.child_id);
+ }
+
+ // Build week dates (Mon–Sun or Sun–Sat based on HA locale)
+ const weekDays = this._getWeekDays(tz);
+ const todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
+
+ // Group completions by day
+ const completionsByDay = {};
+ allCompletions.forEach(c => {
+ if (!c.completed_at) return;
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ if (!completionsByDay[day]) completionsByDay[day] = [];
+ completionsByDay[day].push(c);
+ });
+
+ // Only show completions within this week
+ const weekCompletions = allCompletions.filter(c => {
+ if (!c.completed_at) return false;
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ return weekDays.some(d => d.key === day);
+ });
+
+ // Build chore points lookup — completions don't carry points directly
+ const chorePointsMap = {};
+ chores.forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
+
+ // Only count approved completions for all stats
+ const approvedWeekCompletions = weekCompletions.filter(c => c.approved);
+
+ const weekPoints = approvedWeekCompletions
+ .reduce((sum, c) => sum + (c.points !== undefined ? c.points : (chorePointsMap[c.chore_id] || 0)), 0);
+ const weekChores = approvedWeekCompletions.length;
+ const daysActive = new Set(approvedWeekCompletions.map(c =>
+ new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz })
+ )).size;
+
+ // Bar chart also uses approved completions only
+ const approvedCompletionsByDay = {};
+ approvedWeekCompletions.forEach(c => {
+ if (!c.completed_at) return;
+ const day = new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz });
+ if (!approvedCompletionsByDay[day]) approvedCompletionsByDay[day] = [];
+ approvedCompletionsByDay[day].push(c);
+ });
+
+ // Max completions in a day for chart scale
+ const maxPerDay = Math.max(1, ...weekDays.map(d => (approvedCompletionsByDay[d.key] || []).length));
+ const weekLabel = this._getWeekLabel(weekDays, tz);
+
+ return html`
+
+
+
+
+
+
+
+ ${weekChores}
+ Chores
+
+
+ ${weekPoints}
+ ${pointsName}
+
+
+ ${daysActive}/7
+ Days Active
+
+
+
+
+
+
Chores Per Day
+
+ ${weekDays.map(day => {
+ const count = (approvedCompletionsByDay[day.key] || []).length;
+ const isToday = day.key === todayKey;
+ const isFuture = day.key > todayKey;
+ const heightPct = isFuture ? 0 : Math.round((count / maxPerDay) * 60);
+ const barClass = isFuture ? "future" : isToday ? "today" : count === 0 ? "zero" : "past";
+ return html`
+
+
${!isFuture && count > 0 ? count : ''}
+
+
${day.short}
+
+ `;
+ })}
+
+
+
+
+ ${children.length > 0 ? html`
+
+
Children This Week
+ ${children.map(child => {
+ // Only count approved completions for all per-child stats
+ const childApprovedCompletions = approvedWeekCompletions.filter(c => c.child_id === child.id);
+ const childPoints = childApprovedCompletions
+ .reduce((s, comp) => s + (comp.points !== undefined ? comp.points : (chorePointsMap[comp.chore_id] || 0)), 0);
+ const childChoreCount = childApprovedCompletions.length;
+ const childDaysActive = new Set(childApprovedCompletions.map(c =>
+ new Date(c.completed_at).toLocaleDateString("en-CA", { timeZone: tz })
+ )).size;
+
+ // Avatar now included directly in children array from the overview sensor
+ const avatar = child.avatar || "mdi:account-circle";
+
+ const pct = Math.min((childDaysActive / 7) * 100, 100);
+
+ return html`
+
+
+
+
${child.name}
+
+ ${childChoreCount} chores
+ ${childPoints} ${pointsName}
+ ${childDaysActive}/7 days
+
+
+
+
+ `;
+ })}
+
+ ` : ''}
+
+
+ `;
+ }
+
+ _getWeekDays(tz) {
+ const today = new Date();
+ const todayDay = today.getDay(); // 0=Sun
+ // Start week on Monday
+ const mondayOffset = (todayDay === 0 ? -6 : 1 - todayDay);
+ const monday = new Date(today);
+ monday.setDate(today.getDate() + mondayOffset);
+
+ const days = [];
+ const shortNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(monday);
+ d.setDate(monday.getDate() + i);
+ days.push({
+ key: d.toLocaleDateString("en-CA", { timeZone: tz }),
+ short: shortNames[i],
+ date: d,
+ });
+ }
+ return days;
+ }
+
+ _getWeekLabel(weekDays, tz) {
+ const first = weekDays[0].date;
+ const last = weekDays[6].date;
+ const fmt = { month: "short", day: "numeric" };
+ return `${first.toLocaleDateString(undefined, fmt)} – ${last.toLocaleDateString(undefined, fmt)}`;
+ }
+}
+
+// Card Editor
+class ChoremanderWeeklyCardEditor extends LitElement {
+ static get properties() {
+ return { hass: { type: Object }, config: { type: Object } };
+ }
+
+ static get styles() {
+ return css`
+ :host { display: block; }
+ ha-textfield { width: 100%; margin-bottom: 16px; }
+ .form-row { margin-bottom: 16px; }
+ .form-label {
+ display: block; font-size: 0.85rem; font-weight: 500;
+ color: var(--primary-text-color); margin-bottom: 6px; padding: 0 2px;
+ }
+ .form-select {
+ width: 100%; padding: 10px 12px;
+ border: 1px solid var(--divider-color, #e0e0e0);
+ border-radius: 4px;
+ background: var(--card-background-color, #fff);
+ color: var(--primary-text-color);
+ font-size: 1rem; box-sizing: border-box; cursor: pointer; appearance: auto;
+ }
+ .form-select:focus { outline: none; border-color: var(--primary-color); }
+ .form-helper { display: block; font-size: 0.78rem; color: var(--secondary-text-color); margin-top: 4px; padding: 0 2px; }
+ `;
+ }
+
+ setConfig(config) { this.config = config; }
+
+ render() {
+ if (!this.hass || !this.config) return html``;
+ const entity = this.config.entity ? this.hass.states[this.config.entity] : null;
+ const children = entity?.attributes?.children || [];
+
+ return html`
+
this._updateConfig('entity', e.target.value)}"
+ helper="The Choremander overview sensor entity"
+ helperPersistent
+ placeholder="sensor.choremander_overview"
+ >
+
this._updateConfig('title', e.target.value)}"
+ placeholder="This Week"
+ >
+
+
+
+ Show weekly summary for a specific child only
+
+ `;
+ }
+
+ _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("choremander-weekly-card", ChoremanderWeeklyCard);
+customElements.define("choremander-weekly-card-editor", ChoremanderWeeklyCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "choremander-weekly-card",
+ name: "Choremander Weekly Summary",
+ description: "Week at a glance — chores, points, and daily bar chart",
+ preview: true,
+});
+
+console.info(
+ "%c CHOREMANDER-WEEKLY-CARD %c v1.0.0 ",
+ "background: #2ecc71; color: white; font-weight: bold; border-radius: 4px 0 0 4px;",
+ "background: #27ae60; color: white; font-weight: bold; border-radius: 0 4px 4px 0;"
+);