Skip to content

feat: 모임 상세 페이지 구현 (#50)#58

Merged
mgYang53 merged 6 commits intodevelopfrom
feat/gatherings-detail-50
Feb 8, 2026
Merged

feat: 모임 상세 페이지 구현 (#50)#58
mgYang53 merged 6 commits intodevelopfrom
feat/gatherings-detail-50

Conversation

@mgYang53
Copy link
Contributor

@mgYang53 mgYang53 commented Feb 5, 2026

🚀 풀 리퀘스트 제안

📋 작업 내용

모임 상세 페이지를 구현했습니다. 모임 정보, 약속 목록, 책장을 한 화면에서 조회할 수 있습니다.

🔧 변경 사항

공유 레이어 확장

  • Badge에 xsmall 사이즈 추가
  • TabsList gap을 gap-medium 디자인 토큰으로 통일
  • date.tsformatToDateTimeRange, getDdayText 함수 추가
  • theme.cssscrollbar-hide, shadow-drop-bottom 유틸리티 추가
  • Header z-index를 z-20으로 조정 (상세 헤더와 충돌 방지)
  • useScrollCollapse 공유 훅 추가

모임 상세 데이터 레이어

  • 상세/약속/책장 타입, API, 엔드포인트 추가
  • useGatheringDetail, useGatheringMeetings, useGatheringBooks 훅 구현
  • useGatheringBooksuseInfiniteQuery로 구현 (무한 스크롤)
  • useToggleFavorite에 상세 페이지 optimistic update 추가
  • meetingStatus 유틸을 lib/ 디렉토리로 분리

모임 상세 컴포넌트

  • GatheringDetailHeader: 즐겨찾기/설정/초대링크 sticky 헤더
  • GatheringDetailInfo: 모임 통계, 멤버 목록, 설명 표시
  • GatheringMeetingSection: 약속 탭 필터/페이지네이션/정렬
  • GatheringMeetingCard: 약속 중/예정/종료 상태별 카드
  • GatheringBookshelfSection: 캐러셀 + 무한스크롤 책장
  • GatheringBookCard: 책 표지/평점 카드
  • EmptyStatemeetings, bookshelf 타입 추가

레이아웃/라우팅

  • FullWidthLayout 추가 (GNB 있고 컨텐츠 패딩 없는 레이아웃)
  • 모임 상세 페이지를 FullWidthLayout 하위로 라우팅

페이지

  • GatheringDetailPage 전체 구현 (헤더/정보/약속/책장 섹션 조합)
  • 즐겨찾기 토글, 초대링크 복사, 에러 처리 등 인터랙션 연결

📸 스크린샷 (선택 사항)

스크린샷 2026-02-05 오후 11 29 22 스크린샷 2026-02-05 오후 11 25 10 스크린샷 2026-02-05 오후 11 21 38

📄 기타

  • 데이터가 없는 상황이어서 목데이터 기반으로 UI 확인 후 실제 API 기반으로 테스트는 못해봤습니다. 추후 데이터 확보 시 진행할 예정입니다.

Summary by CodeRabbit

Release Notes

New Features

  • 모임 상세 페이지 추가: 모임 정보, 멤버, 약속, 책장을 한 화면에서 확인 가능
  • 약속 필터링: 전체, 진행 중, 예정, 참여 중인 약속으로 분류 표시
  • 모임 즐겨찾기 토글 기능
  • 초대 링크 복사 및 공유 기능
  • 모임 리더 전용: 약속 설정 및 생성 버튼
  • 책장 섹션: 모임의 책들을 가로 스크롤로 탐색 가능

@mgYang53 mgYang53 linked an issue Feb 5, 2026 that may be closed by this pull request
58 tasks
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Warning

Rate limit exceeded

@mgYang53 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 14 minutes and 15 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

모임 상세 페이지를 새로운 FullWidthLayout으로 구현하고, 헤더·정보·모임·책장 섹션 컴포넌트를 추가했습니다. 관련 API 엔드포인트, React Query 훅, 타입을 정의하고, EmptyState를 데이터 기반 구조로 리팩토링했습니다.

Changes

Cohort / File(s) Summary
Gathering Detail Components
src/features/gatherings/components/GatheringDetailHeader, GatheringDetailInfo, GatheringMeetingCard, GatheringMeetingSection, GatheringBookCard, GatheringBookshelfSection
6개의 새로운 상세 페이지 섹션 컴포넌트 추가 (헤더, 정보, 모임 카드/섹션, 책 카드/섹션)
EmptyState Refactor
src/features/gatherings/components/EmptyState, index.ts
타입 안전성을 위해 EmptyStateType 추가하고 EMPTY_STATE_MESSAGES 맵으로 메시지 중앙 집중화
Gathering API & Types
src/features/gatherings/gatherings.api.ts, gatherings.endpoints.ts, gatherings.types.ts
4개의 새 엔드포인트(DETAIL, MEETINGS, BOOKS, MEETING_TAB_COUNTS)와 대응 타입 정의
Gathering Hooks
src/features/gatherings/hooks/useGatheringDetail.ts, useGatheringMeetings.ts, useGatheringBooks.ts, useMeetingTabCounts.ts, gatheringQueryKeys.ts, useGatherings.ts, useToggleFavorite.ts, index.ts
4개의 React Query 훅 추가, 쿼리 키 확장, 즐겨찾기 토글에 상세 페이지 낙관적 업데이트 추가
Gathering Detail Page & Routing
src/pages/Gatherings/GatheringDetailPage.tsx, src/routes/index.tsx, src/shared/constants/routes.ts
상세 페이지 구현, FullWidthLayout 라우트 그룹 추가, GATHERING_SETTING 경로 추가
Shared Layout & Scroll
src/shared/layout/FullWidthLayout.tsx, src/shared/hooks/useScrollCollapse.ts, src/shared/layout/components/Header.tsx, src/shared/layout/index.ts, src/shared/hooks/index.ts
sticky 헤더 지원 레이아웃 추가, 스크롤 상태 훅 추가, 헤더 z-index 조정
Shared Utilities & Styles
src/shared/lib/date.ts, src/shared/styles/theme.css, src/shared/ui/Badge.tsx, src/shared/constants/pagination.ts
날짜 유틸리티 확장(dateTimeRange, dDay), scrollbar-hide/shadow-drop-bottom 스타일 추가, Badge xsmall 사이즈 추가, 페이징 상수 추가
Gathering Library
src/features/gatherings/lib/meetingStatus.ts, src/features/gatherings/index.ts
모임 상태 계산·정렬 유틸리티 추가, 공개 API에서 re-export
Minor Changes
src/pages/Gatherings/GatheringListPage.tsx
TabsList 간격 조정

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • choiyoungae
  • haruyam15
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 변경사항의 핵심을 명확하게 반영합니다. '모임 상세 페이지 구현'은 이 PR의 주요 목표를 정확하게 요약하고 있으며, 전체 파일 변경사항과 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/gatherings-detail-50

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mgYang53 mgYang53 self-assigned this Feb 5, 2026
@mgYang53 mgYang53 added the feat 새로운 기능 추가 label Feb 5, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/features/gatherings/components/GatheringMeetingSection.tsx`:
- Around line 1-207: The file fails Prettier formatting; run Prettier to fix
formatting issues (e.g., run `prettier --write` on this file or your repo) and
commit the formatted changes so CI passes; ensure you format the React component
GatheringMeetingSection and related constants (TAB_FILTERS, FILTER_LABELS,
ITEMS_PER_PAGE) and keep only stylistic changes (no logic edits) before pushing.
- Around line 80-102: tabCounts computed inside the useMemo currently derives
counts from rawMeetings (filtered view) and uses a placeholder JOINED value,
causing incorrect badge numbers; update the logic so tabCounts is computed from
the full dataset (or server-provided aggregate counts) instead of
rawMeetings—move the counting out of the filtered/rawMeetings view or call the
server aggregate endpoint, use getMeetingDisplayStatus(meeting.startDateTime,
meeting.endDateTime) to determine UPCOMING/DONE as before, and remove the
Math.floor placeholder for JOINED (either compute JOINED from real participation
data or hide/disable the JOINED badge until real data is available); ensure this
change is applied where tabCounts is defined (the useMemo block) and consumers
of tabCounts expect the new reliable counts.
- Around line 49-109: The component currently builds
rawMeetings/tabCounts/pagination from only the first useGatheringMeetings page;
fix by ensuring all infinite-query pages are loaded before computing these
values: either (A) eagerly load all pages by calling fetchNextPage repeatedly
while hasNextPage in a useEffect and only compute
rawMeetings/tabCounts/totalPages/otherMeetings after data.pages includes all
pages, or (B) on pagination changes, call fetchNextPage in a useEffect when
currentPage requires items not yet present (use data.pages length and
ITEMS_PER_PAGE to decide); update references to useGatheringMeetings,
fetchNextPage, rawMeetings, tabCounts, otherMeetings and totalPages so
computations use the fully-loaded data (or trigger fetchNextPage) before
slicing/paging.

In `@src/features/gatherings/hooks/useGatheringBooks.ts`:
- Line 1: The file has Prettier formatting issues detected by CI for the import
line (the import of useInfiniteQuery), so run the project's formatter to fix it:
execute prettier --write across the repo (or target this file) and reformat the
import and surrounding code so it matches the project's Prettier rules, then
re-run lint/CI; ensure the import statement for useInfiniteQuery remains correct
and saved before committing.

In `@src/features/gatherings/lib/meetingStatus.ts`:
- Around line 41-50: The DONE branch in meetingStatus.ts wrongly compares start
times; change it to compute endA = new Date(a.endDateTime) and endB = new
Date(b.endDateTime) (instead of startA/startB) and return endB.getTime() -
endA.getTime() so DONE items are sorted by most-recent endDateTime (descending);
ensure variables used in that else branch (previously startA/startB) are
replaced or new endA/endB are introduced in the comparison.

In `@src/pages/Gatherings/GatheringDetailPage.tsx`:
- Around line 17-21: The code converts route param id directly via const
gatheringId = Number(id), which yields NaN when id is undefined; change this to
defensively parse and validate the param (e.g., check id truthiness and that
parsed value is a positive integer) before using it: compute gatheringId only
when id is defined and a valid numeric string (fallback to undefined or null
otherwise), update any consumers (like the useGatheringDetail enabled flag) to
rely on this validated value (e.g., enabled: typeof gatheringId === 'number' &&
gatheringId > 0) and ensure other uses of gatheringId handle the undefined/null
case rather than NaN.
🧹 Nitpick comments (11)
src/shared/ui/Badge.tsx (1)

39-43: JSDoc에 xsmall size 설명 누락.

size 옵션 설명에 xsmall이 추가되었으나 문서에 반영되지 않음.

📝 JSDoc 업데이트 제안
 /**
  * Badge (상태/라벨 배지)
- * - `size`로 배지 크기를 지정합니다: small, medium
+ * - `size`로 배지 크기를 지정합니다: xsmall, small, medium
  * - `color`로 배지 색상을 지정합니다: red, blue, grey, purple, green, yellow
  * - `effect`로 그림자 효과를 지정합니다: on, off
src/features/gatherings/components/GatheringMeetingCard.tsx (1)

40-56: TODO 핸들러 구현 필요.

console.log로 처리된 핸들러들이 있음. 추후 구현 누락 방지를 위해 이슈 트래킹 권장.

이 TODO 항목들을 추적할 이슈를 생성해 드릴까요?

src/features/gatherings/hooks/useToggleFavorite.ts (1)

101-104: 주석이 실제 동작과 불일치.

주석에는 "즐겨찾기 목록만" 갱신한다고 되어 있으나, 실제로는 detail 쿼리도 invalidate함.

📝 주석 수정 제안
     onSettled: (_data, _error, gatheringId) => {
-      // 즐겨찾기 목록만 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분)
+      // 즐겨찾기 목록과 상세 페이지를 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분)
       queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.favorites() })
       queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) })
     },
src/shared/lib/date.ts (1)

114-137: getDdayText에서 new Date() 직접 호출 - 테스트 어려움 가능성.

문제: new Date()를 직접 호출하면 단위 테스트 시 시간 모킹이 필요합니다.
영향: 날짜 경계 케이스 테스트가 어려워질 수 있습니다.
대안: 필요시 now 파라미터를 옵션으로 받거나, 테스트에서 vi.useFakeTimers() 사용을 고려하세요.

현재 구현은 기능적으로 정확하며, 추후 테스트 필요 시 리팩토링 가능합니다.

src/routes/index.tsx (1)

95-128: MainLayout 라우트 그룹이 분리됨 - 의도적 구조인지 확인 필요.

문제: MainLayout이 두 개의 별도 라우트 그룹(Lines 64-96, 108-128)으로 분리되어 있습니다.
영향: 기능적으로는 정상 동작하지만, 라우트 구조의 가독성이 다소 떨어집니다.
대안: FullWidthLayout 라우트를 MainLayout 그룹 외부로 분리하거나, 주석으로 의도를 명시하는 것을 고려해보세요.

현재 구조가 의도된 것이라면 그대로 유지해도 무방합니다.

라우트 구조 대안 예시
          // 메인 페이지들 (GNB 있음)
          {
            element: <MainLayout />,
            children: [
              // ... 기존 라우트들
            ],
          },
+         // 전체 너비 레이아웃 페이지 (GNB 있음, 컨텐츠 패딩 없음)
          {
            element: <FullWidthLayout />,
            children: [
              {
                path: `${ROUTES.GATHERINGS}/:id`,
                element: <GatheringDetailPage />,
              },
+             // 추후 전체 너비가 필요한 페이지들 추가
            ],
          },
-         // 메인 페이지들 계속 (GNB 있음)
-         {
-           element: <MainLayout />,
-           children: [
+         // 또는 아래 라우트들을 첫 번째 MainLayout 그룹으로 통합
src/features/gatherings/hooks/useGatheringBooks.ts (1)

30-43: queryKeysize 포함 여부 검토 필요

현재 queryKeysize가 포함되어 있어 size가 변경되면 별도 캐시 엔트리가 생성됩니다.

size가 런타임에 변경되지 않는다면 gatheringQueryKeys.books(gatheringId)만으로 충분합니다. 의도적으로 size별 캐시 분리가 필요한 경우라면 현재 구현이 맞습니다.

♻️ size를 queryKey에서 제거하는 경우
 export const useGatheringBooks = ({ gatheringId, size = DEFAULT_PAGE_SIZE }: UseGatheringBooksOptions) => {
   return useInfiniteQuery({
-    queryKey: [...gatheringQueryKeys.books(gatheringId), size],
+    queryKey: gatheringQueryKeys.books(gatheringId),
     queryFn: async ({ pageParam }) => {
src/features/gatherings/components/GatheringBookCard.tsx (1)

21-53: 참고: 키보드 접근성 개선 가능

divonClick만 있어 키보드 사용자가 접근하기 어렵습니다. GatheringCard.tsx처럼 role="button", tabIndex={0}, onKeyDown 핸들러 추가를 고려해 보세요.

♻️ 제안 코드
     <div
       className="flex flex-col gap-base cursor-pointer group w-40"
       onClick={handleClick}
+      role="button"
+      tabIndex={0}
+      onKeyDown={(e) => {
+        if (e.key === 'Enter' || e.key === ' ') {
+          e.preventDefault()
+          handleClick()
+        }
+      }}
     >
src/features/gatherings/components/GatheringBookshelfSection.tsx (2)

29-41: fetchNextPage 호출 후 즉시 스크롤 시 타이밍 이슈 가능

fetchNextPage() 호출 후 새 데이터가 렌더링되기 전에 scrollBy가 실행됩니다. 새 책 카드가 아직 DOM에 없어서 스크롤 범위가 제한될 수 있습니다.

♻️ 데이터 로드 후 스크롤 처리
   const handleScrollRight = () => {
     const container = scrollContainerRef.current
     if (!container) return

     const isAtEnd = container.scrollLeft + container.clientWidth >= container.scrollWidth - 10

     if (isAtEnd && hasNextPage && !isFetchingNextPage) {
-      fetchNextPage()
+      fetchNextPage().then(() => {
+        // 다음 프레임에서 스크롤 (새 데이터 렌더링 후)
+        requestAnimationFrame(() => {
+          container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' })
+        })
+      })
+      return
     }

     container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' })
   }

22-23: 선택: books 배열 메모이제이션 고려

data?.pages.flatMap(...) 이 매 렌더마다 실행됩니다. 목록이 커지면 useMemo로 최적화할 수 있습니다.

♻️ useMemo 적용
+import { useRef, useMemo } from 'react'
...
-  const books = data?.pages.flatMap((page) => page.items) ?? []
+  const books = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data?.pages])
src/pages/Gatherings/GatheringDetailPage.tsx (1)

32-45: useCallback 의존성 최적화 가능

gathering이 의존성에 포함되어 있지만 실제로는 null 체크용으로만 사용됩니다. 데이터가 갱신될 때마다 콜백이 재생성됩니다.

♻️ 의존성 최적화
   const handleFavoriteToggle = useCallback(() => {
-    if (!gathering) return
+    // gathering 존재 여부는 렌더링 조건에서 이미 보장됨
+    // 또는 gatheringId > 0 체크로 대체 가능

     toggleFavorite(gatheringId, {
       onError: (error: ApiError) => {
         if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {
           openAlert('알림', '즐겨찾기는 최대 4개까지만 등록할 수 있습니다.')
         } else {
           openAlert('오류', '즐겨찾기 변경에 실패했습니다.')
         }
       },
     })
-  }, [gatheringId, gathering, toggleFavorite, openAlert])
+  }, [gatheringId, toggleFavorite, openAlert])
src/features/gatherings/gatherings.types.ts (1)

116-133: description 네이밍이 기존 필드와 불일치합니다
문제: 목록/생성은 gatheringDescription인데 상세 응답만 description으로 정의돼 있습니다.
영향: 매핑 누락 시 설명이 undefined로 표시될 위험이 있습니다.
대안: API 어댑터에서 description → gatheringDescription으로 정규화하거나 네이밍을 통일해 주세요.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/pages/Gatherings/GatheringDetailPage.tsx`:
- Around line 66-72: The openError callback currently calls navigate(-1) which
can send users outside the app when there is no previous history; update the
callback used in useEffect (where error, openError, and navigate are referenced)
to detect history availability (e.g., check window.history.length > 1) and only
call navigate(-1) when safe, otherwise navigate to a defined in-app fallback
route (e.g., the gatherings list or '/'); ensure this change is applied inside
the useEffect error branch so openError's onClose handler uses the safe
navigation logic.
🧹 Nitpick comments (2)
src/features/gatherings/lib/meetingStatus.ts (1)

28-55: sortMeetings 내부에서 getMeetingDisplayStatus가 비교마다 재계산됩니다.

문제: sort comparator 내부에서 매 비교(O(n log n))마다 두 아이템의 상태를 new Date()와 함께 재계산합니다.
영향: 목록 크기가 작으면 문제 없지만, 정렬 도중 new Date()가 미세하게 달라지면 경계값 아이템의 상태가 비교 중간에 바뀔 수 있어 정렬 불안정을 유발할 수 있습니다.
대안: Schwartzian transform(decorate-sort-undecorate)으로 상태를 한 번만 계산하세요.

♻️ 제안 수정
 export const sortMeetings = (meetings: GatheringMeetingItem[]): GatheringMeetingItem[] => {
-  return [...meetings].sort((a, b) => {
-    const statusA = getMeetingDisplayStatus(a.startDateTime, a.endDateTime)
-    const statusB = getMeetingDisplayStatus(b.startDateTime, b.endDateTime)
+  const statusOrder: Record<MeetingDisplayStatus, number> = {
+    IN_PROGRESS: 0,
+    UPCOMING: 1,
+    DONE: 2,
+  }
+
+  const decorated = meetings.map((m) => ({
+    meeting: m,
+    status: getMeetingDisplayStatus(m.startDateTime, m.endDateTime),
+  }))
+
+  decorated.sort((a, b) => {
+    const statusA = a.status
+    const statusB = b.status
 
-    // 약속 중이 최상단
-    if (statusA === 'IN_PROGRESS' && statusB !== 'IN_PROGRESS') return -1
-    if (statusA !== 'IN_PROGRESS' && statusB === 'IN_PROGRESS') return 1
-
-    // 예정 > 종료 순서
-    if (statusA === 'UPCOMING' && statusB === 'DONE') return -1
-    if (statusA === 'DONE' && statusB === 'UPCOMING') return 1
+    // 상태 그룹 순서: IN_PROGRESS > UPCOMING > DONE
+    if (statusA !== statusB) return statusOrder[statusA] - statusOrder[statusB]
 
     // 같은 상태 내에서 정렬
-    const startA = new Date(a.startDateTime)
-    const startB = new Date(b.startDateTime)
+    const startA = new Date(a.meeting.startDateTime)
+    const startB = new Date(b.meeting.startDateTime)
 
     if (statusA === 'UPCOMING') {
-      // 예정: 가까운 미래 순 (오름차순)
       return startA.getTime() - startB.getTime()
     } else {
-      // 종료: 최근 종료 순 (내림차순) - endDateTime 기준
-      const endA = new Date(a.endDateTime)
-      const endB = new Date(b.endDateTime)
+      const endA = new Date(a.meeting.endDateTime)
+      const endB = new Date(b.meeting.endDateTime)
       return endB.getTime() - endA.getTime()
     }
   })
+
+  return decorated.map((d) => d.meeting)
 }
src/pages/Gatherings/GatheringDetailPage.tsx (1)

33-45: onError 콜백 내 error 파라미터가 외부 스코프 error를 섀도잉합니다.

가독성 관점에서 콜백 파라미터명을 mutationError 등으로 변경하면 혼동을 줄일 수 있습니다.

♻️ 제안 수정
-    toggleFavorite(gatheringId, {
-      onError: (error: ApiError) => {
-        if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {
+    toggleFavorite(gatheringId, {
+      onError: (mutationError: ApiError) => {
+        if (mutationError.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/features/gatherings/components/GatheringMeetingSection.tsx`:
- Around line 53-86: The hook useGatheringMeetings is called without a size so
it defaults to the server's 50-item cap causing items beyond 50 to be missing;
update the call in GatheringMeetingSection to request more items (e.g., pass
size: 1000) or implement server-side pagination by wiring useGatheringMeetings
to accept page/size parameters and driving it from currentPage (and using the
server's total/count to compute totalPages) so otherMeetings/paginatedMeetings
operate on the full result set rather than the truncated default.

In `@src/pages/Gatherings/GatheringDetailPage.tsx`:
- Line 22: The page computes gatheringId with const gatheringId = id ?
Number(id) : 0 which yields NaN for non-numeric ids and causes the query to
never run (enabled: gatheringId > 0), leaving users stuck on the isLoading ||
!gathering guard; update GatheringDetailPage to validate the route param early:
parse id safely (e.g., use Number.isFinite/Number.isInteger or parseInt and
isNaN checks) and if invalid immediately redirect or render a not-found/error
state via router.push or a 404 component instead of continuing to call the fetch
logic—adjust references to gatheringId and the isLoading || !gathering guard
accordingly so invalid ids never cause an infinite loading screen.
🧹 Nitpick comments (3)
src/features/gatherings/gatherings.types.ts (1)

117-141: GatheringBase와 필드명 불일치 확인 필요

GatheringBase(Line 9)는 gatheringDescription?: string을, GatheringDetailResponsedescription: string | null을 사용합니다. 서버 API 스펙이 실제로 다른 필드명을 반환한다면 문제없지만, 같은 도메인 내 동일 개념의 네이밍이 다르면 혼동의 원인이 됩니다.

영향: 타입 재사용이나 extends 활용이 어려워짐.
대안: 서버 스펙을 확인하고, 가능하면 필드명을 통일하거나 GatheringBase를 확장하는 방식을 검토하세요.

src/features/gatherings/components/GatheringMeetingSection.tsx (1)

168-184: 닉네임 문자열 비교로 호스트를 판별하는 방식은 취약할 수 있습니다

meeting.meetingLeaderName === currentUserNickname 비교는 동명이인이나 닉네임 변경 시 오판될 수 있습니다.

영향: 잘못된 호스트 UI 표시.
대안: 가능하면 meetingLeaderId(userId) 기반 비교를 고려하세요. API 스펙 상 어렵다면 현재 방식을 유지하되 주석으로 한계를 명시해 두는 것을 권장합니다.

src/pages/Gatherings/GatheringDetailPage.tsx (1)

49-51: ROUTES 상수 대신 하드코딩된 경로 사용

Line 50에서 `/gatherings/${gatheringId}/settings`를 직접 작성했지만, 다른 핸들러에서는 ROUTES.MEETING_SETTING, ROUTES.MEETING_CREATE 등 상수를 사용합니다.

영향: 라우트 변경 시 이 경로만 누락될 수 있습니다.
대안: ROUTES에 모임 설정 경로 헬퍼를 추가하고 여기서 활용하세요.

}

/** 페이지당 표시할 약속 수 */
const PAGE_SIZE = 5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared/constants/pagination.ts 요기에 추가하거나 디폴트값 사용하는거 어떠신지요?

Image

import { getGatheringBooks } from '../gatherings.api'
import { gatheringQueryKeys } from './gatheringQueryKeys'

const DEFAULT_PAGE_SIZE = 12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요기도 상수파일로 가는거 어떠신지요?

const previousDetail = queryClient.getQueryData(gatheringQueryKeys.detail(gatheringId))

// Optimistic update - 목록에서 isFavorite 토글
queryClient.setQueryData<{ pages: GatheringListResponse[]; pageParams: unknown[] }>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InfiniteData 제네릭 지원 해주더라고여!

queryClient.setQueryData<InfiniteData<GatheringListResponse[]>>

https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchinfinitequery

Comment on lines +11 to +12
import { useGatheringMeetings } from '../hooks/useGatheringMeetings'
import { useMeetingTabCounts } from '../hooks/useMeetingTabCounts'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

../hooks 로 한번에 import 가넝

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/features/gatherings/components/GatheringMeetingSection.tsx`:
- Line 36: Remove the unused ref declaration: delete the const sectionRef =
useRef<HTMLElement>(null) in the GatheringMeetingSection component and remove
any unused imports related to useRef; ensure no other references to sectionRef
remain in the component (search for "sectionRef") so ESLint/type checker stops
reporting an unused variable.
- Line 155: The host check uses nickname equality
(isHost={meeting.meetingLeaderName === currentUserNickname}) which misidentifies
hosts when nicknames are duplicated; update the model and UI to compare stable
IDs instead: add meetingLeaderId to the GatheringMeetingItem type and ensure the
API returns it, then change the isHost prop to compare meeting.meetingLeaderId
with the current user's id (e.g., currentUserId) wherever isHost is computed
(including GatheringMeetingSection and any related components that expect host
logic) so host UI (badge/permissions) is driven by IDs not nicknames.
🧹 Nitpick comments (3)
src/features/gatherings/hooks/useToggleFavorite.ts (2)

32-36: cancelQueries 3개를 Promise.all로 병렬 처리 가능

순차 await는 불필요한 직렬 대기를 유발합니다. 독립적인 취소 요청이므로 병렬로 실행하면 onMutate 응답이 빨라집니다.

♻️ 제안
-      await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() })
-      await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() })
-      await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) })
+      await Promise.all([
+        queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }),
+        queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }),
+        queryClient.cancelQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }),
+      ])

