feat(#37-41): Add domains, ideas, relationships, agents, widgets schema
Schema additions for issues #37-41: New models: - Domain (#37): Life domains (work, marriage, homelab, etc.) - Idea (#38): Brain dumps with pgvector embeddings - Relationship (#39): Generic entity linking (blocks, depends_on) - Agent (#40): ClawdBot agent tracking with metrics - AgentSession (#40): Conversation session tracking - WidgetDefinition (#41): HUD widget registry - UserLayout (#41): Per-user dashboard configuration Updated models: - Task, Event, Project: Added domainId foreign key - User, Workspace: Added new relations New enums: - IdeaStatus: CAPTURED, PROCESSING, ACTIONABLE, ARCHIVED, DISCARDED - RelationshipType: BLOCKS, BLOCKED_BY, DEPENDS_ON, etc. - AgentStatus: IDLE, WORKING, WAITING, ERROR, TERMINATED - EntityType: Added IDEA, DOMAIN Migration: 20260129182803_add_domains_ideas_agents_widgets
This commit is contained in:
45
apps/web/src/components/auth/LoginButton.test.tsx
Normal file
45
apps/web/src/components/auth/LoginButton.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LoginButton } from "./LoginButton";
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
href: "",
|
||||
assign: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "location", {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("LoginButton", () => {
|
||||
beforeEach(() => {
|
||||
mockLocation.href = "";
|
||||
mockLocation.assign.mockClear();
|
||||
});
|
||||
|
||||
it("should render sign in button", () => {
|
||||
render(<LoginButton />);
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to OIDC endpoint on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
await user.click(button);
|
||||
|
||||
expect(mockLocation.assign).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/auth/callback/authentik"
|
||||
);
|
||||
});
|
||||
|
||||
it("should have proper styling", () => {
|
||||
render(<LoginButton />);
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
expect(button).toHaveClass("w-full");
|
||||
});
|
||||
});
|
||||
19
apps/web/src/components/auth/LoginButton.tsx
Normal file
19
apps/web/src/components/auth/LoginButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mosaic/ui";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
export function LoginButton() {
|
||||
const handleLogin = () => {
|
||||
// Redirect to the backend OIDC authentication endpoint
|
||||
// BetterAuth will handle the OIDC flow and redirect back to the callback
|
||||
window.location.assign(`${API_URL}/auth/callback/authentik`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="primary" onClick={handleLogin} className="w-full">
|
||||
Sign In with Authentik
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/auth/LogoutButton.test.tsx
Normal file
83
apps/web/src/components/auth/LogoutButton.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LogoutButton } from "./LogoutButton";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LogoutButton", () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockSignOut.mockClear();
|
||||
});
|
||||
|
||||
it("should render sign out button", () => {
|
||||
render(<LogoutButton />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call signOut and redirect on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSignOut.mockResolvedValue(undefined);
|
||||
|
||||
render(<LogoutButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignOut).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to login even if signOut fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSignOut.mockRejectedValue(new Error("Sign out failed"));
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(<LogoutButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignOut).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should have secondary variant by default", () => {
|
||||
render(<LogoutButton />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
// The Button component from @mosaic/ui should apply the variant
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept custom variant prop", () => {
|
||||
render(<LogoutButton variant="primary" />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
31
apps/web/src/components/auth/LogoutButton.tsx
Normal file
31
apps/web/src/components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, type ButtonProps } from "@mosaic/ui";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
interface LogoutButtonProps {
|
||||
variant?: ButtonProps["variant"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoutButton({ variant = "secondary", className }: LogoutButtonProps) {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
console.error("Sign out error:", error);
|
||||
} finally {
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant={variant} onClick={handleSignOut} className={className}>
|
||||
Sign Out
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/calendar/Calendar.tsx
Normal file
64
apps/web/src/components/calendar/Calendar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { getDateGroupLabel } from "@/lib/utils/date-format";
|
||||
|
||||
interface CalendarProps {
|
||||
events: Event[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function Calendar({ events, isLoading }: CalendarProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading calendar...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No events scheduled</p>
|
||||
<p className="text-sm mt-2">Your calendar is clear</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group events by date
|
||||
const groupedEvents = events.reduce((groups, event) => {
|
||||
const label = getDateGroupLabel(event.startTime);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label].push(event);
|
||||
return groups;
|
||||
}, {} as Record<string, Event[]>);
|
||||
|
||||
const groupOrder = ["Today", "Tomorrow", "This Week", "Next Week", "Later"];
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
{groupOrder.map((groupLabel) => {
|
||||
const groupEvents = groupedEvents[groupLabel];
|
||||
if (!groupEvents || groupEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={groupLabel}>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-3">
|
||||
{groupLabel}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{groupEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/calendar/EventCard.tsx
Normal file
32
apps/web/src/components/calendar/EventCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { formatTime } from "@/lib/utils/date-format";
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg border-l-4 border-blue-500 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{event.title}</h3>
|
||||
{event.allDay ? (
|
||||
<span className="text-xs text-gray-500 px-2 py-1 bg-gray-100 rounded">
|
||||
All day
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(event.startTime)}
|
||||
{event.endTime && ` - ${formatTime(event.endTime)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{event.description}</p>
|
||||
)}
|
||||
{event.location && (
|
||||
<p className="text-xs text-gray-500">📍 {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/error-boundary.test.tsx
Normal file
114
apps/web/src/components/error-boundary.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
|
||||
// Component that throws an error for testing
|
||||
function ThrowError({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) {
|
||||
throw new Error("Test error");
|
||||
}
|
||||
return <div>No error</div>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
// Suppress console.error for these tests
|
||||
const originalError = console.error;
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it("should render children when there is no error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error UI when child throws error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Should show PDA-friendly message, not harsh "error" language
|
||||
expect(screen.getByText(/something unexpected happened/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language without demanding words", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const errorText = screen.getByText(/something unexpected happened/i).textContent || "";
|
||||
|
||||
// Should NOT contain demanding/harsh words
|
||||
expect(errorText.toLowerCase()).not.toMatch(/error|critical|urgent|must|required/);
|
||||
});
|
||||
|
||||
it("should provide a reload option", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const reloadButton = screen.getByRole("button", { name: /refresh/i });
|
||||
expect(reloadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reload page when reload button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { reload: mockReload },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const reloadButton = screen.getByRole("button", { name: /refresh/i });
|
||||
await user.click(reloadButton);
|
||||
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should provide a way to go back home", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const homeLink = screen.getByRole("link", { name: /home/i });
|
||||
expect(homeLink).toBeInTheDocument();
|
||||
expect(homeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("should have calm, non-alarming visual design", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const container = screen.getByText(/something unexpected happened/i).closest("div");
|
||||
|
||||
// Should not have aggressive red colors (check for calm colors)
|
||||
const className = container?.className || "";
|
||||
expect(className).not.toMatch(/bg-red-|text-red-/);
|
||||
});
|
||||
});
|
||||
116
apps/web/src/components/error-boundary.tsx
Normal file
116
apps/web/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component for graceful error handling
|
||||
* Uses PDA-friendly language and calm visual design
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log to console for debugging (could also send to error tracking service)
|
||||
console.error("Component error:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
{/* Icon - calm blue instead of alarming red */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-blue-100 p-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message - PDA-friendly, no harsh language */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Something unexpected happened
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
The page ran into an issue while loading. You can try refreshing
|
||||
or head back home to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Refresh page
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Technical details in dev mode */}
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-8 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-gray-600 bg-gray-100 p-3 rounded overflow-auto max-h-40">
|
||||
{this.state.error.message}
|
||||
{"\n\n"}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
56
apps/web/src/components/layout/Navigation.tsx
Normal file
56
apps/web/src/components/layout/Navigation.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/tasks", label: "Tasks" },
|
||||
{ href: "/calendar", label: "Calendar" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/tasks" className="text-xl font-bold text-gray-900">
|
||||
Mosaic Stack
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
)}
|
||||
<LogoutButton variant="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
227
apps/web/src/components/tasks/TaskItem.test.tsx
Normal file
227
apps/web/src/components/tasks/TaskItem.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||
|
||||
describe("TaskItem", () => {
|
||||
const baseTask: Task = {
|
||||
id: "task-1",
|
||||
title: "Test task",
|
||||
description: "Task description",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
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"),
|
||||
};
|
||||
|
||||
it("should render task title", () => {
|
||||
render(<TaskItem task={baseTask} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render task description when present", () => {
|
||||
render(<TaskItem task={baseTask} />);
|
||||
expect(screen.getByText("Task description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for active task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.IN_PROGRESS }} />);
|
||||
expect(screen.getByText("🟢")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for not started task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.NOT_STARTED }} />);
|
||||
expect(screen.getByText("⚪")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for paused task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.PAUSED }} />);
|
||||
expect(screen.getByText("⏸️")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display priority badge", () => {
|
||||
render(<TaskItem task={{ ...baseTask, priority: TaskPriority.HIGH }} />);
|
||||
expect(screen.getByText("High priority")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not use demanding language", () => {
|
||||
const { container } = render(<TaskItem task={baseTask} />);
|
||||
const text = container.textContent;
|
||||
expect(text).not.toMatch(/overdue/i);
|
||||
expect(text).not.toMatch(/urgent/i);
|
||||
expect(text).not.toMatch(/must/i);
|
||||
expect(text).not.toMatch(/critical/i);
|
||||
});
|
||||
|
||||
it("should show 'Target passed' for past due dates", () => {
|
||||
const pastTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("2026-01-27"), // Past date
|
||||
};
|
||||
render(<TaskItem task={pastTask} />);
|
||||
expect(screen.getByText(/target passed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Approaching target' for near due dates", () => {
|
||||
const soonTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date(Date.now() + 12 * 60 * 60 * 1000), // 12 hours from now
|
||||
};
|
||||
render(<TaskItem task={soonTask} />);
|
||||
expect(screen.getByText(/approaching target/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("error states", () => {
|
||||
it("should handle task with missing title", () => {
|
||||
const taskWithoutTitle = {
|
||||
...baseTask,
|
||||
title: "",
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithoutTitle} />);
|
||||
// Should render without crashing, even with empty title
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing description", () => {
|
||||
const taskWithoutDescription = {
|
||||
...baseTask,
|
||||
description: null,
|
||||
};
|
||||
|
||||
render(<TaskItem task={taskWithoutDescription} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
// Description paragraph should not be rendered when null
|
||||
expect(screen.queryByText("Task description")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid status", () => {
|
||||
const taskWithInvalidStatus = {
|
||||
...baseTask,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
status: "invalid-status" as any,
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidStatus} />);
|
||||
// Should render without crashing even with invalid status
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid priority", () => {
|
||||
const taskWithInvalidPriority = {
|
||||
...baseTask,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
priority: "invalid-priority" as any,
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidPriority} />);
|
||||
// Should render without crashing even with invalid priority
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing dueDate", () => {
|
||||
const taskWithoutDueDate = {
|
||||
...baseTask,
|
||||
dueDate: null,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskItem task={taskWithoutDueDate as any} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid dueDate", () => {
|
||||
const taskWithInvalidDate = {
|
||||
...baseTask,
|
||||
dueDate: new Date("invalid-date"),
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidDate} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with very long title", () => {
|
||||
const longTitle = "A".repeat(500);
|
||||
const taskWithLongTitle = {
|
||||
...baseTask,
|
||||
title: longTitle,
|
||||
};
|
||||
|
||||
render(<TaskItem task={taskWithLongTitle} />);
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with special characters in title", () => {
|
||||
const taskWithSpecialChars = {
|
||||
...baseTask,
|
||||
title: '<img src="x" onerror="alert(1)">',
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithSpecialChars} />);
|
||||
// Should render escaped HTML entities, not execute
|
||||
// React escapes to <img... > which is safe
|
||||
expect(container.innerHTML).toContain("<img");
|
||||
expect(container.innerHTML).not.toContain("<img src=");
|
||||
// Text should be displayed as-is
|
||||
expect(screen.getByText(/<img src="x" onerror="alert\(1\)">/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with HTML in description", () => {
|
||||
const taskWithHtmlDesc = {
|
||||
...baseTask,
|
||||
description: '<b>Bold text</b><script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithHtmlDesc} />);
|
||||
// Should render as text, not HTML - React escapes by default
|
||||
expect(container.innerHTML).not.toContain("<script>");
|
||||
// Text should be displayed as-is
|
||||
expect(screen.getByText(/Bold text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing required IDs", () => {
|
||||
const taskWithMissingIds = {
|
||||
...baseTask,
|
||||
id: "",
|
||||
workspaceId: "",
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithMissingIds} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with extremely old due date", () => {
|
||||
const veryOldTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("1970-01-01"),
|
||||
};
|
||||
|
||||
render(<TaskItem task={veryOldTask} />);
|
||||
expect(screen.getByText(/target passed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with far future due date", () => {
|
||||
const farFutureTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("2099-12-31"),
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={farFutureTask} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/components/tasks/TaskItem.tsx
Normal file
69
apps/web/src/components/tasks/TaskItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const statusIcons: Record<TaskStatus, string> = {
|
||||
[TaskStatus.NOT_STARTED]: "⚪",
|
||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
||||
[TaskStatus.PAUSED]: "⏸️",
|
||||
[TaskStatus.COMPLETED]: "✅",
|
||||
[TaskStatus.ARCHIVED]: "💤",
|
||||
};
|
||||
|
||||
const priorityLabels: Record<TaskPriority, string> = {
|
||||
[TaskPriority.HIGH]: "High priority",
|
||||
[TaskPriority.MEDIUM]: "Medium priority",
|
||||
[TaskPriority.LOW]: "Low priority",
|
||||
};
|
||||
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const statusIcon = statusIcons[task.status];
|
||||
const priorityLabel = priorityLabels[task.priority];
|
||||
|
||||
// PDA-friendly date status
|
||||
let dateStatus = "";
|
||||
if (task.dueDate) {
|
||||
if (isPastTarget(task.dueDate)) {
|
||||
dateStatus = "Target passed";
|
||||
} else if (isApproachingTarget(task.dueDate)) {
|
||||
dateStatus = "Approaching target";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{task.title}</h3>
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{task.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{task.priority && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full">
|
||||
{priorityLabel}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="text-gray-500">
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{dateStatus && (
|
||||
<span className="px-2 py-1 bg-amber-100 text-amber-700 rounded-full">
|
||||
{dateStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/components/tasks/TaskList.test.tsx
Normal file
156
apps/web/src/components/tasks/TaskList.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskList } from "./TaskList";
|
||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||
|
||||
describe("TaskList", () => {
|
||||
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",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-02-05"),
|
||||
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"),
|
||||
},
|
||||
];
|
||||
|
||||
it("should render empty state when no tasks", () => {
|
||||
render(<TaskList tasks={[]} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render loading state", () => {
|
||||
render(<TaskList tasks={[]} isLoading={true} />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render tasks list", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update documentation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should group tasks by date", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Should have date group sections (Today, This Week, etc.)
|
||||
// The exact sections depend on the current date, so just verify grouping works
|
||||
const sections = screen.getAllByRole("heading", { level: 2 });
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Should NOT contain demanding language
|
||||
const text = screen.getByRole("main").textContent;
|
||||
expect(text).not.toMatch(/overdue/i);
|
||||
expect(text).not.toMatch(/urgent/i);
|
||||
expect(text).not.toMatch(/must do/i);
|
||||
});
|
||||
|
||||
it("should display status indicators", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Check for emoji status indicators (rendered as text)
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems.length).toBe(mockTasks.length);
|
||||
});
|
||||
|
||||
describe("error states", () => {
|
||||
it("should handle undefined tasks gracefully", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={undefined as any} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null tasks gracefully", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={null as any} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with missing required fields", () => {
|
||||
const malformedTasks = [
|
||||
{
|
||||
...mockTasks[0],
|
||||
title: "", // Empty title
|
||||
},
|
||||
];
|
||||
|
||||
render(<TaskList tasks={malformedTasks} isLoading={false} />);
|
||||
// Component should render without crashing
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with invalid dates", () => {
|
||||
const tasksWithBadDates = [
|
||||
{
|
||||
...mockTasks[0],
|
||||
dueDate: new Date("invalid-date"),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TaskList tasks={tasksWithBadDates} isLoading={false} />);
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle extremely large task lists", () => {
|
||||
const largeTasks = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...mockTasks[0],
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
}));
|
||||
|
||||
render(<TaskList tasks={largeTasks} isLoading={false} />);
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with very long titles", () => {
|
||||
const longTitleTask = {
|
||||
...mockTasks[0],
|
||||
title: "A".repeat(500),
|
||||
};
|
||||
|
||||
render(<TaskList tasks={[longTitleTask]} isLoading={false} />);
|
||||
expect(screen.getByText(/A{500}/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with special characters in title", () => {
|
||||
const specialCharTask = {
|
||||
...mockTasks[0],
|
||||
title: '<script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
render(<TaskList tasks={[specialCharTask]} isLoading={false} />);
|
||||
// Should render escaped, not execute
|
||||
expect(screen.getByRole("main").innerHTML).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/web/src/components/tasks/TaskList.tsx
Normal file
70
apps/web/src/components/tasks/TaskList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
import { getDateGroupLabel } from "@/lib/utils/date-format";
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, isLoading }: TaskListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle null/undefined tasks gracefully
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No tasks scheduled</p>
|
||||
<p className="text-sm mt-2">Your task list is clear</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group tasks by date
|
||||
const groupedTasks = tasks.reduce((groups, task) => {
|
||||
if (!task.dueDate) {
|
||||
return groups;
|
||||
}
|
||||
const label = getDateGroupLabel(task.dueDate);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label].push(task);
|
||||
return groups;
|
||||
}, {} as Record<string, Task[]>);
|
||||
|
||||
const groupOrder = ["Today", "Tomorrow", "This Week", "Next Week", "Later"];
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
{groupOrder.map((groupLabel) => {
|
||||
const groupTasks = groupedTasks[groupLabel];
|
||||
if (!groupTasks || groupTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={groupLabel}>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-3">
|
||||
{groupLabel}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{groupTasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskItem task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user