Add side panels, task selection, graph animation, and project docs

- Foldable left panel (user profile) and right panel (task details)
- Clicking a task in the list or graph node selects it and shows details
- Both views (task list + graph) always mounted via absolute inset-0 for
  correct canvas dimensions; tabs toggle visibility with opacity
- Graph node selection animation: other nodes repel outward (charge -600),
  then selected node smoothly slides to center (500ms cubic ease-out),
  then charge restores to -120 and graph stabilizes
- Graph re-fits on tab switch and panel resize via ResizeObserver
- Fix UUID string IDs throughout (backend returns UUIDs, not integers)
- Add TaskDetailPanel, UserPanel components
- Add CLAUDE.md project documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-04-08 11:23:06 +00:00
parent 5c7edd4bbc
commit f1d51b8cc8
23998 changed files with 3242708 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HomePage from '../../app/page';
// Override next/dynamic to return stubs synchronously (no async loading)
jest.mock('next/dynamic', () => {
return function dynamicMock(_importFn: () => Promise<unknown>, _options?: unknown) {
const Stub = (props: Record<string, unknown>) => React.createElement('div', { 'data-testid': 'graph-stub', ...props }, 'Graph');
Stub.displayName = 'GraphViewStub';
return Stub;
};
});
const TASKS = [
{ id: 1, title: 'First task', completed: false },
{ id: 2, title: 'Second task', completed: true },
];
const mockFetch = jest.fn();
global.fetch = mockFetch;
function setupFetchMocks(tasks = TASKS) {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
if (url === '/api/tasks' && method === 'GET') {
return Promise.resolve({ ok: true, json: () => Promise.resolve(tasks) });
}
if (url === '/api/tasks' && method === 'POST') {
const body = JSON.parse((init?.body as string) ?? '{}');
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 99, ...body, completed: false }),
});
}
if (url?.match(/\/api\/tasks\/\d+/) && method === 'PATCH') {
const id = parseInt(url.split('/').pop()!);
const body = JSON.parse((init?.body as string) ?? '{}');
const task = tasks.find((t) => t.id === id) ?? tasks[0];
return Promise.resolve({ ok: true, json: () => Promise.resolve({ ...task, ...body }) });
}
if (url?.match(/\/api\/tasks\/\d+/) && method === 'DELETE') {
return Promise.resolve({ ok: true });
}
if (url === '/api/graph') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ nodes: [], edges: [] }),
});
}
return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('Not Found') });
});
}
beforeEach(() => {
mockFetch.mockClear();
setupFetchMocks();
});
describe('Tasks tab - full user flow', () => {
it('loads and displays tasks on mount', async () => {
render(<HomePage />);
await waitFor(() => expect(screen.queryByTestId('task-list-loading')).toBeNull());
expect(screen.getByText('First task')).toBeTruthy();
expect(screen.getByText('Second task')).toBeTruthy();
});
it('shows remaining count in header', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('1 remaining'));
});
it('adds a new task and shows it in the list', async () => {
const user = userEvent.setup();
render(<HomePage />);
await waitFor(() => screen.getByTestId('add-task-form'));
await user.type(screen.getByTestId('title-input'), 'New shiny task');
await user.click(screen.getByTestId('submit-button'));
await waitFor(() => expect(screen.getByText('New shiny task')).toBeTruthy());
});
it('clears input after successful add', async () => {
const user = userEvent.setup();
render(<HomePage />);
await waitFor(() => screen.getByTestId('title-input'));
const input = screen.getByTestId('title-input') as HTMLInputElement;
await user.type(input, 'Temp task');
await user.click(screen.getByTestId('submit-button'));
await waitFor(() => expect(input.value).toBe(''));
});
it('completes a task when complete button is clicked', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('First task'));
fireEvent.click(screen.getAllByTestId('complete-button')[0]);
await waitFor(() =>
expect(mockFetch).toHaveBeenCalledWith(
'/api/tasks/1',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ completed: true }) }),
),
);
});
it('removes task from list after delete', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('First task'));
fireEvent.click(screen.getAllByTestId('delete-button')[0]);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith('/api/tasks/1', { method: 'DELETE' }));
await waitFor(() => expect(screen.queryByText('First task')).toBeNull());
});
it('shows empty state when no tasks', async () => {
setupFetchMocks([]);
render(<HomePage />);
await waitFor(() => screen.getByTestId('task-list-empty'));
expect(screen.getByTestId('task-list-empty').textContent).toContain('No tasks yet');
});
});
describe('Tab switching', () => {
it('switches to graph tab when clicked', async () => {
render(<HomePage />);
fireEvent.click(screen.getByTestId('tab-graph'));
await waitFor(() => expect(screen.getByTestId('graph-panel')).toBeTruthy());
});
it('renders graph stub on graph tab', async () => {
render(<HomePage />);
fireEvent.click(screen.getByTestId('tab-graph'));
await waitFor(() => expect(screen.getByTestId('graph-stub')).toBeTruthy());
});
it('switches back to tasks tab', async () => {
render(<HomePage />);
fireEvent.click(screen.getByTestId('tab-graph'));
fireEvent.click(screen.getByTestId('tab-tasks'));
await waitFor(() => expect(screen.getByTestId('add-task-form')).toBeTruthy());
});
});
describe('Panel toggling', () => {
it('toggles left panel visibility', async () => {
render(<HomePage />);
await waitFor(() => screen.getByTestId('left-panel'));
const leftPanel = screen.getByTestId('left-panel');
expect(leftPanel.className).toContain('w-64');
fireEvent.click(screen.getByTestId('toggle-left-panel'));
expect(leftPanel.className).toContain('w-0');
fireEvent.click(screen.getByTestId('toggle-left-panel'));
expect(leftPanel.className).toContain('w-64');
});
it('toggles right panel visibility', async () => {
render(<HomePage />);
await waitFor(() => screen.getByTestId('right-panel'));
const rightPanel = screen.getByTestId('right-panel');
expect(rightPanel.className).toContain('w-80');
fireEvent.click(screen.getByTestId('toggle-right-panel'));
expect(rightPanel.className).toContain('w-0');
fireEvent.click(screen.getByTestId('toggle-right-panel'));
expect(rightPanel.className).toContain('w-80');
});
});
describe('Task selection', () => {
it('shows empty state in detail panel when no task selected', async () => {
render(<HomePage />);
await waitFor(() => screen.getByTestId('task-detail-empty'));
expect(screen.getByTestId('task-detail-empty').textContent).toContain('Select a task');
});
it('shows task details when a task item is clicked', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('First task'));
fireEvent.click(screen.getAllByTestId('task-item')[0]);
await waitFor(() => expect(screen.getByTestId('task-detail')).toBeTruthy());
expect(screen.getByTestId('task-detail').textContent).toContain('First task');
});
it('highlights selected task in the list', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('First task'));
const taskItems = screen.getAllByTestId('task-item');
fireEvent.click(taskItems[0]);
await waitFor(() => {
expect(taskItems[0].className).toContain('border-blue-500');
});
});
it('clears selection when selected task is deleted', async () => {
render(<HomePage />);
await waitFor(() => screen.getByText('First task'));
fireEvent.click(screen.getAllByTestId('task-item')[0]);
await waitFor(() => screen.getByTestId('task-detail'));
fireEvent.click(screen.getAllByTestId('delete-button')[0]);
await waitFor(() => screen.getByTestId('task-detail-empty'));
});
});

