Skip to content

Comments

fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결#377

Merged
Hyeonjun0527 merged 1 commit intomainfrom
fix/one-to-one-new-feature-bug-main
Feb 4, 2026
Merged

fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결#377
Hyeonjun0527 merged 1 commit intomainfrom
fix/one-to-one-new-feature-bug-main

Conversation

@Hyeonjun0527
Copy link
Member

@Hyeonjun0527 Hyeonjun0527 commented Feb 4, 2026

🌱 연관된 이슈

☘️ 작업 내용

아카이브 ui 보완
밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔)
밸런스게임에서 프사가 안보임 해결
나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈.
밸런스게임에 태그 필터 기능 추가.
밸런스게임 글 작성에 기존 태그 목록 보여지도록 함.
답변자 -> 지원자.

fix: 다중태그 지원

fix: 검색 자동완성 및 ui 다듬기

fix: 복구

fix: ui디자인 변경 어드민 기능 삭제

feat: 스터디 튜토리얼 추가

refactor: 코드리팩토링

refactor: 코드 리팩토링

fix: ui 수정

🍀 참고사항

스크린샷 (선택)

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 자료실 편집 및 필터링 기능 추가 (정렬, 검색, 개인 자료 필터)
    • 균형게임 태그 기반 필터링 및 검색 기능 강화
    • 1:1 스터디 튜토리얼 오버레이 시스템 도입
    • 명예의 전당 기능 추가 (상위 랭킹 표시)
    • 스터디 기록 목록/캘린더 뷰 모드 추가
    • 공유 기능 및 토스트 알림 추가
  • 개선 사항

    • UI 컴포넌트 및 필터 인터페이스 개선
    • API 로깅 기능 추가

아카이브 ui 보완
밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔)
밸런스게임에서 프사가 안보임 해결
나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈.
밸런스게임에 태그 필터 기능 추가.
밸런스게임 글 작성에 기존 태그 목록 보여지도록 함.
답변자 -> 지원자.

fix: 다중태그 지원

fix: 검색  자동완성 및 ui 다듬기

fix: 복구

fix: ui디자인 변경 어드민 기능 삭제

feat: 스터디 튜토리얼 추가

refactor: 코드리팩토링

refactor: 코드 리팩토링

fix: ui 수정
@vercel
Copy link

vercel bot commented Feb 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
study-platform-client-dev Ready Ready Preview, Comment Feb 4, 2026 2:02pm

@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

다양한 기능을 추가하고 기존 컴포넌트를 리팩토링합니다: API 로깅 시스템 도입, 아카이브/밸런스 게임 필터링 및 검색 기능 확장, 멀티스텝 튜토리얼 오버레이 시스템 추가, 스터디 히스토리 UI 재구성, 공유 기능 통합, 재사용 가능한 UI 컴포넌트 추가.

Changes

