Files
fast-next-template/frontend/src/app/[locale]/(authenticated)/activity/page.tsx
Felipe Cardoso d0a88d1fd1 feat(frontend): implement activity feed component (#43)
Add shared ActivityFeed component for real-time project activity:

- Real-time connection indicator (Live, Connecting, Disconnected, Error)
- Time-based event grouping (Today, Yesterday, This Week, Older)
- Event type filtering with category checkboxes
- Search functionality for filtering events
- Expandable event details with raw payload view
- Approval request handling (approve/reject buttons)
- Loading skeleton and empty state handling
- Compact mode for dashboard embedding
- WCAG AA accessibility (keyboard navigation, ARIA labels)

Components:
- ActivityFeed.tsx: Main shared component (900+ lines)
- Activity page at /activity for full-page view
- Demo events when SSE not connected

Testing:
- 45 unit tests covering all features
- E2E tests for page functionality

Closes #43

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 23:41:12 +01:00

239 lines
7.5 KiB
TypeScript

/**
* Activity Feed Page
*
* Full-page view of real-time project activity with:
* - Real-time SSE connection
* - Event filtering and search
* - Time-based grouping
* - Approval handling
*/
'use client';
import { useState, useCallback } from 'react';
import { useProjectEvents } from '@/lib/hooks/useProjectEvents';
import { ActivityFeed } from '@/components/activity';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Bell, BellOff, AlertTriangle } from 'lucide-react';
import { EventType, type ProjectEvent } from '@/lib/types/events';
import { toast } from 'sonner';
// For demo purposes, use a placeholder project ID
// In a real app, this would come from route params or user context
const DEMO_PROJECT_ID = 'demo-project-001';
// Demo events for when SSE is not connected
const DEMO_EVENTS: ProjectEvent[] = [
{
id: 'demo-001',
type: EventType.APPROVAL_REQUESTED,
timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: 'agent-001',
actor_type: 'agent',
payload: {
approval_id: 'apr-001',
approval_type: 'architecture_decision',
description: 'Approval required for API design document for the checkout flow.',
requested_by: 'Architect',
timeout_minutes: 60,
},
},
{
id: 'demo-002',
type: EventType.AGENT_MESSAGE,
timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: 'agent-002',
actor_type: 'agent',
payload: {
agent_instance_id: 'agent-002',
message: 'Completed JWT token generation and validation. Moving on to session management.',
message_type: 'info',
metadata: { progress: 65 },
},
},
{
id: 'demo-003',
type: EventType.AGENT_STATUS_CHANGED,
timestamp: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: 'agent-003',
actor_type: 'agent',
payload: {
agent_instance_id: 'agent-003',
previous_status: 'idle',
new_status: 'active',
reason: 'Started working on product catalog component',
},
},
{
id: 'demo-004',
type: EventType.ISSUE_UPDATED,
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: 'agent-002',
actor_type: 'agent',
payload: {
issue_id: 'issue-038',
changes: { status: { from: 'in_progress', to: 'in_review' } },
},
},
{
id: 'demo-005',
type: EventType.SPRINT_STARTED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: null,
actor_type: 'system',
payload: {
sprint_id: 'sprint-003',
sprint_name: 'Sprint 3 - Authentication',
goal: 'Complete user authentication module',
issue_count: 8,
},
},
{
id: 'demo-006',
type: EventType.WORKFLOW_COMPLETED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: null,
actor_type: 'system',
payload: {
workflow_id: 'wf-001',
duration_seconds: 3600,
result: { issues_completed: 5, code_coverage: 92 },
},
},
{
id: 'demo-007',
type: EventType.ISSUE_CREATED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
project_id: DEMO_PROJECT_ID,
actor_id: 'agent-001',
actor_type: 'agent',
payload: {
issue_id: 'issue-050',
title: 'Add rate limiting to API endpoints',
priority: 'medium',
labels: ['security', 'api'],
},
},
];
export default function ActivityFeedPage() {
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
// SSE hook for real-time events
const {
events: sseEvents,
connectionState,
reconnect,
isConnected,
} = useProjectEvents(DEMO_PROJECT_ID, {
autoConnect: true,
onEvent: (event) => {
// Show notification for new events if enabled
if (notificationsEnabled && event.type === EventType.APPROVAL_REQUESTED) {
toast.info('New approval request', {
description: 'An agent is requesting your approval.',
});
}
},
});
// Use demo events when not connected or no events received
const events = isConnected && sseEvents.length > 0 ? sseEvents : DEMO_EVENTS;
// Approval handlers
const handleApprove = useCallback((event: ProjectEvent) => {
// In a real app, this would call an API to approve the request
toast.success('Approval granted', {
description: `Approved request ${event.id}`,
});
}, []);
const handleReject = useCallback((event: ProjectEvent) => {
// In a real app, this would call an API to reject the request
toast.info('Approval rejected', {
description: `Rejected request ${event.id}`,
});
}, []);
const handleEventClick = useCallback((event: ProjectEvent) => {
// In a real app, this might navigate to a detail view
if (process.env.NODE_ENV === 'development') {
console.log('[ActivityFeedPage] Event clicked:', event);
}
}, []);
const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length;
return (
<div className="container mx-auto px-4 py-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* Page Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Activity Feed</h1>
<p className="mt-1 text-muted-foreground">Real-time updates from your projects</p>
</div>
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{pendingCount} pending
</Badge>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
aria-label={notificationsEnabled ? 'Disable notifications' : 'Enable notifications'}
>
{notificationsEnabled ? (
<Bell className="h-4 w-4" />
) : (
<BellOff className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={reconnect}
aria-label="Refresh connection"
>
<RefreshCw className={connectionState === 'connecting' ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
</Button>
</div>
</div>
{/* Demo Mode Banner */}
{(!isConnected || sseEvents.length === 0) && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Demo Mode:</strong> Showing sample events. Connect to a real project to see live updates.
</p>
</div>
)}
{/* Activity Feed Component */}
<ActivityFeed
events={events}
connectionState={isConnected ? connectionState : 'disconnected'}
onReconnect={reconnect}
onApprove={handleApprove}
onReject={handleReject}
onEventClick={handleEventClick}
maxHeight={600}
showHeader={false}
enableFiltering
enableSearch
/>
</div>
</div>
);
}