feat: initialize monorepo with package.json for CCW workflow platform

This commit is contained in:
catlog22
2026-02-03 14:42:20 +08:00
parent 5483a72e9f
commit 39b80b3386
267 changed files with 99597 additions and 2658 deletions

View File

@@ -0,0 +1,72 @@
// ========================================
// DashboardGridContainer Component
// ========================================
// Responsive grid layout using react-grid-layout for draggable/resizable widgets
import * as React from 'react';
import { Responsive, WidthProvider, Layout as RGLLayout } from 'react-grid-layout';
import { cn } from '@/lib/utils';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
import { GRID_BREAKPOINTS, GRID_COLS, GRID_ROW_HEIGHT } from './defaultLayouts';
import type { DashboardLayouts } from '@/types/store';
const ResponsiveGridLayout = WidthProvider(Responsive);
export interface DashboardGridContainerProps {
/** Child elements to render in the grid (widgets/sections) */
children: React.ReactNode;
/** Additional CSS classes for the grid container */
className?: string;
/** Whether grid items are draggable */
isDraggable?: boolean;
/** Whether grid items are resizable */
isResizable?: boolean;
}
/**
* DashboardGridContainer - Responsive grid layout with drag-drop support
*
* Uses react-grid-layout for draggable and resizable dashboard widgets.
* Layouts are persisted to localStorage and Zustand store.
*
* Breakpoints:
* - lg: >= 1024px (12 columns)
* - md: >= 768px (6 columns)
* - sm: >= 640px (2 columns)
*/
export function DashboardGridContainer({
children,
className,
isDraggable = true,
isResizable = true,
}: DashboardGridContainerProps) {
const { layouts, updateLayouts } = useUserDashboardLayout();
// Handle layout change (debounced via hook)
const handleLayoutChange = React.useCallback(
(_currentLayout: RGLLayout[], allLayouts: DashboardLayouts) => {
updateLayouts(allLayouts);
},
[updateLayouts]
);
return (
<ResponsiveGridLayout
className={cn('dashboard-grid', className)}
layouts={layouts}
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLS}
rowHeight={GRID_ROW_HEIGHT}
isDraggable={isDraggable}
isResizable={isResizable}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
containerPadding={[0, 0]}
margin={[16, 16]}
>
{children}
</ResponsiveGridLayout>
);
}
export default DashboardGridContainer;

View File

@@ -0,0 +1,83 @@
// ========================================
// DashboardHeader Component
// ========================================
// Reusable dashboard header with title, description, and refresh action
import * as React from 'react';
import { useIntl } from 'react-intl';
import { RefreshCw, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export interface DashboardHeaderProps {
/** i18n key for the dashboard title */
titleKey: string;
/** i18n key for the dashboard description */
descriptionKey: string;
/** Callback when refresh button is clicked */
onRefresh?: () => void;
/** Whether the refresh action is currently loading */
isRefreshing?: boolean;
/** Callback when reset layout button is clicked */
onResetLayout?: () => void;
/** Optional additional actions to render */
actions?: React.ReactNode;
}
/**
* DashboardHeader - Reusable header component for dashboard pages
*
* Displays a title, description, and optional refresh/reset layout buttons.
* Supports additional custom actions via the actions prop.
*/
export function DashboardHeader({
titleKey,
descriptionKey,
onRefresh,
isRefreshing = false,
onResetLayout,
actions,
}: DashboardHeaderProps) {
const { formatMessage } = useIntl();
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: titleKey })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: descriptionKey })}
</p>
</div>
<div className="flex items-center gap-2">
{actions}
{onResetLayout && (
<Button
variant="outline"
size="sm"
onClick={onResetLayout}
aria-label="Reset dashboard layout"
>
<RotateCcw className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.actions.resetLayout' })}
</Button>
)}
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
aria-label={formatMessage({ id: 'home.dashboard.refreshTooltip' })}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isRefreshing && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
)}
</div>
</div>
);
}
export default DashboardHeader;