Cohort / File(s) Summary
API 로깅 인프라
src/api/client/api-logger.ts, src/api/client/axios.ts, src/api/client/axios.server.ts, src/api/client/axiosV2.ts, src/api/client/axiosV2.server.ts
Axios 요청/응답 로깅 유틸리티 추가. 모든 Axios 인스턴스에 attachApiLogger 호출로 로깅 활성화.
아카이브 API 및 모델
src/features/study/one-to-one/archive/api/*, src/features/study/one-to-one/archive/model/*
아카이브 검색 제안, 업데이트, 가시성 토글 기능 추가. ARCHIVE_QUERY_KEYS 쿼리 키 구조 도입. 여러 변이 훅 추가 (업데이트, 가시성, 뷰 등).
아카이브 UI
src/features/study/one-to-one/archive/ui/archive-filters.tsx, src/features/study/one-to-one/archive/ui/archive-grid.tsx, src/features/study/one-to-one/archive/ui/archive-list.tsx, src/features/study/one-to-one/archive/ui/archive-header.tsx, src/features/study/one-to-one/archive/ui/archive-pagination.tsx, src/features/study/one-to-one/archive/ui/archive-tab-client.tsx, src/features/study/one-to-one/archive/ui/use-archive-filters.ts
필터링, 검색, 정렬, 뷰 모드 전환 추가. 인라인 편집 기능 도입. 관리자 전용에서 canEdit 기반 권한으로 전환. 새로운 FilterPillButton, SortDropdown, ViewModeToggle 통합.
밸런스 게임 API 및 쿼리
src/features/study/one-to-one/balance-game/api/balance-game-api.ts, src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts, src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts, src/features/study/one-to-one/balance-game/const/tags.ts, src/features/study/one-to-one/balance-game/model/balance-game-keys.ts, src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts
태그 필터링, 검색 기능 추가. 태그 제약 상수 정의 (최대 길이 40자, 최대 3개). BALANCE_GAME_QUERY_KEYS 쿼리 키 구조 도입.
밸런스 게임 UI
src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx, src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx, src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx, src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts, src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx, src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts, src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
상태 필터, 정렬, 태그 선택을 위한 필터 바 추가. TagAutocomplete 컴포넌트로 태그 검색/제안 구현. URL 기반 상태 관리 및 상세 보기 라우팅. 무한 스크롤 커스텀 훅 추가. 기존 balance-game-page.tsx 제거.
투표 카드 및 투표 UI
src/components/card/voting-card.tsx, src/components/voting/voting-create-modal.tsx, src/components/voting/voting-edit-modal.tsx, src/components/voting/voting-deadline-field.tsx, src/components/voting/voting-detail-view.tsx, src/components/voting/voting-modal-footer.tsx, src/components/voting/voting-modal-header.tsx, src/components/voting/voting-option-fields.tsx, src/components/voting/voting-tag-field.tsx, src/components/voting/vote-timer.tsx
onTagClick prop 추가로 태그 인터랙션 지원. 투표 생성/편집 모달 폼 구조 리팩토링. 태그 필드, 옵션 필드, 마감일 필드 컴포넌트화. 공유 기능 및 토스트 피드백 추가. 투표 타이머 UI 개선.
튜토리얼 시스템
src/components/tutorial/tutorial-overlay.tsx, src/components/tutorial/index.ts, src/features/study/one-to-one/schedule/model/study-tutorial-steps.ts, src/features/study/one-to-one/schedule/model/tutorial-mock.ts, src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx, src/features/study/one-to-one/schedule/ui/home-study-tab.tsx, src/features/study/one-to-one/schedule/ui/study-card.tsx, src/features/study/one-to-one/schedule/ui/today-study-card.tsx
멀티스텝 튜토리얼 오버레이 컴포넌트 (480줄). TutorialStep 인터페이스 및 TutorialOverlay 컴포넌트 추가. 튜토리얼 단계 구성 및 시나리오 로직 정의. StudyCard에 튜토리얼 모드 및 강제 상태 prop 추가. 튜토리얼 컨트롤러로 흐름 관리.
스터디 히스토리 리팩토링
src/features/study/one-to-one/history/ui/study-history-tab-client.tsx, src/features/study/one-to-one/history/ui/study-history-header.tsx, src/features/study/one-to-one/history/ui/study-history-list-section.tsx, src/features/study/one-to-one/history/ui/study-history-calendar-section.tsx, src/features/study/one-to-one/history/ui/study-history-pagination.tsx, src/features/study/one-to-one/history/ui/study-history-summary.tsx, src/features/study/one-to-one/history/ui/study-history-utils.ts
모놀리식 컴포넌트를 작은 섹션 컴포넌트들로 분해. 뷰 모드 전환 (LIST/CALENDAR). 페이지네이션 컴포넌트 추가. 데이터 변환 유틸 분리.
홀 오브 페임
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx, src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx, src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx, src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
홀 오브 페임 UI를 모듈화된 컴포넌트로 재구성. 순위별 배지, 랭커 목록 항목, MVP 팀 카드 컴포넌트 추가.
일반 UI 컴포넌트
src/components/ui/action-pill-button.tsx, src/components/ui/filters/filter-pill-button.tsx, src/components/ui/filters/sort-dropdown.tsx, src/components/ui/filters/view-mode-toggle.tsx, src/components/ui/inline-section-header.tsx, src/components/ui/section-header.tsx, src/components/ui/section-shell.tsx, src/components/ui/stat-item.tsx, src/components/ui/modal-shell.tsx, src/components/ui/toast.tsx, src/components/ui/tooltip/index.tsx, src/components/ui/input/base.tsx
제네릭 필터링 컴포넌트 (FilterPillButton, SortDropdown, ViewModeToggle), 섹션 헤더/쉘, 액션 버튼, 통계 항목, 모달 쉘 추가. 토스트 위치 및 스타일 조정. 도구팁 지연 duration prop 추가. BaseInput 제어 상태 로직 개선.
홈 및 네비게이션
src/app/(service)/home/page.tsx, src/app/(service)/insights/page.tsx, src/app/(service)/insights/weekly/page.tsx, src/app/(service)/insights/weekly/[id]/page.tsx, src/components/home/tab-navigation.tsx, src/hooks/use-scroll-to-home-content.ts, src/components/section/my-participating-studies-section.tsx
인사이트 주간 탭 제거. 홈 페이지 스페이서 추가. 탭 네비게이션에 스크롤-투-앵커 기능 추가. 인증 기반 히스토리 탭 가시성 토글. 튜토리얼 버튼 추가. 스크롤 유틸 훅 추가.
스터디 관련 UI
src/features/study/interview/ui/study-done-modal.tsx, src/features/study/interview/ui/study-ready-modal.tsx, src/features/study/participation/ui/reservation-list.tsx, src/widgets/home/study-list-table.tsx, src/features/study/one-to-one/ui/one-on-one-page.tsx
ModalShell로 모달 구조 리팩토링. forceOpen prop으로 튜토리얼 모드 지원. SectionHeader 사용 통일. 튜토리얼 모드 토글 추가. 주간 링크 제거.
기타 컴포넌트
src/components/discussion/comment-list.tsx, src/components/study-history/study-history-row.tsx, src/features/study/group/channel/ui/sub-comments.tsx
프로필 이미지 해상도 처리 확장. 역할 배지 텍스트 변경 ("답변자" → "지원자"). 임포트 정리.
타입 및 설정
src/types/archive.ts, src/types/balance-game.ts, src/types/schemas/zod-schema.ts, src/utils/voting-id.ts, docs/balance-game-tag-filter-request.md, package.json
아카이브 타입에 authorId, profileImage, isPrivate, bookmarks 추가. BalanceGame 타입 확장. Zod 스키마 태그 길이 제약 추가. Hashids 기반 투표 ID 인코딩/디코딩 유틸 추가. hashids 의존성 추가. 밸런스 게임 태그 필터링 API 문서 추가.

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as Archive/Balance Game UI
    participant Filter as Filter Hook
    participant API as API Client
    participant Server as Server
    participant Cache as React Query Cache

    User->>UI: 필터/태그 선택
    activate UI
    UI->>Filter: 필터 상태 업데이트
    activate Filter
    Filter->>Filter: 상태 변경 (tags, sort, search)
    Filter-->>UI: 새 필터 반환
    deactivate Filter
    
    UI->>UI: 검색어 디바운스 (300ms)
    UI->>API: 태그/검색 제안 요청
    activate API
    API->>Server: GET /tags 또는 /suggestions?q=...
    activate Server
    Server-->>API: 제안 배열 반환
    deactivate Server
    deactivate API
    
    UI->>UI: 드롭다운 제안 렌더링
    
    User->>UI: 태그 선택 또는 제안 클릭
    UI->>Filter: onAddTag() / onRemoveTag()
    UI->>UI: 필터 업데이트
    
    UI->>API: useQuery(filters) → getArchiveList/getBalanceGameList
    activate API
    API->>Server: GET /archive?tags=tag1,tag2&q=search&sort=latest
    activate Server
    Server-->>API: 필터된 결과 배열
    deactivate Server
    deactivate API
    
    API->>Cache: 쿼리 키로 캐시 저장
    Cache-->>UI: 캐시된 데이터 반환
    
    UI->>UI: 필터된 항목들 렌더링
    deactivate UI
Loading
sequenceDiagram
    actor User
    participant Page as Study Page
    participant Controller as StudyTutorialController
    participant Card as StudyCard
    participant Overlay as TutorialOverlay
    participant Router as Next.js Router

    User->>Router: /home?tutorial=true
    Router-->>Page: 튜토리얼 모드 활성화
    activate Page
    Page->>Controller: 렌더링
    activate Controller
    
    Controller->>Controller: searchParams에서 tutorial 확인
    Controller->>Controller: stepIndex = 0
    Controller->>Card: tutorialMode=true, 시나리오 props
    activate Card
    Card->>Card: forcedStatus, forcedRole 적용
    Card-->>Controller: 렌더링
    deactivate Card
    
    Controller->>Overlay: steps, activeIndex, handlers
    activate Overlay
    Overlay->>Overlay: targetSelector로 요소 찾기
    Overlay->>Overlay: spotlight rect 계산
    Overlay->>Overlay: tooltip 위치 계산
    Overlay-->>Controller: 렌더링
    deactivate Overlay
    
    User->>Overlay: ArrowRight / "다음" 클릭
    Overlay->>Controller: onStepChange(stepIndex + 1)
    Controller->>Controller: stepIndex 증가
    Controller->>Card: 새 시나리오 props
    Controller->>Overlay: 새 단계 렌더링
    
    User->>Overlay: "완료" 또는 Escape
    Overlay->>Controller: onClose()
    Controller->>Router: navigate("/home", { scroll: false })
    deactivate Controller
    deactivate Page
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 태그와 필터로 정리된 아카이브,
튜토리얼 빛이 길을 밝히고,
모달들은 쉘에 담겨 우아하게,
히스토리는 섹션으로 춤을 추며,
밸런스 게임의 검색은 더 깊어지네! 🎯

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목이 'fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결'이지만, 실제 변경 사항은 hydration/시간 문제 해결뿐만 아니라 아카이브 UI 개선, Balance Game 기능 추가(태그 필터링, 공유, 프로필 이미지 수정), 검색 자동완성, 관리자 기능 제거, 스터디 튜토리얼 추가 등 매우 광범위하다. PR 제목을 변경하여 주요 변경 사항을 더 정확히 반영하도록 하세요. 예: 'feat: Balance Game 태그 필터링, 투표 공유, 아카이브 UI 개선 및 hydration 버그 수정'
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-to-one-new-feature-bug-main

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

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: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/features/study/group/channel/ui/sub-comments.tsx (1)

32-32: ⚠️ Potential issue | 🟡 Minor

디버그용 console.log 문을 제거하세요.

프로덕션 코드에 디버그 로그가 남아있습니다. 불필요한 콘솔 출력은 성능에 영향을 줄 수 있고 민감한 데이터가 노출될 수 있으므로 제거해야 합니다.

🧹 제거 제안
-  console.log(data, 'subcomment');
src/components/card/voting-card.tsx (1)

144-149: ⚠️ Potential issue | 🟠 Major

상대 경로 import 수정 및 조건부 로직 정리 필요

  1. Path alias 위반: 7번 줄의 import VoteTimer from '../voting/vote-timer'@/components/voting/vote-timer 로 변경하세요.

  2. 사용되지 않는 코드: 145-149번 줄의 조건문은 양쪽 분기가 동일한 cardContent 를 반환하므로 정리가 필요합니다.

  3. 미구현된 Link 래핑: 주석에는 "onClick 없으면 Link로 감싸기"라고 명시되어 있으나 실제로는 구현되지 않았습니다. 현재 community-tab-client.tsx에서 onClick prop 없이 VotingCard를 사용하고 있으므로 카드가 완전히 비활성 상태입니다. Link 래핑을 구현하거나 의도를 명확히 해야 합니다.

src/features/study/one-to-one/schedule/ui/today-study-card.tsx (1)

354-361: ⚠️ Potential issue | 🟡 Minor

이미지 경로 일관성 문제.

src="icons/Link.svg"가 다른 이미지 경로(/icons/...)와 달리 앞에 /가 없습니다. 이로 인해 특정 라우팅 컨텍스트에서 이미지가 로드되지 않을 수 있습니다.

🐛 경로 수정 제안
           <Image
-            src="icons/Link.svg"
+            src="/icons/Link.svg"
             width={24}
             height={24}
             alt="스터디 링크"
           />
🤖 Fix all issues with AI agents
In `@docs/balance-game-tag-filter-request.md`:
- Around line 44-46: The fenced code block containing the HTTP example "GET
/api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react"
needs a language specifier to satisfy markdownlint MD040; update that fenced
block (the triple-backtick block wrapping the GET request) to use ```http as the
opening fence and keep the closing ``` unchanged so the code block is properly
annotated.

In `@src/api/client/api-logger.ts`:
- Around line 29-81: The request logger currently prints full params and
response bodies (see attachApiLogger, stringifyData, stringifyParams, and the
request/response interceptors); update it to avoid PII by adding an explicit
opt-in flag to attachApiLogger (e.g., enableSensitiveLogging) and only emit full
payloads when that flag is true, implement masking in
stringifyData/stringifyParams to redact known sensitive keys (password, token,
ssn, email, authorization, etc.), and enforce a maximum length/truncation for
strings and arrays to avoid huge dumps; ensure the interceptors use the new flag
and masked/truncated output when logging params and response bodies.

In `@src/components/ui/filters/sort-dropdown.tsx`:
- Around line 30-33: The button in SortDropdown (the <button> element rendering
{icon} and {label} in src/components/ui/filters/sort-dropdown.tsx) lacks an
explicit type, which can cause unintended form submission; update the button
element to include type="button" to prevent default submit behavior when used
inside forms, keeping the existing classes and children unchanged.

In `@src/components/ui/filters/view-mode-toggle.tsx`:
- Around line 33-46: The icon-only buttons in the ViewModeToggle rendering lack
proper button type and accessibility attributes; update the <button> created
inside the map (the element using key={option.value}, onClick={() =>
onChange(option.value)}, option.icon, etc.) to include type="button",
aria-pressed={isActive} to expose toggle state to assistive tech, and an
accessible name via aria-label={option.title} (or aria-label derived from
option.title) so screen readers announce the control.

In `@src/components/ui/modal-shell.tsx`:
- Around line 30-34: Modal.Close에 스크린리더용 레이블이 빠져 있어 접근성이 떨어집니다; Modal.Close (안의
XIcon)를 수정해 aria-label을 명시적으로 추가하세요 — 예: Modal.Close에 aria-label 속성으로 "닫기" 또는
`Close ${title}` 형태의 설명을 전달하여 버튼 목적을 명확히 하며 Modal.Header, Modal.Title,
Modal.Close 식별자를 찾아 해당 컴포넌트에 레이블을 추가하도록 변경하세요.

In `@src/components/ui/stat-item.tsx`:
- Around line 30-46: In the StatItem component replace the invalid Tailwind
utility "gap-25" with a valid spacing token (e.g., "gap-250") or an arbitrary
value (e.g., "gap-[25px]") wherever it's used in the component's className
strings (both the button variant that composes hoverClassName and the div root
variant that composes className); update the className occurrences that include
'gap-25' (the ones surrounding iconClassName, valueClassName, hoverClassName) so
they use the chosen valid utility consistently.

In `@src/components/voting/vote-timer.tsx`:
- Around line 82-98: The placeholder block returned when !isMounted || !timeLeft
currently renders only "00:00:00" which omits the days segment and can cause a
layout shift when timeLeft.days > 0; update the placeholder in vote-timer.tsx
(the branch guarded by isMounted and timeLeft) to include the same days span
structure used in the active timer (i.e., render a days <span> when
timeLeft?.days might be > 0 or always include a days <span> with "00" and the
same classes) so the DOM shape matches the active render (references: isMounted,
timeLeft, timeLeft.days, Timer component) and prevents hydration/layout jumps.

In `@src/components/voting/voting-modal-header.tsx`:
- Around line 23-31: The close button currently attaches onClick={onClose}
inside Modal.Close which causes onClose to be invoked twice because Modal.Root
already triggers onClose via its onOpenChange; remove the onClick prop from the
button inside Modal.Close (leave disabled, className and icon intact) and ensure
modal close logic remains handled only by Modal.Root's onOpenChange handler so
onClose is not called from the button directly.

In `@src/features/study/interview/ui/study-ready-modal.tsx`:
- Around line 42-49: resolvedOpen currently uses forceOpen with nullish
coalescing so passing forceOpen={false} permanently closes the modal; change
behavior to treat forceOpen as "force open only" by deriving resolvedOpen as
(forceOpen === true ? true : isOpen) and update the effect that syncs state so
it only calls setIsOpen(true) when forceOpen === true (leave uncontrolled
behavior when forceOpen is false or undefined). Update references in this
component (resolvedOpen, forceOpen, isOpen, useEffect, setIsOpen, onClose) to
use that logic so the parent can force-open without permanently disabling user
toggles, or alternatively convert the component to a fully controlled pattern
(expose onOpenChange) if you prefer the controlled approach.

In `@src/features/study/one-to-one/archive/ui/archive-grid.tsx`:
- Around line 75-81: The cancel handler handleCancel can set description to null
when item.description is null, which can pass null into the input component;
update the handler to normalize description to an empty string (use the existing
setDescription call with item.description fallback to '') so
setDescription(item.description ?? '') is used (also keep setTitle(item.title)
and setNextPrivate(!!item.isPrivate) unchanged) to ensure the input always
receives a string.

In `@src/features/study/one-to-one/archive/ui/archive-list.tsx`:
- Around line 54-73: The current handleSave calls onUpdate(item.id, request) and
immediately setIsEditing(false), so users get no feedback on failure; change
handleSave to await the update mutation (or use its returned promise) and only
call setIsEditing(false) on success, and on error show user feedback and keep
editing mode; specifically update the handleSave flow around the onUpdate call
(and its returned mutation promise or callbacks in the mutation used by
onUpdate) to: 1) await the mutation result or use onSuccess/onError handlers, 2)
call setIsEditing(false) only on success, and 3) show a toast or set an error
state on failure so the UI retains editing with an error message.

In `@src/features/study/one-to-one/archive/ui/archive-pagination.tsx`:
- Around line 22-35: The previous/next pagination buttons
(PaginationCircleButton wrapping ChevronLeft and ChevronRight) are icon-only and
need accessible labels; update the PaginationCircleButton instances that call
onPageChange (the left button using currentPage - 1 and the right button using
currentPage + 1) to include descriptive aria-label attributes (e.g., "Previous
page" and "Next page" or project-localized equivalents) and ensure the disabled
prop remains unchanged so screen readers announce state correctly.

In `@src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx`:
- Around line 86-157: The Enter handler and handleAdd currently allow adding
empty/whitespace tags; update the logic to block additions when the trimmed
value length is less than minQueryLength: either validate inside handleAdd
(check const trimmed = tag.trim(); if (!trimmed || trimmed.length <
minQueryLength) return;) and call onAddTag(trimmed) only when valid, or apply
the same trimmed + minQueryLength check before calling handleAdd in the Enter
branch (and when adding from filteredSuggestions ensure the suggestion.name is
trimmed/non-empty). Ensure references: handleAdd, onAddTag, minQueryLength,
value, filteredSuggestions, activeIndex are used so the guard prevents adding
blank tags from Enter or suggestion paths.
- Around line 236-245: The remove-tag button inside the JSX mapping of
selectedTags (the selectedTags.map(...) block) lacks an explicit type and can
trigger form submit; update the button element rendered for each tag (the one
using key={tag} and onClick={() => onRemoveTag(tag)}) to include type="button"
so clicking a tag only invokes onRemoveTag and does not submit the surrounding
form.

In `@src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts`:
- Around line 17-58: The updater functions (setStatus, setSort, addTag,
removeTag) call triggerChange() inside React state updaters which can run twice
in Strict Mode; remove all triggerChange() calls from within those updater
callbacks and instead call options.onChange from a useEffect that watches the
derived filter state (statusFilter, sortMode, selectedTags) so onChange runs
only after state commits; keep triggerChange/ options?.onChange invocation logic
inside that useEffect and ensure the effect's dependency array includes set of
values representing the current filters.

In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx`:
- Around line 49-51: The hardcoded label "1월 4주차 MVP 팀" should be replaced with
a dynamic string derived from the team prop (use team.weekDate,
team.weekStartDate or team.weekEndDate) inside the MVPTeamCard component
(mvp-team-card.tsx); update the span rendering (current static content) to
format the week/date consistently with the "이번 주" text used later (see reference
to the other label), ensuring the same locale/format logic is applied so both
places display matching week date text.

In `@src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx`:
- Around line 11-29: The component currently builds an iconPath and renders SVGs
via Next/Image (iconPath and Image in rank-badge.tsx); replace that with SVGR
imports and render the SVG React components directly: import the three icons as
components (e.g., GoldRank, SilverRank, BronzeRank), remove iconPath and Image
usage, and conditionally render the correct SVG component for rank 1/2/3 inside
the existing wrapper (keep the numeric fallback for rank > 3). Ensure the SVG
components receive the same className/size props used on the Image (e.g.,
object-contain and container sizing) so styling remains consistent.

In `@src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx`:
- Around line 16-60: The modal trigger is currently a non-focusable <div> passed
to UserProfileModal via the trigger prop; replace that wrapper with a focusable
button element (e.g., <button type="button">) in the UserProfileModal trigger so
keyboard users can open the modal, keep the same className and children
(RankBadge, UserAvatar, score) but ensure to set type="button", add an
accessible name/aria-label (use ranker.nickname or similar), and avoid nested
interactive controls inside the button (convert any inner anchors/buttons to
non-interactive spans if present); update references in this file around
UserProfileModal/trigger to reflect this change.

In `@src/features/study/one-to-one/history/ui/study-history-header.tsx`:
- Around line 59-83: The icon-only view mode buttons (the elements using
onViewModeChange('LIST') and onViewModeChange('CALENDAR') with the List and
CalendarIcon components) lack explicit type and accessible labels; add
type="button" to both buttons to prevent accidental form submits and add
descriptive aria-label attributes (e.g., "리스트 보기" and "달력 보기") so the hidden
<span> text still has an accessible name for screen readers when it’s visually
hidden.

In `@src/utils/voting-id.ts`:
- Around line 9-14: The decodeVotingId function currently rejects bigint results
from hashids.decode by only checking typeof id === 'number'; update
decodeVotingId to handle NumberLike values returned by hashids.decode: either
accept bigint IDs (handle typeof id === 'bigint' by returning Number(id) if safe
or returning the bigint directly) or explicitly document the intentional
limitation with a comment explaining why bigints are unsupported; reference the
decodeVotingId function and the decoded/id variables and the hashids.decode call
when applying the change.
🧹 Nitpick comments (39)
src/widgets/home/study-list-table.tsx (2)

9-9: 경로 별칭 @/* 사용 필요

코딩 가이드라인에 따라 public/icons/Link.svg 대신 경로 별칭을 사용해야 합니다.

♻️ 수정 제안
-import LinkIcon from 'public/icons/Link.svg';
+import LinkIcon from '@/public/icons/Link.svg';

As per coding guidelines: "Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements"


123-135: Props 타입 설계 개선 고려

tutorialModetrue일 때 studyDate는 사용되지 않지만 필수 prop으로 요구됩니다. 호출부에서 불필요한 값을 전달해야 하는 상황이 발생할 수 있습니다.

♻️ 선택적 개선안: Discriminated Union 사용
type StudyListSectionProps =
  | { tutorialMode: true; studyDate?: never }
  | { tutorialMode?: false; studyDate: string };

export default function StudyListSection(props: StudyListSectionProps) {
  return props.tutorialMode ? (
    <MockStudyListSection />
  ) : (
    <RealStudyListSection studyDate={props.studyDate} />
  );
}
src/components/section/my-participating-studies-section.tsx (2)

178-194: groupStudyId가 undefined인 경우에 대한 방어 코드 검토 필요.

studyId0으로 fallback되면 잘못된 URL(/group-study/0)이 생성되고, GTM 이벤트에 부정확한 데이터가 전송될 수 있습니다. 또한 여러 스터디의 groupStudyId가 undefined인 경우 React key 충돌이 발생할 수 있습니다.

♻️ 유효한 studyId가 있는 항목만 필터링하는 방안
       <div className="grid grid-cols-3 gap-300">
-        {participatingStudies.map((study) => {
-          const studyId = study.basicInfo?.groupStudyId ?? 0;
+        {participatingStudies
+          .filter((study) => study.basicInfo?.groupStudyId != null)
+          .map((study) => {
+          const studyId = study.basicInfo!.groupStudyId;
           const title = study.simpleDetailInfo?.title ?? '';

또는 participatingStudies useMemo에서 미리 필터링하는 것도 방법입니다.


148-159: handleStudyClick 함수 메모이제이션 고려.

현재 구현은 매 렌더링마다 새 함수 인스턴스가 생성됩니다. StudyCardReact.memo로 감싸져 있다면, useCallback으로 최적화할 수 있습니다.

♻️ useCallback 적용 예시
+import { useMemo, useCallback } from 'react';

-  const handleStudyClick = (studyId: number, title: string) => {
+  const handleStudyClick = useCallback((studyId: number, title: string) => {
     sendGTMEvent({
       event:
         classification === 'GROUP_STUDY'
           ? 'group_study_detail_view'
           : 'premium_study_detail_view',
       dl_timestamp: new Date().toISOString(),
       dl_member_id: hashValue(String(memberId)),
       dl_study_id: String(studyId),
       dl_study_title: title,
     });
-  };
+  }, [classification, memberId]);
src/components/ui/toast.tsx (1)

35-38: Tailwind 클래스 정렬 규칙을 맞춰주세요.

그룹 내 알파벳 정렬 규칙이 깨져 보여요. prettier-plugin-tailwindcss로 재정렬 부탁드립니다.
As per coding guidelines: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.

src/components/ui/action-pill-button.tsx (1)

4-44: variant/size 조합은 CVA로 관리하는 편이 일관됩니다. 동적 Tailwind 변형을 수동 맵으로 관리하면 확장·타입 안전성이 떨어집니다.

♻️ 제안 리팩터링
-import React from 'react';
-import { cn } from '@/components/ui/(shadcn)/lib/utils';
-
-type ActionPillVariant = 'primary' | 'neutral' | 'ghost';
-type ActionPillSize = 'xs' | 'sm' | 'md';
-
-interface ActionPillButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
-  variant?: ActionPillVariant;
-  size?: ActionPillSize;
-  icon?: React.ReactNode;
-}
-
-const VARIANT_CLASSES: Record<ActionPillVariant, string> = {
-  primary:
-    'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover',
-  neutral:
-    'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover',
-  ghost:
-    'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover',
-};
-
-const SIZE_CLASSES: Record<ActionPillSize, string> = {
-  xs: 'px-100 py-50 font-designer-11m',
-  sm: 'px-150 py-50 font-designer-12m',
-  md: 'px-200 py-100 font-designer-12m',
-};
+import React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+
+const actionPillButtonVariants = cva(
+  'rounded-100 flex items-center gap-50 transition-colors',
+  {
+    variants: {
+      variant: {
+        primary:
+          'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover',
+        neutral:
+          'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover',
+        ghost: 'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover',
+      },
+      size: {
+        xs: 'px-100 py-50 font-designer-11m',
+        sm: 'px-150 py-50 font-designer-12m',
+        md: 'px-200 py-100 font-designer-12m',
+      },
+    },
+    defaultVariants: {
+      variant: 'ghost',
+      size: 'sm',
+    },
+  },
+);
+
+interface ActionPillButtonProps
+  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof actionPillButtonVariants> {
+  icon?: React.ReactNode;
+}
@@
 export default function ActionPillButton({
-  variant = 'ghost',
-  size = 'sm',
+  variant,
+  size,
   icon,
   className,
   children,
   ...props
 }: ActionPillButtonProps) {
   return (
     <button
       type="button"
       className={cn(
-        'rounded-100 flex items-center gap-50 transition-colors',
-        VARIANT_CLASSES[variant],
-        SIZE_CLASSES[size],
+        actionPillButtonVariants({ variant, size }),
         className,
       )}
       {...props}
     >
As per coding guidelines: Use class composition utilities clsx, tailwind-merge, and class-variance-authority (CVA) for dynamic Tailwind class management.
src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts (1)

26-37: entries 배열 접근 시 안전성 검사 추가 권장

entries[0]에 직접 접근하고 있는데, IntersectionObserver 콜백에서 배열이 비어있는 경우는 거의 없지만, 방어적 코딩을 위해 검사를 추가하는 것이 좋습니다.

♻️ 제안된 수정
     const observer = new IntersectionObserver(
       (entries) => {
+        const entry = entries[0];
+        if (!entry) return;
+
         if (
-          entries[0].isIntersecting &&
+          entry.isIntersecting &&
           hasNextPage &&
           !isFetchingNextPage &&
           !isFetching
         ) {
           fetchNextPage().catch(() => {
             // 무한 스크롤 실패 시 무시
           });
         }
       },
       { threshold },
     );
src/components/voting/voting-option-fields.tsx (2)

57-65: 접근성 향상을 위해 aria-label 추가 권장

삭제 버튼에 시각적 아이콘만 있어서 스크린 리더 사용자가 버튼의 용도를 파악하기 어려울 수 있습니다.

♻️ 제안된 수정
             {fields.length > 2 && (
               <button
                 type="button"
                 onClick={() => remove(index)}
+                aria-label={`선택지 ${index + 1} 삭제`}
                 className="rounded-100 text-text-subtle hover:bg-fill-critical-subtle-default hover:text-text-critical p-150 transition-colors"
               >
                 <Trash2 className="h-4 w-4" />
               </button>
             )}

74-83: 추가 버튼에도 aria-label 추가 권장

일관성을 위해 추가 버튼에도 명시적인 aria-label을 추가하는 것이 좋습니다. 버튼 내 텍스트가 있지만, 아이콘과 텍스트를 포함한 전체 의미를 명확히 전달할 수 있습니다.

♻️ 제안된 수정
         <button
           type="button"
           onClick={() => append({ label: '' })}
+          aria-label="새 선택지 추가"
           className="rounded-100 border-border-brand font-designer-13b text-text-brand hover:bg-fill-brand-subtle-default mt-100 flex items-center gap-100 border border-dashed px-300 py-200 transition-colors"
         >
src/features/study/one-to-one/history/ui/study-history-list-section.tsx (1)

17-25: 고정 너비 열이 작은 화면에서 레이아웃 문제를 일으킬 수 있음

헤더 열들이 고정 픽셀 너비(w-[150px], w-[120px] 등)를 사용하고 있어, 작은 화면에서 콘텐츠가 잘리거나 레이아웃이 깨질 수 있습니다. 수평 스크롤을 허용하거나 반응형 처리를 고려해 보세요.

♻️ 수평 스크롤 추가 제안
-    <div className="bg-background-default rounded-200 border-border-subtle shadow-1 overflow-hidden border">
+    <div className="bg-background-default rounded-200 border-border-subtle shadow-1 overflow-x-auto border">
+      <div className="min-w-[900px]">
       <div className="bg-background-alternative/80 border-border-subtlest font-designer-13b text-text-subtle flex gap-400 border-b px-400 py-250 tracking-wider uppercase">
         ...
       </div>
       <div className="divide-border-subtlest divide-y">
         ...
       </div>
+      </div>
     </div>
src/features/study/one-to-one/history/ui/study-history-pagination.tsx (1)

21-35: 화살표 문자 대신 아이콘 사용 권장

현재 화살표 문자(, )를 사용하고 있는데, 다른 컴포넌트들이 lucide-react 아이콘을 사용하는 점을 고려하면 ChevronLeftChevronRight 아이콘으로 변경하는 것이 디자인 시스템의 일관성에 더 좋을 수 있습니다.

♻️ 제안된 수정
+'use client';
+
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import React from 'react';
+import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';
       <PaginationCircleButton
         onClick={() => onPageChange(Math.max(1, currentPage - 1))}
         disabled={currentPage === 1}
       >
-
+        <ChevronLeft className="h-4 w-4" />
       </PaginationCircleButton>
       ...
       <PaginationCircleButton
         onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
         disabled={currentPage === totalPages}
       >
-
+        <ChevronRight className="h-4 w-4" />
       </PaginationCircleButton>
src/components/ui/filters/sort-dropdown.tsx (1)

34-51: 접근성 개선이 필요합니다: 호버 전용 드롭다운은 키보드 및 터치 사용자에게 접근 불가합니다.

현재 group-hover:block만 사용하여 드롭다운이 표시됩니다. 이는 다음 사용자에게 문제가 됩니다:

  • 키보드 사용자 (Tab 네비게이션)
  • 터치 디바이스 사용자
  • 스크린 리더 사용자

권장 개선사항:

  1. focus-within:block 추가하여 키보드 접근성 확보
  2. ARIA 속성 추가 (aria-expanded, aria-haspopup, role="menu")
  3. 옵션 버튼에 type="button" 추가
♻️ 접근성 개선 제안
-      <div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block">
+      <div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block group-focus-within:block">
             <button
               key={option.value}
+              type="button"
               onClick={() => onChange(option.value)}
               className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors"
             >
src/features/study/one-to-one/balance-game/model/balance-game-keys.ts (1)

14-22: tags 함수의 파라미터를 객체 형태로 변경하는 것을 고려해 주세요.

다른 함수들(list, searchSuggestionList)은 객체 파라미터를 사용하는데, tags만 위치 인자를 사용하고 있어 API 일관성이 떨어집니다.

♻️ 리팩토링 제안
-  tags: (query: string, limit: number, minLength: number, sort: string) =>
-    [
-      ...BALANCE_GAME_QUERY_KEYS.all,
-      'tags',
-      query,
-      limit,
-      minLength,
-      sort,
-    ] as const,
+  tags: (params: { query: string; limit: number; minLength: number; sort: string }) =>
+    [...BALANCE_GAME_QUERY_KEYS.all, 'tags', params] as const,
src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts (1)

14-28: 검색어를 공통으로 트리밍해 캐시 분산을 줄여주세요.
enabled 계산은 trim 기준인데 queryKey/queryFn은 원문 query를 사용하고 있어 공백 유무로 캐시가 분산될 수 있습니다. trim 값을 공통 사용하면 의도와 일치합니다.

💡 수정 제안
-  const enabled = options?.enabled ?? query.trim().length >= minLength;
+  const trimmedQuery = query.trim();
+  const enabled = options?.enabled ?? trimmedQuery.length >= minLength;

   return useQuery({
     queryKey: BALANCE_GAME_QUERY_KEYS.searchSuggestionList({
-      q: query,
+      q: trimmedQuery,
       size,
       minLength,
       scope,
     }),
     queryFn: () =>
-      getBalanceGameSearchSuggestions({ q: query, size, minLength, scope }),
+      getBalanceGameSearchSuggestions({ q: trimmedQuery, size, minLength, scope }),
src/hooks/use-scroll-to-home-content.ts (1)

49-75: 의존성 배열에 옵션/콜백을 포함해 최신 값이 반영되게 해주세요.
effect 내부에서 scrollToHomeContent와 options를 참조하지만 deps에 포함되지 않아 옵션 변경 시 오래된 값으로 동작할 수 있습니다.

💡 수정 제안
   const scrollToHomeContent = useScrollToHomeContent(options?.offset);
   const isFirst = useRef(true);
+  const effectDeps = [
+    ...deps,
+    scrollToHomeContent,
+    options?.enabled,
+    options?.stabilize,
+    options?.stabilizeDurationMs,
+  ];

   useEffect(() => {
     if (options?.enabled === false) return;
     if (isFirst.current) {
       isFirst.current = false;

       return;
     }
     scrollToHomeContent();
     if (options?.stabilize) {
       stabilizeScrollAfterLayout(
         scrollToHomeContent,
         options?.stabilizeDurationMs ?? 800,
       );
     }
-  }, deps);
+  }, effectDeps);
src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts (2)

7-12: src 내부 import는 @/ 별칭으로 통일해주세요.
상대경로 대신 tsconfig alias를 사용하면 경로 일관성과 리팩터링 안정성이 좋아집니다.

💡 수정 제안
 import {
   getBalanceGameComments,
   getBalanceGameDetail,
   getBalanceGameList,
   getBalanceGameTagSuggestions,
-} from '../api/balance-game-api';
+} from '@/features/study/one-to-one/balance-game/api/balance-game-api';
As per coding guidelines, `**/*.{ts,tsx,js,jsx}`: Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.

14-27: 태그 순서로 인한 캐시 분산을 방지하도록 정규화 권장.
같은 태그 집합이라도 배열 순서가 다르면 queryKey가 달라져 캐시/요청이 분산될 수 있습니다. tags를 정규화해서 queryKey와 API 호출에 사용하세요.

💡 수정 제안
 ) => {
+  const normalizedTags = tags?.filter(Boolean).sort();
   return useInfiniteQuery({
-    queryKey: BALANCE_GAME_QUERY_KEYS.list({ sort, status, tags, q }),
+    queryKey: BALANCE_GAME_QUERY_KEYS.list({
+      sort,
+      status,
+      tags: normalizedTags,
+      q,
+    }),
     queryFn: ({ pageParam = 1 }) =>
-      getBalanceGameList({ page: pageParam, size: 10, sort, status, tags, q }),
+      getBalanceGameList({
+        page: pageParam,
+        size: 10,
+        sort,
+        status,
+        tags: normalizedTags,
+        q,
+      }),
src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx (1)

3-10: 로컬 UI 컴포넌트도 @/ 별칭 경로로 통일해주세요.
src 내부 경로는 alias를 쓰는 규칙과 맞추는 편이 유지보수에 유리합니다.

💡 수정 제안
 import { ArrowUpDown, Search } from 'lucide-react';
 import React from 'react';
 import { cn } from '@/components/ui/(shadcn)/lib/utils';
 import { useBalanceGameSearchSuggestionsQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query';
 import { useDebounce } from '@/hooks/use-debounce';
-import FilterPillButton from './filter-pill-button';
-import TagAutocomplete from './tag-autocomplete';
+import FilterPillButton from '@/features/study/one-to-one/balance-game/ui/filter-pill-button';
+import TagAutocomplete from '@/features/study/one-to-one/balance-game/ui/tag-autocomplete';
As per coding guidelines, `**/*.{ts,tsx,js,jsx}`: Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.
src/components/voting/voting-tag-field.tsx (1)

32-36: 최대 태그 수 문구를 상수로 동기화하세요.
BALANCE_GAME_TAG_MAX_COUNT와 UI 문구가 분리돼 있어 값 변경 시 불일치가 생길 수 있습니다. 상수를 사용해 표시를 생성하는 편이 안전합니다.

♻️ 제안 변경
-        <span className="font-designer-12r text-text-subtle ml-100">
-          (최대 3개)
-        </span>
+        <span className="font-designer-12r text-text-subtle ml-100">
+          (최대 {BALANCE_GAME_TAG_MAX_COUNT}개)
+        </span>
src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx (2)

30-32: 상대 경로 대신 @/ 경로 별칭으로 통일해주세요.
src 하위 모듈은 @/ 별칭 사용 규칙에 맞춰 정리하는 게 좋습니다.

♻️ 제안 변경
-import BalanceGameFiltersBar from './balance-game-filters-bar';
-import { useBalanceGameFilters } from './use-balance-game-filters';
-import { useInfiniteScroll } from './use-infinite-scroll';
+import BalanceGameFiltersBar from '@/features/study/one-to-one/balance-game/ui/balance-game-filters-bar';
+import { useBalanceGameFilters } from '@/features/study/one-to-one/balance-game/ui/use-balance-game-filters';
+import { useInfiniteScroll } from '@/features/study/one-to-one/balance-game/ui/use-infinite-scroll';

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements.


273-276: 리스트 키에 index를 섞지 않는 편이 좋습니다.
정렬/필터 변경 시 불필요한 리마운트가 발생할 수 있어 voting.id만 사용하는 것을 권장합니다.

♻️ 제안 변경
-              {visibleVotings.map((voting, index) => (
+              {visibleVotings.map((voting) => (
                 <VotingCard
-                  key={`${voting.id}-${index}`}
+                  key={voting.id}
                   voting={voting}
src/app/(service)/home/page.tsx (1)

31-31: 빈 spacer div 대신 컨테이너 패딩으로 여백을 주는 게 낫습니다.
DOM 노이즈를 줄이고 접근성 관점에서도 더 깔끔합니다.

♻️ 제안 변경
-    <div className="mx-auto flex w-[1496px] flex-col gap-500 px-600 py-600">
+    <div className="mx-auto flex w-[1496px] flex-col gap-500 px-600 py-600 pb-[400px]">
       <Banner />
       <FeedbackLink />
       <StartStudyButton />
       <HomeContent activeTab={activeTab} />
-      <div className="h-[400px]" aria-hidden />
     </div>
src/api/client/axios.ts (1)

1-4: src 내부 import는 @/ 별칭으로 통일해주세요.

♻️ 제안 변경
-import { ApiError, isApiError } from './api-error';
-import { attachApiLogger } from './api-logger';
-import { getCookie, setCookie } from './cookie';
+import { ApiError, isApiError } from '@/api/client/api-error';
+import { attachApiLogger } from '@/api/client/api-logger';
+import { getCookie, setCookie } from '@/api/client/cookie';

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements.

src/components/discussion/comment-list.tsx (1)

89-97: any 타입 캐스팅으로 인한 타입 안전성 저하

(comment.author as any).profileImage 패턴은 TypeScript의 타입 체크를 우회합니다. 또한 중첩된 삼항 연산자로 인해 가독성이 떨어집니다.

타입 가드 함수를 사용하거나, DiscussionComment | VotingComment | BalanceGameComment 유니온 타입에서 profileImage 속성을 명시적으로 처리하는 것이 좋습니다.

♻️ 헬퍼 함수를 사용한 리팩토링 제안
+// 컴포넌트 외부에 헬퍼 함수 추가
+const getAuthorImage = (author: DiscussionComment['author'] | VotingComment['author'] | BalanceGameComment['author']): string | undefined => {
+  if ('avatar' in author) return author.avatar;
+  if ('profileImage' in author) {
+    const profileImage = author.profileImage;
+    if (typeof profileImage === 'string') return profileImage;
+    if (profileImage && typeof profileImage === 'object' && 'resizedImages' in profileImage) {
+      return profileImage.resizedImages?.[0]?.resizedImageUrl;
+    }
+  }
+  return undefined;
+};

 // 사용 시
-        const authorImage =
-          'avatar' in comment.author
-            ? comment.author.avatar
-            : 'profileImage' in comment.author
-              ? typeof (comment.author as any).profileImage === 'string'
-                ? (comment.author as any).profileImage
-                : (comment.author as any).profileImage?.resizedImages?.[0]
-                    ?.resizedImageUrl
-              : undefined;
+        const authorImage = getAuthorImage(comment.author);
src/features/study/one-to-one/history/ui/study-history-utils.ts (2)

1-1: 불필요한 'use client' 지시어

이 파일은 React 훅이나 브라우저 API를 사용하지 않는 순수 유틸리티 함수입니다. 'use client' 지시어는 필요하지 않으며, 서버 컴포넌트에서도 이 유틸리티를 사용할 수 있도록 제거하는 것이 좋습니다.

♻️ 제안
-'use client';
-
 import type {

9-11: 날짜 포맷팅에 date-fns 사용 고려

코드베이스의 다른 파일(예: comment-list.tsx)에서 이미 date-fns를 사용하고 있습니다. 일관성을 위해 수동 날짜 포맷팅 대신 date-fnsformat 함수를 사용하는 것을 고려해 보세요.

♻️ date-fns 사용 예시
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';

const dateObj = new Date(data.scheduledAt);
const date = format(dateObj, 'yyyy.MM.dd (E)', { locale: ko });
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx (1)

4-4: React 19에서 불필요한 import

React 19와 새로운 JSX Transform을 사용하면 JSX만을 위한 import React from 'react'는 필요하지 않습니다. 다만 코드베이스 전체의 일관성을 위해 유지해도 무방합니다.

src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts (1)

13-24: 검색어 공백 정규화로 캐시 중복을 줄이는 것 검토하세요.
enabled는 trim 길이를 기준으로 판단하지만 실제 queryKey/요청은 원본 문자열을 사용해 공백 차이로 캐시가 분리될 수 있습니다. trim()을 한 번 계산해 일관되게 사용하면 호출/캐시 효율이 좋아집니다.

♻️ 제안 변경
-  const limit = options?.limit ?? 10;
-  const minLength = options?.minLength ?? 1;
-  const enabled = options?.enabled ?? query.trim().length >= minLength;
+  const limit = options?.limit ?? 10;
+  const minLength = options?.minLength ?? 1;
+  const normalizedQuery = query.trim();
+  const enabled = options?.enabled ?? normalizedQuery.length >= minLength;

   return useQuery({
     queryKey: ARCHIVE_QUERY_KEYS.searchSuggestionList({
-      q: query,
+      q: normalizedQuery,
       limit,
       minLength,
     }),
-    queryFn: () => getArchiveSearchSuggestions({ q: query, limit, minLength }),
+    queryFn: () =>
+      getArchiveSearchSuggestions({ q: normalizedQuery, limit, minLength }),
src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx (1)

6-10: src 경로는 @/ 별칭으로 통일해 주세요.*

현재 상대 경로 import가 있어 프로젝트 import 규칙과 어긋납니다. @/ 별칭으로 통일해 주세요.

♻️ 제안 수정안
-import StudyCard from './study-card';
-import {
-  STUDY_TUTORIAL_STEPS,
-  getStudyTutorialScenario,
-} from '../model/study-tutorial-steps';
+import StudyCard from '@/features/study/one-to-one/schedule/ui/study-card';
+import {
+  STUDY_TUTORIAL_STEPS,
+  getStudyTutorialScenario,
+} from '@/features/study/one-to-one/schedule/model/study-tutorial-steps';
As per coding guidelines, src 경로는 `@/`* 별칭으로 import해야 합니다.
src/features/study/one-to-one/archive/model/use-update-archive-mutation.ts (1)

12-57: 전반적으로 잘 구현된 optimistic update 패턴입니다.

몇 가지 권장 개선 사항:

  1. Line 43: errvariables 파라미터가 사용되지 않습니다. 언더스코어 접두사를 사용하거나 제거하는 것이 좋습니다.

  2. Line 50-56: onSettled에서 invalidation을 수행하는 것은 괜찮지만, 학습된 패턴에 따르면 onSuccess에서 수행하는 것이 더 일반적입니다. 현재 방식은 에러 발생 시에도 invalidation이 실행되므로 의도적이라면 문제없습니다.

♻️ 미사용 파라미터 정리 제안
-    onError: (err, variables, context) => {
+    onError: (_err, _variables, context) => {

Based on learnings: "In TanStack Query useMutation hooks, call queryClient.invalidateQueries() in onSuccess callback to invalidate related queries"

src/features/study/one-to-one/archive/api/update-archive.ts (1)

16-26: API 함수 구현이 적절합니다.

axiosInstance를 올바르게 사용하고 있으며, data.data ?? data.content 패턴으로 API 응답의 유연성을 잘 처리하고 있습니다.

반환 타입을 명시적으로 지정하면 타입 안전성이 향상됩니다.

♻️ 명시적 반환 타입 추가 제안
 export const updateArchive = async (
   id: number,
   request: UpdateArchiveRequest,
-) => {
+): Promise<UpdateArchiveResponse | undefined> => {
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx (1)

29-33: 아이콘 렌더링 패턴을 간소화할 수 있습니다.

IIFE 패턴 대신 직접 렌더링하면 코드가 더 간결해집니다. 동일한 패턴이 Line 54-58에도 반복됩니다.

♻️ 아이콘 렌더링 간소화 제안
             <span className={TAB_CONFIG[rankingType].colorClass}>
-              {(() => {
-                const Icon = TAB_CONFIG[rankingType].icon;
-
-                return <Icon className="h-4 w-4" />;
-              })()}
+              {React.createElement(TAB_CONFIG[rankingType].icon, { className: 'h-4 w-4' })}
             </span>

또는 더 간단하게:

+          const IconComponent = TAB_CONFIG[rankingType].icon;
           <span className={TAB_CONFIG[rankingType].colorClass}>
-              {(() => {
-                const Icon = TAB_CONFIG[rankingType].icon;
-
-                return <Icon className="h-4 w-4" />;
-              })()}
+              <IconComponent className="h-4 w-4" />
           </span>
src/features/study/one-to-one/archive/ui/use-archive-filters.ts (1)

28-48: 상호 배제 토글 로직이 잘 구현되어 있습니다.

toggleBookmarkedOnlytoggleMyOnly가 서로를 해제하는 상호 배제 패턴이 올바르게 구현되었습니다.

다만, 필터 변경 시 currentPage를 1로 리셋하지 않으면 유효하지 않은 페이지 번호가 될 수 있습니다.

♻️ 필터 변경 시 페이지 리셋 제안
   const toggleBookmarkedOnly = () => {
     setShowBookmarkedOnly((prev) => {
       if (!prev) {
         setShowMyOnly(false);
       }

       return !prev;
     });
+    setCurrentPage(1);
     triggerScroll();
   };

   const toggleMyOnly = () => {
     setShowMyOnly((prev) => {
       if (!prev) {
         setShowBookmarkedOnly(false);
       }

       return !prev;
     });
+    setCurrentPage(1);
     triggerScroll();
   };
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx (1)

7-14: 상대 경로 대신 @/ 경로 별칭을 사용해 주세요.
동일한 src 하위 모듈이므로 alias로 통일하면 이동/리팩터링 시 안전합니다.

♻️ 제안 수정
-import {
-  addScoreLabel,
-  type RankingType,
-  type RankerWithLabel,
-} from './hall-of-fame-constants';
-import HallOfFameHeader from './hall-of-fame-header';
-import HallOfFameMvpSection from './hall-of-fame-mvp-section';
-import HallOfFameRankerSection from './hall-of-fame-ranker-section';
+import {
+  addScoreLabel,
+  type RankingType,
+  type RankerWithLabel,
+} from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants';
+import HallOfFameHeader from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header';
+import HallOfFameMvpSection from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section';
+import HallOfFameRankerSection from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section';
As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts (1)

39-56: TAB_CONFIG의 unit을 재사용하면 중복/불일치 위험을 줄일 수 있습니다.

♻️ 제안 수정
 export const addScoreLabel = (
   ranker: Ranker,
   type: RankingType,
 ): RankerWithLabel => {
-  let scoreLabel = '';
-
-  if (type === 'ATTENDANCE') {
-    scoreLabel = `${ranker.score}회`;
-  } else if (type === 'STUDY_LOG') {
-    scoreLabel = `${ranker.score}건`;
-  } else {
-    scoreLabel = `${ranker.score}℃`;
-  }
-
   return {
     ...ranker,
-    scoreLabel,
+    scoreLabel: `${ranker.score}${TAB_CONFIG[type].unit}`,
   };
 };
src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx (1)

7-8: 상대 경로 대신 @/ 경로 별칭을 사용해 주세요.

♻️ 제안 수정
-import type { RankerWithLabel } from './hall-of-fame-constants';
-import RankBadge from './rank-badge';
+import type { RankerWithLabel } from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants';
+import RankBadge from '@/features/study/one-to-one/hall-of-fame/ui/rank-badge';
As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.
src/components/card/voting-card.tsx (1)

7-7: VoteTimer import도 @/ 경로 별칭으로 통일해 주세요.

♻️ 제안 수정
-import VoteTimer from '../voting/vote-timer';
+import VoteTimer from '@/components/voting/vote-timer';
As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.
src/features/study/one-to-one/archive/ui/archive-list.tsx (1)

29-36: canEdit prop 타입 일관성 검토.

ArchiveListProps에서는 canEdit이 필수(boolean)로 정의되어 있지만, LibraryRow에서는 선택적(boolean?)으로 정의되어 있습니다. ArchiveList에서 항상 canEdit을 전달하므로 LibraryRow에서도 필수로 변경하면 타입 일관성이 향상됩니다.

♻️ 타입 일관성 개선 제안
 }: {
   item: ArchiveItem;
   onLike: (e: React.MouseEvent, id: number) => void;
   onView: (item: ArchiveItem) => void;
   onBookmark: (e: React.MouseEvent, id: number) => void;
   onUpdate: (id: number, request: UpdateArchiveRequest) => void;
-  canEdit?: boolean;
+  canEdit: boolean;
 }) => {
src/features/study/one-to-one/schedule/ui/today-study-card.tsx (1)

51-56: 중첩 삼항 연산자 가독성 개선 제안.

isInterviewee 결정 로직이 중첩 삼항 연산자로 되어 있어 가독성이 다소 떨어집니다. 향후 유지보수를 위해 더 명확한 조건문으로 리팩토링을 고려해 보세요.

♻️ 가독성 개선 제안
-  const isInterviewee =
-    forcedRole === 'INTERVIEWER'
-      ? false
-      : forcedRole === 'INTERVIEWEE'
-        ? true
-        : memberId === resolvedStudyData.intervieweeId;
+  const isInterviewee = (() => {
+    if (forcedRole === 'INTERVIEWER') return false;
+    if (forcedRole === 'INTERVIEWEE') return true;
+    return memberId === resolvedStudyData.intervieweeId;
+  })();

Comment on lines +44 to +46
```
GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

코드 블록에 언어 지정이 필요합니다. markdownlint(MD040) 경고가 발생합니다.

✏️ 제안 수정안
-```
-GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
-```
+```http
+GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
```
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 44-44: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/balance-game-tag-filter-request.md` around lines 44 - 46, The fenced
code block containing the HTTP example "GET
/api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react"
needs a language specifier to satisfy markdownlint MD040; update that fenced
block (the triple-backtick block wrapping the GET request) to use ```http as the
opening fence and keep the closing ``` unchanged so the code block is properly
annotated.

Comment on lines +29 to +81
const stringifyData = (data: unknown) => {
if (data === undefined) return '';

if (typeof data === 'string') return data;

try {
return JSON.stringify(data);
} catch {
return String(data);
}
};

export const attachApiLogger = (instance: AxiosInstance, label: string) => {
if (!shouldLog) return;

instance.interceptors.request.use((config) => {
const method = (config.method || 'get').toUpperCase();
const url = normalizeUrl(config);
const params = stringifyParams(config.params);

console.log(
`[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`,
);

return config;
});

instance.interceptors.response.use(
(response) => {
const method = (response.config.method || 'get').toUpperCase();
const url = normalizeUrl(response.config);
const data = stringifyData(response.data);

console.log(`[API ${label}] ${method} ${url} -> ${response.status}`);
if (data) {
console.log(`[API ${label}] response=${data}`);
}

return response;
},
(error: AxiosError) => {
const config = error.config;
const method = config?.method?.toUpperCase() || 'UNKNOWN';
const url = config ? normalizeUrl(config) : 'unknown';
const status = error.response?.status;
const data = stringifyData(error.response?.data);

console.log(
`[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`,
);
if (data) {
console.log(`[API ${label}] response=${data}`);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

응답/파라미터 전체 로깅은 PII 노출 위험이 큽니다.
비프로덕션에서도 실제 사용자 데이터가 섞일 수 있으니 민감 키 마스킹 + 길이 제한 + 명시적 플래그로 제한하는 편이 안전합니다.

🛡️ 제안 수정
+const MAX_LOG_LENGTH = 2000;
+const REDACT_KEYS = new Set([
+  'password',
+  'token',
+  'accessToken',
+  'refreshToken',
+  'authorization',
+]);
+
+const truncate = (value: string) =>
+  value.length > MAX_LOG_LENGTH ? `${value.slice(0, MAX_LOG_LENGTH)}…` : value;
+
 const stringifyData = (data: unknown) => {
   if (data === undefined) return '';
 
-  if (typeof data === 'string') return data;
+  if (typeof data === 'string') return truncate(data);
 
   try {
-    return JSON.stringify(data);
+    return truncate(
+      JSON.stringify(data, (key, value) =>
+        REDACT_KEYS.has(key) ? '[REDACTED]' : value,
+      ),
+    );
   } catch {
-    return String(data);
+    return truncate(String(data));
   }
 };
🤖 Prompt for AI Agents
In `@src/api/client/api-logger.ts` around lines 29 - 81, The request logger
currently prints full params and response bodies (see attachApiLogger,
stringifyData, stringifyParams, and the request/response interceptors); update
it to avoid PII by adding an explicit opt-in flag to attachApiLogger (e.g.,
enableSensitiveLogging) and only emit full payloads when that flag is true,
implement masking in stringifyData/stringifyParams to redact known sensitive
keys (password, token, ssn, email, authorization, etc.), and enforce a maximum
length/truncation for strings and arrays to avoid huge dumps; ensure the
interceptors use the new flag and masked/truncated output when logging params
and response bodies.

Comment on lines +30 to +33
<button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
{icon}
{label}
</button>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

버튼에 type="button" 속성을 추가하세요.

form 내부에서 사용될 경우 의도치 않은 form 제출이 발생할 수 있습니다.

🔧 수정 제안
-      <button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
+      <button type="button" className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
{icon}
{label}
</button>
<button type="button" className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
{icon}
{label}
</button>
🤖 Prompt for AI Agents
In `@src/components/ui/filters/sort-dropdown.tsx` around lines 30 - 33, The button
in SortDropdown (the <button> element rendering {icon} and {label} in
src/components/ui/filters/sort-dropdown.tsx) lacks an explicit type, which can
cause unintended form submission; update the button element to include
type="button" to prevent default submit behavior when used inside forms, keeping
the existing classes and children unchanged.

Comment on lines +33 to +46
return (
<button
key={option.value}
onClick={() => onChange(option.value)}
className={cn(
'rounded-75 p-100 transition-colors',
isActive
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
title={option.title}
>
{option.icon}
</button>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/ui/filters/view-mode-toggle.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 1673


🌐 Web query:

HTML button element default type behavior when omitted specification

💡 Result:

If a <button> omits the type attribute, the HTML Standard defines its missing-value default (and invalid-value default) as the “Auto” state [1]. In that Auto state, the button is treated as a submit button (i.e., activation submits the form) as long as it has no command/commandfor attributes and its parent is not a <select> element [1]. MDN summarizes this behavior as: the default is effectively type="submit" for buttons associated with a form [2].

Sources:
[1] WHATWG HTML Standard – button element / type missing-value default and “submit button” conditions: (html.spec.whatwg.org)
[2] MDN – <button> type default behavior: (developer.mozilla.org)

Citations:


🌐 Web query:

ARIA toggle button aria-pressed accessibility requirements

💡 Result:

ARIA toggle button (aria-pressed) accessibility requirements

  • Use a real <button> when possible. If you must use a non-button element, add role="button" and make it keyboard-focusable (e.g., tabindex="0"). [1]
  • Provide an accessible name (visible text is usually enough; otherwise use aria-label/aria-labelledby). [1]
  • Add aria-pressed only for toggle buttons. Its presence indicates “this is a toggle button,” and you must keep it in sync with the on/off state. [1][2]
  • Valid aria-pressed values: true (pressed/on), false (not pressed/off), mixed (controls multiple items with mixed states). If aria-pressed is absent/undefined, it’s not a toggle button. [2][3]
  • Keyboard interaction: must toggle/activate on Space and Enter. (Native <button> already does; custom elements must implement it.) [4]
  • Don’t change the toggle’s label when the state changes. Keep the label stable and let aria-pressed convey the state (e.g., “Mute”, not “Mute/Unmute”). [1][2]
  • If the control is unavailable, use aria-disabled="true" (or native disabled on <button>). [1]

Minimal example

<button type="button" aria-pressed="false" id="mute">Mute</button>
<script>
  const b = document.getElementById('mute');
  b.addEventListener('click', () => {
    b.setAttribute('aria-pressed',
      b.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'
    );
  });
</script>

Sources: WAI-ARIA APG Button Pattern [1], MDN aria-pressed reference [2], WAI-ARIA aria-pressed state definition [3], APG Button example keyboard support [4].


아이콘 전용 버튼에 type/ARIA 속성을 추가하세요.
현재 버튼은 접근성 이름이 없고 토글 상태가 스크린리더에 전달되지 않습니다. type="button"을 명시하고, aria-pressed로 상태를 전달하며, aria-label로 접근성 이름을 제공하면 됩니다.

🛠️ 제안 수정
-          <button
+          <button
             key={option.value}
+            type="button"
+            aria-pressed={isActive}
+            aria-label={option.title ?? option.value}
             onClick={() => onChange(option.value)}
             className={cn(
               'rounded-75 p-100 transition-colors',
               isActive
                 ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
                 : 'text-text-subtlest hover:text-text-subtle',
             )}
             title={option.title}
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<button
key={option.value}
onClick={() => onChange(option.value)}
className={cn(
'rounded-75 p-100 transition-colors',
isActive
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
title={option.title}
>
{option.icon}
</button>
return (
<button
key={option.value}
type="button"
aria-pressed={isActive}
aria-label={option.title ?? option.value}
onClick={() => onChange(option.value)}
className={cn(
'rounded-75 p-100 transition-colors',
isActive
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
title={option.title}
>
{option.icon}
</button>
🤖 Prompt for AI Agents
In `@src/components/ui/filters/view-mode-toggle.tsx` around lines 33 - 46, The
icon-only buttons in the ViewModeToggle rendering lack proper button type and
accessibility attributes; update the <button> created inside the map (the
element using key={option.value}, onClick={() => onChange(option.value)},
option.icon, etc.) to include type="button", aria-pressed={isActive} to expose
toggle state to assistive tech, and an accessible name via
aria-label={option.title} (or aria-label derived from option.title) so screen
readers announce the control.

Comment on lines +30 to +34
<Modal.Header className="border-border-default flex items-center justify-between border-b">
<Modal.Title>{title}</Modal.Title>
<Modal.Close>
<XIcon />
</Modal.Close>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/ui/modal-shell.tsx | head -50

Repository: code-zero-to-one/study-platform-client

Length of output: 1444


🏁 Script executed:

rg "Modal\.Close" -A 3 -B 3 src/components/ui/

Repository: code-zero-to-one/study-platform-client

Length of output: 750


🏁 Script executed:

rg "XIcon" -B 2 -A 2 src/components/ui/modal-shell.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 361


🏁 Script executed:

fd "modal" -e tsx -e ts src/ | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 1108


🏁 Script executed:

find src/components/ui -name "modal*" -type f

Repository: code-zero-to-one/study-platform-client

Length of output: 115


🏁 Script executed:

cat -n src/components/ui/modal.tsx | head -100

Repository: code-zero-to-one/study-platform-client

Length of output: 141


🏁 Script executed:

rg "Modal.Close" -B 2 -A 3 src/components/modals/ | head -30

Repository: code-zero-to-one/study-platform-client

Length of output: 2254


🏁 Script executed:

rg "from.*modal" src/components/ui/modal-shell.tsx -i

Repository: code-zero-to-one/study-platform-client

Length of output: 128


🏁 Script executed:

rg "import.*Modal" src/components/modals/ | head -5

Repository: code-zero-to-one/study-platform-client

Length of output: 567


🏁 Script executed:

fd "modal" src -e ts -e tsx | xargs rg "^import.*Modal" | head -10

Repository: code-zero-to-one/study-platform-client

Length of output: 1135


🏁 Script executed:

rg "@radix-ui|@headlessui|react-aria" src/ --type json | head -5

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

cat -n package.json | grep -A 20 -B 5 "dependencies"

Repository: code-zero-to-one/study-platform-client

Length of output: 1285


🏁 Script executed:

find src -name "modal.ts*" -o -name "modal.ts" -o -name "modal.tsx"

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

ls -la src/components/ui/ | grep modal

Repository: code-zero-to-one/study-platform-client

Length of output: 203


🏁 Script executed:

rg "export.*Modal" src/components/ui/ -A 5

Repository: code-zero-to-one/study-platform-client

Length of output: 721


🏁 Script executed:

ls -la src/components/ui/modal/

Repository: code-zero-to-one/study-platform-client

Length of output: 254


🏁 Script executed:

cat -n src/components/ui/modal/index.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 5202


🏁 Script executed:

cat -n src/components/ui/modal/close.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 147


🏁 Script executed:

rg "aria-label" src/components/ | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 573


🏁 Script executed:

rg "asChild" src/components/modals/ -B 2 -A 2 | head -30

Repository: code-zero-to-one/study-platform-client

Length of output: 2066


아이콘 전용 닫기 버튼에 접근성 레이블 추가

스크린리더 사용자가 버튼의 목적을 인식할 수 없습니다. Modal.Close 컴포넌트는 기본 aria-label을 제공하지 않으므로 명시적으로 추가하세요.

제안 변경
-            <Modal.Close>
-              <XIcon />
-            </Modal.Close>
+            <Modal.Close aria-label="닫기">
+              <XIcon aria-hidden="true" />
+            </Modal.Close>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Modal.Header className="border-border-default flex items-center justify-between border-b">
<Modal.Title>{title}</Modal.Title>
<Modal.Close>
<XIcon />
</Modal.Close>
<Modal.Header className="border-border-default flex items-center justify-between border-b">
<Modal.Title>{title}</Modal.Title>
<Modal.Close aria-label="닫기">
<XIcon aria-hidden="true" />
</Modal.Close>
🤖 Prompt for AI Agents
In `@src/components/ui/modal-shell.tsx` around lines 30 - 34, Modal.Close에 스크린리더용
레이블이 빠져 있어 접근성이 떨어집니다; Modal.Close (안의 XIcon)를 수정해 aria-label을 명시적으로 추가하세요 — 예:
Modal.Close에 aria-label 속성으로 "닫기" 또는 `Close ${title}` 형태의 설명을 전달하여 버튼 목적을 명확히 하며
Modal.Header, Modal.Title, Modal.Close 식별자를 찾아 해당 컴포넌트에 레이블을 추가하도록 변경하세요.

Comment on lines +49 to +51
<span className="bg-fill-warning-subtle-default text-text-warning font-designer-12b border-border-warning-subtle rounded-[9999px] border px-150 py-50">
1월 4주차 MVP 팀
</span>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

하드코딩된 날짜 텍스트를 동적으로 변경해야 합니다.

"1월 4주차 MVP 팀"이 하드코딩되어 있습니다. team prop에 weekDate, weekStartDate, weekEndDate 필드가 있으므로 이를 활용해야 합니다. 또한 Line 96의 "이번 주"와도 일관성이 맞지 않습니다.

🐛 동적 날짜 표시 제안
           <span className="bg-fill-warning-subtle-default text-text-warning font-designer-12b border-border-warning-subtle rounded-[9999px] border px-150 py-50">
-            1월 4주차 MVP 팀
+            {team.weekDate} MVP 팀
           </span>
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx` around lines
49 - 51, The hardcoded label "1월 4주차 MVP 팀" should be replaced with a dynamic
string derived from the team prop (use team.weekDate, team.weekStartDate or
team.weekEndDate) inside the MVPTeamCard component (mvp-team-card.tsx); update
the span rendering (current static content) to format the week/date consistently
with the "이번 주" text used later (see reference to the other label), ensuring the
same locale/format logic is applied so both places display matching week date
text.

Comment on lines +11 to +29
const iconPath =
rank === 1
? '/icons/gold-rank.svg'
: rank === 2
? '/icons/silver-rank.svg'
: '/icons/bronze-rank.svg';

if (rank > 3) {
return (
<div className="font-bold-h3 text-text-subtle w-[36px] text-center">
{rank}
</div>
);
}

return (
<div className="relative h-[48px] w-[36px] md:h-[60px] md:w-[45px]">
<Image src={iconPath} alt={`${rank}위`} fill className="object-contain" />
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check next.config.ts for SVGR configuration
find . -name "next.config.*" -type f | head -5

Repository: code-zero-to-one/study-platform-client

Length of output: 98


🏁 Script executed:

# Search for existing SVG imports in the codebase to understand current patterns
rg "import.*\.svg" --type ts --type tsx --type js --type jsx | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 109


🏁 Script executed:

# Check if public/icons directory exists with SVG files
find . -path "*/public/icons/*.svg" -type f | head -10

Repository: code-zero-to-one/study-platform-client

Length of output: 383


🏁 Script executed:

# Read next.config.ts to check SVGR configuration
cat next.config.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 2453


🏁 Script executed:

# Search for SVG imports in the codebase
rg "\.svg" | grep -i "import" | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 2087


🏁 Script executed:

# Read the rank-badge.tsx file to see the actual implementation
cat -n src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 965


SVG 아이콘을 SVGR 컴포넌트로 import하여 사용해 주세요.

현재 코드는 Next/Image와 문자열 경로로 SVG를 렌더링하고 있습니다. 프로젝트 가이드라인에 따라 SVG 파일을 React 컴포넌트로 직접 import해야 합니다. 같은 아이콘들이 src/features/study/group/ui/group-study-member-item.tsx에서 올바르게 SVGR로 import되고 있으므로, 동일한 방식으로 적용하면 됩니다:

import Image from 'next/image';
+ import BronzeRankIcon from 'public/icons/bronze-rank.svg';
+ import GoldRankIcon from 'public/icons/gold-rank.svg';
+ import SilverRankIcon from 'public/icons/silver-rank.svg';

export default function RankBadge({ rank }: RankBadgeProps) {
- const iconPath = rank === 1 ? '/icons/gold-rank.svg' : rank === 2 ? '/icons/silver-rank.svg' : '/icons/bronze-rank.svg';
- <Image src={iconPath} alt={`${rank}위`} fill className="object-contain" />
+ const Icon = rank === 1 ? GoldRankIcon : rank === 2 ? SilverRankIcon : BronzeRankIcon;
+ <Icon />
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx` around lines 11
- 29, The component currently builds an iconPath and renders SVGs via Next/Image
(iconPath and Image in rank-badge.tsx); replace that with SVGR imports and
render the SVG React components directly: import the three icons as components
(e.g., GoldRank, SilverRank, BronzeRank), remove iconPath and Image usage, and
conditionally render the correct SVG component for rank 1/2/3 inside the
existing wrapper (keep the numeric fallback for rank > 3). Ensure the SVG
components receive the same className/size props used on the Image (e.g.,
object-contain and container sizing) so styling remains consistent.

Comment on lines +16 to +60
<UserProfileModal
memberId={ranker.userId}
trigger={
<div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all">
<div className="flex w-[50px] shrink-0 items-center justify-center">
<RankBadge rank={ranker.rank} />
</div>

<UserAvatar
image={
ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ??
undefined
}
alt={ranker.nickname}
size={48}
/>

<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-100">
<span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors">
{ranker.nickname}
</span>
{ranker.rank === 1 && (
<Crown
className="text-text-warning h-3 w-3"
fill="currentColor"
/>
)}
</div>
<span className="font-designer-13r text-text-subtle truncate">
{ranker.jobs && ranker.jobs.length > 0
? ranker.jobs
.map((job) => job.description || job.job || '')
.filter(Boolean)
.join(', ')
: ranker.major}
</span>
</div>

<div className="shrink-0 text-right">
<div className="font-bold-h5 text-text-strong">
{ranker.scoreLabel}
</div>
</div>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

모달 트리거가 div라 키보드 접근이 어렵습니다.
포커스 가능한 버튼 요소로 바꿔주세요.

🛠️ 제안 수정
-        <div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all">
+        <button
+          type="button"
+          className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all"
+        >
@@
-        </div>
+        </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<UserProfileModal
memberId={ranker.userId}
trigger={
<div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all">
<div className="flex w-[50px] shrink-0 items-center justify-center">
<RankBadge rank={ranker.rank} />
</div>
<UserAvatar
image={
ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ??
undefined
}
alt={ranker.nickname}
size={48}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-100">
<span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors">
{ranker.nickname}
</span>
{ranker.rank === 1 && (
<Crown
className="text-text-warning h-3 w-3"
fill="currentColor"
/>
)}
</div>
<span className="font-designer-13r text-text-subtle truncate">
{ranker.jobs && ranker.jobs.length > 0
? ranker.jobs
.map((job) => job.description || job.job || '')
.filter(Boolean)
.join(', ')
: ranker.major}
</span>
</div>
<div className="shrink-0 text-right">
<div className="font-bold-h5 text-text-strong">
{ranker.scoreLabel}
</div>
</div>
</div>
<UserProfileModal
memberId={ranker.userId}
trigger={
<button
type="button"
className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all"
>
<div className="flex w-[50px] shrink-0 items-center justify-center">
<RankBadge rank={ranker.rank} />
</div>
<UserAvatar
image={
ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ??
undefined
}
alt={ranker.nickname}
size={48}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-100">
<span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors">
{ranker.nickname}
</span>
{ranker.rank === 1 && (
<Crown
className="text-text-warning h-3 w-3"
fill="currentColor"
/>
)}
</div>
<span className="font-designer-13r text-text-subtle truncate">
{ranker.jobs && ranker.jobs.length > 0
? ranker.jobs
.map((job) => job.description || job.job || '')
.filter(Boolean)
.join(', ')
: ranker.major}
</span>
</div>
<div className="shrink-0 text-right">
<div className="font-bold-h5 text-text-strong">
{ranker.scoreLabel}
</div>
</div>
</button>
}
/>
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx` around
lines 16 - 60, The modal trigger is currently a non-focusable <div> passed to
UserProfileModal via the trigger prop; replace that wrapper with a focusable
button element (e.g., <button type="button">) in the UserProfileModal trigger so
keyboard users can open the modal, keep the same className and children
(RankBadge, UserAvatar, score) but ensure to set type="button", add an
accessible name/aria-label (use ranker.nickname or similar), and avoid nested
interactive controls inside the button (convert any inner anchors/buttons to
non-interactive spans if present); update references in this file around
UserProfileModal/trigger to reflect this change.

Comment on lines +59 to +83
<div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50">
<button
onClick={() => onViewModeChange('LIST')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'LIST'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<List className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">리스트</span>
</button>
<button
onClick={() => onViewModeChange('CALENDAR')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'CALENDAR'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<CalendarIcon className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">달력</span>
</button>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

아이콘-only 버튼에 접근성 레이블과 type 지정이 필요합니다.
모바일에서 텍스트가 숨겨져 버튼 이름이 사라지고, form 내부 사용 시 submit 동작 위험이 있습니다.

🛠️ 제안 수정
           <button
+            type="button"
+            aria-label="리스트 보기"
             onClick={() => onViewModeChange('LIST')}
             className={cn(
               'rounded-75 flex items-center gap-50 p-100 transition-colors',
               viewMode === 'LIST'
                 ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
                 : 'text-text-subtlest hover:text-text-subtle',
             )}
           >
@@
           <button
+            type="button"
+            aria-label="달력 보기"
             onClick={() => onViewModeChange('CALENDAR')}
             className={cn(
               'rounded-75 flex items-center gap-50 p-100 transition-colors',
               viewMode === 'CALENDAR'
                 ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
                 : 'text-text-subtlest hover:text-text-subtle',
             )}
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50">
<button
onClick={() => onViewModeChange('LIST')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'LIST'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<List className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">리스트</span>
</button>
<button
onClick={() => onViewModeChange('CALENDAR')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'CALENDAR'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<CalendarIcon className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">달력</span>
</button>
<div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50">
<button
type="button"
aria-label="리스트 보기"
onClick={() => onViewModeChange('LIST')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'LIST'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<List className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">리스트</span>
</button>
<button
type="button"
aria-label="달력 보기"
onClick={() => onViewModeChange('CALENDAR')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'CALENDAR'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
<CalendarIcon className="h-4 w-4" />
<span className="font-designer-13m hidden sm:inline">달력</span>
</button>
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/history/ui/study-history-header.tsx` around
lines 59 - 83, The icon-only view mode buttons (the elements using
onViewModeChange('LIST') and onViewModeChange('CALENDAR') with the List and
CalendarIcon components) lack explicit type and accessible labels; add
type="button" to both buttons to prevent accidental form submits and add
descriptive aria-label attributes (e.g., "리스트 보기" and "달력 보기") so the hidden
<span> text still has an accessible name for screen readers when it’s visually
hidden.

Comment on lines +9 to +14
export const decodeVotingId = (hash: string) => {
const decoded = hashids.decode(hash);
const id = decoded[0];

return typeof id === 'number' && Number.isFinite(id) ? id : null;
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

hashids library decode returns bigint large numbers JavaScript

💡 Result:

In modern hashids (v2.x), decode() returns NumberLike[]—i.e., an array whose elements can be number or bigint (not always plain JS numbers). If the id was encoded from a BigInt (or from values that require BigInt), you’ll get bigint back. [1]

What to do

const [x] = hashids.decode(id);

if (typeof x === "bigint") {
  // keep it as BigInt
  console.log(x.toString());        // exact decimal string
  // or if you need hex:
  console.log(x.toString(16));
} else {
  // regular JS number
  console.log(x);
}

Common pitfall (looks like “wrong huge numbers”)

If you originally encoded an integer larger than Number.MAX_SAFE_INTEGER using a JS number, it may already be imprecise before Hashids ever sees it. In that case, switch to BigInt end-to-end:

const id = hashids.encode(9007199254740993n); // note the `n`
const [x] = hashids.decode(id);               // BigInt back

Hashids explicitly supports encoding/decoding BigInt “the same way as ordinary numbers” when the environment supports it. [2]

If you need arbitrarily large integers but can’t/won’t use BigInt

Use a BigNumber-based fork such as hashids-bn (designed so numbers “aren’t limited”). [3]

Sources: hashids TypeScript definitions showing decode(): NumberLike[] and BigInt-related API notes [1], hashids README/Skypack docs on BigInt behavior [2], hashids-bn README summary [3].

[1] https://app.unpkg.com/hashids@2.3.0/files/esm/hashids.d.ts
[2] https://www.skypack.dev/view/hashids
[3] https://www.skypack.dev/view/hashids-bn


hashids.decode()bigint 반환값 처리 필요

hashids v2.x의 decode() 메서드는 NumberLike[] 타입을 반환하므로, 반환 배열의 원소는 number 또는 bigint일 수 있습니다. 큰 정수가 BigInt로 인코딩되었다면 디코딩 시에도 bigint가 반환됩니다.

현재 코드의 typeof id === 'number' 체크는 bigint를 명시적으로 거부합니다. 이 동작이 의도적인지 확인이 필요합니다:

  • 큰 ID를 지원해야 한다면 bigint 처리를 추가하세요.
  • 의도적인 제한이라면 왜 bigint를 지원하지 않는지 주석으로 명시하세요.
🤖 Prompt for AI Agents
In `@src/utils/voting-id.ts` around lines 9 - 14, The decodeVotingId function
currently rejects bigint results from hashids.decode by only checking typeof id
=== 'number'; update decodeVotingId to handle NumberLike values returned by
hashids.decode: either accept bigint IDs (handle typeof id === 'bigint' by
returning Number(id) if safe or returning the bigint directly) or explicitly
document the intentional limitation with a comment explaining why bigints are
unsupported; reference the decodeVotingId function and the decoded/id variables
and the hashids.decode call when applying the change.

@Hyeonjun0527 Hyeonjun0527 merged commit b6020f4 into main Feb 4, 2026
10 checks passed
@Hyeonjun0527 Hyeonjun0527 deleted the fix/one-to-one-new-feature-bug-main branch February 4, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant