Conversation
…ations - Rename ResultLoadingPage to ResultGeneratingPage for clarity - Rename ResultLoadingTooltip to ResultGeneratingSpeechBubble for accuracy - Rename ResultLoadingIllustration to ResultGeneratingIllustration - Increase chopstick rotation angles for more dynamic animation - Speed up chopstick animation from 0.6s to 0.4s Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add ResultGeneratingPage display during result generation - Implement refetch before navigation to ensure data is ready - Fix race condition where gathering-full event arrives before state update - Add SSE event handler memoization in CompletePage - Prevent duplicate result generation requests with status check - Skip drumroll animation when result is already completed Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Replace if-else chain with switch-case for better readability - Add early return for null/undefined status check - Remove unnecessary optional chaining in switch block Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Increase rotation angle from ±8° to ±18° for wider spread - Create more dynamic drumroll-like visual effect Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Display ResultGeneratingPage for 2.5s even when result is pre-generated - Remove state cleanup before navigation to prevent view flickering - Improve user experience consistency across different entry points Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
b06d2f1 to
0609415
Compare
Summary of ChangesHello @RookieAND, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 PR은 추천 결과 생성 대기 페이지와 애니메이션을 구현하여 사용자 경험을 개선하고, 기존 의견 수렴 플로우의 안정성을 강화합니다. SSE 이벤트 처리의 경쟁 조건을 해결하고 중복 요청을 방지하며, API 에러 발생 시 사용자에게 더 명확한 피드백과 적절한 페이지 전환을 제공하도록 전역 및 특정 페이지 에러 핸들링을 개선했습니다. 또한, 코드 가독성과 유지보수성을 높이기 위한 대규모 리팩토링과 UI/UX 개선이 포함되어 있습니다. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Changelog
Ignored Files
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
추천 결과 대기 페이지 및 애니메이션을 구현하고, 결과 생성 플로우를 리팩토링하는 PR입니다. 전반적으로 변경 사항이 잘 구조화되어 있지만, 훅의 상태 관리 및 스타일 가이드 위반과 관련된 몇 가지 문제를 발견했습니다.
특히 useProceedRecommendResult 훅과 PendingPage의 SSE 이벤트 핸들러에서 stale closure로 인해 발생할 수 있는 경쟁 상태 및 잘못된 타이머 로직이 있습니다. 또한 새로 추가된 컴포넌트에서 저장소 스타일 가이드를 위반하는 하드코딩된 색상과 정의되지 않은 타이포그래피 토큰 사용 사례를 여러 곳에서 발견했습니다. 자세한 수정 제안은 각 리뷰 코멘트를 확인해주세요.
| const onResultComplete = useCallback(async () => { | ||
| if (!isProcessingRef.current) { | ||
| return; | ||
| } | ||
|
|
||
| isPollingRef.current = true; | ||
| const remaining = Math.max(0, NAVIGATION_DELAY); | ||
|
|
||
| const poll = async () => { | ||
| try { | ||
| const response = await getRecommendResult(accessKey); | ||
| const { status } = response.data; | ||
| setTimeout(async () => { | ||
| const { data: latestResult } = await fetchRecommendResult(); | ||
|
|
||
| switch (status) { | ||
| case RecommendationResultStatus.COMPLETED: | ||
| isPollingRef.current = false; | ||
| setManualPollingTrigger(false); | ||
| queryClient.setQueryData( | ||
| recommendResultKeys.detail(accessKey), | ||
| response, | ||
| ); | ||
| router.push(`/gathering/${accessKey}/opinion/result`); | ||
| break; | ||
|
|
||
| case RecommendationResultStatus.PENDING: | ||
| timeoutRef.current = setTimeout(() => { | ||
| poll(); | ||
| }, 1000); | ||
| break; | ||
|
|
||
| case RecommendationResultStatus.FAILED: | ||
| default: | ||
| isPollingRef.current = false; | ||
| setManualPollingTrigger(false); | ||
| toast.warning( | ||
| "추천 결과 생성에 실패했습니다. 다시 시도해주세요.", | ||
| ); | ||
| break; | ||
| } | ||
| } catch { | ||
| isPollingRef.current = false; | ||
| setManualPollingTrigger(false); | ||
| toast.warning("추천 결과 조회에 실패했습니다."); | ||
| if (latestResult?.status === RecommendationResultStatus.COMPLETED) { | ||
| router.push(`/gathering/${accessKey}/opinion/result`); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| poll(); | ||
|
|
||
| return () => { | ||
| isPollingRef.current = false; | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| } | ||
| }; | ||
| }, [shouldPoll, accessKey, router, queryClient]); | ||
| isProcessingRef.current = false; | ||
| setProceedState({ status: PROCEED_STATUS.IDLE }); | ||
| toast.warning( | ||
| "추천 결과가 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.", | ||
| ); | ||
| }, remaining); | ||
| }, [accessKey, router, fetchRecommendResult]); |
There was a problem hiding this comment.
현재 onResultComplete 함수에서 NAVIGATION_DELAY를 그대로 사용하여 항상 2.5초를 기다리도록 되어 있습니다. 이로 인해 proceed 함수에서 기록한 startTime이 무시되고, 애니메이션이 시작된 시점부터 최소 2.5초를 보장하려는 의도와 다르게 동작할 수 있습니다.
useServerSentEvent 훅의 구현상 onResultComplete 콜백은 stale closure가 될 수 있으므로, startTime을 useRef로 관리하여 항상 최신 값을 참조하도록 수정하는 것이 좋습니다. proceed 함수 내에서 setProceedState를 호출할 때 startTimeRef.current도 함께 업데이트해야 합니다.
수정 제안:
- 훅 최상단에
const startTimeRef = useRef(0);를 추가합니다. proceed함수 내에서setProceedState를 호출하는 모든 위치에startTimeRef.current = Date.now();을 추가합니다.- 아래와 같이
onResultComplete함수를 수정합니다.
const onResultComplete = useCallback(async () => {
if (!isProcessingRef.current) {
return;
}
const elapsedTime = Date.now() - startTimeRef.current;
const remaining = Math.max(0, NAVIGATION_DELAY - elapsedTime);
setTimeout(async () => {
const { data: latestResult } = await fetchRecommendResult();
if (latestResult?.status === RecommendationResultStatus.COMPLETED) {
router.push(`/gathering/${accessKey}/opinion/result`);
return;
}
isProcessingRef.current = false;
setProceedState({ status: PROCEED_STATUS.IDLE });
toast.warning(
"추천 결과가 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
);
}, remaining);
}, [accessKey, router, fetchRecommendResult]);| fill="url(#paint0_linear_1865_35373)" | ||
| /> | ||
|
|
||
| {/* Bowl Bottom */} | ||
| <path | ||
| d="M256.889 93.3672C256.465 101.288 252.257 108.624 245.374 114.819C232.954 125.995 211.717 133.388 187.5 133.388C163.283 133.388 142.046 125.995 129.626 114.819C122.747 108.624 118.535 101.288 118.111 93.3672C118.042 93.1832 118 93.0699 118 93.0699V111.365C118 150.787 149.114 182.749 187.5 182.749C225.886 182.749 257 150.791 257 111.365V93.0699C257 93.0699 256.958 93.1879 256.889 93.3672Z" | ||
| fill="url(#paint1_linear_1865_35373)" | ||
| /> | ||
|
|
||
| {/* Bowl Top - Inner */} | ||
| <path | ||
| d="M187.5 56.4092C206.312 56.4092 223.212 60.7837 235.315 67.7188C247.493 74.6962 254.327 83.9303 254.327 93.5566C254.327 103.183 247.493 112.418 235.315 119.396C223.212 126.331 206.312 130.705 187.5 130.705C168.688 130.705 151.788 126.331 139.685 119.396C127.507 112.418 120.673 103.183 120.673 93.5566C120.673 83.9303 127.507 74.6962 139.685 67.7188C151.788 60.7837 168.688 56.4092 187.5 56.4092Z" | ||
| fill="url(#paint2_linear_1865_35373)" | ||
| stroke="#7FC3FF" | ||
| strokeWidth="5.34615" | ||
| strokeLinecap="round" | ||
| /> | ||
|
|
||
| {/* Bowl Top - Outer */} | ||
| <path | ||
| d="M187.5 56.4092C206.312 56.4092 223.212 60.7837 235.315 67.7188C247.493 74.6962 254.327 83.9303 254.327 93.5566C254.327 103.183 247.493 112.418 235.315 119.396C223.212 126.331 206.312 130.705 187.5 130.705C168.688 130.705 151.788 126.331 139.685 119.396C127.507 112.418 120.673 103.183 120.673 93.5566C120.673 83.9303 127.507 74.6962 139.685 67.7188C151.788 60.7837 168.688 56.4092 187.5 56.4092Z" | ||
| stroke="#ADD9FF" | ||
| strokeWidth="5.34615" | ||
| strokeLinecap="round" | ||
| /> | ||
|
|
||
| {/* Left Chopstick */} | ||
| <motion.g | ||
| style={{ originX: "162px", originY: "186px" }} | ||
| animate={{ | ||
| rotate: [0, -18, 0], | ||
| }} | ||
| transition={{ | ||
| duration: 0.4, | ||
| repeat: Infinity, | ||
| ease: "easeInOut", | ||
| }} | ||
| > | ||
| <path | ||
| d="M177.478 87.8644C178.537 88.1337 179.184 89.2029 178.931 90.2658L155.766 187.59L148.29 185.688L174.223 89.073C174.507 88.0161 175.587 87.3834 176.648 87.6532L177.478 87.8644Z" | ||
| fill="url(#paint3_linear_1865_35373)" | ||
| /> | ||
| <path | ||
| d="M153.921 195.343C153.663 196.43 152.565 197.094 151.483 196.818L147.662 195.847C146.581 195.572 145.934 194.467 146.223 193.39L148.291 185.688L155.767 187.59L153.921 195.343Z" | ||
| fill="#1F2933" | ||
| /> | ||
| </motion.g> | ||
|
|
||
| {/* Right Chopstick */} | ||
| <motion.g | ||
| style={{ originX: "219px", originY: "202px" }} | ||
| animate={{ | ||
| rotate: [0, 18, 0], | ||
| }} | ||
| transition={{ | ||
| duration: 0.4, | ||
| repeat: Infinity, | ||
| ease: "easeInOut", | ||
| }} | ||
| > | ||
| <path | ||
| d="M200.325 103.101C199.252 103.305 198.54 104.333 198.728 105.409L215.876 203.971L223.455 202.532L203.5 104.507C203.281 103.435 202.242 102.737 201.167 102.941L200.325 103.101Z" | ||
| fill="url(#paint4_linear_1865_35373)" | ||
| /> | ||
| <path | ||
| d="M217.242 211.823C217.433 212.923 218.489 213.654 219.586 213.445L223.459 212.71C224.554 212.502 225.268 211.438 225.045 210.346L223.455 202.532L215.876 203.971L217.242 211.823Z" | ||
| fill="#1F2933" | ||
| /> | ||
| </motion.g> | ||
|
|
||
| <defs> | ||
| <linearGradient | ||
| id="paint0_linear_1865_35373" | ||
| x1="188" | ||
| y1="171.5" | ||
| x2="187.5" | ||
| y2="204.264" | ||
| gradientUnits="userSpaceOnUse" | ||
| > | ||
| <stop stopColor="#E5E7EB" /> | ||
| <stop offset="1" stopColor="#F3F4F6" /> | ||
| </linearGradient> | ||
| <linearGradient | ||
| id="paint1_linear_1865_35373" | ||
| x1="187.5" | ||
| y1="93.0699" | ||
| x2="187.5" | ||
| y2="182.749" | ||
| gradientUnits="userSpaceOnUse" | ||
| > | ||
| <stop stopColor="#66B9FF" /> | ||
| <stop offset="1" stopColor="#ADD9FF" /> | ||
| </linearGradient> | ||
| <linearGradient | ||
| id="paint2_linear_1865_35373" | ||
| x1="187.5" | ||
| y1="53.7363" | ||
| x2="187.5" | ||
| y2="133.378" | ||
| gradientUnits="userSpaceOnUse" | ||
| > | ||
| <stop stopColor="#7FC3FF" /> | ||
| <stop offset="1" stopColor="#53B7FF" /> | ||
| </linearGradient> | ||
| <linearGradient | ||
| id="paint3_linear_1865_35373" | ||
| x1="177.06" | ||
| y1="87.7582" | ||
| x2="149.452" | ||
| y2="196.302" | ||
| gradientUnits="userSpaceOnUse" | ||
| > | ||
| <stop stopColor="#FF5A3C" /> | ||
| <stop offset="1" stopColor="#FF7F6B" /> | ||
| </linearGradient> | ||
| <linearGradient | ||
| id="paint4_linear_1865_35373" | ||
| x1="200.748" | ||
| y1="103.021" | ||
| x2="221.644" | ||
| y2="213.054" | ||
| gradientUnits="userSpaceOnUse" | ||
| > | ||
| <stop stopColor="#FF5A3C" /> | ||
| <stop offset="1" stopColor="#FF7F6B" /> | ||
| </linearGradient> |
There was a problem hiding this comment.
SVG 내부에 fill, stroke, stopColor 등으로 색상이 하드코딩되어 있습니다. 스타일 가이드(라인 315-317, 489)에서는 하드코딩된 색상 값 대신 디자인 토큰 사용을 권장합니다.
SVG를 컴포넌트로 다루고 있으므로, 색상 값을 props로 받거나 CSS 변수를 활용하여 동적으로 주입하는 방식을 고려해볼 수 있습니다. 예를 들어, fill="var(--color-primary)"와 같이 사용할 수 있습니다. 이렇게 하면 디자인 시스템과의 일관성을 유지하고 향후 색상 변경에 유연하게 대처할 수 있습니다.
References
- 스타일링 시 하드코딩된 값을 사용하는 대신 항상 디자인 토큰을 사용해야 합니다. (link)
src/pageComponents/gathering/opinion/result/ResultGeneratingSpeechBubble.tsx
Show resolved
Hide resolved
## [2.0.0](v1.5.1...v2.0.0) (2026-02-27) ### ⚠ BREAKING CHANGES * OpinionForm field names changed Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: update schema field names and exports - foodCategorySchema → categorySchema - dislikedFoodSchema → dislikedCategoriesSchema - preferredMenusSchema → preferredCategoriesSchema - Update opinionFormSchema with new field names - Replace hardcoded "ANY" with CATEGORY.ANY * Schema field names changed Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: rename component files to use Category naming - FoodCard → CategoryCard - FoodCategoryCarousel → CategoryCarousel - DislikedFoodButton → DislikedCategoryButton Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: update component export names - FoodCard → CategoryCard - FoodCategoryCarousel → CategoryCarousel - DislikedFoodButton → DislikedCategoryButton - Update component function names to match new file names - Internal logic unchanged (will be updated in next PR) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: update import references to use new constant names - Update all components to use new constant names: * RANKS → RANK_LIST * RANK_LABELS → RANK_LABEL * FOOD_CATEGORIES → CATEGORY_LIST * FOOD_CATEGORY_LABEL → CATEGORY_LABEL * FOOD_CATEGORY_VALUES → CATEGORY_VALUES * dislikedFoodSchema → dislikedCategoriesSchema * preferredMenusSchema → preferredCategoriesSchema - Update type imports: * FoodCategory → Category - Update form field references: * dislikedFoods → dislikedCategories * preferredMenus → preferredCategories - Fix component imports after file renames: * FoodCategoryCarousel → CategoryCarousel * DislikedFoodButton → DislikedCategoryButton This ensures the build succeeds after type system changes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * style: run prettier format - Format code according to project prettier rules - No logic changes, only formatting Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> ### Features * 404, 500 에러 페이지 추가 ([#107](#107)) ([e9e5772](e9e5772)) * opinion 랜딩 페이지 UI 업데이트 (로고·lottie·footer) ([#120](#120)) ([2817c50](2817c50)) * SSE Event Registry 시스템 구현 및 마이그레이션 ([#119](#119)) ([178c79c](178c79c)) * SSE 기반 실시간 모임 현황 업데이트 구현 ([#87](#87)) ([0d220e0](0d220e0)) * 결과 페이지 - 투표 결과 섹션 구현 ([#106](#106)) ([c633225](c633225)) * 과반수 이상 의견 제출 시 추천 결과 생성 관련 API, Hook 추가 ([#103](#103)) ([5b31eec](5b31eec)), closes [#105](#105) * 인원 수 선택 시 시각적 피드백 추가 ([e9f6de6](e9f6de6)) * 추천 결과 API response 타입 업데이트 ([#100](#100)) ([1d046ab](1d046ab)), closes [#101](#101) [#102](#102) * 추천 결과 생성 대기 페이지 및 애니메이션 구현 ([#118](#118)) ([a720a76](a720a76)) * 취향 요약 카드 추가 ([#104](#104)) ([816fa96](816fa96)) ### Bug Fixes * 1, 3순위를 선택했으나 2순위를 선택하지 않았을 경우 Validation 을 막지 않았던 문제 수정 ([#113](#113)) ([22e58e8](22e58e8)) * GitHub Actions 워크플로우 개선 및 스타일 가이드 업데이트 ([#91](#91)) ([e6c009e](e6c009e)) * 결과 대기 페이지 내 공유 버튼 스타일을 Tertiary 로 수정 ([#115](#115)) ([a63ddd1](a63ddd1)) * 결과 페이지 - 상단 취향 요약 섹션, 하단 투표 결과 선호 카테고리 노출 순서 로직 수정 ([#117](#117)) ([f5c4a1c](f5c4a1c)) * 모임 인원 초과 및 결과 생성 완료 시 Toast 커스텀 기능 추가 ([#116](#116)) ([6088935](6088935)) * 음식 카테고리 일러스트레이터를 Figma 내 최신 시안으로 수정 ([#114](#114)) ([18f9cc3](18f9cc3)) ### Code Refactoring * opinion 페이지 컴포넌트 구조 개선 및 로직 통합 ([#109](#109)) ([a76f75f](a76f75f)) * PeopleIllustration 캐릭터 배치 및 구조 개선 ([#112](#112)) ([ae95b72](ae95b72)) * ProgressBar 컴포넌트 UI 개선 및 코드 최적화 ([#122](#122)) ([c73de1d](c73de1d)) * SSE 이벤트를 recommend-result-created로 변경 ([#121](#121)) ([222e613](222e613)) * 타입 시스템 리팩토링 - enum을 as const 패턴으로 전환 ([#110](#110)) ([d427378](d427378)), closes [#111](#111)
🎯 PR 제목
feat: 추천 결과 생성 대기 페이지 및 애니메이션 구현
📑 작업 상세 내역
기능 추가
버그 수정
리팩토링
🙏 리뷰 요청 사항
📃 참고 자료
b06d2f1- 결과 완료 시에도 애니메이션 표시cafea72- 젓가락 애니메이션 각도 조정a7e7d4b- switch-case 리팩토링18d7a12- 추천 결과 생성 플로우 개선feff116- 네이밍 변경 및 애니메이션 개선🖼️ 작업 결과물