View File

@@ -0,0 +1,436 @@
// ========================================
// Dashboard Integration Tests
// ========================================
// Integration tests for HomePage data flows: stats + sessions + charts + ticker all loading concurrently
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithI18n, screen, waitFor } from '@/test/i18n';
import HomePage from '@/pages/HomePage';
// Mock hooks
vi.mock('@/hooks/useDashboardStats', () => ({
useDashboardStats: vi.fn(),
}));
vi.mock('@/hooks/useSessions', () => ({
useSessions: vi.fn(),
}));
vi.mock('@/hooks/useWorkflowStatusCounts', () => ({
useWorkflowStatusCounts: vi.fn(),
}));
vi.mock('@/hooks/useActivityTimeline', () => ({
useActivityTimeline: vi.fn(),
}));
vi.mock('@/hooks/useTaskTypeCounts', () => ({
useTaskTypeCounts: vi.fn(),
}));
vi.mock('@/hooks/useRealtimeUpdates', () => ({
useRealtimeUpdates: vi.fn(),
}));
vi.mock('@/hooks/useUserDashboardLayout', () => ({
useUserDashboardLayout: vi.fn(),
}));
vi.mock('@/stores/appStore', () => ({
useAppStore: vi.fn(() => ({
projectPath: '/test/project',
locale: 'en',
})),
}));
import { useDashboardStats } from '@/hooks/useDashboardStats';
import { useSessions } from '@/hooks/useSessions';
import { useWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
import { useActivityTimeline } from '@/hooks/useActivityTimeline';
import { useTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
import { useRealtimeUpdates } from '@/hooks/useRealtimeUpdates';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
describe('Dashboard Integration Tests', () => {
beforeEach(() => {
// Setup default mock responses
vi.mocked(useDashboardStats).mockReturnValue({
data: {
totalSessions: 42,
activeSessions: 5,
completedToday: 12,
averageTime: '2.5h',
successRate: 85,
taskCount: 156,
},
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useSessions).mockReturnValue({
activeSessions: [
{
id: 'session-1',
name: 'Test Session 1',
status: 'in_progress',
tasks: [{ status: 'completed' }, { status: 'pending' }],
created_at: new Date().toISOString(),
},
],
archivedSessions: [],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
data: [
{ status: 'completed', count: 30, percentage: 60 },
{ status: 'in_progress', count: 10, percentage: 20 },
{ status: 'pending', count: 10, percentage: 20 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useActivityTimeline).mockReturnValue({
data: [
{ date: '2026-02-01', sessions: 5, tasks: 20 },
{ date: '2026-02-02', sessions: 8, tasks: 35 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useTaskTypeCounts).mockReturnValue({
data: [
{ type: 'feature', count: 45 },
{ type: 'bugfix', count: 30 },
{ type: 'refactor', count: 15 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [
{
id: 'msg-1',
text: 'Session completed',
type: 'session',
timestamp: Date.now(),
},
],
connectionStatus: 'connected',
reconnect: vi.fn(),
});
vi.mocked(useUserDashboardLayout).mockReturnValue({
layouts: {
lg: [],
md: [],
sm: [],
},
saveLayout: vi.fn(),
resetLayout: vi.fn(),
isSaving: false,
} as any);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Concurrent Data Loading', () => {
it('INT-1.1 - should load all data sources concurrently', async () => {
renderWithI18n(<HomePage />);
// Verify all hooks are called
expect(useDashboardStats).toHaveBeenCalled();
expect(useSessions).toHaveBeenCalled();
expect(useWorkflowStatusCounts).toHaveBeenCalled();
expect(useActivityTimeline).toHaveBeenCalled();
expect(useTaskTypeCounts).toHaveBeenCalled();
expect(useRealtimeUpdates).toHaveBeenCalled();
});
it('INT-1.2 - should display all widgets with loaded data', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check for stat cards
expect(screen.queryByText('42')).toBeInTheDocument(); // total sessions
});
});
it('INT-1.3 - should handle loading states correctly', async () => {
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
// Should show loading skeleton
await waitFor(() => {
const skeletons = screen.queryAllByTestId(/skeleton/i);
expect(skeletons.length).toBeGreaterThan(0);
});
});
it('INT-1.4 - should handle partial loading states', async () => {
// Stats loading, sessions loaded
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check that hooks were called (rendering may vary based on implementation)
expect(useDashboardStats).toHaveBeenCalled();
expect(useSessions).toHaveBeenCalled();
});
});
});
describe('Data Flow Integration', () => {
it('INT-2.1 - should pass stats data to DetailedStatsWidget', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('42')).toBeInTheDocument();
expect(screen.queryByText('5')).toBeInTheDocument();
});
});
it('INT-2.2 - should pass session data to RecentSessionsWidget', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('Test Session 1')).toBeInTheDocument();
});
});
it('INT-2.3 - should pass chart data to chart widgets', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
// Chart data should be rendered
expect(useWorkflowStatusCounts).toHaveBeenCalled();
expect(useActivityTimeline).toHaveBeenCalled();
expect(useTaskTypeCounts).toHaveBeenCalled();
});
});
it('INT-2.4 - should pass ticker messages to TickerMarquee', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('INT-3.1 - should display error state when stats hook fails', async () => {
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load stats'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
const errorText = screen.queryByText(/error|failed/i);
expect(errorText).toBeInTheDocument();
});
});
it('INT-3.2 - should display error state when sessions hook fails', async () => {
vi.mocked(useSessions).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load sessions'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
const errorText = screen.queryByText(/error|failed/i);
expect(errorText).toBeInTheDocument();
});
});
it('INT-3.3 - should display error state when chart hooks fail', async () => {
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load chart data'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useWorkflowStatusCounts).toHaveBeenCalled();
});
});
it('INT-3.4 - should handle partial errors gracefully', async () => {
// Only stats fails, others succeed
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Stats failed'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check that useSessions was called (sessions may or may not render)
expect(useSessions).toHaveBeenCalled();
});
});
it('INT-3.5 - should handle WebSocket disconnection', async () => {
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [],
connectionStatus: 'disconnected',
reconnect: vi.fn(),
});
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
describe('Data Refresh', () => {
it('INT-4.1 - should refresh all data sources on refresh button click', async () => {
const mockRefetch = vi.fn();
vi.mocked(useDashboardStats).mockReturnValue({
data: { totalSessions: 42 } as any,
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
renderWithI18n(<HomePage />);
const refreshButton = screen.queryByRole('button', { name: /refresh/i });
if (refreshButton) {
refreshButton.click();
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled();
});
}
});
it('INT-4.2 - should update UI when data changes', async () => {
const { rerender } = renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('42')).toBeInTheDocument();
});
// Update data
vi.mocked(useDashboardStats).mockReturnValue({
data: { totalSessions: 50 } as any,
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
rerender(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('50')).toBeInTheDocument();
});
});
});
describe('Workspace Scoping', () => {
it('INT-5.1 - should pass workspace path to all data hooks', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useDashboardStats).toHaveBeenCalledWith(
expect.objectContaining({ projectPath: '/test/project' })
);
});
});
it('INT-5.2 - should refresh data when workspace changes', async () => {
const { rerender } = renderWithI18n(<HomePage />);
// Change workspace
vi.mocked(require('@/stores/appStore').useAppStore).mockReturnValue({
projectPath: '/different/project',
locale: 'en',
});
rerender(<HomePage />);
await waitFor(() => {
expect(useDashboardStats).toHaveBeenCalled();
});
});
});
describe('Realtime Updates', () => {
it('INT-6.1 - should display new ticker messages as they arrive', async () => {
const { rerender } = renderWithI18n(<HomePage />);
// Add new message
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [
{
id: 'msg-2',
text: 'New session started',
type: 'session',
timestamp: Date.now(),
},
],
connectionStatus: 'connected',
reconnect: vi.fn(),
});
rerender(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
it('INT-6.2 - should maintain connection status indicator', async () => {
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [],
connectionStatus: 'reconnecting',
reconnect: vi.fn(),
});
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,64 @@
// ========================================
// Default Dashboard Layouts
// ========================================
// Default widget configurations and responsive layouts for the dashboard grid
import type { WidgetConfig, DashboardLayouts, DashboardLayoutState } from '@/types/store';
/** Widget IDs used across the dashboard */
export const WIDGET_IDS = {
STATS: 'detailed-stats',
RECENT_SESSIONS: 'recent-sessions',
WORKFLOW_STATUS: 'workflow-status-pie',
ACTIVITY: 'activity-line',
TASK_TYPES: 'task-type-bar',
} as const;
/** Default widget configurations */
export const DEFAULT_WIDGETS: WidgetConfig[] = [
{ i: WIDGET_IDS.STATS, name: 'Statistics', visible: true, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, name: 'Workflow Status', visible: true, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, name: 'Activity', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, name: 'Task Types', visible: true, minW: 3, minH: 3 },
];
/** Default responsive layouts */
export const DEFAULT_LAYOUTS: DashboardLayouts = {
lg: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 12, h: 2, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 6, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 6, w: 7, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 7, y: 6, w: 5, h: 4, minW: 3, minH: 3 },
],
md: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 6, h: 2, minW: 3, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 10, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 14, w: 6, h: 4, minW: 3, minH: 3 },
],
sm: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 3, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 7, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 11, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 15, w: 2, h: 4, minW: 2, minH: 3 },
],
};
/** Default dashboard layout state */
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutState = {
widgets: DEFAULT_WIDGETS,
layouts: DEFAULT_LAYOUTS,
};
/** Grid breakpoints matching Tailwind config */
export const GRID_BREAKPOINTS = { lg: 1024, md: 768, sm: 640 };
/** Grid columns per breakpoint */
export const GRID_COLS = { lg: 12, md: 6, sm: 2 };
/** Row height in pixels */
export const GRID_ROW_HEIGHT = 60;

