# Task: 프로젝트뷰 스크롤 위치 보존 — 근본 수정 (v3)

## 파일
`/home/jay/workspace/dashboard/index.html` — ProjectView 컴포넌트 (line 582~)

## 이전 시도 실패 원인 (task-593.1, task-594.1)

### 시도 1: `useLayoutEffect` + `savedScrollTop` (useRef)
→ 실패: SSE 갱신마다 `React.memo`가 무효화되어 리렌더링 발생, 타이밍 race condition

### 시도 2: `useCallback` + per-project props + custom comparator + `requestAnimationFrame`
→ 실패: **custom comparator가 참조 비교(===)를 사용하지만, `tasksByProject[proj]`는 매 렌더마다 새로 계산된 객체**
```jsx
// line 835: 매 렌더마다 새 객체 → 참조가 항상 다름
projectData={tasksByProject[proj] || { tasks: [], running: 0, completed: 0 }}
// line 768: 참조 비교 → 항상 false
prevProps.projectData === nextProps.projectData  // 항상 false!
```
결과: React.memo가 여전히 **모든 SSE 갱신에서 리렌더링 허용** → `useLayoutEffect` scroll 복원이 빠른 연속 리렌더에서 실패

## 근본 원인
SSE EventSource가 데이터를 보낼 때마다:
1. `App`의 state 갱신 → `ProjectView` 리렌더
2. `tasksByProject`, `todoByProject`가 함수 본문에서 재계산 → 매번 새 객체 참조
3. `React.memo` custom comparator가 참조 비교 → 항상 fail
4. `ProjectCard` 리렌더 → DOM 갱신 → 스크롤 초기화

## 해결 방법: 모듈 레벨 스크롤 위치 저장소

React 라이프사이클에 의존하지 않는 방식으로 스크롤 위치를 보존한다.

### 핵심 구현

**Step 1**: 모듈 레벨에 스크롤 위치 Map 선언 (ProjectView 컴포넌트 바깥, `PROJECT_ACCENT` 선언 근처)
```jsx
// 모듈 레벨 — 컴포넌트 생명주기와 무관하게 유지됨
const _scrollPositions = new Map();
```

**Step 2**: ProjectCard 내부의 `scrollRef`/`savedScrollTop` ref를 제거하고, 모듈 레벨 Map 사용
```jsx
const ProjectCard = React.memo(({ projName, projectData, issues, accent, expandedIssues, toggleIssue }) => {
    const data = projectData;
    const recentTasks = [...data.tasks]
        .sort((a, b) => (b.start_time || '').localeCompare(a.start_time || ''))
        .slice(0, 5);
    const hasContent = data.tasks.length > 0 || issues.length > 0;

    const scrollRef = useRef(null);

    // 스크롤 위치 복원 — 모듈 레벨 Map에서 읽기
    React.useLayoutEffect(() => {
        const saved = _scrollPositions.get(projName);
        if (scrollRef.current && saved > 0) {
            scrollRef.current.scrollTop = saved;
        }
    });

    // 스크롤 이벤트 핸들러 — 모듈 레벨 Map에 저장
    const handleScroll = React.useCallback((e) => {
        _scrollPositions.set(projName, e.target.scrollTop);
    }, [projName]);

    return (
        <div className={`bg-white rounded-2xl shadow-sm border ${accent.border} p-4 min-h-[200px] flex flex-col`}>
            {/* 헤더 부분은 그대로 유지 */}
            ...
            {!hasContent ? (
                <div className="flex-1 flex items-center justify-center text-slate-300 text-xs">데이터 없음</div>
            ) : (
                <div ref={scrollRef} onScroll={handleScroll} className="flex-1 flex flex-col gap-3 max-h-[320px] overflow-y-auto">
                    ...
                </div>
            )}
        </div>
    );
}, (prevProps, nextProps) => {
    // 기존 custom comparator 유지
    return prevProps.projName === nextProps.projName &&
           prevProps.projectData === nextProps.projectData &&
           prevProps.issues === nextProps.issues &&
           prevProps.expandedIssues === nextProps.expandedIssues;
});
```

**Step 3**: `useMemo`로 `tasksByProject`와 `todoByProject`에 참조 안정성 부여 (React.memo가 실제로 작동하도록)
```jsx
const ProjectView = ({ tasksData, todoData }) => {
    ...
    // useMemo로 참조 안정성 확보 — tasksData/todoData가 동일하면 동일 객체 반환
    const tasksByProject = React.useMemo(() => {
        const result = {};
        (tasksData || []).forEach(t => {
            if (!t.task_id) return;
            const proj = classifyByDescription(t.description);
            if (!result[proj]) result[proj] = { tasks: [], running: 0, completed: 0 };
            result[proj].tasks.push(t);
            if (t.status === 'running' && !t.is_stale) result[proj].running++;
            if (t.status === 'completed') result[proj].completed++;
        });
        return result;
    }, [tasksData]);

    const todoByProject = React.useMemo(() => {
        const result = {};
        (todoData || []).filter(i => i.status !== 'completed' && i.status !== 'done').forEach(issue => {
            const proj = classifyTodoProject(issue.project);
            if (!result[proj]) result[proj] = [];
            result[proj].push(issue);
        });
        return result;
    }, [todoData]);

    const doneIssueCount = React.useMemo(() =>
        (todoData || []).filter(i => i.status === 'done' || i.status === 'completed').length,
    [todoData]);
    ...
```

**주의**: `useMemo`는 `tasksData`/`todoData` 배열 **참조**가 바뀌면 재계산한다. SSE가 매번 새 배열을 제공하면 여전히 재계산됨. 그래서 **Step 1~2의 모듈 레벨 Map이 핵심 보험**이다. React.memo가 실패해도 스크롤은 복원된다.

## 변경 요약
1. `_scrollPositions` Map을 모듈 레벨에 선언 (line 579 근처)
2. `ProjectCard`의 `savedScrollTop` useRef 제거 → `_scrollPositions.get(projName)` 사용
3. `onScroll` 핸들러를 `useCallback`으로 감싸고 `_scrollPositions.set()` 호출
4. `tasksByProject`, `todoByProject`, `doneIssueCount`를 `useMemo`로 감싸기
5. `requestAnimationFrame` 폴백 제거 (모듈 레벨 Map으로 불필요)

## 검증
- 대시보드 프로젝트뷰에서 ThreadAuto 카드의 안건을 펼치고 스크롤 내린 후, 몇 초 기다려도 스크롤 위치가 유지되는지 확인
- 브라우저 콘솔에서 `_scrollPositions` 접근 불가해도, 안건 클릭 후 스크롤 내리고 → 5~10초 대기 → 스크롤 유지 여부로 판별

## 테스트
프론트엔드 코드이므로 수동 테스트. 대시보드: http://100.76.130.39:8000/dashboard/