Files
HomeAssistantVS/custom_components/taskmate/www/taskmate-graph-card.js
T
2026-07-08 10:43:39 -04:00

1095 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TaskMate 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;
const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d);
// 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 TaskMateGraphCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_mode: { type: String }, // "daily" | "cumulative" | "career"
};
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
}
return true;
}
constructor() {
super();
this._mode = "daily";
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
const base = 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: var(--taskmate-header-bg, #d35400);
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; }
}
/* ── Designed styles (shared .tmd kit comes from taskmate-design.js) ── */
.tmg-chips { gap: 6px; margin-bottom: 11px; }
.tmg-plot { margin-bottom: 4px; }
.tmg-plot-pl { background: var(--tmd-surface-2); border-radius: 16px; padding: 12px 10px 6px; }
.tmg-plot-cn { background: #0b1424; border: 1px solid var(--tmd-border); border-radius: 8px; padding: 12px 10px 6px; }
.tmg-plot-cp { padding: 2px 2px 0; }
.tmg-xaxis { justify-content: space-between; font-size: 10.5px; font-weight: 600; margin-top: 4px; }
.tmg-plot-cn .tmg-xaxis { font-size: 9.5px; }
.tmg-legend { gap: 14px; justify-content: center; margin-top: 11px; font-size: 12px; font-weight: 700; flex-wrap: wrap; }
.tmg-plot-cp ~ .tmg-legend { justify-content: flex-start; font-weight: 600; }
.tmg-dot { font-size: 0.9em; }
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
? window.__taskmate_design.styles() : null;
return tokens ? [tokens, base] : base;
}
setConfig(config) {
if (!config.entity) throw new Error("Please define an entity");
this.config = {
title: "",
child_id: null,
days: 14,
header_color: '#d35400',
...config,
};
}
getCardSize() { return 4; }
static getConfigElement() { return document.createElement("taskmate-graph-card-editor"); }
static getStubConfig() {
return { entity: "sensor.taskmate_overview", title: "Points Graph", days: 14 };
}
render() {
const design = window.__taskmate_design
? window.__taskmate_design.apply(this, this.hass, this.config, this.config?.entity)
: "classic";
if (design !== "classic") {
try {
return this._renderDesigned(design);
} catch (e) {
console.error("[TaskMateGraph] Designed render error:", e);
return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('graph.graph_error', { message: e.message })}</div></div></ha-card>`;
}
}
try {
return this._render();
} catch(e) {
console.error("[TaskMateGraph] Render error:", e);
return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('graph.graph_error', { message: e.message })}</div></div></ha-card>`;
}
}
_render() {
if (!this.hass || !this.config) return html``;
const entity = this.hass.states[this.config.entity];
if (!entity) {
return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
}
if (entity.state === "unavailable" || entity.state === "unknown") {
return html`<ha-card><div class="error-state"><ha-icon icon="mdi:alert-circle"></ha-icon><div>${this._t('common.unavailable')}</div></div></ha-card>`;
}
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
let children = attrs.children || [];
const pointsIcon = attrs.points_icon || "mdi:star";
const pointsName = attrs.points_name || this._t('common.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 = [
...(attrs.recent_completions || attrs.todays_completions || []),
].filter(c => c.approved);
// Get manual transactions
const allTransactions = attrs.recent_transactions || [];
// Build chore points map
const chorePointsMap = {};
(attrs.chores || []).forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
// Build date range
const dateRange = this._buildDateRange(days, tz);
// Build career score history lookup
const careerHistory = attrs.career_score_history || {};
// 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);
const careerPoints = this._buildCareerSeries(child, careerHistory[child.id] || [], dateRange, allCompletions, allTransactions, chorePointsMap, tz);
return { child, color, dailyPoints, cumulativePoints, careerPoints };
});
if (series.length === 0) {
return html`<ha-card><div class="empty-state"><ha-icon icon="mdi:account-group"></ha-icon><div>${this._t('common.no_children')}</div></div></ha-card>`;
}
const dataKey = this._mode === "daily" ? "dailyPoints" : this._mode === "career" ? "careerPoints" : "cumulativePoints";
const hasData = series.some(s => s[dataKey].some(v => v > 0));
const baseTitle = this.config.title || this._t('graph.default_title');
const filterLabel = this.config.child_id
? (series[0]?.child?.name || '')
: this._t('graph.all_children');
const headerTitle = this.config.title
? baseTitle
: (filterLabel ? `${baseTitle}${filterLabel}` : baseTitle);
return html`
<ha-card>
<style>:host { --taskmate-header-bg: ${_safeColor(this.config.header_color, '#d35400')}; }</style>
<div class="card-header">
<div class="header-left">
<ha-icon class="header-icon" icon="mdi:chart-line"></ha-icon>
<span class="header-title">${headerTitle}</span>
</div>
<div class="mode-toggle">
<button
class="mode-btn ${this._mode === 'daily' ? 'active' : ''}"
@click="${() => { this._mode = 'daily'; this.requestUpdate(); }}"
>${this._t('graph.daily')}</button>
<button
class="mode-btn ${this._mode === 'cumulative' ? 'active' : ''}"
@click="${() => { this._mode = 'cumulative'; this.requestUpdate(); }}"
>${this._t('graph.total')}</button>
<button
class="mode-btn ${this._mode === 'career' ? 'active' : ''}"
@click="${() => { this._mode = 'career'; this.requestUpdate(); }}"
>${this._t('graph.career_growth')}</button>
</div>
</div>
<div class="card-content">
${series.length > 1 ? html`
<div class="legend">
${series.map(s => html`
<div class="legend-item">
<div class="legend-dot" style="background: ${s.color.line};"></div>
<span>${s.child.name}</span>
</div>
`)}
</div>
` : ''}
${hasData
? this._renderChart(series, dateRange, dataKey, pointsName)
: html`
<div class="empty-state">
<ha-icon icon="mdi:chart-line-variant"></ha-icon>
<div class="message">${this._t('graph.no_data_yet')}</div>
<div class="submessage">${this._t('graph.no_data_submessage')}</div>
</div>
`}
</div>
</ha-card>
<div class="tooltip" id="graph-tooltip-${this._tooltipId}"></div>
`;
}
/* ══════════════════════════════════════════════════════════════════════
DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js
Ported from docs/design/redesigns/frag/07-graph.html.
Reuses the SAME series/data computation as the classic chart.
══════════════════════════════════════════════════════════════════════ */
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
// Reuse the classic data pipeline to produce per-child series + the active key.
_computeSeries() {
const entity = this.hass.states[this.config.entity];
const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {};
let children = attrs.children || [];
const pointsName = attrs.points_name || this._t('common.points');
const days = Math.max(3, Math.min(90, this.config.days || 14));
if (this.config.child_id) {
children = children.filter(c => c.id === this.config.child_id);
}
const allCompletions = [
...(attrs.recent_completions || attrs.todays_completions || []),
].filter(c => c.approved);
const allTransactions = attrs.recent_transactions || [];
const chorePointsMap = {};
(attrs.chores || []).forEach(ch => { chorePointsMap[ch.id] = ch.points || 0; });
const dateRange = this._buildDateRange(days, tz);
const careerHistory = attrs.career_score_history || {};
const series = children.map((child, idx) => {
const color = CHILD_COLORS[idx % CHILD_COLORS.length];
const tone = this._designTone(idx);
const dailyPoints = this._buildDailyPoints(child.id, dateRange, allCompletions, allTransactions, chorePointsMap, tz);
const cumulativePoints = this._buildCumulative(dailyPoints);
const careerPoints = this._buildCareerSeries(child, careerHistory[child.id] || [], dateRange, allCompletions, allTransactions, chorePointsMap, tz);
return { child, color, tone, dailyPoints, cumulativePoints, careerPoints };
});
const dataKey = this._mode === "daily" ? "dailyPoints" : this._mode === "career" ? "careerPoints" : "cumulativePoints";
return { series, dateRange, dataKey, pointsName };
}
// Build SVG point string for a data array, mapped into a 0..VW × 0..VH box.
_svgPoints(data, niceMax, VW, VH) {
const n = data.length;
return data.map((v, i) => {
const x = (i / Math.max(n - 1, 1)) * VW;
const y = VH - (Math.max(0, v) / niceMax) * VH;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
}
_renderDesigned(design) {
if (!this.hass || !this.config) return html``;
const hd = _safeColor(this.config.header_color, '#d35400');
const entity = this.hass.states[this.config.entity];
const header = (sub) => html`
<div class="tmd-hd">
<span class="ic">${design === "console" ? "⌁" : design === "cleanpro" ? "◔" : "📈"}</span>
<span class="tt">${this.config.title || this._t('graph.default_title')}${sub ? html`<small>${sub}</small>` : ""}</span>
${design === "console" ? html`<span class="pill">LIVE</span>` : ""}
</div>`;
if (!entity) {
return html`<ha-card class="tmd" style="--hd:${hd}">${header()}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.entity_not_found', { entity: this.config.entity })}</div></div></ha-card>`;
}
if (entity.state === "unavailable" || entity.state === "unknown") {
return html`<ha-card class="tmd" style="--hd:${hd}">${header()}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.unavailable')}</div></div></ha-card>`;
}
const { series, dateRange, dataKey } = this._computeSeries();
if (series.length === 0) {
return html`<ha-card class="tmd" style="--hd:${hd}">${header()}
<div class="tmd-bd"><div class="tmd-empty">${this._t('common.no_children')}</div></div></ha-card>`;
}
// Match classic: surface the active child filter (or "all children") as a subline.
const filterLabel = this.config.child_id
? (series[0]?.child?.name || '')
: this._t('graph.all_children');
const hasData = series.some(s => s[dataKey].some(v => v > 0));
// Mode chips wired to the SAME handler/state as the classic toggle.
const chip = (mode, label) => html`
<span
class="chip ${this._mode === mode ? "soft" : ""}"
style="cursor:pointer"
@click="${() => { this._mode = mode; this.requestUpdate(); }}"
>${label}</span>`;
const chips = html`
<div class="row tmg-chips">
${chip("daily", this._t('graph.daily'))}
${chip("cumulative", this._t('graph.total'))}
${chip("career", this._t('graph.career_growth'))}
</div>`;
const legend = html`
<div class="row tmg-legend">
${series.map(s => html`<span><span class="tmg-dot" style="color:${s.tone}">●</span> ${s.child.name}</span>`)}
</div>`;
const empty = html`<div class="tmd-empty">${this._t('graph.no_data_yet')}</div>`;
const body =
design === "console" ? this._graphConsole(series, dateRange, dataKey, chips, legend, hasData, empty) :
design === "cleanpro" ? this._graphCleanpro(series, dateRange, dataKey, chips, legend, hasData, empty) :
this._graphPlayroom(series, dateRange, dataKey, chips, legend, hasData, empty);
return html`<ha-card class="tmd" style="--hd:${hd}">${header(filterLabel)}<div class="tmd-bd">${body}</div></ha-card>`;
}
// Shared SVG chart builder; per-style flags tweak fills/glow/stroke width.
// Built as a string parsed via <template> so the elements get the proper SVG
// namespace — nested Lit html`` templates render <polyline> in the XHTML
// namespace (zero-size/invisible), and var(--tmd-*) only resolves via `style`
// (not as an SVG presentation attribute). Returning the parsed node sidesteps
// both: LitElement.prototype.svg isn't exposed by HA's Lit build.
_svgChart(series, dataKey, { area, glow, stroke, dash, gridStroke }) {
const VW = 280, VH = 115;
const allValues = series.flatMap(s => s[dataKey]);
const niceMax = this._niceMax(Math.max(1, ...allValues));
let inner =
`<line x1="0" y1="${VH}" x2="${VW}" y2="${VH}" style="stroke:${gridStroke}" stroke-width="1.5"></line>` +
`<line x1="0" y1="${VH * 0.65}" x2="${VW}" y2="${VH * 0.65}" style="stroke:${gridStroke}" stroke-width="1" stroke-dasharray="${dash}"></line>` +
`<line x1="0" y1="${VH * 0.3}" x2="${VW}" y2="${VH * 0.3}" style="stroke:${gridStroke}" stroke-width="1" stroke-dasharray="${dash}"></line>`;
if (area) {
inner += series.map(s => {
const pts = this._svgPoints(s[dataKey], niceMax, VW, VH);
return `<path d="M${pts} L${VW},${VH} L0,${VH} Z" style="fill:${s.tone};opacity:0.16"></path>`;
}).join("");
}
inner += series.map(s => {
const pts = this._svgPoints(s[dataKey], niceMax, VW, VH);
return `<polyline points="${pts}" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round" ` +
`style="fill:none;stroke:${s.tone}${glow ? `;filter:drop-shadow(0 0 6px ${s.tone})` : ""}"></polyline>`;
}).join("");
const tmpl = document.createElement("template");
tmpl.innerHTML = `<svg viewBox="0 0 ${VW} ${VH + 15}" width="100%" height="130" preserveAspectRatio="none" style="overflow:visible">${inner}</svg>`;
return tmpl.content.firstElementChild;
}
_xAxis(dateRange) {
const n = dateRange.length;
const every = n <= 7 ? 1 : n <= 14 ? 2 : n <= 31 ? 4 : 7;
return html`
<div class="row tmg-xaxis muted">
${dateRange.map((d, i) =>
(i % every === 0 || i === n - 1)
? html`<span>${this._shortDate(d)}</span>` : "")}
</div>`;
}
_graphPlayroom(series, dateRange, dataKey, chips, legend, hasData, empty) {
return html`
${chips}
${hasData ? html`
<div class="tmg-plot tmg-plot-pl">
${this._svgChart(series, dataKey, { area: true, glow: false, stroke: 3.5, dash: "3 4", gridStroke: "var(--tmd-border)" })}
${this._xAxis(dateRange)}
</div>
${series.length > 1 ? legend : ""}` : empty}`;
}
_graphConsole(series, dateRange, dataKey, chips, legend, hasData, empty) {
return html`
${chips}
${hasData ? html`
<div class="tmg-plot tmg-plot-cn">
${this._svgChart(series, dataKey, { area: false, glow: true, stroke: 2.5, dash: "2 5", gridStroke: "var(--tmd-border)" })}
${this._xAxis(dateRange)}
</div>
${series.length > 1 ? html`<div class="num">${legend}</div>` : ""}` : empty}`;
}
_graphCleanpro(series, dateRange, dataKey, chips, legend, hasData, empty) {
return html`
${chips}
${hasData ? html`
<div class="tmg-plot tmg-plot-cp">
${this._svgChart(series, dataKey, { area: false, glow: false, stroke: 2, dash: "0", gridStroke: "var(--tmd-border)" })}
${this._xAxis(dateRange)}
</div>
${series.length > 1 ? html`<div class="divide"></div>${legend}` : ""}` : empty}`;
}
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`
<div class="chart-wrap"
@mousemove="${(e) => this._onChartInteract(e, false)}"
@mouseleave="${() => this._hideTooltip()}"
@touchmove="${(e) => this._onChartInteract(e, true)}"
@touchend="${() => this._hideTooltip()}"
>
<canvas
id="chart-canvas-${this._tooltipId}"
height="180"
style="width:100%;height:180px;display:block;"
></canvas>
<div id="hover-line-${this._tooltipId}" style="
position: absolute; top: 16px; width: 1px;
height: 136px; background: var(--divider-color, #ccc);
pointer-events: none; display: none;
"></div>
</div>
`;
}
updated(changedProps) {
if (
changedProps.has("hass")
|| changedProps.has("config")
|| changedProps.has("_mode")
) {
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');
if (!ctx) return;
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);
while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild);
const dateDiv = document.createElement('div');
dateDiv.className = 'tooltip-date';
dateDiv.textContent = dateLabel;
tooltip.appendChild(dateDiv);
series.forEach(s => {
const val = s[dataKey][nearest] || 0;
const row = document.createElement('div');
row.className = 'tooltip-row';
const dot = document.createElement('div');
dot.className = 'tooltip-dot';
dot.style.background = s.color.line;
const span = document.createElement('span');
const prefix = series.length > 1 ? `${s.child.name}: ` : '';
span.textContent = `${prefix}${val} ${pointsName}`;
row.appendChild(dot);
row.appendChild(span);
tooltip.appendChild(row);
});
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 todayKey = new Date().toLocaleDateString("en-CA", { timeZone: tz });
const today = new Date(todayKey + "T12:00:00"); // noon avoids DST edge cases
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
range.push(d.toLocaleDateString("en-CA"));
}
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; });
}
_buildCareerSeries(child, history, dateRange, completions, transactions, chorePointsMap, tz) {
const historyMap = {};
(history || []).forEach(h => { historyMap[h.date] = h.score; });
const historyDatesInRange = dateRange.filter(d => d in historyMap).length;
if (historyDatesInRange >= Math.min(dateRange.length * 0.5, 7)) {
let lastKnown = child.career_score || 0;
const earliest = dateRange[0];
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].date <= earliest) { lastKnown = history[i].score; break; }
}
if (history.length > 0 && history[0].date > earliest) {
lastKnown = history[0].score;
}
let carry = lastKnown;
return dateRange.map(d => { if (d in historyMap) carry = historyMap[d]; return carry; });
}
const byDay = {};
dateRange.forEach(d => { byDay[d] = 0; });
(completions || []).filter(c => c.child_id === child.id).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);
});
(transactions || []).filter(t => t.child_id === child.id).forEach(t => {
const day = new Date(t.created_at).toLocaleDateString("en-CA", { timeZone: tz });
if (day in byDay) byDay[day] += t.points || 0;
});
const dailyNets = dateRange.map(d => byDay[d]);
const totalNet = dailyNets.reduce((a, b) => a + b, 0);
const startScore = (child.career_score || 0) - totalNet;
let running = startScore;
return dailyNets.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 tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const today = new Date().toLocaleDateString("en-CA", { timeZone: tz });
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString("en-CA", { timeZone: tz });
if (dateStr === today) return this._t('common.today');
if (dateStr === yesterday) return this._t('common.yesterday');
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
}
}
// ── Card Editor ──────────────────────────────────────────────
class TaskMateGraphCardEditor extends LitElement {
static get properties() {
return { hass: { type: Object }, config: { type: Object } };
}
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
}
static get styles() {
return css`
:host { display: block; }
ha-form { display: block; margin-bottom: 16px; }
.colour-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border: 1px solid var(--outline-color, var(--divider-color, #e0e0e0)); border-radius: 4px; background: var(--mdc-text-field-fill-color, var(--card-background-color)); }
.colour-field-label { font-size: 0.82rem; color: var(--primary-color); font-weight: 500; }
.colour-field-body { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.colour-swatch-wrapper { position: relative; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); flex-shrink: 0; }
.colour-swatch-wrapper input[type="color"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: 0; padding: 0; }
.colour-swatch-preview { position: absolute; inset: 0; pointer-events: none; }
.colour-hex { font-family: var(--code-font-family, monospace); font-size: 0.85rem; color: var(--secondary-text-color); min-width: 70px; }
.colour-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer; border: 2px solid var(--divider-color, #e0e0e0); transition: transform 0.1s; padding: 0; }
.preset-swatch:hover { transform: scale(1.15); }
.preset-swatch.active { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--primary-color); }
.colour-reset { font-size: 0.78rem; color: var(--secondary-text-color); background: none; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; padding: 4px 10px; cursor: pointer; margin-left: auto; }
.colour-helper { color: var(--secondary-text-color); font-size: 0.82rem; line-height: 1.3; }
`;
}
setConfig(config) { this.config = config; }
_buildSchema() {
const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null;
const children = entity?.attributes?.children || [];
return [
{ name: 'entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'title', selector: { text: {} } },
{
name: 'card_design',
selector: {
select: {
options: window.__taskmate_design
? window.__taskmate_design.editorOptions(this._t.bind(this))
: [{ value: 'global', label: 'Use global default' }],
mode: 'dropdown',
},
},
},
{ name: 'days', selector: { number: { min: 3, max: 90, mode: 'box' } } },
{
name: 'child_id',
selector: {
select: {
options: [
{ value: '__all__', label: this._t('common.editor.filter_by_child_all') },
...children.map((c) => ({ value: c.id, label: c.name })),
],
mode: 'dropdown',
},
},
},
];
}
_computeLabel = (entry) => {
const labels = {
entity: this._t('common.editor.overview_entity'),
title: this._t('common.editor.card_title'),
card_design: this._t('common.design.field_label'),
days: this._t('graph.editor.days_to_show'),
child_id: this._t('common.editor.filter_by_child'),
};
return labels[entry.name] ?? entry.name;
};
_computeHelper = (entry) => {
const helpers = {
entity: this._t('common.editor.overview_entity_helper'),
days: this._t('graph.editor.days_helper'),
child_id: this._t('graph.editor.child_filter_helper'),
};
return helpers[entry.name] ?? '';
};
render() {
if (!this.hass || !this.config) return html``;
const data = {
entity: this.config.entity || '',
title: this.config.title || '',
card_design: this.config.card_design || 'global',
days: this.config.days || 14,
child_id: this.config.child_id || '__all__',
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._buildSchema()}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._formChanged}
></ha-form>
${this._renderColourPicker('header_color', '#d35400')}
`;
}
_renderColourPicker(key, defaultValue) {
const d = window.__taskmate_design;
const current = this.config[key] || defaultValue;
if (!d || !d.colourPicker) return html``;
return d.colourPicker({
defaultValue, current,
label: this._t('common.editor.header_colour'),
helper: this._t('common.editor.header_colour_helper'),
resetLabel: this._t('common.reset'),
onInput: (v) => this._updateConfig(key, v),
onPreset: (v) => this._updateConfig(key, v),
onReset: () => this._updateConfig(key, defaultValue),
});
}
_formChanged(e) {
const newValues = e.detail.value || {};
const newConfig = { ...this.config };
for (const [key, value] of Object.entries(newValues)) {
if (
value === '' || value === null || value === undefined
|| (key === 'child_id' && value === '__all__')
|| (key === 'card_design' && value === 'global')
) {
delete newConfig[key];
} else if (key === 'days' && value === 14) {
delete newConfig[key];
} else {
newConfig[key] = value;
}
}
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
_updateConfig(key, value) {
const newConfig = { ...this.config, [key]: value };
if (value === null || value === "" || value === undefined) delete newConfig[key];
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: newConfig }, bubbles: true, composed: true,
}));
}
}
customElements.define("taskmate-graph-card", TaskMateGraphCard);
customElements.define("taskmate-graph-card-editor", TaskMateGraphCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "taskmate-graph-card",
name: "TaskMate Points Graph",
description: "Line graph tracking daily or cumulative points over time",
preview: true,
getEntitySuggestion: (hass, entityId) =>
window.__taskmate_suggest(hass, entityId, "taskmate-graph-card", "overview"),
});
// Version is injected by the HA resource URL (?v=x.x.x) and read from the DOM
const _tmVersion = new URLSearchParams(
Array.from(document.querySelectorAll('script[src*="/taskmate-graph-card.js"]'))
.map(s => s.src.split("?")[1]).find(Boolean) || ""
).get("v") || "?";
console.info(
"%c TASKMATE GRAPH CARD %c v" + _tmVersion + " ",
"background:#d35400;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
);