101-104: lists 쿼리 미갱신 — 의도된 설계인지 확인 필요

favoritesdetailinvalidateQueries로 서버 동기화하지만, lists는 낙관적 업데이트에만 의존합니다. 뮤테이션이 실패→롤백 후 onSettled에서 lists를 갱신하지 않으면, 롤백이 최종 상태가 됩니다(서버 재검증 없음).

실패 빈도가 낮다면 현행 유지도 괜찮지만, favorites처럼 listsinvalidate 하는 편이 일관적입니다.

src/features/gatherings/components/GatheringMeetingSection.tsx (1)

60-69: 이중 정렬을 하나로 합치는 것을 권장합니다.

문제: sortMeetings()의 결과를 다시 .sort()로 재정렬하고 있어 의도 파악이 어렵고, .sort()는 배열을 in-place로 변이시킵니다.
영향: 가독성 저하 및 sortMeetings 내부 정렬 의도가 두 번째 sort에 의해 부분적으로 덮어씌워질 수 있습니다.
대안: 하나의 정렬 함수로 통합하거나, sortMeetings에 IN_PROGRESS 우선 로직을 포함시키세요.

♻️ 정렬 로직 통합 예시
-  // 정렬 후 진행 중 약속을 상단에 배치
-  const sortedMeetings = sortMeetings(meetings)
-  const displayMeetings = sortedMeetings.sort((a, b) => {
-    const statusA = getMeetingDisplayStatus(a.startDateTime, a.endDateTime)
-    const statusB = getMeetingDisplayStatus(b.startDateTime, b.endDateTime)
-    // 진행 중인 약속을 맨 앞으로
-    if (statusA === 'IN_PROGRESS' && statusB !== 'IN_PROGRESS') return -1
-    if (statusA !== 'IN_PROGRESS' && statusB === 'IN_PROGRESS') return 1
-    return 0
-  })
+  // 진행 중 약속 우선 → 기존 정렬 기준 유지
+  const displayMeetings = [...sortMeetings(meetings)].sort((a, b) => {
+    const statusA = getMeetingDisplayStatus(a.startDateTime, a.endDateTime)
+    const statusB = getMeetingDisplayStatus(b.startDateTime, b.endDateTime)
+    if (statusA === 'IN_PROGRESS' && statusB !== 'IN_PROGRESS') return -1
+    if (statusA !== 'IN_PROGRESS' && statusB === 'IN_PROGRESS') return 1
+    return 0
+  })

