forked from cardosofelipe/fast-next-template
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>
208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|