@echothink-ui/todo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import type * as React from "react";
2
+ import type { EthAction, EthOperationalStatus } from "@echothink-ui/core";
3
+ export interface TodoTaskItem {
4
+ id: string;
5
+ label: string;
6
+ done: boolean;
7
+ dueAt?: string;
8
+ assignee?: string;
9
+ priority?: string;
10
+ }
11
+ export interface KanbanCard {
12
+ id: string;
13
+ title: string;
14
+ assignee?: string;
15
+ priority?: string;
16
+ status?: string;
17
+ dueAt?: string;
18
+ labels?: string[];
19
+ }
20
+ export interface KanbanColumnModel {
21
+ id: string;
22
+ title: string;
23
+ status?: string;
24
+ wipLimit?: number;
25
+ items: KanbanCard[];
26
+ }
27
+ export interface TaskTableTask extends Record<string, unknown> {
28
+ id: string;
29
+ title: string;
30
+ status?: EthOperationalStatus;
31
+ assignee?: string;
32
+ priority?: string;
33
+ dueAt?: string;
34
+ labels?: string[];
35
+ }
36
+ export interface TaskDependency {
37
+ id: string;
38
+ title: string;
39
+ status: string;
40
+ relation: "blocks" | "blocked-by";
41
+ }
42
+ export interface TaskTimelineEvent {
43
+ id: string;
44
+ timestamp: string;
45
+ actor?: string;
46
+ kind: string;
47
+ summary: React.ReactNode;
48
+ }
49
+ export type TaskRowActions = (task: TaskTableTask) => EthAction[];
@@ -0,0 +1,3 @@
1
+ export declare function formatDateTime(value: string | undefined): string;
2
+ export declare function priorityLabel(priority: string | undefined): string;
3
+ export declare function prioritySeverity(priority: string | undefined): "danger" | "warning" | "info" | "neutral";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@echothink-ui/todo",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "**/*.css"
8
+ ],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./styles.css": "./src/styles.css"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "peerDependencies": {
26
+ "react": ">=18.3.0",
27
+ "react-dom": ">=18.3.0"
28
+ },
29
+ "dependencies": {
30
+ "@echothink-ui/core": "0.2.0",
31
+ "@echothink-ui/icons": "0.2.0",
32
+ "@echothink-ui/data": "0.2.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup src/index.tsx --format esm,cjs --sourcemap --clean --external react --external react-dom && tsc -p tsconfig.json --declaration --emitDeclarationOnly --noEmit false --outDir dist",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit",
40
+ "test": "vitest run --config ../../vitest.config.ts --passWithNoTests",
41
+ "lint": "eslint src"
42
+ }
43
+ }
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+ import type { KanbanCard, KanbanColumnModel } from "../types";
3
+ import { KanbanColumn } from "./KanbanColumn";
4
+ import { TaskCard } from "./TaskCard";
5
+
6
+ export interface KanbanBoardProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ columns?: KanbanColumnModel[];
8
+ onCardMove?: (cardId: string, fromColumn: string, toColumn: string, index: number) => void;
9
+ onAddCard?: (columnId: string) => void;
10
+ }
11
+
12
+ interface LiftedCard {
13
+ cardId: string;
14
+ fromColumn: string;
15
+ toColumn: string;
16
+ index: number;
17
+ }
18
+
19
+ export function KanbanBoard({
20
+ columns = [],
21
+ onCardMove,
22
+ onAddCard,
23
+ className,
24
+ ...props
25
+ }: KanbanBoardProps) {
26
+ const [announcement, setAnnouncement] = React.useState("");
27
+ const [lifted, setLifted] = React.useState<LiftedCard | null>(null);
28
+
29
+ const findCard = (cardId: string) => {
30
+ for (const column of columns) {
31
+ const index = column.items.findIndex((item) => item.id === cardId);
32
+ if (index >= 0) return { column, index, card: column.items[index] };
33
+ }
34
+ return undefined;
35
+ };
36
+
37
+ const announce = (message: string) => setAnnouncement(message);
38
+
39
+ const moveLifted = (direction: "left" | "right" | "up" | "down") => {
40
+ if (!lifted) return;
41
+ const currentColumnIndex = columns.findIndex((column) => column.id === lifted.toColumn);
42
+ if (currentColumnIndex < 0) return;
43
+ if (direction === "left" || direction === "right") {
44
+ const offset = direction === "left" ? -1 : 1;
45
+ const nextColumn = columns[currentColumnIndex + offset];
46
+ if (!nextColumn) return;
47
+ const nextIndex = Math.min(lifted.index, Math.max(0, nextColumn.items.length));
48
+ setLifted({ ...lifted, toColumn: nextColumn.id, index: nextIndex });
49
+ announce(`Moved ${lifted.cardId} to ${nextColumn.title}, position ${nextIndex + 1}`);
50
+ return;
51
+ }
52
+ const currentColumn = columns[currentColumnIndex];
53
+ const maxIndex = Math.max(0, currentColumn.items.length - 1);
54
+ const nextIndex =
55
+ direction === "up" ? Math.max(0, lifted.index - 1) : Math.min(maxIndex, lifted.index + 1);
56
+ setLifted({ ...lifted, index: nextIndex });
57
+ announce(`Moved ${lifted.cardId} to position ${nextIndex + 1}`);
58
+ };
59
+
60
+ const handleCardKeyDown = (
61
+ event: React.KeyboardEvent<HTMLDivElement>,
62
+ card: KanbanCard,
63
+ column: KanbanColumnModel,
64
+ index: number
65
+ ) => {
66
+ if (event.key === " ") {
67
+ event.preventDefault();
68
+ setLifted({ cardId: card.id, fromColumn: column.id, toColumn: column.id, index });
69
+ announce(`Lifted ${card.title}`);
70
+ return;
71
+ }
72
+ if (!lifted) return;
73
+ if (event.key === "ArrowLeft") {
74
+ event.preventDefault();
75
+ moveLifted("left");
76
+ }
77
+ if (event.key === "ArrowRight") {
78
+ event.preventDefault();
79
+ moveLifted("right");
80
+ }
81
+ if (event.key === "ArrowUp") {
82
+ event.preventDefault();
83
+ moveLifted("up");
84
+ }
85
+ if (event.key === "ArrowDown") {
86
+ event.preventDefault();
87
+ moveLifted("down");
88
+ }
89
+ if (event.key === "Enter") {
90
+ event.preventDefault();
91
+ onCardMove?.(lifted.cardId, lifted.fromColumn, lifted.toColumn, lifted.index);
92
+ announce(`Dropped ${lifted.cardId}`);
93
+ setLifted(null);
94
+ }
95
+ if (event.key === "Escape") {
96
+ event.preventDefault();
97
+ announce(`Cancelled move for ${lifted.cardId}`);
98
+ setLifted(null);
99
+ }
100
+ };
101
+
102
+ const handleDrop = (event: React.DragEvent, toColumn: KanbanColumnModel, index: number) => {
103
+ event.preventDefault();
104
+ const cardId = event.dataTransfer.getData("text/plain");
105
+ const source = findCard(cardId);
106
+ if (!source) return;
107
+ onCardMove?.(cardId, source.column.id, toColumn.id, index);
108
+ announce(`Moved ${source.card.title} to ${toColumn.title}`);
109
+ };
110
+
111
+ return (
112
+ <div
113
+ {...props}
114
+ className={`eth-kanban-board ${className ?? ""}`}
115
+ data-eth-component="KanbanBoard"
116
+ >
117
+ <div className="eth-kanban-board__columns" role="list" aria-label="Kanban columns">
118
+ {columns.map((column) => (
119
+ <KanbanColumn
120
+ key={column.id}
121
+ column={column}
122
+ role="listitem"
123
+ onAddCard={onAddCard}
124
+ onDragOver={(event) => event.preventDefault()}
125
+ onDrop={(event) => handleDrop(event, column, column.items.length)}
126
+ >
127
+ {column.items.map((card, index) => (
128
+ <div
129
+ key={card.id}
130
+ className={`eth-kanban-card-shell ${
131
+ lifted?.cardId === card.id ? "eth-kanban-card-shell--lifted" : ""
132
+ }`}
133
+ draggable
134
+ tabIndex={0}
135
+ role="listitem"
136
+ aria-label={`${card.title}, ${column.title}, position ${index + 1} of ${
137
+ column.items.length
138
+ }`}
139
+ aria-grabbed={lifted?.cardId === card.id}
140
+ aria-roledescription="Draggable task card"
141
+ aria-keyshortcuts="Space ArrowLeft ArrowRight ArrowUp ArrowDown Enter Escape"
142
+ onDragStart={(event) => {
143
+ event.dataTransfer.setData("text/plain", card.id);
144
+ event.dataTransfer.effectAllowed = "move";
145
+ }}
146
+ onDragOver={(event) => event.preventDefault()}
147
+ onDrop={(event) => handleDrop(event, column, index)}
148
+ onKeyDown={(event) => handleCardKeyDown(event, card, column, index)}
149
+ >
150
+ <TaskCard card={card} />
151
+ </div>
152
+ ))}
153
+ </KanbanColumn>
154
+ ))}
155
+ </div>
156
+ <div className="eth-kanban-board__live" aria-live="polite" aria-atomic="true">
157
+ {announcement}
158
+ </div>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,105 @@
1
+ import * as React from "react";
2
+ import { Badge, IconButton } from "@echothink-ui/core";
3
+ import type { KanbanColumnModel } from "../types";
4
+
5
+ export interface KanbanColumnProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ column: KanbanColumnModel;
7
+ children?: React.ReactNode;
8
+ onAddCard?: (columnId: string) => void;
9
+ }
10
+
11
+ export function KanbanColumn({
12
+ column,
13
+ children,
14
+ onAddCard,
15
+ className,
16
+ "aria-label": ariaLabel,
17
+ ...props
18
+ }: KanbanColumnProps) {
19
+ const itemCount = column.items.length;
20
+ const taskLabel = `${itemCount} ${itemCount === 1 ? "task" : "tasks"}`;
21
+ const wipLimit = column.wipLimit;
22
+ const hasWipLimit = typeof wipLimit === "number";
23
+ const overLimit = hasWipLimit ? itemCount > wipLimit : false;
24
+ const atLimit = hasWipLimit ? itemCount === wipLimit : false;
25
+ const hasRenderedChildren = React.Children.count(children) > 0;
26
+ const titleId = React.useId();
27
+ const { "aria-labelledby": ariaLabelledBy, ...sectionProps } = props;
28
+
29
+ let wipPercent = 0;
30
+ let wipStatusLabel = "";
31
+ let wipSeverity: "danger" | "warning" | "neutral" = "neutral";
32
+
33
+ if (hasWipLimit) {
34
+ wipPercent =
35
+ wipLimit > 0 ? Math.min(100, (itemCount / wipLimit) * 100) : itemCount > 0 ? 100 : 0;
36
+ const remaining = wipLimit - itemCount;
37
+ wipStatusLabel = overLimit
38
+ ? `${Math.abs(remaining)} over limit`
39
+ : remaining === 0
40
+ ? "At limit"
41
+ : `${remaining} ${remaining === 1 ? "slot" : "slots"} open`;
42
+ if (overLimit) wipSeverity = "danger";
43
+ else if (atLimit) wipSeverity = "warning";
44
+ }
45
+
46
+ return (
47
+ <section
48
+ {...sectionProps}
49
+ aria-label={ariaLabel}
50
+ aria-labelledby={ariaLabel ? undefined : (ariaLabelledBy ?? titleId)}
51
+ className={`eth-kanban-column ${overLimit ? "eth-kanban-column--over-limit" : ""} ${
52
+ className ?? ""
53
+ }`}
54
+ data-eth-component="KanbanColumn"
55
+ >
56
+ <header className="eth-kanban-column__header">
57
+ <div className="eth-kanban-column__header-main">
58
+ <div className="eth-kanban-column__heading">
59
+ <h3 id={titleId}>{column.title}</h3>
60
+ <span className="eth-kanban-column__count">{taskLabel}</span>
61
+ </div>
62
+ {onAddCard ? (
63
+ <div className="eth-kanban-column__actions">
64
+ <IconButton
65
+ className="eth-kanban-column__add"
66
+ intent="ghost"
67
+ density="compact"
68
+ label={`Add task to ${column.title}`}
69
+ icon={
70
+ <span className="eth-kanban-column__add-glyph" aria-hidden="true">
71
+ +
72
+ </span>
73
+ }
74
+ onClick={() => onAddCard(column.id)}
75
+ />
76
+ </div>
77
+ ) : null}
78
+ </div>
79
+ {hasWipLimit ? (
80
+ <div className="eth-kanban-column__metrics">
81
+ <div
82
+ className="eth-kanban-column__limit"
83
+ aria-label={`Work in progress ${itemCount} of ${wipLimit}`}
84
+ >
85
+ <div className="eth-kanban-column__limit-row">
86
+ <Badge severity={wipSeverity}>WIP {itemCount}/{wipLimit}</Badge>
87
+ <span className="eth-kanban-column__limit-note">{wipStatusLabel}</span>
88
+ </div>
89
+ <span className="eth-kanban-column__meter" aria-hidden="true">
90
+ <span style={{ inlineSize: `${wipPercent}%` }} />
91
+ </span>
92
+ </div>
93
+ </div>
94
+ ) : null}
95
+ </header>
96
+ <div className="eth-kanban-column__body" role="list" aria-label={`${column.title} tasks`}>
97
+ {hasRenderedChildren ? (
98
+ children
99
+ ) : itemCount === 0 ? (
100
+ <div className="eth-kanban-column__empty">No tasks in this column</div>
101
+ ) : null}
102
+ </div>
103
+ </section>
104
+ );
105
+ }
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+ import { Badge, Surface, StatusDot, Tag, type EthOperationalStatus } from "@echothink-ui/core";
3
+ import type { KanbanCard } from "../types";
4
+ import { formatDateTime, priorityLabel, prioritySeverity } from "../utils";
5
+
6
+ export interface TaskCardProps extends React.HTMLAttributes<HTMLElement> {
7
+ card: KanbanCard;
8
+ }
9
+
10
+ const operationalStatuses = new Set<string>([
11
+ "queued",
12
+ "running",
13
+ "paused",
14
+ "blocked",
15
+ "failed",
16
+ "succeeded",
17
+ "warning",
18
+ "stale",
19
+ "synced",
20
+ "pending-approval",
21
+ "approval-required",
22
+ "in-progress",
23
+ "not-started",
24
+ "completed",
25
+ "active",
26
+ "inactive"
27
+ ]);
28
+
29
+ function isOperationalStatus(status: string | undefined): status is EthOperationalStatus {
30
+ return Boolean(status && operationalStatuses.has(status));
31
+ }
32
+
33
+ function statusLabel(status: string) {
34
+ return status.replace(/-/g, " ");
35
+ }
36
+
37
+ export function TaskCard({ card, className, ...props }: TaskCardProps) {
38
+ return (
39
+ <Surface
40
+ {...props}
41
+ className={`eth-todo-task-card ${className ?? ""}`}
42
+ data-priority={card.priority}
43
+ data-status={card.status}
44
+ data-eth-component="TaskCard"
45
+ >
46
+ <div className="eth-todo-task-card__header">
47
+ <strong>{card.title}</strong>
48
+ {card.priority ? (
49
+ <Badge severity={prioritySeverity(card.priority)}>{priorityLabel(card.priority)}</Badge>
50
+ ) : null}
51
+ </div>
52
+ <div className="eth-todo-task-card__meta">
53
+ {card.assignee ? <Tag>{card.assignee}</Tag> : null}
54
+ {isOperationalStatus(card.status) ? (
55
+ <StatusDot status={card.status} label={statusLabel(card.status)} />
56
+ ) : card.status ? (
57
+ <Tag>{card.status}</Tag>
58
+ ) : null}
59
+ {card.dueAt ? (
60
+ <time className="eth-todo-task-card__due" dateTime={card.dueAt}>
61
+ {formatDateTime(card.dueAt)}
62
+ </time>
63
+ ) : null}
64
+ </div>
65
+ {card.labels?.length ? (
66
+ <div className="eth-todo-task-card__labels">
67
+ {card.labels.map((label) => (
68
+ <Tag key={label}>{label}</Tag>
69
+ ))}
70
+ </div>
71
+ ) : null}
72
+ </Surface>
73
+ );
74
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Badge, Panel } from "@echothink-ui/core";
3
+ import type { TaskDependency } from "../types";
4
+
5
+ export interface TaskDependencyListProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ dependencies?: TaskDependency[];
7
+ }
8
+
9
+ export function TaskDependencyList({
10
+ dependencies = [],
11
+ className,
12
+ ...props
13
+ }: TaskDependencyListProps) {
14
+ const groups = {
15
+ "blocked-by": dependencies.filter((dependency) => dependency.relation === "blocked-by"),
16
+ blocks: dependencies.filter((dependency) => dependency.relation === "blocks")
17
+ };
18
+
19
+ return (
20
+ <Panel
21
+ {...props}
22
+ className={`eth-todo-dependencies ${className ?? ""}`}
23
+ title="Dependencies"
24
+ data-eth-component="TaskDependencyList"
25
+ >
26
+ <DependencyGroup title="Blocked by" dependencies={groups["blocked-by"]} />
27
+ <DependencyGroup title="Blocks" dependencies={groups.blocks} />
28
+ </Panel>
29
+ );
30
+ }
31
+
32
+ function DependencyGroup({
33
+ title,
34
+ dependencies
35
+ }: {
36
+ title: string;
37
+ dependencies: TaskDependency[];
38
+ }) {
39
+ return (
40
+ <section className="eth-todo-dependencies__group">
41
+ <h3>{title}</h3>
42
+ {dependencies.length ? (
43
+ <ul>
44
+ {dependencies.map((dependency) => (
45
+ <li key={dependency.id}>
46
+ <span>{dependency.title}</span>
47
+ <Badge>{dependency.status}</Badge>
48
+ </li>
49
+ ))}
50
+ </ul>
51
+ ) : (
52
+ <p>None</p>
53
+ )}
54
+ </section>
55
+ );
56
+ }
@@ -0,0 +1,69 @@
1
+ import * as React from "react";
2
+ import { Badge, StatusDot, Tag } from "@echothink-ui/core";
3
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
4
+ import type { TaskRowActions, TaskTableTask } from "../types";
5
+ import { formatDateTime } from "../utils";
6
+
7
+ export interface TaskTableProps extends React.HTMLAttributes<HTMLDivElement> {
8
+ tasks?: TaskTableTask[];
9
+ density?: "compact" | "default" | "comfortable";
10
+ selectable?: boolean;
11
+ rowActions?: TaskRowActions;
12
+ }
13
+
14
+ const columns: DataColumn<TaskTableTask>[] = [
15
+ {
16
+ key: "title",
17
+ header: "Task",
18
+ render: (task) => (
19
+ <div className="eth-todo-task-table__title">
20
+ <strong>{task.title}</strong>
21
+ {task.labels?.map((label) => (
22
+ <Tag key={label}>{label}</Tag>
23
+ ))}
24
+ </div>
25
+ )
26
+ },
27
+ {
28
+ key: "status",
29
+ header: "Status",
30
+ render: (task) => (task.status ? <StatusDot status={task.status} label={task.status} /> : null)
31
+ },
32
+ { key: "assignee", header: "Assignee" },
33
+ {
34
+ key: "priority",
35
+ header: "Priority",
36
+ render: (task) => (task.priority ? <Badge>{task.priority}</Badge> : null)
37
+ },
38
+ {
39
+ key: "dueAt",
40
+ header: "Due",
41
+ render: (task) => (task.dueAt ? <time dateTime={task.dueAt}>{formatDateTime(task.dueAt)}</time> : null)
42
+ }
43
+ ];
44
+
45
+ export function TaskTable({
46
+ tasks = [],
47
+ density = "default",
48
+ selectable,
49
+ rowActions,
50
+ className,
51
+ ...props
52
+ }: TaskTableProps) {
53
+ return (
54
+ <div
55
+ {...props}
56
+ className={`eth-todo-task-table ${className ?? ""}`}
57
+ data-eth-component="TaskTable"
58
+ >
59
+ <DataTable
60
+ rows={tasks}
61
+ columns={columns}
62
+ rowKey="id"
63
+ density={density}
64
+ selectable={selectable}
65
+ rowActions={rowActions}
66
+ />
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,40 @@
1
+ import * as React from "react";
2
+ import { Panel, Tag } from "@echothink-ui/core";
3
+ import type { TaskTimelineEvent } from "../types";
4
+ import { formatDateTime } from "../utils";
5
+
6
+ export interface TaskTimelineProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ events?: TaskTimelineEvent[];
8
+ }
9
+
10
+ export function TaskTimeline({ events = [], className, ...props }: TaskTimelineProps) {
11
+ const sortedEvents = React.useMemo(
12
+ () =>
13
+ [...events].sort(
14
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
15
+ ),
16
+ [events]
17
+ );
18
+
19
+ return (
20
+ <Panel
21
+ {...props}
22
+ className={`eth-todo-timeline ${className ?? ""}`}
23
+ title="Task timeline"
24
+ data-eth-component="TaskTimeline"
25
+ >
26
+ <ol className="eth-todo-timeline__events">
27
+ {sortedEvents.map((event) => (
28
+ <li key={event.id} className="eth-todo-timeline__event">
29
+ <time dateTime={event.timestamp}>{formatDateTime(event.timestamp)}</time>
30
+ <div>
31
+ <Tag>{event.kind}</Tag>
32
+ {event.actor ? <span>{event.actor}</span> : null}
33
+ <p>{event.summary}</p>
34
+ </div>
35
+ </li>
36
+ ))}
37
+ </ol>
38
+ </Panel>
39
+ );
40
+ }
@@ -0,0 +1,46 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { TodoItem } from "./TodoItem";
4
+
5
+ describe("@echothink-ui/todo TodoItem", () => {
6
+ it("renders a compact actionable task row with structured metadata", () => {
7
+ const onToggle = vi.fn();
8
+
9
+ const { container } = render(
10
+ <TodoItem
11
+ item={{
12
+ id: "brief",
13
+ label: "Approve campaign brief",
14
+ done: false,
15
+ assignee: "MK",
16
+ priority: "high",
17
+ dueAt: "2026-05-30T16:00:00Z"
18
+ }}
19
+ onToggle={onToggle}
20
+ />
21
+ );
22
+
23
+ const row = container.querySelector('[data-eth-component="TodoItem"]');
24
+ const due = container.querySelector(".eth-todo-item__due time");
25
+
26
+ expect(row?.getAttribute("data-priority")).toBe("high");
27
+ expect(screen.getByRole("checkbox", { name: "Approve campaign brief" })).toBeTruthy();
28
+ expect(screen.getByText("MK")).toBeTruthy();
29
+ expect(screen.getByText("high").closest(".eth-badge")).toBeTruthy();
30
+ expect(due?.getAttribute("datetime")).toBe("2026-05-30T16:00:00Z");
31
+
32
+ fireEvent.click(screen.getByRole("checkbox", { name: "Approve campaign brief" }));
33
+ expect(onToggle).toHaveBeenCalledWith("brief");
34
+ });
35
+
36
+ it("does not emit an invalid datetime attribute for relative due labels", () => {
37
+ const { container } = render(
38
+ <TodoItem item={{ id: "relative", label: "Approve brief", done: false, dueAt: "today" }} />
39
+ );
40
+
41
+ const due = container.querySelector(".eth-todo-item__due time");
42
+
43
+ expect(due?.textContent).toBe("today");
44
+ expect(due?.hasAttribute("datetime")).toBe(false);
45
+ });
46
+ });