From 1f6d0d2e6374119c777497e89d0f699c2575bb54 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:45:10 -0700 Subject: [PATCH] Add a period option to todo-list-card (#30151) * Added a todo-list card option "days_to_show" to filter tasks far in the future (#24020) * Adjusted min and 0 values as suggested in PR * Adjusted as suggested in review * Switched from days_to_show to period (calendar only for now) as suggested * removed days_to_show from editor UI * fixed lint error * Fixed code style with prettier * fix filtering * Update filtering period options * Update src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts Co-authored-by: Petar Petrov * Apply suggestion from @MindFreeze Co-authored-by: Petar Petrov * prettier/lint * fix memoization, items without status * no rolling window * refresh on date change * Show dialog on create when using due_date filter --------- Co-authored-by: cpetry Co-authored-by: Petar Petrov --- .../lovelace/cards/hui-todo-list-card.ts | 199 ++++++++++++++++-- src/panels/lovelace/cards/types.ts | 3 + .../config-elements/hui-todo-list-editor.ts | 17 +- src/panels/todo/dialog-todo-item-editor.ts | 10 +- .../todo/show-dialog-todo-item-editor.ts | 2 +- 5 files changed, 207 insertions(+), 24 deletions(-) diff --git a/src/panels/lovelace/cards/hui-todo-list-card.ts b/src/panels/lovelace/cards/hui-todo-list-card.ts index bf5eb29407..a33d700c5d 100644 --- a/src/panels/lovelace/cards/hui-todo-list-card.ts +++ b/src/panels/lovelace/cards/hui-todo-list-card.ts @@ -8,7 +8,17 @@ import { mdiPlus, mdiSort, } from "@mdi/js"; -import { endOfDay, isSameDay } from "date-fns"; +import { + addDays, + addMonths, + addWeeks, + addYears, + endOfDay, + endOfMonth, + endOfWeek, + endOfYear, + isSameDay, +} from "date-fns"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { PropertyValueMap, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; @@ -19,6 +29,8 @@ import memoizeOne from "memoize-one"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { supportsFeature } from "../../../common/entity/supports-feature"; import { caseInsensitiveStringCompare } from "../../../common/string/compare"; +import { calcDate } from "../../../common/datetime/calc_date"; +import { firstWeekdayIndex } from "../../../common/datetime/first_weekday"; import "../../../components/ha-card"; import "../../../components/ha-check-list-item"; import "../../../components/ha-checkbox"; @@ -57,6 +69,13 @@ import type { TodoListCardConfig } from "./types"; export const ITEM_TAP_ACTION_EDIT = "edit"; export const ITEM_TAP_ACTION_TOGGLE = "toggle"; +interface TodoDueDatePeriod { + calendar?: { + period: string; + offset?: number; + }; +} + @customElement("hui-todo-list-card") export class HuiTodoListCard extends LitElement implements LovelaceCard { public static async getConfigElement(): Promise { @@ -96,17 +115,21 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { private _unsubItems?: Promise; + private _refreshTimer?: number; + connectedCallback(): void { super.connectedCallback(); if (this.hasUpdated) { this._subscribeItems(); } + this._setRefreshTimer(); } disconnectedCallback(): void { super.disconnectedCallback(); this._unsubItems?.then((unsub) => unsub()); this._unsubItems = undefined; + this._clearRefreshTimer(); } public getCardSize(): number { @@ -162,12 +185,18 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { } private _getUncheckedAndItemsWithoutStatus = memoizeOne( - (items?: TodoItem[], sort?: string | undefined): TodoItem[] => + ( + items?: TodoItem[], + sort?: string | undefined, + due_date_period?: TodoDueDatePeriod, + _memoTime?: number + ): TodoItem[] => items ? this._sortItems( - items.filter( - (item) => - item.status === TodoItemStatus.NeedsAction || !item.status + this._filterItems( + items, + [null, TodoItemStatus.NeedsAction], + due_date_period ), sort ) @@ -175,35 +204,134 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { ); private _getCheckedItems = memoizeOne( - (items?: TodoItem[], sort?: string | undefined): TodoItem[] => + ( + items?: TodoItem[], + sort?: string | undefined, + due_date_period?: TodoDueDatePeriod, + _memoTime?: number + ): TodoItem[] => items ? this._sortItems( - items.filter((item) => item.status === TodoItemStatus.Completed), + this._filterItems( + items, + [TodoItemStatus.Completed], + due_date_period + ), sort ) : [] ); private _getUncheckedItems = memoizeOne( - (items?: TodoItem[], sort?: string | undefined): TodoItem[] => + ( + items?: TodoItem[], + sort?: string | undefined, + due_date_period?: TodoDueDatePeriod, + _memoTime?: number + ): TodoItem[] => items ? this._sortItems( - items.filter((item) => item.status === TodoItemStatus.NeedsAction), + this._filterItems( + items, + [TodoItemStatus.NeedsAction], + due_date_period + ), sort ) : [] ); private _getItemsWithoutStatus = memoizeOne( - (items?: TodoItem[], sort?: string | undefined): TodoItem[] => + ( + items?: TodoItem[], + sort?: string | undefined, + due_date_period?: TodoDueDatePeriod, + _memoTime?: number + ): TodoItem[] => items ? this._sortItems( - items.filter((item) => !item.status), + this._filterItems(items, [null], due_date_period), sort ) : [] ); + private _filterItems( + items: TodoItem[], + status: (TodoItemStatus | null)[], + period?: TodoDueDatePeriod + ): TodoItem[] { + const endDate = + period && period.calendar && period.calendar.period + ? this._addPeriod(new Date(), period.calendar) + : undefined; + + return items.filter((item) => { + if (!status.includes(item.status || null)) { + return false; + } + if (!endDate) { + return true; + } + const dueDate = this._getDueDate(item); + return dueDate && dueDate <= endDate; + }); + } + + private _addPeriod( + date: Date, + calendar: { period: string; offset?: number } + ): Date | undefined { + const locale = this.hass!.locale; + const config = this.hass!.config; + const offset = calendar.offset || 0; + switch (calendar.period) { + case "day": + return addDays(calcDate(date, endOfDay, locale, config), offset); + case "week": { + const weekStartsOn = firstWeekdayIndex(locale); + return addWeeks( + calcDate(date, endOfWeek, locale, config, { + weekStartsOn, + }), + offset + ); + } + case "month": + return addMonths(calcDate(date, endOfMonth, locale, config), offset); + case "year": + return addYears(calcDate(date, endOfYear, locale, config), offset); + default: + return undefined; + } + } + + private _setRefreshTimer() { + this._clearRefreshTimer(); + if (!this.hass || !this._config?.due_date_period) { + return; + } + const nowDate = new Date(); + const timeout = calcDate( + nowDate, + endOfDay, + this.hass.locale, + this.hass.config + ); + this._refreshTimer = window.setTimeout(() => { + this._refreshTimer = undefined; + this.requestUpdate(); + }, timeout.getTime() - nowDate.getTime()); + } + + private _clearRefreshTimer() { + if (this._refreshTimer === undefined) { + return; + } + window.clearTimeout(this._refreshTimer); + this._refreshTimer = undefined; + } + public willUpdate( changedProperties: PropertyValueMap | Map ): void { @@ -216,6 +344,10 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { this._items = undefined; this._subscribeItems(); } + + if (!this._refreshTimer) { + this._setRefreshTimer(); + } } protected updated(changedProps: PropertyValues): void { @@ -254,24 +386,42 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { const unavailable = isUnavailableState(stateObj.state); + // Discard memoization when we rollover to a new day, so filters can be recalculated + const memoTime = this._config.due_date_period + ? calcDate( + new Date(), + endOfDay, + this.hass.locale, + this.hass.config + ).getTime() + : 0; + const checkedItems = this._getCheckedItems( this._items, - this._config.display_order + this._config.display_order, + this._config.due_date_period, + memoTime ); const uncheckedItems = this._getUncheckedItems( this._items, - this._config.display_order + this._config.display_order, + this._config.due_date_period, + memoTime ); const itemsWithoutStatus = this._getItemsWithoutStatus( this._items, - this._config.display_order + this._config.display_order, + this._config.due_date_period, + memoTime ); const reorderableItems = this._reordering ? this._getUncheckedAndItemsWithoutStatus( this._items, - this._config.display_order + this._config.display_order, + this._config.due_date_period, + memoTime ) : undefined; @@ -671,12 +821,23 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard { return this._input; } - private _addItem(ev): void { + private async _addItem(ev): Promise { const newItem = this._newItem; if (newItem.value!.length > 0) { - createItem(this.hass!, this._entityId!, { - summary: newItem.value!, - }); + if (this._config?.due_date_period) { + const item = { + summary: newItem.value!, + status: TodoItemStatus.NeedsAction, + }; + await showTodoItemEditDialog(this, { + entity: this._entityId!, + item, + }); + } else { + createItem(this.hass!, this._entityId!, { + summary: newItem.value!, + }); + } } newItem.value = ""; diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 72f25eb323..d5fd108bc2 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -598,6 +598,9 @@ export interface TodoListCardConfig extends LovelaceCardConfig { hide_create?: boolean; hide_section_headers?: boolean; sort?: string; + due_date_period?: { + calendar?: { period: string; offset?: number }; + }; } export interface StackCardConfig extends LovelaceCardConfig { diff --git a/src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts b/src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts index c976e75d69..1953e60c00 100644 --- a/src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts @@ -2,7 +2,15 @@ import type { CSSResultGroup } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; -import { assert, assign, boolean, object, optional, string } from "superstruct"; +import { + assert, + assign, + boolean, + number, + object, + optional, + string, +} from "superstruct"; import { mdiGestureTap } from "@mdi/js"; import { ITEM_TAP_ACTION_EDIT, @@ -35,6 +43,13 @@ const cardConfigStruct = assign( hide_section_headers: optional(boolean()), display_order: optional(string()), item_tap_action: optional(string()), + due_date_period: optional( + object({ + calendar: optional( + object({ period: string(), offset: optional(number()) }) + ), + }) + ), }) ); diff --git a/src/panels/todo/dialog-todo-item-editor.ts b/src/panels/todo/dialog-todo-item-editor.ts index a9bdac6fdb..88b4e0f2d7 100644 --- a/src/panels/todo/dialog-todo-item-editor.ts +++ b/src/panels/todo/dialog-todo-item-editor.ts @@ -16,6 +16,7 @@ import "../../components/ha-textarea"; import "../../components/ha-textfield"; import "../../components/ha-time-input"; import "../../components/ha-dialog"; +import type { TodoItem } from "../../data/todo"; import { TodoItemStatus, TodoListEntityFeature, @@ -109,7 +110,8 @@ class DialogTodoItemEditor extends LitElement { if (!this._params) { return nothing; } - const isCreate = this._params.item === undefined; + const isCreate = + this._params.item === undefined || !("uid" in this._params.item); const listName = this._params.entity in this.hass.states ? computeStateName(this.hass.states[this._params.entity]) @@ -364,7 +366,7 @@ class DialogTodoItemEditor extends LitElement { try { await updateItem(this.hass!, this._params!.entity, { - ...entry, + ...(entry as TodoItem), summary: this._summary, description: this._description || @@ -416,7 +418,9 @@ class DialogTodoItemEditor extends LitElement { return; } try { - await deleteItems(this.hass!, this._params!.entity, [entry.uid]); + await deleteItems(this.hass!, this._params!.entity, [ + (entry as TodoItem).uid, + ]); } catch (err: any) { this._error = err ? err.message : "Unknown error"; return; diff --git a/src/panels/todo/show-dialog-todo-item-editor.ts b/src/panels/todo/show-dialog-todo-item-editor.ts index 0b3969ea1e..a8ddec0288 100644 --- a/src/panels/todo/show-dialog-todo-item-editor.ts +++ b/src/panels/todo/show-dialog-todo-item-editor.ts @@ -3,7 +3,7 @@ import type { TodoItem } from "../../data/todo"; export interface TodoItemEditDialogParams { entity: string; - item?: TodoItem; + item?: TodoItem | Omit; } export const loadTodoItemEditDialog = () => import("./dialog-todo-item-editor");