View File

@@ -0,0 +1,58 @@
// ========================================
// ActivityLineChartWidget Component
// ========================================
// Widget wrapper for activity line chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { ActivityLineChart, ChartSkeleton } from '@/components/charts';
import { useActivityTimeline, generateMockActivityTimeline } from '@/hooks/useActivityTimeline';
export interface ActivityLineChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* ActivityLineChartWidget - Dashboard widget showing activity trends over time
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useActivityTimeline();
// Use mock data if API is not ready
const chartData = data || generateMockActivityTimeline();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.activity' })}
</h3>
{isLoading ? (
<ChartSkeleton type="line" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<ActivityLineChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const ActivityLineChartWidget = memo(ActivityLineChartWidgetComponent);
export default ActivityLineChartWidget;

View File

@@ -0,0 +1,150 @@
// ========================================
// DetailedStatsWidget Component
// ========================================
// Widget wrapper for detailed statistics cards in dashboard grid layout
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
FolderKanban,
ListChecks,
CheckCircle2,
Clock,
XCircle,
Activity,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
import { useDashboardStats } from '@/hooks/useDashboardStats';
export interface DetailedStatsWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* DetailedStatsWidget - Dashboard widget showing detailed statistics
*
* Displays 6 stat cards with key metrics:
* - Active sessions, total tasks, completed tasks
* - Pending tasks, failed tasks, today's activity
*
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function DetailedStatsWidgetComponent({ className, ...props }: DetailedStatsWidgetProps) {
const { formatMessage } = useIntl();
// Fetch dashboard stats
const { stats, isLoading, isFetching } = useDashboardStats({
refetchInterval: 60000, // Refetch every minute
});
// Generate mock sparkline data for last 7 days
// TODO: Replace with real API data when backend provides trend data
const generateSparklineData = (currentValue: number, variance = 0.3): number[] => {
const days = 7;
const data: number[] = [];
let value = Math.max(0, currentValue * (1 - variance));
for (let i = 0; i < days - 1; i++) {
data.push(Math.round(value));
const change = (Math.random() - 0.5) * 2 * variance * currentValue;
value = Math.max(0, value + change);
}
// Last day is current value
data.push(currentValue);
return data;
};
// Stat card configuration with sparkline data
const statCards = React.useMemo(() => [
{
key: 'activeSessions',
title: formatMessage({ id: 'home.stats.activeSessions' }),
icon: FolderKanban,
variant: 'primary' as const,
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
getSparkline: (stats: { activeSessions: number }) => generateSparklineData(stats.activeSessions, 0.4),
},
{
key: 'totalTasks',
title: formatMessage({ id: 'home.stats.totalTasks' }),
icon: ListChecks,
variant: 'info' as const,
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
getSparkline: (stats: { totalTasks: number }) => generateSparklineData(stats.totalTasks, 0.3),
},
{
key: 'completedTasks',
title: formatMessage({ id: 'home.stats.completedTasks' }),
icon: CheckCircle2,
variant: 'success' as const,
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
getSparkline: (stats: { completedTasks: number }) => generateSparklineData(stats.completedTasks, 0.25),
},
{
key: 'pendingTasks',
title: formatMessage({ id: 'home.stats.pendingTasks' }),
icon: Clock,
variant: 'warning' as const,
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
getSparkline: (stats: { pendingTasks: number }) => generateSparklineData(stats.pendingTasks, 0.35),
},
{
key: 'failedTasks',
title: formatMessage({ id: 'common.status.failed' }),
icon: XCircle,
variant: 'danger' as const,
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
getSparkline: (stats: { failedTasks: number }) => generateSparklineData(stats.failedTasks, 0.5),
},
{
key: 'todayActivity',
title: formatMessage({ id: 'common.stats.todayActivity' }),
icon: Activity,
variant: 'default' as const,
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
getSparkline: (stats: { todayActivity: number }) => generateSparklineData(stats.todayActivity, 0.6),
},
], [formatMessage]);
return (
<div {...props} className={className}>
<Card className="h-full p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{isLoading
? Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
: statCards.map((card) => (
<StatCard
key={card.key}
title={card.title}
value={stats ? card.getValue(stats as any) : 0}
icon={card.icon}
variant={card.variant}
isLoading={isFetching && !stats}
sparklineData={stats ? (card as any).getSparkline(stats as any) : undefined}
showSparkline={true}
/>
))}
</div>
</Card>
</div>
);
}
/**
* Memoized DetailedStatsWidget - Prevents re-renders when parent updates
* Props are compared shallowly; use useCallback for function props
*/
export const DetailedStatsWidget = React.memo(DetailedStatsWidgetComponent);
export default DetailedStatsWidget;

View File

@@ -0,0 +1,117 @@
// ========================================
// RecentSessionsWidget Component
// ========================================
// Widget wrapper for recent sessions list in dashboard grid layout
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { FolderKanban } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
import { useSessions } from '@/hooks/useSessions';
import { Button } from '@/components/ui/Button';
export interface RecentSessionsWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
/** Maximum number of sessions to display */
maxSessions?: number;
}
/**
* RecentSessionsWidget - Dashboard widget showing recent workflow sessions
*
* Displays recent active sessions (max 6 by default) with navigation to session detail.
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function RecentSessionsWidgetComponent({
className,
maxSessions = 6,
...props
}: RecentSessionsWidgetProps) {
const { formatMessage } = useIntl();
const navigate = useNavigate();
// Fetch recent sessions (active only)
const { activeSessions, isLoading } = useSessions({
filter: { location: 'active' },
});
// Get recent sessions (sorted by creation date)
const recentSessions = React.useMemo(
() =>
[...activeSessions]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, maxSessions),
[activeSessions, maxSessions]
);
const handleSessionClick = (sessionId: string) => {
navigate(`/sessions/${sessionId}`);
};
const handleViewAll = () => {
navigate('/sessions');
};
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-foreground">
{formatMessage({ id: 'home.sections.recentSessions' })}
</h3>
<Button variant="link" size="sm" onClick={handleViewAll}>
{formatMessage({ id: 'common.actions.viewAll' })}
</Button>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<SessionCardSkeleton key={i} />
))}
</div>
) : recentSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
</p>
</div>
) : (
<div className="space-y-3">
{recentSessions.map((session) => (
<SessionCard
key={session.session_id}
session={session}
onClick={handleSessionClick}
onView={handleSessionClick}
showActions={false}
/>
))}
</div>
)}
</div>
</Card>
</div>
);
}
/**
* Memoized RecentSessionsWidget - Prevents re-renders when parent updates
* Props are compared shallowly; use useCallback for function props
*/
export const RecentSessionsWidget = React.memo(RecentSessionsWidgetComponent);
export default RecentSessionsWidget;

