${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`
${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 || "?").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`
this._onDragStart(e, category, index)}"
@dragover="${(e) => this._onDragOver(e)}"
@dragenter="${(e) => this._onDragEnter(e)}"
@dragleave="${(e) => this._onDragLeave(e)}"
@drop="${(e) => this._onDrop(e, category, index)}"
@dragend="${(e) => this._onDragEnd(e)}"
@touchstart="${(e) => this._onTouchStart(e, category, index)}"
@touchmove="${(e) => this._onTouchMove(e)}"
@touchend="${(e) => this._onTouchEnd(e, category)}"
>
⋮⋮
${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`
this._onDragStart(e, category, index)}"
@dragover="${(e) => this._onDragOver(e)}"
@dragenter="${(e) => this._onDragEnter(e)}"
@dragleave="${(e) => this._onDragLeave(e)}"
@drop="${(e) => this._onDrop(e, category, index)}"
@dragend="${(e) => this._onDragEnd(e)}"
@touchstart="${(e) => this._onTouchStart(e, category, index)}"
@touchmove="${(e) => this._onTouchMove(e)}"
@touchend="${(e) => this._onTouchEnd(e, category)}"
>
${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;"
);