1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

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 <MindFreeze@users.noreply.github.com>

* Apply suggestion from @MindFreeze

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* 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 <petry2christian@gmail.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
karwosts
2026-03-24 23:45:10 -07:00
committed by GitHub
parent 0327b02d0b
commit 1f6d0d2e63
5 changed files with 207 additions and 24 deletions

View File

@@ -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<LovelaceCardEditor> {
@@ -96,17 +115,21 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
private _unsubItems?: Promise<UnsubscribeFunc>;
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<any> | Map<PropertyKey, unknown>
): 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<void> {
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 = "";

View File

@@ -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 {

View File

@@ -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()) })
),
})
),
})
);

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import type { TodoItem } from "../../data/todo";
export interface TodoItemEditDialogParams {
entity: string;
item?: TodoItem;
item?: TodoItem | Omit<TodoItem, "uid">;
}
export const loadTodoItemEditDialog = () => import("./dialog-todo-item-editor");