LC-3021 멘토 마이페이지 v2.1#2402
Hidden character warning
Conversation
- 무한스크롤 유지한 채 캘린더 상단에 보이는 영역의 날짜 범위(YYYY.MM.DD ~ MM.DD) 라벨 표시, 스크롤에 따라 자동 갱신 - < / > 버튼으로 한 주(7일)씩 좌측 정렬 이동 - 라벨+요일·날짜 행을 sticky 헤더로 분리해 세로 스크롤 시 상단 고정(본문 가로 스크롤 미러링) - 스크롤 컨테이너 패딩(py-6)으로 본문이 비치는 상단 여백 버그를 -top-6로 보정 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….일-추가 LC-3132 멘토 피드백 피드백 캘린더 년.월.일 추가
There was a problem hiding this comment.
Code Review
이번 PR은 주간 캘린더(WeeklyCalendar)에 스크롤 위치에 따라 현재 보이는 날짜 범위를 관찰하고 표시하는 기능(useVisibleDateRange, CalendarRangeHeader)을 추가하고 헤더 영역을 고정(sticky)하도록 개선하였습니다. 이에 대해 (1) useVisibleDateRange 훅에서 Date 객체 참조 비교로 인한 불필요한 useEffect 재실행 방지를 위해 timelineStart.getTime()을 사용할 것, (2) -top-6와 같은 하드코딩된 음수 top 값 대신 결합도를 낮추는 방식을 고려할 것, (3) 테스트 코드에서 타임존 이슈를 방지하기 위해 toISOString() 대신 date-fns의 format을 사용할 것을 제안합니다.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function useVisibleDateRange( | ||
| containerRef: RefObject<HTMLDivElement | null>, | ||
| timelineStart: Date, | ||
| totalDays: number, | ||
| ): VisibleRange | null { | ||
| const [range, setRange] = useState<VisibleRange | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| const el = containerRef.current; | ||
| if (!el) return; | ||
|
|
||
| let raf = 0; | ||
| const update = () => { | ||
| cancelAnimationFrame(raf); | ||
| raf = requestAnimationFrame(() => { | ||
| setRange( | ||
| computeVisibleRange({ | ||
| scrollLeft: el.scrollLeft, | ||
| scrollWidth: el.scrollWidth, | ||
| clientWidth: el.clientWidth, | ||
| timelineStart, | ||
| totalDays, | ||
| }), | ||
| ); | ||
| }); | ||
| }; | ||
|
|
||
| update(); | ||
| el.addEventListener('scroll', update, { passive: true }); | ||
|
|
||
| // ResizeObserver 미지원 환경(일부 테스트 jsdom 등)에서는 scroll 갱신만 사용. | ||
| const resizeObserver = | ||
| typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; | ||
| resizeObserver?.observe(el); | ||
|
|
||
| return () => { | ||
| cancelAnimationFrame(raf); | ||
| el.removeEventListener('scroll', update); | ||
| resizeObserver?.disconnect(); | ||
| }; | ||
| }, [containerRef, timelineStart, totalDays]); | ||
|
|
||
| return range; | ||
| } |
There was a problem hiding this comment.
useEffect 의 의존성 배열에 Date 객체인 timelineStart가 그대로 사용되고 있습니다. JavaScript에서 Date 객체는 참조 비교(Reference Comparison)를 하므로, 동일한 날짜를 가리키더라도 새로운 인스턴스가 생성되면 useEffect가 불필요하게 재실행됩니다.
상위 컴포넌트에서 timelineStart가 항상 완벽히 메모이제이션된다고 보장하기 어렵기 때문에, 안전하게 timelineStart.getTime()과 같은 원시 값(number)을 의존성 배열에 사용하도록 개선하는 것을 권장합니다.
export function useVisibleDateRange(
containerRef: RefObject<HTMLDivElement | null>,
timelineStart: Date,
totalDays: number,
): VisibleRange | null {
const [range, setRange] = useState<VisibleRange | null>(null);
const timelineStartMs = timelineStart.getTime();
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let raf = 0;
const update = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setRange(
computeVisibleRange({
scrollLeft: el.scrollLeft,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
timelineStart,
totalDays,
}),
);
});
};
update();
el.addEventListener('scroll', update, { passive: true });
// ResizeObserver 미지원 환경(일부 테스트 jsdom 등)에서는 scroll 갱신만 사용.
const resizeObserver =
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
resizeObserver?.observe(el);
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('scroll', update);
resizeObserver?.disconnect();
};
}, [containerRef, timelineStartMs, totalDays]);
return range;
}| {/* -top-6: 페이지 스크롤 컨테이너(py-6=24px)의 상단 패딩만큼 끌어올려, 그 패딩 | ||
| 영역으로 본문이 비쳐 보이는 여백 버그를 막는다 (헤더가 패딩 구간까지 덮음). */} | ||
| <div className="sticky -top-6 z-20 overflow-hidden rounded-t-2xl bg-white"> |
There was a problem hiding this comment.
sticky -top-6와 같이 음수 top 값을 하드코딩하여 상위 페이지 컨테이너의 패딩(py-6)을 덮도록 구현되어 있습니다.
이 방식은 현재 페이지의 레이아웃 구조에 강하게 결합(Tight Coupling)되어 있어, 만약 이 컴포넌트가 다른 패딩 값을 가진 페이지에서 재사용되거나 모바일 등 반응형 대응으로 패딩이 변경될 경우 헤더가 잘리거나 공백이 생기는 레이아웃 버그가 발생할 수 있습니다.
컴포넌트의 재사용성과 독립성을 높이기 위해, top-0을 기본값으로 사용하고 상위 패딩/마진 구조를 페이지 레벨에서 조정하거나, 필요하다면 sticky top 오프셋을 prop으로 전달받을 수 있도록 개선하는 것을 권장합니다.
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { computeVisibleRange } from '../useVisibleDateRange'; | ||
|
|
||
| // 타임라인: 2026-06-01 시작, 28일. dayWidth = 2800/28 = 100px, 뷰포트 700px = 7일. | ||
| const BASE = { | ||
| timelineStart: new Date('2026-06-01'), | ||
| totalDays: 28, | ||
| scrollWidth: 2800, | ||
| clientWidth: 700, | ||
| }; | ||
|
|
||
| function iso(date: Date): string { | ||
| return date.toISOString().slice(0, 10); | ||
| } |
There was a problem hiding this comment.
테스트 코드에서 날짜를 검증할 때 date.toISOString().slice(0, 10)을 사용하고 있습니다. toISOString()은 항상 UTC 기준의 시간을 반환하는 반면, date-fns의 addDays나 프로덕션 코드의 format은 실행 환경의 로컬 타임존을 기준으로 동작합니다.
또한 new Date('2026-06-01')과 같은 ISO 형식 문자열 파싱은 실행 환경(CI 서버 vs 개발자 로컬 PC)의 타임존 설정에 따라 하루 전/후로 어긋나 테스트가 실패할 위험이 있습니다.
테스트의 일관성을 위해 프로덕션 코드와 동일하게 date-fns의 format 함수를 사용하여 로컬 타임존 기준으로 포맷팅하고, new Date(2026, 5, 1)과 같이 타임존에 안전한 방식으로 날짜 객체를 생성하는 것을 권장합니다.
| import { describe, expect, it } from 'vitest'; | |
| import { computeVisibleRange } from '../useVisibleDateRange'; | |
| // 타임라인: 2026-06-01 시작, 28일. dayWidth = 2800/28 = 100px, 뷰포트 700px = 7일. | |
| const BASE = { | |
| timelineStart: new Date('2026-06-01'), | |
| totalDays: 28, | |
| scrollWidth: 2800, | |
| clientWidth: 700, | |
| }; | |
| function iso(date: Date): string { | |
| return date.toISOString().slice(0, 10); | |
| } | |
| import { format } from 'date-fns'; | |
| import { describe, expect, it } from 'vitest'; | |
| import { computeVisibleRange } from '../useVisibleDateRange'; | |
| // 타임라인: 2026-06-01 시작, 28일. dayWidth = 2800/28 = 100px, 뷰포트 700px = 7일. | |
| const BASE = { | |
| timelineStart: new Date(2026, 5, 1), | |
| totalDays: 28, | |
| scrollWidth: 2800, | |
| clientWidth: 700, | |
| }; | |
| function iso(date: Date): string { | |
| return format(date, 'yyyy-MM-dd'); | |
| } |
연관 작업