feat(frontend): implement main dashboard page (#48)
Implement the main dashboard / projects list page for Syndarix as the landing page after login. The implementation includes: Dashboard Components: - QuickStats: Overview cards showing active projects, agents, issues, approvals - ProjectsSection: Grid/list view with filtering and sorting controls - ProjectCardGrid: Rich project cards for grid view - ProjectRowList: Compact rows for list view - ActivityFeed: Real-time activity sidebar with connection status - PerformanceCard: Performance metrics display - EmptyState: Call-to-action for new users - ProjectStatusBadge: Status indicator with icons - ComplexityIndicator: Visual complexity dots - ProgressBar: Accessible progress bar component Features: - Projects grid/list view with view mode toggle - Filter by status (all, active, paused, completed, archived) - Sort by recent, name, progress, or issues - Quick stats overview with counts - Real-time activity feed sidebar with live/reconnecting status - Performance metrics card - Create project button linking to wizard - Responsive layout for mobile/desktop - Loading skeleton states - Empty state for new users API Integration: - useProjects hook for fetching projects (mock data until backend ready) - useDashboardStats hook for statistics - TanStack Query for caching and data fetching Testing: - 37 unit tests covering all dashboard components - E2E test suite for dashboard functionality - Accessibility tests (keyboard nav, aria attributes, heading hierarchy) Technical: - TypeScript strict mode compliance - ESLint passing - WCAG AA accessibility compliance - Mobile-first responsive design - Dark mode support via semantic tokens - Follows design system guidelines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
207
frontend/src/components/projects/RecentActivity.tsx
Normal file
207
frontend/src/components/projects/RecentActivity.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Recent Activity Component
|
||||
*
|
||||
* Displays recent project activity feed with action items.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Activity,
|
||||
MessageSquare,
|
||||
GitPullRequest,
|
||||
PlayCircle,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Cog,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { ActivityItem } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface RecentActivityProps {
|
||||
/** Activity items to display */
|
||||
activities: ActivityItem[];
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Maximum items to show */
|
||||
maxItems?: number;
|
||||
/** Callback when "View All" is clicked */
|
||||
onViewAll?: () => void;
|
||||
/** Callback when an action item is clicked */
|
||||
onActionClick?: (activityId: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getActivityIcon(type: ActivityItem['type']): LucideIcon {
|
||||
switch (type) {
|
||||
case 'agent_message':
|
||||
return MessageSquare;
|
||||
case 'issue_update':
|
||||
return GitPullRequest;
|
||||
case 'agent_status':
|
||||
return PlayCircle;
|
||||
case 'approval_request':
|
||||
return AlertCircle;
|
||||
case 'sprint_event':
|
||||
return Users;
|
||||
case 'system':
|
||||
default:
|
||||
return Cog;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
||||
} catch {
|
||||
return 'Unknown time';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subcomponents
|
||||
// ============================================================================
|
||||
|
||||
interface ActivityItemRowProps {
|
||||
activity: ActivityItem;
|
||||
onActionClick?: (activityId: string) => void;
|
||||
}
|
||||
|
||||
function ActivityItemRow({ activity, onActionClick }: ActivityItemRowProps) {
|
||||
const Icon = getActivityIcon(activity.type);
|
||||
const timestamp = formatTimestamp(activity.timestamp);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3" data-testid={`activity-item-${activity.id}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
|
||||
activity.requires_action
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
{activity.agent && (
|
||||
<span className="font-medium">{activity.agent}</span>
|
||||
)}{' '}
|
||||
<span className="text-muted-foreground">{activity.message}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{timestamp}</p>
|
||||
{activity.requires_action && onActionClick && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 h-7 text-xs"
|
||||
onClick={() => onActionClick(activity.id)}
|
||||
>
|
||||
Review Request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivitySkeleton({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export function RecentActivity({
|
||||
activities,
|
||||
isLoading = false,
|
||||
maxItems = 5,
|
||||
onViewAll,
|
||||
onActionClick,
|
||||
className,
|
||||
}: RecentActivityProps) {
|
||||
if (isLoading) {
|
||||
return <RecentActivitySkeleton count={maxItems} />;
|
||||
}
|
||||
|
||||
const displayedActivities = activities.slice(0, maxItems);
|
||||
|
||||
return (
|
||||
<Card className={className} data-testid="recent-activity">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Activity className="h-5 w-5" aria-hidden="true" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
{onViewAll && activities.length > maxItems && (
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={onViewAll}>
|
||||
View All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{displayedActivities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Activity className="mb-2 h-8 w-8" aria-hidden="true" />
|
||||
<p className="text-sm">No recent activity</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" role="list" aria-label="Recent project activity">
|
||||
{displayedActivities.map((activity) => (
|
||||
<ActivityItemRow
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user