View File

@@ -0,0 +1,70 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TaskItem from '../../components/TaskItem';
import type { Task } from '../../lib/types';
const baseTask: Task = {
id: 1,
title: 'Buy milk',
completed: false,
};
const completedTask: Task = {
id: 2,
title: 'Done task',
description: 'This is done',
completed: true,
};
describe('TaskItem', () => {
it('renders task title', () => {
render(<TaskItem task={baseTask} onComplete={jest.fn()} onDelete={jest.fn()} />);
expect(screen.getByTestId('task-title').textContent).toBe('Buy milk');
});
it('renders description when present', () => {
render(<TaskItem task={completedTask} onComplete={jest.fn()} onDelete={jest.fn()} />);
expect(screen.getByTestId('task-description').textContent).toBe('This is done');
});
it('does not render description when absent', () => {
render(<TaskItem task={baseTask} onComplete={jest.fn()} onDelete={jest.fn()} />);
expect(screen.queryByTestId('task-description')).toBeNull();
});
it('calls onComplete with toggled value when complete button clicked', () => {
const onComplete = jest.fn();
render(<TaskItem task={baseTask} onComplete={onComplete} onDelete={jest.fn()} />);
fireEvent.click(screen.getByTestId('complete-button'));
expect(onComplete).toHaveBeenCalledWith(1, true);
});
it('calls onComplete to un-complete a completed task', () => {
const onComplete = jest.fn();
render(<TaskItem task={completedTask} onComplete={onComplete} onDelete={jest.fn()} />);
fireEvent.click(screen.getByTestId('complete-button'));
expect(onComplete).toHaveBeenCalledWith(2, false);
});
it('calls onDelete with task id when delete button clicked', () => {
const onDelete = jest.fn();
render(<TaskItem task={baseTask} onComplete={jest.fn()} onDelete={onDelete} />);
fireEvent.click(screen.getByTestId('delete-button'));
expect(onDelete).toHaveBeenCalledWith(1);
});
it('applies completed styling when task is done', () => {
render(<TaskItem task={completedTask} onComplete={jest.fn()} onDelete={jest.fn()} />);
const title = screen.getByTestId('task-title');
expect(title.className).toContain('line-through');
});
it('does not apply completed styling for pending task', () => {
render(<TaskItem task={baseTask} onComplete={jest.fn()} onDelete={jest.fn()} />);
const title = screen.getByTestId('task-title');
expect(title.className).not.toContain('line-through');
});
});

