/** * TaskMate Reorder Card * A Lovelace card for reordering chores per child, organized by time category. * Clean, parent-friendly UI for managing chore order. * * Version: 2.0.0 - Fixed child assignment filtering * Last Updated: 2025-12-31 */ 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); class TaskMateReorderCard extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object }, _saving: { type: Boolean }, _localChoreOrder: { type: Object }, _hasChanges: { type: Boolean }, }; } shouldUpdate(changedProps) { if (changedProps.has("hass")) { return window.__taskmate_hasChanged ? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity) : true; } return true; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } constructor() { super(); this._saving = false; this._localChoreOrder = {}; this._hasChanges = false; } static get styles() { const base = css` :host { display: block; } ha-card { overflow: hidden; } .card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; background: var(--taskmate-header-bg, var(--primary-color)); color: var(--text-primary-color, #fff); gap: 12px; } .header-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } .header-icon { --mdc-icon-size: 22px; opacity: 0.9; flex-shrink: 0; color: var(--text-primary-color, #fff); } .card-title { font-size: 1.05rem; font-weight: 600; color: var(--text-primary-color, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .child-name { font-size: 0.8rem; font-weight: 600; color: rgba(255,255,255,0.85); padding: 3px 10px; background: rgba(255,255,255,0.15); border-radius: 12px; white-space: nowrap; flex-shrink: 0; } .save-button { background: rgba(255,255,255,0.15); color: var(--text-primary-color, #fff); border: 1px solid rgba(255,255,255,0.3); border-radius: 8px; padding: 7px 14px; font-size: 0.85rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; transition: background 0.2s ease, transform 0.15s ease; white-space: nowrap; flex-shrink: 0; } .save-button:hover:not(:disabled) { background: rgba(255,255,255,0.25); transform: scale(1.02); } .save-button:active:not(:disabled) { transform: scale(0.98); } .save-button:disabled { opacity: 0.5; cursor: not-allowed; } .save-button.has-changes { animation: pulse-save 2s ease-in-out infinite; } @keyframes pulse-save { 0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.3); } 50% { box-shadow: 0 0 0 6px rgba(255,255,255,0); } } .save-button ha-icon { --mdc-icon-size: 18px; } .save-button.saving ha-icon { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .time-category-section { margin-bottom: 24px; } .time-category-section:last-child { margin-bottom: 0; } .time-category-header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--secondary-background-color); border-radius: 10px; margin-bottom: 12px; font-weight: 500; color: var(--primary-text-color); } .time-category-header ha-icon { --mdc-icon-size: 22px; color: var(--primary-color); } .time-category-header .count { margin-left: auto; font-size: 0.85em; color: var(--secondary-text-color); background: var(--card-background-color); padding: 2px 10px; border-radius: 12px; } .chores-list { display: flex; flex-direction: column; gap: 8px; padding-left: 8px; } .chore-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--card-background-color); border: 1px solid var(--divider-color); border-radius: 10px; transition: box-shadow 0.2s ease, border-color 0.2s ease; } .chore-item:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); border-color: var(--primary-color); } .order-number { min-width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: var(--primary-color); color: var(--text-primary-color); border-radius: 50%; font-size: 0.85em; font-weight: 600; } .chore-icon { --mdc-icon-size: 24px; color: var(--secondary-text-color); } .chore-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } .chore-name { font-weight: 500; color: var(--primary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chore-points { font-size: 0.85em; color: var(--secondary-text-color); display: flex; align-items: center; gap: 4px; } .chore-points ha-icon { --mdc-icon-size: 14px; color: #ca8a04; } .reorder-buttons { display: flex; gap: 4px; } .reorder-button { width: 36px; height: 36px; border: 1px solid var(--divider-color); border-radius: 8px; background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; } .reorder-button:hover:not(:disabled) { background: var(--secondary-background-color); border-color: var(--primary-color); } .reorder-button:active:not(:disabled) { transform: scale(0.92); } .reorder-button:disabled { opacity: 0.3; cursor: not-allowed; } .reorder-button ha-icon { --mdc-icon-size: 20px; } .chore-item.drag-over { border-color: var(--primary-color); background: var(--secondary-background-color); box-shadow: 0 0 0 2px var(--primary-color); } .chore-item.dragging { opacity: 0.4; cursor: grabbing; } .drag-handle { cursor: grab; color: var(--secondary-text-color); display: flex; align-items: center; padding: 4px; touch-action: none; } .drag-handle ha-icon { --mdc-icon-size: 20px; } .drag-handle:active { cursor: grabbing; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; color: var(--secondary-text-color); text-align: center; } .empty-state ha-icon { --mdc-icon-size: 56px; margin-bottom: 16px; opacity: 0.5; } .empty-state .message { font-size: 1.1em; margin-bottom: 8px; color: var(--primary-text-color); } .empty-state .submessage { font-size: 0.9em; opacity: 0.8; } .error-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; color: var(--error-color); text-align: center; } .error-state ha-icon { --mdc-icon-size: 48px; margin-bottom: 16px; } /* Status indicator */ .card-body { padding: 16px; } .status-bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: var(--secondary-background-color); border-radius: 8px; margin-bottom: 16px; font-size: 0.85em; } .status-text { color: var(--secondary-text-color); } .status-text.unsaved { color: var(--warning-color, #ff9800); font-weight: 500; } .status-text.saved { color: var(--success-color, #4caf50); } /* Responsive adjustments */ @media (max-width: 500px) { .card-header { flex-direction: column; align-items: stretch; gap: 12px; } .header-left { justify-content: space-between; } .save-button { width: 100%; justify-content: center; } .chore-item { padding: 10px 12px; } .reorder-button { width: 32px; height: 32px; } } /* ══════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) Shared .tmd kit + tokens come from taskmate-design.js styles(). NOTE: no ha-card { padding } rule — body padding comes from .tmd-bd. ══════════════════════════════════════════════════════════════════ */ .d-childchip { display: flex; align-items: center; gap: 5px; margin-left: auto; font-weight: 800; font-size: 12px; color: #fff; background: rgba(255,255,255,.25); border-radius: 999px; padding: 3px 10px 3px 3px; white-space: nowrap; } :host([data-tm-design="console"]) .d-childchip { border-radius: 6px; font-weight: 700; font-size: 11px; background: var(--tmd-surface-2); border: 1px solid color-mix(in srgb, var(--ac, var(--tmd-accent)) 55%, transparent); color: var(--tmd-text); } :host([data-tm-design="cleanpro"]) .d-childchip { border-radius: 8px; font-weight: 700; font-size: 12px; } .d-childchip .av { box-shadow: none; } .d-save { width: 100%; justify-content: center; margin-bottom: 13px; } .d-save.saving ha-icon { animation: spin 1s linear infinite; } .d-cat { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: 13px; color: var(--tmd-text); margin: 13px 0 9px; } .d-cat:first-child { margin-top: 0; } .d-cat ha-icon { --mdc-icon-size: 18px; color: var(--tmd-dim); } .d-cat .d-cat-name { flex: 1; min-width: 0; } :host([data-tm-design="console"]) .d-cat .d-cat-name { font-family: var(--tmd-font-mono); letter-spacing: .08em; text-transform: uppercase; font-size: 12px; } .d-list { display: flex; flex-direction: column; } :host([data-tm-design="playroom"]) .d-list, :host([data-tm-design="console"]) .d-list { gap: 9px; } .d-row { display: flex; align-items: center; gap: 8px; background: var(--tmd-surface-2); border-radius: 16px; padding: 9px 11px; } :host([data-tm-design="console"]) .d-row { border-radius: 8px; padding: 9px; border: 1px solid var(--tmd-border); } :host([data-tm-design="cleanpro"]) .d-row { background: transparent; border-radius: 0; padding: 9px 0; gap: 9px; } .d-row.drag-over { box-shadow: 0 0 0 2px var(--tmd-accent); } .d-row.dragging { opacity: 0.4; } .d-handle { cursor: grab; color: var(--tmd-dim); font-size: 18px; line-height: 1; display: flex; align-items: center; padding: 2px; touch-action: none; } .d-handle:active { cursor: grabbing; } .d-num { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 800; flex: none; background: var(--tmd-accent); color: #fff; } :host([data-tm-design="console"]) .d-num { border-radius: 6px; width: 24px; height: 24px; font-family: var(--tmd-font-mono); font-size: 12px; background: color-mix(in srgb, var(--tmd-accent) 22%, transparent); color: var(--tmd-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tmd-accent) 55%, transparent); } :host([data-tm-design="cleanpro"]) .d-num { width: 24px; height: 24px; border-radius: 7px; background: var(--tmd-surface-2); color: var(--tmd-accent); font-size: 12px; } .d-emoji { font-size: 18px; flex: none; } .d-emoji ha-icon { --mdc-icon-size: 20px; color: var(--tmd-text); } .d-info { flex: 1; min-width: 0; } .d-row-name { font-weight: 800; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } :host([data-tm-design="console"]) .d-row-name, :host([data-tm-design="cleanpro"]) .d-row-name { font-weight: 600; font-size: 13px; } .d-row-pts { font-size: 11px; color: var(--tmd-dim); } :host([data-tm-design="console"]) .d-row-pts { font-family: var(--tmd-font-mono); font-size: 10px; } .d-arrows { display: flex; gap: 4px; flex: none; } .d-arrow { width: 30px; height: 30px; padding: 0; justify-content: center; font-size: 15px; } :host([data-tm-design="console"]) .d-arrow, :host([data-tm-design="cleanpro"]) .d-arrow { width: auto; height: auto; padding: 4px 8px; border-radius: var(--tmd-radius-sm); } `; 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 (TaskMate overview sensor)"); } if (!config.child_id) { throw new Error("Please define a child_id"); } this.config = { title: "Reorder Chores", ...config, }; } getCardSize() { return 4; } static getConfigElement() { return document.createElement("taskmate-reorder-card-editor"); } static getStubConfig() { return { entity: "sensor.taskmate_overview", child_id: "", title: "Reorder Chores", }; } updated(changedProperties) { super.updated(changedProperties); // Initialize local chore order from server data when hass changes if (changedProperties.has("hass") && this.hass && this.config) { this._initializeLocalOrder(); } } _initializeLocalOrder() { const entity = this.hass.states[this.config.entity]; if (!entity) return; if (entity.state === "unavailable" || entity.state === "unknown") { return; } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; const child = children.find((c) => c.id === this.config.child_id) || (!this.config.child_id && children[0]); if (!child) return; // Only initialize if we don't have local changes if (!this._hasChanges) { const serverOrder = child.chore_order || []; // Build a map of time_category -> ordered chore IDs const chores = attrs.chores || []; const childChores = this._getChoresForChild(chores, child.id); const newLocalOrder = {}; const timeCategories = this._getTimeCategories(); for (const category of timeCategories) { const categoryChores = childChores.filter( (c) => c.time_category === category ); // Sort by server order const sorted = this._sortChoresByOrder(categoryChores, serverOrder); newLocalOrder[category] = sorted.map((c) => c.id); } this._localChoreOrder = newLocalOrder; } } _getChoresForChild(chores, childId) { // Ensure childId is a string for consistent comparison const childIdStr = String(childId || ""); return chores.filter((chore) => { // Ensure assigned_to is always an array let assignedTo = chore.assigned_to; if (!Array.isArray(assignedTo)) { assignedTo = []; } // Convert all assigned_to values to strings for consistent comparison const assignedToStrings = assignedTo.map(id => String(id)); // If no assignments, show to all children. Otherwise, check if child is assigned. const isAssignedToAll = assignedToStrings.length === 0; const isAssignedToChild = isAssignedToAll || assignedToStrings.includes(childIdStr); return isAssignedToChild; }); } _sortChoresByOrder(chores, choreOrder) { if (!choreOrder || choreOrder.length === 0) { return [...chores]; } return [...chores].sort((a, b) => { const indexA = choreOrder.indexOf(a.id); const indexB = choreOrder.indexOf(b.id); if (indexA !== -1 && indexB !== -1) { return indexA - indexB; } if (indexA !== -1) return -1; if (indexB !== -1) return 1; return 0; }); } // User-defined periods from the overview sensor; legacy fixed four when // the integration hasn't published time_periods yet. _getTimePeriods() { const entity = this.hass?.states?.[this.config?.entity]; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity)) || entity?.attributes || {}; if (Array.isArray(attrs.time_periods) && attrs.time_periods.length) { return attrs.time_periods.filter(p => p && p.id); } return [ { id: "morning", label: "", icon: "mdi:weather-sunset-up" }, { id: "afternoon", label: "", icon: "mdi:weather-sunny" }, { id: "evening", label: "", icon: "mdi:weather-sunset-down" }, { id: "night", label: "", icon: "mdi:weather-night" }, ]; } _getTimeCategories() { return [...this._getTimePeriods().map(p => p.id), "anytime"]; } _getTimeCategoryIcon(category) { const period = this._getTimePeriods().find(p => p.id === category); if (period && period.icon) return period.icon; const icons = { morning: "mdi:weather-sunset-up", afternoon: "mdi:weather-sunny", evening: "mdi:weather-sunset-down", night: "mdi:weather-night", anytime: "mdi:clock-outline", }; return icons[category] || icons.anytime; } _getTimeCategoryLabel(category) { const period = this._getTimePeriods().find(p => p.id === category); if (period && period.label) return period.label; const keys = { morning: 'common.morning', afternoon: 'common.afternoon', evening: 'common.evening', night: 'common.night', anytime: 'common.anytime', }; return keys[category] ? this._t(keys[category]) : category; } render() { if (!this.hass || !this.config) { return html``; } const design = window.__taskmate_design ? window.__taskmate_design.apply(this, this.hass, this.config, this.config.entity) : "classic"; if (design !== "classic") return this._renderDesigned(design); const entity = this.hass.states[this.config.entity]; if (!entity) { return html`
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; const child = children.find((c) => c.id === this.config.child_id) || (!this.config.child_id && children[0]); if (!child) { return html`
${this._t('reorder.child_not_found', { child_id: this.config.child_id })}
`; } const chores = attrs.chores || []; const childChores = this._getChoresForChild(chores, child.id); if (childChores.length === 0) { return html`
${this.config.title || this._t('reorder.default_title')} ${child.name}
${this._t('reorder.no_chores_assigned')}
${this._t('reorder.add_chores_first')}
`; } const timeCategories = this._getTimeCategories(); const pointsIcon = attrs.points_icon || "mdi:star"; const headerColor = _safeColor(this.config.header_color, ''); const headerStyle = headerColor ? `--taskmate-header-bg: ${headerColor};` : ''; return html` ${headerStyle ? html`` : ''}
${this.config.title || this._t('reorder.default_title')} ${child.name}
${this._hasChanges ? html`
${this._t('reorder.unsaved_changes')}
` : ""} ${timeCategories.map((category) => { const categoryChoreIds = this._localChoreOrder[category] || []; const categoryChores = categoryChoreIds .map((id) => chores.find((c) => c.id === id)) .filter((c) => c); // Also include any chores in this category that aren't in the order yet const orderedIds = new Set(categoryChoreIds); const missingChores = childChores.filter( (c) => c.time_category === category && !orderedIds.has(c.id) ); const allCategoryChores = [...categoryChores, ...missingChores]; if (allCategoryChores.length === 0) { return ""; } return html`
${this._getTimeCategoryLabel(category)} ${allCategoryChores.length !== 1 ? this._t('reorder.chore_count_plural', { count: allCategoryChores.length }) : this._t('reorder.chore_count', { count: allCategoryChores.length })}
${allCategoryChores.map((chore, index) => this._renderChoreItem(chore, index, allCategoryChores.length, category, pointsIcon) )}
`; })} `; } /* ══════════════════════════════════════════════════════════════════════ DESIGNED STYLES (playroom / console / cleanpro) — see taskmate-design.js Ported from docs/design/redesigns/frag/16-reorder.html. Reuses every classic handler (drag/touch/_moveChore/_handleSave) so reordering behaviour is identical across designs. ══════════════════════════════════════════════════════════════════════ */ _designAv(child, size) { const a = child.avatar || ""; const tone = "var(--tmd-c1)"; const inner = a.startsWith("mdi:") ? html`` : a ? html`${child.name}` : (child.name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase(); return html`${inner}`; } _renderDesigned(design) { const hd = _safeColor(this.config.header_color, '#5b7fb9'); const entity = this.hass.states[this.config.entity]; if (!entity) { return html`
${this.config.title || this._t('reorder.default_title')}
${this._t('common.entity_not_found', { entity: this.config.entity })}
`; } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; const children = attrs.children || []; const child = children.find((c) => c.id === this.config.child_id) || (!this.config.child_id && children[0]); const icon = design === "console" ? "≡" : design === "cleanpro" ? "⇅" : "↕️"; const sub = design === "console" ? this._t('reorder.default_title') : this._t('reorder.drag_to_reorder'); if (!child) { return html`
${icon}${this.config.title || this._t('reorder.default_title')}
${this._t('reorder.child_not_found', { child_id: this.config.child_id })}
`; } const chores = attrs.chores || []; const childChores = this._getChoresForChild(chores, child.id); const pointsIcon = attrs.points_icon || "mdi:star"; const header = html`
${icon} ${this.config.title || this._t('reorder.default_title')}${sub} ${this._designAv(child, 22)}${child.name}
`; if (childChores.length === 0) { return html` ${header}
${this._t('reorder.no_chores_assigned')}
${this._t('reorder.add_chores_first')}
`; } const timeCategories = this._getTimeCategories(); const saveLabel = this._saving ? this._t('reorder.saving') : this._t('reorder.save_order'); return html` ${header}
${this._hasChanges ? html`
${this._t('reorder.unsaved_changes')}
` : ""} ${timeCategories.map((category) => { const categoryChoreIds = this._localChoreOrder[category] || []; const categoryChores = categoryChoreIds .map((id) => chores.find((c) => c.id === id)) .filter((c) => c); const orderedIds = new Set(categoryChoreIds); const missingChores = childChores.filter( (c) => c.time_category === category && !orderedIds.has(c.id) ); const allCategoryChores = [...categoryChores, ...missingChores]; if (allCategoryChores.length === 0) return ""; return html`
${this._getTimeCategoryLabel(category)} ${allCategoryChores.length}
${allCategoryChores.map((chore, index) => this._renderDesignedChoreItem(chore, index, allCategoryChores.length, category, pointsIcon) )}
`; })}
`; } _renderDesignedChoreItem(chore, index, total, category, pointsIcon) { const isFirst = index === 0; const isLast = index === total - 1; const choreIcon = chore.icon || "mdi:broom"; const emoji = choreIcon.startsWith("mdi:") ? html`` : html`${choreIcon}`; return html`
⋮⋮ ${index + 1} ${emoji}
${chore.name}
+${chore.points} ${this._t('common.points').toLowerCase()}
`; } _renderChoreItem(chore, index, total, category, pointsIcon) { const isFirst = index === 0; const isLast = index === total - 1; return html`
${index + 1}
${chore.name} ${chore.points} ${this._t('common.points').toLowerCase()}
`; } // -- Drag and Drop (mouse/pointer) -- _onDragStart(e, category, index) { this._dragState = { category, index }; e.dataTransfer.effectAllowed = "move"; e.currentTarget.classList.add("dragging"); } _onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; } _onDragEnter(e) { e.currentTarget.classList.add("drag-over"); } _onDragLeave(e) { e.currentTarget.classList.remove("drag-over"); } _onDrop(e, category, toIndex) { e.preventDefault(); e.currentTarget.classList.remove("drag-over"); if (!this._dragState) return; const { category: fromCategory, index: fromIndex } = this._dragState; if (fromCategory === category && fromIndex !== toIndex) { this._swapChores(category, fromIndex, toIndex); } this._dragState = null; } _onDragEnd(e) { e.currentTarget.classList.remove("dragging", "drag-over"); this._dragState = null; } // -- Touch drag (mobile) -- _onTouchStart(e, category, index) { this._touchState = { category, index, startY: e.touches[0].clientY }; } _onTouchMove(e) { e.preventDefault(); // prevent scroll while dragging if (!this._touchState) return; const touch = e.touches[0]; const el = this.shadowRoot?.elementFromPoint(touch.clientX, touch.clientY); if (!el) return; // Classic rows use .chore-item, designed rows use .d-row — match either. const item = el.closest(".chore-item, .d-row"); this.shadowRoot.querySelectorAll(".chore-item, .d-row").forEach(i => i.classList.remove("drag-over")); if (item) item.classList.add("drag-over"); } _onTouchEnd(e, category) { if (!this._touchState) return; const touch = e.changedTouches[0]; const el = this.shadowRoot?.elementFromPoint(touch.clientX, touch.clientY); this.shadowRoot?.querySelectorAll(".chore-item, .d-row").forEach(i => i.classList.remove("drag-over")); const item = el ? el.closest(".chore-item, .d-row") : null; if (item) { const toIndex = parseInt(item.dataset.index, 10); const { index: fromIndex } = this._touchState; if (!isNaN(toIndex) && fromIndex !== toIndex) { this._swapChores(category, fromIndex, toIndex); } } this._touchState = null; } _swapChores(category, fromIndex, toIndex) { const order = [...(this._localChoreOrder[category] || [])]; const item = order.splice(fromIndex, 1)[0]; order.splice(toIndex, 0, item); this._localChoreOrder = { ...this._localChoreOrder, [category]: order }; this._hasChanges = true; this.requestUpdate(); } _moveChore(category, currentIndex, direction) { const newIndex = currentIndex + direction; const categoryOrder = [...(this._localChoreOrder[category] || [])]; if (newIndex < 0 || newIndex >= categoryOrder.length) { return; } // Swap the items const temp = categoryOrder[currentIndex]; categoryOrder[currentIndex] = categoryOrder[newIndex]; categoryOrder[newIndex] = temp; this._localChoreOrder = { ...this._localChoreOrder, [category]: categoryOrder, }; this._hasChanges = true; this.requestUpdate(); } async _handleSave() { if (this._saving || !this._hasChanges) { return; } this._saving = true; this.requestUpdate(); try { // Combine all category orders into a single flat array const timeCategories = this._getTimeCategories(); const fullOrder = []; for (const category of timeCategories) { const categoryOrder = this._localChoreOrder[category] || []; fullOrder.push(...categoryOrder); } await this.hass.callService("taskmate", "set_chore_order", { child_id: this.config.child_id, chore_order: fullOrder, }); this._hasChanges = false; // Show success feedback if (this.hass.callService) { this.hass.callService("persistent_notification", "create", { title: this._t('reorder.notification.save_success_title'), message: this._t('reorder.notification.save_success_message'), notification_id: "taskmate_reorder_success", }); // Auto-dismiss after 3 seconds setTimeout(() => { this.hass.callService("persistent_notification", "dismiss", { notification_id: "taskmate_reorder_success", }); }, 3000); } } catch (error) { console.error("Failed to save chore order:", error); if (this.hass.callService) { this.hass.callService("persistent_notification", "create", { title: this._t('reorder.notification.save_error_title'), message: this._t('reorder.notification.save_error_message', { error: error.message }), notification_id: "taskmate_reorder_error", }); } } finally { this._saving = false; this.requestUpdate(); } } } // Card Editor class TaskMateReorderCardEditor extends LitElement { static get properties() { return { hass: { type: Object }, config: { type: Object } }; } 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; } _t(key, params) { const fn = window.__taskmate_localize; return fn ? fn(this.hass, key, params) : key; } _buildSchema() { const entity = this.config?.entity ? this.hass?.states?.[this.config.entity] : null; const children = entity?.attributes?.children || []; return [ { name: 'entity', selector: { entity: { domain: 'sensor' } } }, { name: 'child_id', selector: { select: { options: [ { value: '', label: this._t('reorder.editor.select_child') }, ...children.map((c) => ({ value: c.id, label: c.name })), ], mode: 'dropdown', }, }, }, { name: 'title', selector: { text: {} } }, { name: 'card_design', selector: { select: { options: window.__taskmate_design ? window.__taskmate_design.editorOptions(this._t.bind(this)) : [{ value: 'global', label: 'Use global default' }], mode: 'dropdown', }, }, }, ]; } _computeLabel = (entry) => { const labels = { entity: this._t('common.editor.overview_entity'), child_id: this._t('reorder.editor.child'), title: this._t('common.editor.card_title'), card_design: this._t('common.design.field_label'), }; return labels[entry.name] ?? entry.name; }; _computeHelper = (entry) => { const helpers = { entity: this._t('common.editor.overview_entity_helper'), child_id: this._t('reorder.editor.child_helper'), }; return helpers[entry.name] ?? ''; }; render() { if (!this.hass || !this.config) return html``; const data = { entity: this.config.entity || '', child_id: this.config.child_id || '', title: this.config.title || '', card_design: this.config.card_design || 'global', }; return html` ${this._renderColourPicker('header_color', '')} `; } _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) delete newConfig[key]; else if (key === 'card_design' && value === 'global') 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 === undefined || value === "") delete newConfig[key]; this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: newConfig }, bubbles: true, composed: true, })); } } // Register the cards customElements.define("taskmate-reorder-card", TaskMateReorderCard); customElements.define("taskmate-reorder-card-editor", TaskMateReorderCardEditor); // Register with Home Assistant window.customCards = window.customCards || []; window.customCards.push({ type: "taskmate-reorder-card", name: "TaskMate Reorder Card", description: "A card for reordering chores per child, organized by time category", preview: true, getEntitySuggestion: (hass, entityId) => window.__taskmate_suggest(hass, entityId, "taskmate-reorder-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-reorder-card.js"]')) .map(s => s.src.split("?")[1]).find(Boolean) || "" ).get("v") || "?"; console.info( "%c TASKMATE REORDER CARD %c v" + _tmVersion + " ", "background:#03a9f4;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;" );