diff --git a/apps/web/src/app/(authenticated)/tasks/page.test.tsx b/apps/web/src/app/(authenticated)/tasks/page.test.tsx
index a0c9966..bcc7b75 100644
--- a/apps/web/src/app/(authenticated)/tasks/page.test.tsx
+++ b/apps/web/src/app/(authenticated)/tasks/page.test.tsx
@@ -1,5 +1,8 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Task } from "@mosaic/shared";
+import { TaskStatus, TaskPriority } from "@mosaic/shared";
import TasksPage from "./page";
// Mock the TaskList component
@@ -9,21 +12,121 @@ vi.mock("@/components/tasks/TaskList", () => ({
),
}));
+// Mock MosaicSpinner
+vi.mock("@/components/ui/MosaicSpinner", () => ({
+ MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
+
{label ?? "Loading..."}
+ ),
+}));
+
+// Mock useWorkspaceId
+const mockUseWorkspaceId = vi.fn<() => string | null>();
+vi.mock("@/lib/hooks", () => ({
+ useWorkspaceId: (): string | null => mockUseWorkspaceId(),
+}));
+
+// Mock fetchTasks
+const mockFetchTasks = vi.fn<() => Promise>();
+vi.mock("@/lib/api/tasks", () => ({
+ fetchTasks: (...args: unknown[]): Promise => mockFetchTasks(...(args as [])),
+}));
+
+const fakeTasks: Task[] = [
+ {
+ id: "task-1",
+ title: "Test task 1",
+ description: "Description 1",
+ status: TaskStatus.IN_PROGRESS,
+ priority: TaskPriority.HIGH,
+ dueDate: new Date("2026-02-01"),
+ creatorId: "user-1",
+ assigneeId: "user-1",
+ workspaceId: "ws-1",
+ projectId: null,
+ parentId: null,
+ sortOrder: 0,
+ metadata: {},
+ completedAt: null,
+ createdAt: new Date("2026-01-28"),
+ updatedAt: new Date("2026-01-28"),
+ },
+ {
+ id: "task-2",
+ title: "Test task 2",
+ description: "Description 2",
+ status: TaskStatus.NOT_STARTED,
+ priority: TaskPriority.MEDIUM,
+ dueDate: new Date("2026-02-02"),
+ creatorId: "user-1",
+ assigneeId: "user-1",
+ workspaceId: "ws-1",
+ projectId: null,
+ parentId: null,
+ sortOrder: 1,
+ metadata: {},
+ completedAt: null,
+ createdAt: new Date("2026-01-28"),
+ updatedAt: new Date("2026-01-28"),
+ },
+];
+
describe("TasksPage", (): void => {
+ beforeEach((): void => {
+ vi.clearAllMocks();
+ mockUseWorkspaceId.mockReturnValue("ws-1");
+ mockFetchTasks.mockResolvedValue(fakeTasks);
+ });
+
it("should render the page title", (): void => {
render();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
});
- it("should show loading state initially", (): void => {
+ it("should show loading spinner initially", (): void => {
+ // Never resolve so we stay in loading state
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ mockFetchTasks.mockReturnValue(new Promise(() => {}));
render();
- expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
+ expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
});
it("should render the TaskList with tasks after loading", async (): Promise => {
render();
await waitFor((): void => {
- expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
+ expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
+ });
+ });
+
+ it("should show empty state when no tasks exist", async (): Promise => {
+ mockFetchTasks.mockResolvedValue([]);
+ render();
+ await waitFor((): void => {
+ expect(screen.getByText("No tasks found")).toBeInTheDocument();
+ });
+ });
+
+ it("should show error state on API failure", async (): Promise => {
+ mockFetchTasks.mockRejectedValue(new Error("Network error"));
+ render();
+ await waitFor((): void => {
+ expect(screen.getByText("Network error")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
+ });
+
+ it("should retry fetching on retry button click", async (): Promise => {
+ mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
+ render();
+ await waitFor((): void => {
+ expect(screen.getByText("Network error")).toBeInTheDocument();
+ });
+
+ mockFetchTasks.mockResolvedValueOnce(fakeTasks);
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: /try again/i }));
+
+ await waitFor((): void => {
+ expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
});
});
@@ -37,4 +140,14 @@ describe("TasksPage", (): void => {
render();
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
});
+
+ it("should not fetch when workspace ID is not available", async (): Promise => {
+ mockUseWorkspaceId.mockReturnValue(null);
+ render();
+
+ // Wait a tick to ensure useEffect ran
+ await waitFor((): void => {
+ expect(mockFetchTasks).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx
index 6873ce1..a22a2c6 100644
--- a/apps/web/src/app/(authenticated)/tasks/page.tsx
+++ b/apps/web/src/app/(authenticated)/tasks/page.tsx
@@ -4,57 +4,123 @@ import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { TaskList } from "@/components/tasks/TaskList";
-import { mockTasks } from "@/lib/api/tasks";
+import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
+import { fetchTasks } from "@/lib/api/tasks";
+import { useWorkspaceId } from "@/lib/hooks";
import type { Task } from "@mosaic/shared";
export default function TasksPage(): ReactElement {
+ const workspaceId = useWorkspaceId();
const [tasks, setTasks] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
- void loadTasks();
- }, []);
-
- async function loadTasks(): Promise {
- setIsLoading(true);
- setError(null);
-
- try {
- // TODO: Replace with real API call when backend is ready
- // const data = await fetchTasks();
- await new Promise((resolve) => setTimeout(resolve, 300));
- setTasks(mockTasks);
- } catch (err) {
- setError(
- err instanceof Error
- ? err.message
- : "We had trouble loading your tasks. Please try again when you're ready."
- );
- } finally {
+ if (!workspaceId) {
setIsLoading(false);
+ return;
}
+
+ let cancelled = false;
+ setError(null);
+ setIsLoading(true);
+
+ async function loadTasks(): Promise {
+ try {
+ const filters = workspaceId !== null ? { workspaceId } : {};
+ const data = await fetchTasks(filters);
+ if (!cancelled) {
+ setTasks(data);
+ }
+ } catch (err: unknown) {
+ console.error("[Tasks] Failed to fetch tasks:", err);
+ if (!cancelled) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "We had trouble loading your tasks. Please try again when you're ready."
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void loadTasks();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ function handleRetry(): void {
+ if (!workspaceId) return;
+ setError(null);
+ setIsLoading(true);
+
+ fetchTasks({ workspaceId })
+ .then((data) => {
+ setTasks(data);
+ })
+ .catch((err: unknown) => {
+ console.error("[Tasks] Retry failed:", err);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "We had trouble loading your tasks. Please try again when you're ready."
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
}
return (
-
Tasks
-
Organize your work at your own pace
+
+ Tasks
+
+
+ Organize your work at your own pace
+
- {error !== null ? (
-
-
{error}
+ {isLoading ? (
+
+
+
+ ) : error !== null ? (
+
+
{error}
+ ) : tasks.length === 0 ? (
+
) : (
-
+
)}
);
diff --git a/apps/web/src/lib/api/tasks.ts b/apps/web/src/lib/api/tasks.ts
index db01403..9074b59 100644
--- a/apps/web/src/lib/api/tasks.ts
+++ b/apps/web/src/lib/api/tasks.ts
@@ -4,7 +4,7 @@
*/
import type { Task } from "@mosaic/shared";
-import { TaskStatus, TaskPriority } from "@mosaic/shared";
+import type { TaskStatus, TaskPriority } from "@mosaic/shared";
import { apiGet, type ApiResponse } from "./client";
export interface TaskFilters {
@@ -34,81 +34,3 @@ export async function fetchTasks(filters?: TaskFilters): Promise
{
const response = await apiGet>(endpoint, filters?.workspaceId);
return response.data;
}
-
-/**
- * Mock tasks for development (until backend endpoints are ready)
- */
-export const mockTasks: Task[] = [
- {
- id: "task-1",
- title: "Review pull request",
- description: "Review and provide feedback on frontend PR",
- status: TaskStatus.IN_PROGRESS,
- priority: TaskPriority.HIGH,
- dueDate: new Date("2026-01-29"),
- creatorId: "user-1",
- assigneeId: "user-1",
- workspaceId: "workspace-1",
- projectId: null,
- parentId: null,
- sortOrder: 0,
- metadata: {},
- completedAt: null,
- createdAt: new Date("2026-01-28"),
- updatedAt: new Date("2026-01-28"),
- },
- {
- id: "task-2",
- title: "Update documentation",
- description: "Add setup instructions for new developers",
- status: TaskStatus.IN_PROGRESS,
- priority: TaskPriority.MEDIUM,
- dueDate: new Date("2026-01-30"),
- creatorId: "user-1",
- assigneeId: "user-1",
- workspaceId: "workspace-1",
- projectId: null,
- parentId: null,
- sortOrder: 1,
- metadata: {},
- completedAt: null,
- createdAt: new Date("2026-01-28"),
- updatedAt: new Date("2026-01-28"),
- },
- {
- id: "task-3",
- title: "Plan Q1 roadmap",
- description: "Define priorities for Q1 2026",
- status: TaskStatus.NOT_STARTED,
- priority: TaskPriority.HIGH,
- dueDate: new Date("2026-02-03"),
- creatorId: "user-1",
- assigneeId: "user-1",
- workspaceId: "workspace-1",
- projectId: null,
- parentId: null,
- sortOrder: 2,
- metadata: {},
- completedAt: null,
- createdAt: new Date("2026-01-28"),
- updatedAt: new Date("2026-01-28"),
- },
- {
- id: "task-4",
- title: "Research new libraries",
- description: "Evaluate options for state management",
- status: TaskStatus.PAUSED,
- priority: TaskPriority.LOW,
- dueDate: new Date("2026-02-10"),
- creatorId: "user-1",
- assigneeId: "user-1",
- workspaceId: "workspace-1",
- projectId: null,
- parentId: null,
- sortOrder: 3,
- metadata: {},
- completedAt: null,
- createdAt: new Date("2026-01-28"),
- updatedAt: new Date("2026-01-28"),
- },
-];