feat(FN-2578): merge fusion/fn-2578 (auto-resolved)
- fix(FN-2578): align TodoView placeholder addToast typing - feat(FN-2578): complete Step 4 — route and preload TodoView - feat(FN-2578): complete Step 3 — wire todos nav entries - feat(FN-2578): complete Step 2 — add todos to view state types - feat(FN-2578): complete Step 1 — add useTodoLists hook and tests
This commit is contained in:
@@ -65,6 +65,7 @@ const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) =>
|
||||
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
|
||||
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
||||
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||
|
||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
||||
@@ -83,6 +84,7 @@ function prefetchLazyViews() {
|
||||
void import("./components/SkillsView");
|
||||
void import("./components/MemoryView");
|
||||
void import("./components/DevServerView");
|
||||
void import("./components/TodoView");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -670,6 +672,16 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "todos") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<TodoView addToast={addToast} projectId={currentProject?.id} />
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "devserver" || taskView === "dev-server") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
||||
import "./ProjectSelector.css";
|
||||
@@ -185,8 +185,8 @@ export interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server") => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
|
||||
/** Whether to show the skills tab in the view toggle */
|
||||
showSkillsTab?: boolean;
|
||||
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
||||
@@ -834,7 +834,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "todos"].includes(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
@@ -922,6 +922,18 @@ export function Header({
|
||||
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("todos");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-todos"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Activity,
|
||||
Bot,
|
||||
Brain,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileCode,
|
||||
@@ -32,9 +33,9 @@ import { useViewportMode } from "./Header";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
/** Current task view mode */
|
||||
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
/** Change task view handler */
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server") => void;
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
|
||||
/** Whether the ExecutorStatusBar footer is visible */
|
||||
footerVisible: boolean;
|
||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||
@@ -186,6 +187,7 @@ export function MobileNavBar({
|
||||
|| view === "memory"
|
||||
|| view === "devserver"
|
||||
|| view === "dev-server"
|
||||
|| view === "todos"
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel);
|
||||
|
||||
@@ -582,6 +584,16 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-todos"
|
||||
onClick={() => handleMoreAction(() => onChangeView("todos"))}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
|
||||
<div className="mobile-more-separator" />
|
||||
|
||||
<button
|
||||
|
||||
5
packages/dashboard/app/components/TodoView.tsx
Normal file
5
packages/dashboard/app/components/TodoView.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
export function TodoView(_props: { addToast?: (msg: string, type?: ToastType) => void; projectId?: string }) {
|
||||
return null;
|
||||
}
|
||||
289
packages/dashboard/app/hooks/__tests__/useTodoLists.test.ts
Normal file
289
packages/dashboard/app/hooks/__tests__/useTodoLists.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
|
||||
import { useTodoLists } from "../useTodoLists";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTodoLists: vi.fn(),
|
||||
createTodoList: vi.fn(),
|
||||
updateTodoList: vi.fn(),
|
||||
deleteTodoList: vi.fn(),
|
||||
createTodoItem: vi.fn(),
|
||||
updateTodoItem: vi.fn(),
|
||||
deleteTodoItem: vi.fn(),
|
||||
reorderTodoItems: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchTodoLists,
|
||||
createTodoList,
|
||||
updateTodoList,
|
||||
deleteTodoList,
|
||||
createTodoItem,
|
||||
updateTodoItem,
|
||||
deleteTodoItem,
|
||||
reorderTodoItems,
|
||||
} from "../../api";
|
||||
|
||||
const mockFetchTodoLists = vi.mocked(fetchTodoLists);
|
||||
const mockCreateTodoList = vi.mocked(createTodoList);
|
||||
const mockUpdateTodoList = vi.mocked(updateTodoList);
|
||||
const mockDeleteTodoList = vi.mocked(deleteTodoList);
|
||||
const mockCreateTodoItem = vi.mocked(createTodoItem);
|
||||
const mockUpdateTodoItem = vi.mocked(updateTodoItem);
|
||||
const mockDeleteTodoItem = vi.mocked(deleteTodoItem);
|
||||
const mockReorderTodoItems = vi.mocked(reorderTodoItems);
|
||||
|
||||
function makeList(id: string, title: string, items: TodoItem[] = []): TodoListWithItems {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
title,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function makeItem(id: string, listId: string, text: string, completed = false, sortOrder = 0): TodoItem {
|
||||
return {
|
||||
id,
|
||||
listId,
|
||||
text,
|
||||
completed,
|
||||
completedAt: completed ? "2026-01-01T00:00:00.000Z" : null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTodoLists", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdateTodoList.mockResolvedValue({} as TodoList);
|
||||
mockDeleteTodoItem.mockResolvedValue();
|
||||
});
|
||||
|
||||
it("initial fetch populates lists", async () => {
|
||||
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox")]);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.lists).toHaveLength(1);
|
||||
expect(result.current.lists[0].title).toBe("Inbox");
|
||||
expect(result.current.selectedListId).toBe("list-1");
|
||||
});
|
||||
|
||||
it("selectedListId filters items", async () => {
|
||||
const listOneItems = [makeItem("item-1", "list-1", "One")];
|
||||
const listTwoItems = [makeItem("item-2", "list-2", "Two")];
|
||||
mockFetchTodoLists.mockResolvedValue([
|
||||
makeList("list-1", "Inbox", listOneItems),
|
||||
makeList("list-2", "Work", listTwoItems),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(["item-1"]);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedListId("list-2");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(["item-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("createList optimistically adds and rolls back on error", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox")]);
|
||||
|
||||
let rejectCreate: ((reason?: unknown) => void) | undefined;
|
||||
mockCreateTodoList.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectCreate = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let createPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
createPromise = result.current.createList("Urgent");
|
||||
});
|
||||
|
||||
expect(result.current.lists.some((list) => list.title === "Urgent")).toBe(true);
|
||||
|
||||
rejectCreate?.(new Error("create failed"));
|
||||
await act(async () => {
|
||||
await createPromise;
|
||||
});
|
||||
|
||||
expect(result.current.lists.map((list) => list.title)).toEqual(["Inbox"]);
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to create todo list", "error");
|
||||
});
|
||||
|
||||
it("deleteList optimistically removes and rolls back on error", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchTodoLists.mockResolvedValue([
|
||||
makeList("list-1", "Inbox"),
|
||||
makeList("list-2", "Work"),
|
||||
]);
|
||||
|
||||
let rejectDelete: ((reason?: unknown) => void) | undefined;
|
||||
mockDeleteTodoList.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectDelete = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let deletePromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
deletePromise = result.current.deleteList("list-1");
|
||||
});
|
||||
|
||||
expect(result.current.lists.map((list) => list.id)).toEqual(["list-2"]);
|
||||
|
||||
rejectDelete?.(new Error("delete failed"));
|
||||
await act(async () => {
|
||||
await deletePromise;
|
||||
});
|
||||
|
||||
expect(result.current.lists.map((list) => list.id)).toEqual(["list-1", "list-2"]);
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to delete todo list", "error");
|
||||
});
|
||||
|
||||
it("createItem optimistically adds to selected list items", async () => {
|
||||
const initialItems = [makeItem("item-1", "list-1", "Existing")];
|
||||
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", initialItems)]);
|
||||
|
||||
let resolveCreate: ((value: TodoItem) => void) | undefined;
|
||||
mockCreateTodoItem.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let createPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
createPromise = result.current.createItem("New item");
|
||||
});
|
||||
|
||||
expect(result.current.items).toHaveLength(2);
|
||||
expect(result.current.items[1].text).toBe("New item");
|
||||
|
||||
resolveCreate?.(makeItem("item-2", "list-1", "New item", false, 1));
|
||||
await act(async () => {
|
||||
await createPromise;
|
||||
});
|
||||
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(["item-1", "item-2"]);
|
||||
});
|
||||
|
||||
it("toggleItem flips completed and rolls back on error", async () => {
|
||||
const addToast = vi.fn();
|
||||
const item = makeItem("item-1", "list-1", "Toggle me", false, 0);
|
||||
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", [item])]);
|
||||
|
||||
let rejectUpdate: ((reason?: unknown) => void) | undefined;
|
||||
mockUpdateTodoItem.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectUpdate = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let togglePromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
togglePromise = result.current.toggleItem("item-1");
|
||||
});
|
||||
|
||||
expect(result.current.items[0].completed).toBe(true);
|
||||
|
||||
rejectUpdate?.(new Error("toggle failed"));
|
||||
await act(async () => {
|
||||
await togglePromise;
|
||||
});
|
||||
|
||||
expect(result.current.items[0].completed).toBe(false);
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to update todo item", "error");
|
||||
});
|
||||
|
||||
it("reorderItems optimistically reorders", async () => {
|
||||
const itemOne = makeItem("item-1", "list-1", "One", false, 0);
|
||||
const itemTwo = makeItem("item-2", "list-1", "Two", false, 1);
|
||||
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", [itemOne, itemTwo])]);
|
||||
|
||||
let resolveReorder: (() => void) | undefined;
|
||||
mockReorderTodoItems.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveReorder = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let reorderPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
reorderPromise = result.current.reorderItems(["item-2", "item-1"]);
|
||||
});
|
||||
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
||||
|
||||
resolveReorder?.();
|
||||
await act(async () => {
|
||||
await reorderPromise;
|
||||
});
|
||||
|
||||
expect(mockReorderTodoItems).toHaveBeenCalledWith("list-1", ["item-2", "item-1"], "project-1");
|
||||
});
|
||||
|
||||
it("sets error state on fetch failure", async () => {
|
||||
mockFetchTodoLists.mockRejectedValue(new Error("fetch failed"));
|
||||
|
||||
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("fetch failed");
|
||||
expect(result.current.lists).toEqual([]);
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
376
packages/dashboard/app/hooks/useTodoLists.ts
Normal file
376
packages/dashboard/app/hooks/useTodoLists.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
|
||||
import {
|
||||
fetchTodoLists,
|
||||
createTodoList,
|
||||
updateTodoList,
|
||||
deleteTodoList,
|
||||
createTodoItem,
|
||||
updateTodoItem,
|
||||
deleteTodoItem,
|
||||
reorderTodoItems,
|
||||
} from "../api";
|
||||
|
||||
type ToastType = "info" | "success" | "error" | "warning";
|
||||
|
||||
export interface UseTodoListsOptions {
|
||||
projectId?: string;
|
||||
addToast?: (message: string, type?: ToastType | string) => void;
|
||||
}
|
||||
|
||||
export interface UseTodoListsResult {
|
||||
lists: TodoList[];
|
||||
items: TodoItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
selectedListId: string | null;
|
||||
setSelectedListId: (id: string | null) => void;
|
||||
createList: (title: string) => Promise<void>;
|
||||
renameList: (id: string, title: string) => Promise<void>;
|
||||
deleteList: (id: string) => Promise<void>;
|
||||
createItem: (text: string) => Promise<void>;
|
||||
toggleItem: (id: string) => Promise<void>;
|
||||
deleteItem: (id: string) => Promise<void>;
|
||||
reorderItems: (itemIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function toList(listWithItems: TodoListWithItems): TodoList {
|
||||
const { items: _items, ...list } = listWithItems;
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildTempId(prefix: string): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsResult {
|
||||
const { projectId, addToast } = options;
|
||||
|
||||
const [lists, setLists] = useState<TodoList[]>([]);
|
||||
const [items, setItems] = useState<TodoItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||
const [listData, setListData] = useState<TodoListWithItems[]>([]);
|
||||
|
||||
const selectedListIdRef = useRef<string | null>(selectedListId);
|
||||
selectedListIdRef.current = selectedListId;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLists() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchTodoLists(projectId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setListData(data);
|
||||
setLists(data.map(toList));
|
||||
|
||||
const activeListId =
|
||||
selectedListIdRef.current && data.some((list) => list.id === selectedListIdRef.current)
|
||||
? selectedListIdRef.current
|
||||
: (data[0]?.id ?? null);
|
||||
|
||||
setSelectedListId(activeListId);
|
||||
setItems(activeListId ? (data.find((list) => list.id === activeListId)?.items ?? []) : []);
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setListData([]);
|
||||
setLists([]);
|
||||
setItems([]);
|
||||
setSelectedListId(null);
|
||||
setError(err instanceof Error ? err.message : "Failed to load todo lists");
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadLists();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedListId) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = listData.find((list) => list.id === selectedListId);
|
||||
setItems(selected?.items ?? []);
|
||||
}, [listData, selectedListId]);
|
||||
|
||||
const createListAction = useCallback(async (title: string) => {
|
||||
const previousLists = lists;
|
||||
const previousListData = listData;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const tempList: TodoList = {
|
||||
id: buildTempId("temp-list"),
|
||||
projectId: projectId ?? "",
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
setError(null);
|
||||
setLists((prev) => [...prev, tempList]);
|
||||
setListData((prev) => [...prev, { ...tempList, items: [] }]);
|
||||
|
||||
try {
|
||||
const created = await createTodoList(title, projectId);
|
||||
setLists((prev) => prev.map((list) => (list.id === tempList.id ? created : list)));
|
||||
setListData((prev) => prev.map((list) => (list.id === tempList.id ? { ...created, items: [] } : list)));
|
||||
if (!selectedListIdRef.current) {
|
||||
setSelectedListId(created.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setLists(previousLists);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to create list");
|
||||
addToast?.("Failed to create todo list", "error");
|
||||
}
|
||||
}, [addToast, listData, lists, projectId]);
|
||||
|
||||
const renameListAction = useCallback(async (id: string, title: string) => {
|
||||
const previousLists = lists;
|
||||
const previousListData = listData;
|
||||
|
||||
setError(null);
|
||||
setLists((prev) => prev.map((list) => (list.id === id ? { ...list, title } : list)));
|
||||
setListData((prev) => prev.map((list) => (list.id === id ? { ...list, title } : list)));
|
||||
|
||||
try {
|
||||
const updated = await updateTodoList(id, title, projectId);
|
||||
setLists((prev) => prev.map((list) => (list.id === id ? updated : list)));
|
||||
setListData((prev) => prev.map((list) => (list.id === id ? { ...updated, items: list.items } : list)));
|
||||
} catch (err) {
|
||||
setLists(previousLists);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to rename list");
|
||||
addToast?.("Failed to rename todo list", "error");
|
||||
}
|
||||
}, [addToast, listData, lists, projectId]);
|
||||
|
||||
const deleteListAction = useCallback(async (id: string) => {
|
||||
const previousLists = lists;
|
||||
const previousListData = listData;
|
||||
const previousSelectedListId = selectedListIdRef.current;
|
||||
const nextSelectedListId =
|
||||
previousSelectedListId === id ? (lists.find((list) => list.id !== id)?.id ?? null) : previousSelectedListId;
|
||||
|
||||
setError(null);
|
||||
setLists((prev) => prev.filter((list) => list.id !== id));
|
||||
setListData((prev) => prev.filter((list) => list.id !== id));
|
||||
if (previousSelectedListId === id) {
|
||||
setSelectedListId(nextSelectedListId);
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTodoList(id, projectId);
|
||||
} catch (err) {
|
||||
setLists(previousLists);
|
||||
setListData(previousListData);
|
||||
setSelectedListId(previousSelectedListId);
|
||||
setError(err instanceof Error ? err.message : "Failed to delete list");
|
||||
addToast?.("Failed to delete todo list", "error");
|
||||
}
|
||||
}, [addToast, listData, lists, projectId]);
|
||||
|
||||
const createItemAction = useCallback(async (text: string) => {
|
||||
const listId = selectedListIdRef.current;
|
||||
if (!listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousItems = items;
|
||||
const previousListData = listData;
|
||||
const now = new Date().toISOString();
|
||||
const maxSortOrder = items.reduce((max, item) => Math.max(max, item.sortOrder), -1);
|
||||
|
||||
const tempItem: TodoItem = {
|
||||
id: buildTempId("temp-item"),
|
||||
listId,
|
||||
text,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sortOrder: maxSortOrder + 1,
|
||||
};
|
||||
|
||||
setError(null);
|
||||
setItems((prev) => [...prev, tempItem]);
|
||||
setListData((prev) =>
|
||||
prev.map((list) =>
|
||||
list.id === listId
|
||||
? { ...list, items: [...list.items, tempItem] }
|
||||
: list,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const created = await createTodoItem(listId, text, projectId);
|
||||
setItems((prev) => prev.map((item) => (item.id === tempItem.id ? created : item)));
|
||||
setListData((prev) =>
|
||||
prev.map((list) =>
|
||||
list.id === listId
|
||||
? { ...list, items: list.items.map((item) => (item.id === tempItem.id ? created : item)) }
|
||||
: list,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setItems(previousItems);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to create item");
|
||||
addToast?.("Failed to create todo item", "error");
|
||||
}
|
||||
}, [addToast, items, listData, projectId]);
|
||||
|
||||
const toggleItemAction = useCallback(async (id: string) => {
|
||||
const target = items.find((item) => item.id === id);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousItems = items;
|
||||
const previousListData = listData;
|
||||
const nextCompleted = !target.completed;
|
||||
const optimisticItem: TodoItem = {
|
||||
...target,
|
||||
completed: nextCompleted,
|
||||
completedAt: nextCompleted ? new Date().toISOString() : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setError(null);
|
||||
setItems((prev) => prev.map((item) => (item.id === id ? optimisticItem : item)));
|
||||
setListData((prev) =>
|
||||
prev.map((list) =>
|
||||
list.id === optimisticItem.listId
|
||||
? { ...list, items: list.items.map((item) => (item.id === id ? optimisticItem : item)) }
|
||||
: list,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const updated = await updateTodoItem(
|
||||
id,
|
||||
{
|
||||
completed: nextCompleted,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
|
||||
setListData((prev) =>
|
||||
prev.map((list) =>
|
||||
list.id === updated.listId
|
||||
? { ...list, items: list.items.map((item) => (item.id === id ? updated : item)) }
|
||||
: list,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setItems(previousItems);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to toggle item");
|
||||
addToast?.("Failed to update todo item", "error");
|
||||
}
|
||||
}, [addToast, items, listData, projectId]);
|
||||
|
||||
const deleteItemAction = useCallback(async (id: string) => {
|
||||
const previousItems = items;
|
||||
const previousListData = listData;
|
||||
|
||||
setError(null);
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
setListData((prev) =>
|
||||
prev.map((list) => ({
|
||||
...list,
|
||||
items: list.items.filter((item) => item.id !== id),
|
||||
})),
|
||||
);
|
||||
|
||||
try {
|
||||
await deleteTodoItem(id, projectId);
|
||||
} catch (err) {
|
||||
setItems(previousItems);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to delete item");
|
||||
addToast?.("Failed to delete todo item", "error");
|
||||
}
|
||||
}, [addToast, items, listData, projectId]);
|
||||
|
||||
const reorderItemsAction = useCallback(async (itemIds: string[]) => {
|
||||
const listId = selectedListIdRef.current;
|
||||
if (!listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousItems = items;
|
||||
const previousListData = listData;
|
||||
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
const reorderedItems = itemIds
|
||||
.map((itemId, index) => {
|
||||
const existing = byId.get(itemId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
sortOrder: index,
|
||||
};
|
||||
})
|
||||
.filter((item): item is TodoItem => item !== null);
|
||||
|
||||
setError(null);
|
||||
setItems(reorderedItems);
|
||||
setListData((prev) =>
|
||||
prev.map((list) =>
|
||||
list.id === listId
|
||||
? { ...list, items: reorderedItems }
|
||||
: list,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await reorderTodoItems(listId, itemIds, projectId);
|
||||
} catch (err) {
|
||||
setItems(previousItems);
|
||||
setListData(previousListData);
|
||||
setError(err instanceof Error ? err.message : "Failed to reorder items");
|
||||
addToast?.("Failed to reorder todo items", "error");
|
||||
}
|
||||
}, [addToast, items, listData, projectId]);
|
||||
|
||||
return {
|
||||
lists,
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
selectedListId,
|
||||
setSelectedListId,
|
||||
createList: createListAction,
|
||||
renameList: renameListAction,
|
||||
deleteList: deleteListAction,
|
||||
createItem: createItemAction,
|
||||
toggleItem: toggleItemAction,
|
||||
deleteItem: deleteItemAction,
|
||||
reorderItems: reorderItemsAction,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
|
||||
const TASK_VIEWS: readonly TaskView[] = [
|
||||
"board",
|
||||
@@ -20,6 +20,7 @@ const TASK_VIEWS: readonly TaskView[] = [
|
||||
"memory",
|
||||
"devserver",
|
||||
"dev-server",
|
||||
"todos",
|
||||
];
|
||||
|
||||
function isTaskView(value: string | null): value is TaskView {
|
||||
|
||||
Reference in New Issue
Block a user