View File

@@ -0,0 +1,111 @@
import { getTasks, createTask, updateTask, deleteTask, getGraph } from '../../lib/api';
const mockFetch = jest.fn();
global.fetch = mockFetch;
function mockOk(data: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(data),
text: () => Promise.resolve(''),
});
}
function mockError(status: number, body = 'Server Error') {
mockFetch.mockResolvedValueOnce({
ok: false,
status,
text: () => Promise.resolve(body),
statusText: body,
});
}
beforeEach(() => mockFetch.mockClear());
describe('getTasks', () => {
it('fetches all tasks', async () => {
const tasks = [{ id: 1, title: 'Test', completed: false }];
mockOk(tasks);
const result = await getTasks();
expect(result).toEqual(tasks);
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ headers: expect.anything() }));
});
it('throws on error response', async () => {
mockError(500, 'Internal Server Error');
await expect(getTasks()).rejects.toThrow('API error 500');
});
});
describe('createTask', () => {
it('posts task and returns created task', async () => {
const task = { id: 2, title: 'New task', completed: false };
mockOk(task);
const result = await createTask({ title: 'New task' });
expect(result).toEqual(task);
expect(mockFetch).toHaveBeenCalledWith(
'/api/tasks',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: 'New task' }),
}),
);
});
it('includes description when provided', async () => {
const task = { id: 3, title: 'Task', description: 'Desc', completed: false };
mockOk(task);
await createTask({ title: 'Task', description: 'Desc' });
expect(mockFetch).toHaveBeenCalledWith(
'/api/tasks',
expect.objectContaining({ body: JSON.stringify({ title: 'Task', description: 'Desc' }) }),
);
});
it('throws on error response', async () => {
mockError(400, 'Bad Request');
await expect(createTask({ title: '' })).rejects.toThrow('API error 400');
});
});
describe('updateTask', () => {
it('patches task and returns updated task', async () => {
const task = { id: 1, title: 'Test', completed: true };
mockOk(task);
const result = await updateTask(1, { completed: true });
expect(result).toEqual(task);
expect(mockFetch).toHaveBeenCalledWith(
'/api/tasks/1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ completed: true }),
}),
);
});
});
describe('deleteTask', () => {
it('sends DELETE request', async () => {
mockFetch.mockResolvedValueOnce({ ok: true });
await deleteTask(5);
expect(mockFetch).toHaveBeenCalledWith('/api/tasks/5', { method: 'DELETE' });
});
});
describe('getGraph', () => {
it('fetches graph data', async () => {
const data = {
nodes: [{ id: 1, label: 'Task A', completed: false }],
edges: [],
};
mockOk(data);
const result = await getGraph();
expect(result).toEqual(data);
expect(mockFetch).toHaveBeenCalledWith('/api/graph', expect.anything());
});
it('throws on error', async () => {
mockError(503, 'Service Unavailable');
await expect(getGraph()).rejects.toThrow('API error 503');
});
});

View File

@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0f1117;
--foreground: #e5e7eb;
}
html,
body {
height: 100%;
}
body {
background-color: var(--background);
color: var(--foreground);
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
/* bg-gray-850 helper (between 800 and 900) */
.bg-gray-850 {
background-color: #1c2333;
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Taskpile',
description: 'A minimal task manager with graph view',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full">
<body className="h-full bg-gray-950 text-gray-100 antialiased">{children}</body>
</html>
);
}