최소한 스프레드([...arr])로 원본 변이를 방지하거나, sortMeetings 자체에 IN_PROGRESS 우선 정렬을 포함시키는 것이 더 깔끔합니다.

@mgYang53 mgYang53 force-pushed the feat/gatherings-detail-50 branch from bf948a4 to 94a7b59 Compare February 8, 2026 13:41
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/features/gatherings/components/GatheringBookshelfSection.tsx`:
- Around line 29-41: In handleScrollRight, awaiting fetchNextPage is necessary
so the DOM updates before calling container.scrollBy; change the logic in
handleScrollRight to await the promise returned by fetchNextPage (or listen for
the query hook’s completion/state change) when isAtEnd && hasNextPage &&
!isFetchingNextPage, then call container.scrollBy({ left: SCROLL_AMOUNT,
behavior: 'smooth' }) only after fetchNextPage resolves (or when
isFetchingNextPage becomes false), referencing scrollContainerRef,
fetchNextPage, isFetchingNextPage, hasNextPage and SCROLL_AMOUNT to locate and
update the flow.

In `@src/features/gatherings/components/GatheringDetailInfo.tsx`:
- Around line 23-26: The remainingCount miscalculates when leader is undefined
because it always subtracts 1; update the logic to account for leader existence:
set otherMembers to members.filter(m => m.role !== 'LEADER') only when leader
exists (otherwise otherMembers = members), compute visibleMembers =
otherMembers.slice(0, MAX_VISIBLE_MEMBERS) as before, and compute remainingCount
= totalMembers - (leader ? 1 : 0) - visibleMembers.length so you only subtract
the leader if one is present; use the existing symbols leader, otherMembers,
visibleMembers, totalMembers, MAX_VISIBLE_MEMBERS to locate and update the code.

In `@src/features/gatherings/components/GatheringMeetingSection.tsx`:
- Around line 61-69: Remove the redundant second .sort() that re-sorts the
already-ordered result from sortMeetings; use the sortedMeetings array directly
instead of assigning displayMeetings = sortedMeetings.sort(...). Specifically,
delete the additional .sort(...) block that references getMeetingDisplayStatus
and set displayMeetings = sortedMeetings so sortMeetings retains its IN_PROGRESS
→ UPCOMING → DONE grouping and intra-group ordering.
🧹 Nitpick comments (5)
src/routes/index.tsx (1)

95-128: MainLayout이 두 블록으로 분리되어 있습니다.

문제는 아니지만, MainLayout children이 Line 64-96과 Line 108-128로 나뉘어 있어 가독성이 떨어집니다. React Router v6에서 동일 레이아웃의 라우트를 한 블록으로 모으고, FullWidthLayout 블록을 별도로 두는 구조가 더 읽기 쉽습니다.

♻️ 구조 정리 제안
          // 메인 페이지들 (GNB 있음)
          {
            element: <MainLayout />,
            children: [
              { path: ROUTES.HOME, element: <HomePage /> },
              { path: ROUTES.HOME_ALIAS, element: <Navigate to={ROUTES.HOME} replace /> },
              { path: ROUTES.BOOKS, element: <BookListPage /> },
              { path: `${ROUTES.BOOKS}/:id`, element: <BookDetailPage /> },
              { path: `${ROUTES.BOOKS}/:id/reviews`, element: <BookReviewHistoryPage /> },
              { path: ROUTES.GATHERINGS, element: <GatheringListPage /> },
              { path: ROUTES.GATHERING_CREATE, element: <CreateGatheringPage /> },
+             { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`, element: <MeetingDetailPage /> },
+             { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`, element: <MeetingSettingPage /> },
+             { path: `${ROUTES.GATHERINGS}/:id/meetings/create`, element: <MeetingCreatePage /> },
+             { path: ROUTES.RECORDS, element: <RecordListPage /> },
            ],
          },
          // 전체 너비 레이아웃 페이지
          {
            element: <FullWidthLayout />,
            children: [
              { path: `${ROUTES.GATHERINGS}/:id`, element: <GatheringDetailPage /> },
            ],
          },
-         // 메인 페이지들 계속 (GNB 있음)
-         {
-           element: <MainLayout />,
-           children: [
-             ...
-           ],
-         },
src/features/gatherings/components/GatheringBookCard.tsx (1)

22-22: 클릭 가능한 div → 키보드 접근성 부재

문제: div + onClick만 있어 키보드 사용자가 접근할 수 없습니다 (Tab 포커스, Enter/Space 동작 불가).
대안: <button> 또는 role="link" + tabIndex={0} + onKeyDown 처리, 혹은 React Router <Link>로 감싸는 방법을 고려해 주세요. (참고 사항)

src/features/gatherings/components/GatheringBookshelfSection.tsx (1)

43-50: 로딩 상태 UI가 단순 텍스트

스켈레톤 UI가 아닌 "로딩 중..." 텍스트만 표시됩니다. 다른 섹션과 일관성 있는 로딩 처리를 추후 고려해보세요. 현재 단계에서는 목데이터 기반이므로 큰 문제는 아닙니다.

src/pages/Gatherings/GatheringDetailPage.tsx (1)

35-47: handleFavoriteTogglegathering 의존성 → 불필요한 재생성

gathering은 쿼리 데이터라 매 fetch마다 새 참조가 됩니다. 실제로는 gathering이 truthy인지만 확인하므로, !!gathering을 별도 변수로 빼면 재생성을 줄일 수 있습니다.

♻️ 제안
+ const hasGathering = !!gathering
+
  const handleFavoriteToggle = useCallback(() => {
-   if (!gathering) return
+   if (!hasGathering) return

    toggleFavorite(gatheringId, {
      onError: (error: ApiError) => {
        if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {
          openAlert('알림', '즐겨찾기는 최대 4개까지만 등록할 수 있습니다.')
        } else {
          openAlert('오류', '즐겨찾기 변경에 실패했습니다.')
        }
      },
    })
- }, [gatheringId, gathering, toggleFavorite, openAlert])
+ }, [gatheringId, hasGathering, toggleFavorite, openAlert])
src/features/gatherings/gatherings.types.ts (1)

118-141: GatheringDetailResponseGatheringBase의 필드 중복

gatheringName, totalMembers, daysFromCreation, totalMeetings, invitationLink 등이 GatheringBase와 겹칩니다. 또한 설명 필드명이 description vs gatheringDescription으로 다릅니다.

API 응답 스키마가 다르다면 현재 구조가 맞지만, 동일한 스키마라면 GatheringBase를 확장하여 중복을 줄이는 것을 고려해보세요.

Copy link
Contributor

@haruyam15 haruyam15 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿

@mgYang53 mgYang53 merged commit 9a99981 into develop Feb 8, 2026
2 checks passed
@mgYang53 mgYang53 deleted the feat/gatherings-detail-50 branch February 8, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 모임 상세 페이지 구현

2 participants