feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)

This commit is contained in:
2026-06-11 09:08:39 +09:00
parent 1dc5bc3391
commit 57dfb3a3aa
2 changed files with 98 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
// src/pages/agent-office/components/ActivityTimeline.jsx
import { useState, useRef, useEffect } from 'react';
import { useActivityFeed } from '../hooks/useActivityFeed.js';
import ActivityFilters from './ActivityFilters.jsx';
import ActivityItem from './ActivityItem.jsx';
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
const sentinelRef = useRef(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '120px' });
io.observe(el);
return () => io.disconnect();
}, [loadMore, items.length]);
return (
<div className="ao-sidepanel ao-activity">
<div className="ao-sidepanel-header ao-activity-header">
<div className="ao-sidepanel-name"> 활동 ({total})</div>
</div>
<ActivityFilters filters={filters} onChange={setFilters} />
<div className="ao-sidepanel-content ao-activity-content">
{error && (
<div className="ao-activity-error">
불러오기 실패: {error}
<button type="button" onClick={retry}>재시도</button>
</div>
)}
{!error && items.length === 0 && !loading && (
<div className="ao-empty">최근 {filters.days} 활동 없음</div>
)}
{items.map((item, i) => (
<ActivityItem
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
item={item}
onSelectAgent={onSelectAgent}
/>
))}
{loading && <div className="ao-activity-loading">불러오는 </div>}
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
{!hasMore && items.length > 0 && <div className="ao-activity-end"> 이상 활동 없음</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import ActivityTimeline from './ActivityTimeline.jsx';
// jsdom IntersectionObserver stub
beforeEach(() => {
global.IntersectionObserver = class {
observe() {} unobserve() {} disconnect() {}
};
});
const mockAgentActivity = vi.fn();
vi.mock('../../../api', () => ({
agentActivity: (...args) => mockAgentActivity(...args),
}));
describe('ActivityTimeline', () => {
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
});
it('빈 결과면 안내 문구를 표시한다', async () => {
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
});
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
const onSelect = vi.fn();
mockAgentActivity.mockResolvedValueOnce({
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
total: 1,
});
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
const row = await screen.findByText('signal_check');
fireEvent.click(row.closest('.ao-activity-item'));
expect(onSelect).toHaveBeenCalledWith('lotto');
});
});