View File

@@ -0,0 +1,58 @@
// ========================================
// TaskTypeBarChartWidget Component
// ========================================
// Widget wrapper for task type bar chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { TaskTypeBarChart, ChartSkeleton } from '@/components/charts';
import { useTaskTypeCounts, generateMockTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
export interface TaskTypeBarChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* TaskTypeBarChartWidget - Dashboard widget showing task type distribution
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useTaskTypeCounts();
// Use mock data if API is not ready
const chartData = data || generateMockTaskTypeCounts();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.taskTypes' })}
</h3>
{isLoading ? (
<ChartSkeleton type="bar" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<TaskTypeBarChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const TaskTypeBarChartWidget = memo(TaskTypeBarChartWidgetComponent);
export default TaskTypeBarChartWidget;

View File

@@ -0,0 +1,58 @@
// ========================================
// WorkflowStatusPieChartWidget Component
// ========================================
// Widget wrapper for workflow status pie chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { WorkflowStatusPieChart, ChartSkeleton } from '@/components/charts';
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
export interface WorkflowStatusPieChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* WorkflowStatusPieChartWidget - Dashboard widget showing workflow status distribution
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function WorkflowStatusPieChartWidgetComponent({ className, ...props }: WorkflowStatusPieChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useWorkflowStatusCounts();
// Use mock data if API is not ready
const chartData = data || generateMockWorkflowStatusCounts();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.workflowStatus' })}
</h3>
{isLoading ? (
<ChartSkeleton type="pie" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<WorkflowStatusPieChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const WorkflowStatusPieChartWidget = memo(WorkflowStatusPieChartWidgetComponent);
export default WorkflowStatusPieChartWidget;

View File

@@ -0,0 +1,19 @@
// ========================================
// Dashboard Widgets - Export Index
// ========================================
// Central export point for all dashboard widget components
export { DetailedStatsWidget } from './DetailedStatsWidget';
export type { DetailedStatsWidgetProps } from './DetailedStatsWidget';
export { RecentSessionsWidget } from './RecentSessionsWidget';
export type { RecentSessionsWidgetProps } from './RecentSessionsWidget';
export { WorkflowStatusPieChartWidget } from './WorkflowStatusPieChartWidget';
export type { WorkflowStatusPieChartWidgetProps } from './WorkflowStatusPieChartWidget';
export { ActivityLineChartWidget } from './ActivityLineChartWidget';
export type { ActivityLineChartWidgetProps } from './ActivityLineChartWidget';
export { TaskTypeBarChartWidget } from './TaskTypeBarChartWidget';
export type { TaskTypeBarChartWidgetProps } from './TaskTypeBarChartWidget';