186
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import dynamic from 'next/dynamic';
import AddTaskForm from '../components/AddTaskForm';
import TaskList from '../components/TaskList';
import TaskDetailPanel from '../components/TaskDetailPanel';
import UserPanel from '../components/UserPanel';
import type { Task, CreateTaskInput } from '../lib/types';
import { getTasks, createTask, updateTask, deleteTask } from '../lib/api';
const GraphView = dynamic(() => import('../components/GraphView'), { ssr: false });
type Tab = 'tasks' | 'graph';
export default function HomePage() {
const [activeTab, setActiveTab] = useState<Tab>('tasks');
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const selectedTask = tasks.find((t) => String(t.id) === selectedTaskId) ?? null;
const loadTasks = useCallback(async () => {
try {
setError(null);
const data = await getTasks();
setTasks(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tasks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTasks();
}, [loadTasks]);
async function handleAdd(input: CreateTaskInput) {
const task = await createTask(input);
setTasks((prev) => [task, ...prev]);
}
async function handleComplete(id: number, completed: boolean) {
const updated = await updateTask(id, { completed });
setTasks((prev) => prev.map((t) => (t.id === id ? updated : t)));
}
async function handleDelete(id: number) {
await deleteTask(id);
setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTaskId === String(id)) setSelectedTaskId(null);
}
function handleSelectTask(id: string) {
setSelectedTaskId(id);
setRightPanelOpen(true);
// Switch to graph tab to show centering if we're on tasks tab
// (don't force tab switch — user might want to stay on current tab)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="flex-shrink-0 border-b border-gray-800 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight text-gray-100">
taskpile
</h1>
<span className="text-xs text-gray-500">
{tasks.filter((t) => !t.completed).length} remaining
</span>
</div>
</header>
{/* Tabs */}
<div className="flex-shrink-0 border-b border-gray-800 px-6">
<div className="flex gap-1">
{(['tasks', 'graph'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
data-testid={`tab-${tab}`}
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Main layout: left panel | center | right panel */}
<div className="flex-1 flex overflow-hidden">
{/* Left panel toggle */}
<button
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
className="flex-shrink-0 w-6 flex items-center justify-center border-r border-gray-800 text-gray-500 hover:text-gray-300 hover:bg-gray-800/50 transition-colors"
title={leftPanelOpen ? 'Collapse left panel' : 'Expand left panel'}
data-testid="toggle-left-panel"
>
<svg className={`w-3 h-3 transition-transform ${leftPanelOpen ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Left panel - User info */}
<div
className={`flex-shrink-0 border-r border-gray-800 overflow-hidden transition-all duration-300 ease-in-out ${
leftPanelOpen ? 'w-64' : 'w-0'
}`}
data-testid="left-panel"
>
<div className={`w-64 h-full overflow-y-auto ${leftPanelOpen ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}>
<UserPanel />
</div>
</div>
{/* Center content: both views always mounted, only one visible */}
<div className="flex-1 relative overflow-hidden min-w-0">
{/* Task list — shown on tasks tab */}
<div
className={`absolute inset-0 overflow-y-auto px-6 py-6 transition-opacity duration-150 ${activeTab === 'tasks' ? 'opacity-100 pointer-events-auto z-10' : 'opacity-0 pointer-events-none z-0'}`}
data-testid="task-list-area"
>
<div className="max-w-3xl mx-auto">
<AddTaskForm onAdd={handleAdd} />
<TaskList
tasks={tasks}
loading={loading}
error={error}
selectedTaskId={selectedTaskId}
onComplete={handleComplete}
onDelete={handleDelete}
onSelect={handleSelectTask}
/>
</div>
</div>
{/* Graph — always mounted so simulation runs, shown on graph tab */}
<div
className={`absolute inset-0 transition-opacity duration-150 ${activeTab === 'graph' ? 'opacity-100 pointer-events-auto z-10' : 'opacity-0 pointer-events-none z-0'}`}
data-testid="graph-panel"
>
<GraphView selectedTaskId={selectedTaskId} onSelectTask={handleSelectTask} isVisible={activeTab === 'graph'} />
</div>
</div>
{/* Right panel toggle */}
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
className="flex-shrink-0 w-6 flex items-center justify-center border-l border-gray-800 text-gray-500 hover:text-gray-300 hover:bg-gray-800/50 transition-colors"
title={rightPanelOpen ? 'Collapse right panel' : 'Expand right panel'}
data-testid="toggle-right-panel"
>
<svg className={`w-3 h-3 transition-transform ${rightPanelOpen ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Right panel - Task details */}
<div
className={`flex-shrink-0 border-l border-gray-800 overflow-hidden transition-all duration-300 ease-in-out ${
rightPanelOpen ? 'w-80' : 'w-0'
}`}
data-testid="right-panel"
>
<div className={`w-80 h-full overflow-y-auto ${rightPanelOpen ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}>
<TaskDetailPanel
task={selectedTask}
onComplete={handleComplete}
onDelete={handleDelete}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { useState } from 'react';
import type { CreateTaskInput } from '../lib/types';
interface AddTaskFormProps {
onAdd: (input: CreateTaskInput) => Promise<void>;
}
export default function AddTaskForm({ onAdd }: AddTaskFormProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [expanded, setExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
setLoading(true);
setError(null);
try {
await onAdd({ title: title.trim(), description: description.trim() || undefined });
setTitle('');
setDescription('');
setExpanded(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add task');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="mb-6" data-testid="add-task-form">
<div className="flex gap-2">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new task..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
data-testid="title-input"
disabled={loading}
/>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
title="Add description"
className="px-3 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-400 hover:text-gray-200 hover:border-gray-600 transition-colors"
data-testid="expand-button"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
</button>
<button
type="submit"
disabled={loading || !title.trim()}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium rounded-lg transition-colors"
data-testid="submit-button"
>
{loading ? 'Adding...' : 'Add'}
</button>
</div>
{expanded && (
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
rows={3}
className="mt-2 w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors resize-none"
data-testid="description-input"
disabled={loading}
/>
)}
{error && (
<p className="mt-2 text-xs text-red-400" data-testid="form-error">
{error}
</p>
)}
</form>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
// Separate file so it can be imported via next/dynamic without losing the ref
import { forwardRef } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
// Re-export with forwardRef passthrough so dynamic() wrapping doesn't drop the ref
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraphClient = forwardRef<any, any>((props, ref) => (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<ForceGraph2D ref={ref as any} {...props} />
));
ForceGraphClient.displayName = 'ForceGraphClient';
export default ForceGraphClient;

View File

@@ -0,0 +1,324 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import type { GraphData } from '../lib/types';
import { getGraph } from '../lib/api';
interface FGNode {
id: string;
label: string;
completed: boolean;
x?: number;
y?: number;
fx?: number;
fy?: number;
}
interface FGLink {
source: string;
target: string;
weight: number;
}
interface GraphViewProps {
selectedTaskId?: string | null;
onSelectTask?: (id: string) => void;
isVisible?: boolean;
}
const NODE_R = 5;
export default function GraphView({ selectedTaskId, onSelectTask, isVisible = true }: GraphViewProps) {
const [graphData, setGraphData] = useState<{ nodes: FGNode[]; links: FGLink[] }>({
nodes: [],
links: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fgRef = useRef<any>(null);
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const pendingCenterRef = useRef<string | null>(null);
useEffect(() => {
async function load() {
try {
const data: GraphData = await getGraph();
const nodes: FGNode[] = data.nodes.map((n) => ({
id: String(n.id),
label: n.label,
completed: n.completed,
}));
const nodeIds = new Set(nodes.map((n) => n.id));
const links: FGLink[] =
nodes.length > 1
? data.edges
.filter((e) => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)))
.map((e) => ({ source: String(e.source), target: String(e.target), weight: e.weight }))
: [];
setGraphData({ nodes, links });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load graph');
} finally {
setLoading(false);
}
}
load();
}, []);
// Measure container — always runs since containerRef is always rendered
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const update = () => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
setDimensions((prev) => {
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (prev && prev.width === w && prev.height === h) return prev;
return { width: w, height: h };
});
}
};
const observer = new ResizeObserver(update);
observer.observe(el);
return () => { observer.disconnect(); };
}, []);
// Re-fit when canvas dimensions change (panel toggle, window resize)
useEffect(() => {
if (!dimensions || !fgRef.current) return;
fgRef.current.zoomToFit(300, 60);
}, [dimensions]);
// Re-fit when graph tab becomes visible (engine may have stopped while hidden)
useEffect(() => {
if (!isVisible || !fgRef.current || !dimensions) return;
if (!pendingCenterRef.current) {
fgRef.current.zoomToFit(300, 60);
}
}, [isVisible]); // eslint-disable-line react-hooks/exhaustive-deps
// Unpin previous node and track selection
const prevSelectedRef = useRef<string | null>(null);
useEffect(() => {
if (prevSelectedRef.current && prevSelectedRef.current !== selectedTaskId) {
const prev = graphData.nodes.find((n) => n.id === prevSelectedRef.current) as FGNode | undefined;
if (prev) {
prev.fx = undefined;
prev.fy = undefined;
}
}
prevSelectedRef.current = selectedTaskId ?? null;
pendingCenterRef.current = selectedTaskId ?? null;
if (selectedTaskId && isVisible) {
// On graph tab: start animation immediately (next frame so node coords are fresh)
requestAnimationFrame(() => {
if (pendingCenterRef.current) moveNodeToCenter(pendingCenterRef.current);
});
}
}, [selectedTaskId]); // eslint-disable-line react-hooks/exhaustive-deps
// Move selected node to center when switching to graph tab with a pending selection
useEffect(() => {
if (!isVisible || !pendingCenterRef.current) return;
// Short delay for tab CSS transition (150ms) + re-fit (300ms)
const timer = setTimeout(() => {
if (pendingCenterRef.current) moveNodeToCenter(pendingCenterRef.current);
}, 400);
return () => clearTimeout(timer);
}, [isVisible]); // eslint-disable-line react-hooks/exhaustive-deps
function moveNodeToCenter(nodeId: string) {
if (!fgRef.current || !containerRef.current) return;
const node = graphData.nodes.find((n) => n.id === nodeId) as FGNode | undefined;
if (!node || node.x == null || node.y == null) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const { x: cx, y: cy } = fgRef.current.screen2GraphCoords(rect.width / 2, rect.height / 2);
const fg = fgRef.current;
const n = node;
const startX = node.x!;
const startY = node.y!;
// Phase 1: blast other nodes away with strong repulsion
fg.d3Force('charge')?.strength(-200);
fg.d3ReheatSimulation();
// Phase 2: animate selected node to center (starts after brief repel delay)
const REPEL_DELAY = 80; // ms — let repulsion kick in first
const MOVE_DURATION = 800;
const startTime = performance.now() + REPEL_DELAY;
function animate() {
const elapsed = performance.now() - startTime;
if (elapsed < 0) { requestAnimationFrame(animate); return; }
const t = Math.min(elapsed / MOVE_DURATION, 1);
// Cubic ease-out: fast start, smooth landing
const ease = 1 - Math.pow(1 - t, 3);
n.fx = startX + (cx - startX) * ease;
n.fy = startY + (cy - startY) * ease;
if (t < 1) {
requestAnimationFrame(animate);
} else {
n.fx = cx;
n.fy = cy;
pendingCenterRef.current = null;
// Phase 3: restore normal charge so graph stabilizes naturally
fg.d3Force('charge')?.strength(-120);
fg.d3ReheatSimulation();
}
}
requestAnimationFrame(animate);
}
const handleEngineStop = useCallback(() => {
// Don't zoomToFit when a node is selected — pinned node controls the view
if (!pendingCenterRef.current && !prevSelectedRef.current) {
fgRef.current?.zoomToFit(400, 120);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!fgRef.current) return;
fgRef.current.d3Force('charge')?.strength(-120);
fgRef.current.d3ReheatSimulation();
}, [graphData]);
const handleNodeClick = useCallback((node: object) => {
const n = node as FGNode;
onSelectTask?.(n.id);
}, [onSelectTask]);
const showGraph = !loading && !error && graphData.nodes.length > 0 && dimensions;
const selectedIdStr = selectedTaskId ?? null;
return (
<div ref={containerRef} className="relative w-full h-full overflow-hidden" data-testid="graph-container">
{loading && (
<div className="flex items-center justify-center h-full" data-testid="graph-loading">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && error && (
<div className="flex items-center justify-center h-full" data-testid="graph-error">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{!loading && !error && graphData.nodes.length === 0 && (
<div className="flex items-center justify-center h-full" data-testid="graph-empty">
<p className="text-sm text-gray-500">No tasks to display in graph.</p>
</div>
)}
{showGraph && (
<>
{/* Legend */}
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1.5 bg-gray-900/80 backdrop-blur-sm border border-gray-700 rounded-lg px-3 py-2.5 text-xs text-gray-400">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 flex-shrink-0" />
pending
</div>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-green-500 flex-shrink-0" />
completed
</div>
</div>
{/* Zoom controls */}
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1">
<button
onClick={() => fgRef.current?.zoom(fgRef.current.zoom() * 1.3, 200)}
className="w-8 h-8 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded text-gray-300 text-lg leading-none flex items-center justify-center transition-colors"
title="Zoom in"
>+</button>
<button
onClick={() => fgRef.current?.zoomToFit(400, 60)}
className="w-8 h-8 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded text-gray-300 text-xs flex items-center justify-center transition-colors"
title="Fit"
></button>
<button
onClick={() => fgRef.current?.zoom(fgRef.current.zoom() / 1.3, 200)}
className="w-8 h-8 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded text-gray-300 text-lg leading-none flex items-center justify-center transition-colors"
title="Zoom out"
></button>
</div>
<ForceGraph2D
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
nodeLabel="label"
nodeRelSize={NODE_R}
nodeColor={(node) => {
const n = node as FGNode;
if (n.id === selectedIdStr) return '#f59e0b';
return n.completed ? '#22c55e' : '#3b82f6';
}}
nodeCanvasObjectMode={() => 'after'}
nodeCanvasObject={(node, ctx, globalScale) => {
const n = node as FGNode;
const isSelected = n.id === selectedIdStr;
if (isSelected) {
ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, NODE_R + 3, 0, 2 * Math.PI);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2 / globalScale;
ctx.stroke();
}
const fontSize = 12 / globalScale;
if (fontSize > 20) return;
ctx.font = `${isSelected ? 'bold ' : ''}${fontSize}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const textWidth = ctx.measureText(n.label).width;
const labelY = (n.y ?? 0) + NODE_R / globalScale + fontSize * 0.8;
const pad = 3 / globalScale;
ctx.fillStyle = 'rgba(17,24,39,0.85)';
ctx.beginPath();
ctx.roundRect(
(n.x ?? 0) - textWidth / 2 - pad,
labelY - fontSize / 2 - pad,
textWidth + pad * 2,
fontSize + pad * 2,
3 / globalScale,
);
ctx.fill();
ctx.fillStyle = isSelected ? '#fbbf24' : '#e5e7eb';
ctx.fillText(n.label, n.x ?? 0, labelY);
}}
linkWidth={(link) => {
const l = link as FGLink;
return Math.max(0.5, (l.weight ?? 0.5) * 2.5);
}}
linkColor={(link) => {
const l = link as FGLink;
const alpha = Math.round(((l.weight ?? 0.5) * 0.6 + 0.2) * 255).toString(16).padStart(2, '0');
return `#6b7280${alpha}`;
}}
ref={fgRef}
backgroundColor="#111827"
cooldownTicks={200}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
onEngineStop={handleEngineStop}
onNodeClick={handleNodeClick}
enableNodeDrag
enableZoomInteraction
enablePanInteraction
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import type { Task } from '../lib/types';
interface TaskDetailPanelProps {
task: Task | null;
onComplete: (id: number, completed: boolean) => void;
onDelete: (id: number) => void;
}
export default function TaskDetailPanel({ task, onComplete, onDelete }: TaskDetailPanelProps) {
if (!task) {
return (
<div className="p-4 flex items-center justify-center h-full" data-testid="task-detail-empty">
<p className="text-sm text-gray-500">Select a task to view details</p>
</div>
);
}
return (
<div className="p-4 space-y-4" data-testid="task-detail">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Task Details</h2>
<div className="space-y-3">
<div>
<div className="text-xs text-gray-500 mb-1">Title</div>
<p className="text-sm text-gray-100 font-medium">{task.title}</p>
</div>
{task.description && (
<div>
<div className="text-xs text-gray-500 mb-1">Description</div>
<p className="text-sm text-gray-300 leading-relaxed">{task.description}</p>
</div>
)}
<div>
<div className="text-xs text-gray-500 mb-1">Status</div>
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full ${
task.completed
? 'bg-green-500/10 text-green-400'
: 'bg-blue-500/10 text-blue-400'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${task.completed ? 'bg-green-400' : 'bg-blue-400'}`} />
{task.completed ? 'Completed' : 'Pending'}
</span>
</div>
{task.created_at && (
<div>
<div className="text-xs text-gray-500 mb-1">Created</div>
<p className="text-sm text-gray-400">{new Date(task.created_at).toLocaleString()}</p>
</div>
)}
</div>
<div className="border-t border-gray-800 pt-3 flex gap-2">
<button
onClick={() => onComplete(task.id, !task.completed)}
className={`flex-1 text-xs font-medium py-2 px-3 rounded-lg transition-colors ${
task.completed
? 'bg-gray-700 hover:bg-gray-600 text-gray-300'
: 'bg-green-600 hover:bg-green-500 text-white'
}`}
data-testid="detail-complete-button"
>
{task.completed ? 'Mark Incomplete' : 'Mark Complete'}
</button>
<button
onClick={() => onDelete(task.id)}
className="text-xs font-medium py-2 px-3 rounded-lg bg-red-600/10 hover:bg-red-600/20 text-red-400 transition-colors"
data-testid="detail-delete-button"
>
Delete
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import type { Task } from '../lib/types';
interface TaskItemProps {
task: Task;
selected?: boolean;
onComplete: (id: number, completed: boolean) => void;
onDelete: (id: number) => void;
onSelect?: (id: string) => void;
}
export default function TaskItem({ task, selected, onComplete, onDelete, onSelect }: TaskItemProps) {
return (
<div
onClick={() => onSelect?.(String(task.id))}
className={`flex items-start gap-3 p-4 rounded-lg border transition-colors cursor-pointer ${
selected
? 'bg-blue-500/10 border-blue-500/50'
: task.completed
? 'bg-gray-800 border-gray-700 opacity-60'
: 'bg-gray-850 border-gray-700 hover:border-gray-600'
}`}
data-testid="task-item"
>
<button
onClick={(e) => { e.stopPropagation(); onComplete(task.id, !task.completed); }}
aria-label={task.completed ? 'Mark incomplete' : 'Mark complete'}
className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded-full border-2 flex items-center justify-center transition-colors ${
task.completed
? 'bg-green-500 border-green-500 text-white'
: 'border-gray-500 hover:border-green-400'
}`}
data-testid="complete-button"
>
{task.completed && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium leading-snug ${
task.completed ? 'line-through text-gray-500' : 'text-gray-100'
}`}
data-testid="task-title"
>
{task.title}
</p>
{task.description && (
<p className="mt-1 text-xs text-gray-400 leading-relaxed" data-testid="task-description">
{task.description}
</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(task.id); }}
aria-label="Delete task"
className="flex-shrink-0 p-1 rounded text-gray-500 hover:text-red-400 hover:bg-gray-700 transition-colors"
data-testid="delete-button"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import type { Task } from '../lib/types';
import TaskItem from './TaskItem';
interface TaskListProps {
tasks: Task[];
loading: boolean;
error: string | null;
selectedTaskId?: string | null;
onComplete: (id: number, completed: boolean) => void;
onDelete: (id: number) => void;
onSelect?: (id: string) => void;
}
export default function TaskList({ tasks, loading, error, selectedTaskId, onComplete, onDelete, onSelect }: TaskListProps) {
if (loading) {
return (
<div className="flex justify-center py-12" data-testid="task-list-loading">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error) {
return (
<p className="text-center py-8 text-sm text-red-400" data-testid="task-list-error">
{error}
</p>
);
}
if (tasks.length === 0) {
return (
<p className="text-center py-12 text-sm text-gray-500" data-testid="task-list-empty">
No tasks yet. Add one above.
</p>
);
}
const pending = tasks.filter((t) => !t.completed);
const done = tasks.filter((t) => t.completed);
return (
<div className="space-y-1" data-testid="task-list">
{pending.map((task) => (
<TaskItem
key={task.id}
task={task}
selected={String(task.id) === selectedTaskId}
onComplete={onComplete}
onDelete={onDelete}
onSelect={onSelect}
/>
))}
{done.length > 0 && pending.length > 0 && (
<div className="pt-4 pb-2">
<p className="text-xs text-gray-600 uppercase tracking-wider font-medium">Completed</p>
</div>
)}
{done.map((task) => (
<TaskItem
key={task.id}
task={task}
selected={String(task.id) === selectedTaskId}
onComplete={onComplete}
onDelete={onDelete}
onSelect={onSelect}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
export default function UserPanel() {
return (
<div className="p-4 space-y-4" data-testid="user-panel">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Profile</h2>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-sm">
JD
</div>
<div>
<p className="text-sm font-medium text-gray-100">Jane Doe</p>
<p className="text-xs text-gray-500">jane@example.com</p>
</div>
</div>
<div className="border-t border-gray-800 pt-3 space-y-2">
<div className="text-xs text-gray-500">Role</div>
<div className="text-sm text-gray-300">Project Manager</div>
</div>
<div className="border-t border-gray-800 pt-3 space-y-2">
<div className="text-xs text-gray-500">Team</div>
<div className="text-sm text-gray-300">Engineering</div>
</div>
<div className="border-t border-gray-800 pt-3 space-y-2">
<div className="text-xs text-gray-500">Status</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-gray-300">Online</span>
</div>
</div>
</div>
);
}

41
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Task, GraphData, CreateTaskInput, UpdateTaskInput } from './types';
const BASE = '/api';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`API error ${res.status}: ${text}`);
}
return res.json() as Promise<T>;
}
export async function getTasks(): Promise<Task[]> {
return request<Task[]>('/tasks');
}
export async function createTask(input: CreateTaskInput): Promise<Task> {
return request<Task>('/tasks', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function updateTask(id: number, input: UpdateTaskInput): Promise<Task> {
return request<Task>(`/tasks/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
export async function deleteTask(id: number): Promise<void> {
await fetch(`${BASE}/tasks/${id}`, { method: 'DELETE' });
}
export async function getGraph(): Promise<GraphData> {
return request<GraphData>('/graph');
}

36
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface Task {
id: number;
title: string;
description?: string;
completed: boolean;
created_at?: string;
updated_at?: string;
}
export interface GraphNode {
id: number;
label: string;
completed: boolean;
}
export interface GraphEdge {
source: number;
target: number;
weight: number;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
export interface CreateTaskInput {
title: string;
description?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
completed?: boolean;
}