diff --git a/web/babel.config.js b/web/babel.config.js index 8165fe455..286a34c07 100644 --- a/web/babel.config.js +++ b/web/babel.config.js @@ -1,6 +1,7 @@ module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], + ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], }; diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 3b304910e..8dd715305 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -6,6 +6,9 @@ /** @type {import('jest').Config} */ const config = { clearMocks: true, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, testEnvironment: 'jsdom', }; diff --git a/web/src/mocks/datas/challenge.ts b/web/src/mocks/datas/challenge.ts index 63218265a..cfc417ada 100644 --- a/web/src/mocks/datas/challenge.ts +++ b/web/src/mocks/datas/challenge.ts @@ -158,8 +158,8 @@ export const CHALLENGES: Challenge[] = [ id: 8, title: '매일 아침 뉴스레터 읽기', generation: 2, - startDate: '2026-01-06', - endDate: '2026-01-20', + startDate: '2026-07-07', + endDate: '2026-07-18', participantCount: 35, newsletters: [ { diff --git a/web/src/mocks/datas/challengeComments.ts b/web/src/mocks/datas/challengeComments.ts index cd3f22257..783857161 100644 --- a/web/src/mocks/datas/challengeComments.ts +++ b/web/src/mocks/datas/challengeComments.ts @@ -1,4 +1,97 @@ +const buildTimelineComment = ({ + commentId, + date, + hour, + nickname, + articleTitle, + comment, +}: { + commentId: number; + date: string; + hour: number; + nickname: string; + articleTitle: string; + comment: string; +}) => ({ + commentId, + nickname, + profileImageUrl: `https://i.pravatar.cc/150?img=${commentId % 70}`, + newsletterName: '타임라인 뉴스', + isSubscribed: true, + articleTitle, + createdAt: `${date}T${String(hour).padStart(2, '0')}:00:00.000Z`, + comment, + isMyComment: false, + likeCount: commentId % 12, + isLiked: commentId % 3 === 0, + replyCount: commentId % 4, +}); + +const TIMELINE_COMMENTS = [ + ...Array.from({ length: 8 }, (_, index) => + buildTimelineComment({ + commentId: 100 + index, + date: '2026-03-03', + hour: 18 - index, + nickname: `타임라인유저${index + 1}`, + articleTitle: `3월 3일 뉴스레터 ${index + 1}`, + comment: `3월 3일 ${index + 1}번째 코멘트입니다. 한 날짜에서 여러 페이지가 이어지는지 확인합니다.`, + }), + ), + ...Array.from({ length: 3 }, (_, index) => + buildTimelineComment({ + commentId: 300 + index, + date: '2026-03-02', + hour: 14 - index, + nickname: `월요일독자${index + 1}`, + articleTitle: `3월 2일 뉴스레터 ${index + 1}`, + comment: `3월 2일 ${index + 1}번째 코멘트입니다.`, + }), + ), + ...Array.from({ length: 4 }, (_, index) => + buildTimelineComment({ + commentId: 200 + index, + date: '2026-02-27', + hour: 16 - index, + nickname: `금요일독자${index + 1}`, + articleTitle: `2월 27일 뉴스레터 ${index + 1}`, + comment: `2월 27일 ${index + 1}번째 코멘트입니다. 날짜가 넘어간 뒤에도 목록이 이어지는지 확인합니다.`, + }), + ), + ...Array.from({ length: 5 }, (_, index) => + buildTimelineComment({ + commentId: 400 + index, + date: '2026-02-24', + hour: 10 - index, + nickname: `화요일독자${index + 1}`, + articleTitle: `2월 24일 뉴스레터 ${index + 1}`, + comment: `2월 24일 ${index + 1}번째 코멘트입니다.`, + }), + ), + ...Array.from({ length: 2 }, (_, index) => + buildTimelineComment({ + commentId: 500 + index, + date: '2026-02-17', + hour: 9 - index, + nickname: `수요일독자${index + 1}`, + articleTitle: `2월 17일 뉴스레터 ${index + 1}`, + comment: `2월 17일 ${index + 1}번째 코멘트입니다.`, + }), + ), + ...Array.from({ length: 6 }, (_, index) => + buildTimelineComment({ + commentId: 600 + index, + date: '2026-02-10', + hour: 15 - index, + nickname: `목요일독자${index + 1}`, + articleTitle: `2월 10일 뉴스레터 ${index + 1}`, + comment: `2월 10일 ${index + 1}번째 코멘트입니다.`, + }), + ), +]; + export const CHALLENGE_COMMENTS = [ + ...TIMELINE_COMMENTS, { commentId: 1, nickname: '김철수', diff --git a/web/src/mocks/handlers/challenge.ts b/web/src/mocks/handlers/challenge.ts index 9b40c226c..8677460b1 100644 --- a/web/src/mocks/handlers/challenge.ts +++ b/web/src/mocks/handlers/challenge.ts @@ -12,6 +12,7 @@ import type { GetChallengeCommentsResponse, GetChallengeCommentRepliesResponse, GetChallengeEligibilityResponse, + GetChallengeInfoResponse, GetMemberChallengeProgressResponse, GetMemberChallengeStreakResponse, GetDailyGuideCommentsResponse, @@ -124,6 +125,24 @@ const buildTeamProgressResponse = ( }; }; +const buildChallengeInfoResponse = ( + challengeId: number, +): GetChallengeInfoResponse => { + const challenge = CHALLENGES.find((item) => item.id === challengeId); + const startDate = challenge?.startDate ?? '2026-01-05'; + const endDate = challenge?.endDate ?? '2026-02-04'; + const totalDays = buildWeekdayDates(startDate, endDate).length; + + return { + name: challenge?.title ?? '한달 뉴스레터 읽기 챌린지', + startDate, + endDate, + generation: challenge?.generation ?? 1, + totalDays, + requiredDays: Math.ceil(totalDays * 0.8), + }; +}; + export const challengeHandlers = [ http.get(`${baseURL}/challenges`, ({ request }) => { const url = new URL(request.url); @@ -141,6 +160,12 @@ export const challengeHandlers = [ return HttpResponse.json(CHALLENGES); }), + http.get(`${baseURL}/challenges/:challengeId`, ({ params }) => { + const response = buildChallengeInfoResponse(Number(params.challengeId)); + + return HttpResponse.json(response); + }), + http.get(`${baseURL}/challenges/:challengeId/eligibility`, ({ params }) => { const { challengeId } = params; diff --git a/web/src/pages/challenge/comments/components/CommentCardSkeleton.tsx b/web/src/pages/challenge/comments/components/CommentCardSkeleton.tsx new file mode 100644 index 000000000..9517f492c --- /dev/null +++ b/web/src/pages/challenge/comments/components/CommentCardSkeleton.tsx @@ -0,0 +1,17 @@ +import Skeleton from '@/components/Skeleton/Skeleton'; +import { useDevice } from '@/hooks/useDevice'; + +const CommentCardSkeleton = () => { + const device = useDevice(); + const isMobile = device === 'mobile'; + + return ( + + ); +}; + +export default CommentCardSkeleton; diff --git a/web/src/pages/challenge/comments/components/DateFilter.tsx b/web/src/pages/challenge/comments/components/DateFilter.tsx deleted file mode 100644 index d3c0b3dc0..000000000 --- a/web/src/pages/challenge/comments/components/DateFilter.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { theme } from '@bombom/shared'; -import styled from '@emotion/styled'; -import DateTab from './DateTab'; -import { useDateFilter } from '../hooks/useDateFilter'; -import Button from '@/components/Button/Button'; -import ChevronIcon from '@/components/icons/ChevronIcon'; -import Tabs from '@/components/Tabs/Tabs'; -import { useDevice, type Device } from '@/hooks/useDevice'; - -interface DateFilterProps { - today: string; - dates: string[]; - selectedDate: string; - onDateSelect: (date: string) => void; -} - -const DateFilter = ({ - today, - dates, - selectedDate, - onDateSelect, -}: DateFilterProps) => { - const { - displayDates, - weekStartIndex, - weekEndIndex, - canGoPrevWeek, - canGoNextWeek, - goToPrevWeek, - goToNextWeek, - } = useDateFilter({ today, dates, selectedDate, onDateSelect }); - - const device = useDevice(); - - const weekDates = - device === 'mobile' - ? displayDates.filter((date) => date !== today) - : displayDates.slice(weekStartIndex, weekEndIndex + 1); - - return ( - - {device !== 'mobile' && ( - - - - )} - - - - {weekDates.map((dateString) => ( - - ))} - - - - {device === 'mobile' && ( - - - - )} - - {device !== 'mobile' && ( - - - - )} - - ); -}; - -export default DateFilter; - -const Container = styled.div` - width: 100%; - - display: flex; - align-items: center; - justify-content: center; -`; - -const NavButton = styled(Button)<{ device: Device }>` - padding: ${({ device }) => (device === 'mobile' ? '6px' : '8px')}; - - color: ${({ theme }) => theme.colors.primaryBomBom}; - - transition: opacity 0.2s; - - &:disabled { - background-color: transparent; - } - - &:hover { - background-color: transparent; - opacity: 0.6; - } -`; - -const DateTabsWrapper = styled.div<{ device: Device }>` - border-right: ${({ device, theme }) => - device === 'mobile' - ? 'none' - : `2px solid ${theme.colors.disabledBackground}`}; - border-left: ${({ device, theme }) => - device === 'mobile' - ? 'none' - : `2px solid ${theme.colors.disabledBackground}`}; - - flex: 1; - - text-align: center; - - overflow-x: auto; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } -`; - -const StyledTabs = styled(Tabs)<{ device: Device }>` - display: inline-flex; - gap: ${({ device }) => (device === 'pc' ? '24px' : '8px')}; -`; - -const TodayTabWrapper = styled.div` - padding-left: 8px; - border-left: 2px solid ${({ theme }) => theme.colors.disabledBackground}; - - display: flex; - align-items: center; -`; diff --git a/web/src/pages/challenge/comments/components/DateTab.tsx b/web/src/pages/challenge/comments/components/DateFilter/DateTab.tsx similarity index 68% rename from web/src/pages/challenge/comments/components/DateTab.tsx rename to web/src/pages/challenge/comments/components/DateFilter/DateTab.tsx index 63ce87367..c46c8df1b 100644 --- a/web/src/pages/challenge/comments/components/DateTab.tsx +++ b/web/src/pages/challenge/comments/components/DateFilter/DateTab.tsx @@ -1,28 +1,33 @@ import styled from '@emotion/styled'; import Tab from '@/components/Tab/Tab'; import { useDevice, type Device } from '@/hooks/useDevice'; -import { isToday } from '@/utils/date'; import type { TabProps } from '@/components/Tab/Tab'; interface DateTabProps { dateString: string; + today: string; selectedDate: string; onDateSelect: (date: string) => void; } -const DateTab = ({ dateString, selectedDate, onDateSelect }: DateTabProps) => { +const DateTab = ({ + dateString, + today, + selectedDate, + onDateSelect, +}: DateTabProps) => { const device = useDevice(); - const date = new Date(dateString); - const label = isToday(date) - ? '오늘' - : `${date.getMonth() + 1}/${date.getDate()}`; return ( prop !== 'device', })<{ device: Device }>` min-width: ${({ device }) => (device === 'mobile' ? '52px' : 'fit-content')}; - padding: ${({ device }) => (device === 'mobile' ? '8px' : '12px 16px')}; + padding: ${({ device }) => (device === 'mobile' ? '8px' : '10px 14px')}; border-radius: ${({ device }) => (device === 'mobile' ? '12px' : '24px')}; - font: ${({ device, theme }) => - device === 'mobile' ? theme.fonts.t6Regular : theme.fonts.t7Regular}; + font: ${({ theme }) => theme.fonts.t6Regular}; `; diff --git a/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.test.tsx b/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.test.tsx new file mode 100644 index 000000000..91f31cb99 --- /dev/null +++ b/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.test.tsx @@ -0,0 +1,64 @@ +import { theme } from '@bombom/shared/theme'; +import { ThemeProvider } from '@emotion/react'; +import { render, screen } from '@testing-library/react'; +import MobileDateFilter from './MobileDateFilter'; + +jest.mock('@/hooks/useDevice', () => ({ + useDevice: () => 'mobile', +})); + +const renderMobileDateFilter = ({ + today, + dates, + selectedDate, +}: { + today: string; + dates: string[]; + selectedDate: string; +}) => + render( + + + , + ); + +describe('MobileDateFilter', () => { + it('오늘이 유효한 챌린지 날짜이면 오늘 탭을 표시한다', () => { + renderMobileDateFilter({ + today: '2026-07-10', + dates: ['2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10'], + selectedDate: '2026-07-10', + }); + + expect(screen.getByText('오늘')).toBeTruthy(); + }); + + it('오늘이 주말이면 오늘 탭과 구분 영역을 표시하지 않는다', () => { + renderMobileDateFilter({ + today: '2026-07-12', + dates: ['2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10'], + selectedDate: '2026-07-10', + }); + + expect(screen.queryByText('오늘')).toBeNull(); + expect(screen.getAllByRole('tablist')).toHaveLength(1); + expect(screen.getByText('7/10')).toBeTruthy(); + }); + + it('챌린지 종료 후에는 마지막 챌린지 날짜까지만 표시한다', () => { + renderMobileDateFilter({ + today: '2026-07-20', + dates: ['2026-07-16', '2026-07-17'], + selectedDate: '2026-07-17', + }); + + expect(screen.queryByText('오늘')).toBeNull(); + expect(screen.getAllByRole('tablist')).toHaveLength(1); + expect(screen.getByText('7/17')).toBeTruthy(); + }); +}); diff --git a/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.tsx b/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.tsx new file mode 100644 index 000000000..dcb7a1759 --- /dev/null +++ b/web/src/pages/challenge/comments/components/DateFilter/MobileDateFilter.tsx @@ -0,0 +1,85 @@ +import styled from '@emotion/styled'; +import DateTab from './DateTab'; +import Tabs from '@/components/Tabs/Tabs'; + +interface MobileDateFilterProps { + today: string; + dates: string[]; + selectedDate: string; + onDateSelect: (date: string) => void; +} + +const MobileDateFilter = ({ + today, + dates, + selectedDate, + onDateSelect, +}: MobileDateFilterProps) => { + const isTodayChallengeDate = dates.includes(today); + const scrollableDates = dates.filter((date) => date !== today); + + return ( + + + + {scrollableDates.map((dateString) => ( + + ))} + + + + {isTodayChallengeDate && ( + + + + )} + + ); +}; + +export default MobileDateFilter; + +const Container = styled.div` + width: 100%; + + display: flex; + align-items: center; + justify-content: center; +`; + +const DateTabsWrapper = styled.div` + flex: 1; + + text-align: center; + + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const StyledTabs = styled(Tabs)` + display: inline-flex; + gap: 8px; +`; + +const TodayTabWrapper = styled.div` + padding-left: 8px; + border-left: 2px solid ${({ theme }) => theme.colors.disabledBackground}; + + display: flex; + align-items: center; +`; diff --git a/web/src/pages/challenge/comments/components/DateFilter/PCDateFilter.tsx b/web/src/pages/challenge/comments/components/DateFilter/PCDateFilter.tsx new file mode 100644 index 000000000..4e1327c8b --- /dev/null +++ b/web/src/pages/challenge/comments/components/DateFilter/PCDateFilter.tsx @@ -0,0 +1,124 @@ +import { theme } from '@bombom/shared'; +import styled from '@emotion/styled'; +import DateTab from './DateTab'; +import { useDateFilterScroll } from '../../hooks/useDateFilterScroll'; +import Button from '@/components/Button/Button'; +import ChevronIcon from '@/components/icons/ChevronIcon'; +import Tabs from '@/components/Tabs/Tabs'; + +interface PCDateFilterProps { + today: string; + dates: string[]; + selectedDate: string; + onDateSelect: (date: string) => void; +} + +const PCDateFilter = ({ + today, + dates, + selectedDate, + onDateSelect, +}: PCDateFilterProps) => { + const { scrollRef, canScrollLeft, canScrollRight, scrollDateFilter } = + useDateFilterScroll(dates); + + return ( + + scrollDateFilter('left')} + disabled={!canScrollLeft} + > + + + + + + {dates.map((dateString) => ( + + ))} + + + + scrollDateFilter('right')} + disabled={!canScrollRight} + > + + + + ); +}; + +export default PCDateFilter; + +const Container = styled.div` + width: 100%; + + display: flex; + align-items: center; + justify-content: center; +`; + +const NavButton = styled(Button)` + padding: 8px; + + color: ${({ theme }) => theme.colors.primaryBomBom}; + + transition: opacity 0.2s; + + &:disabled { + background-color: transparent; + } + + &:hover { + background-color: transparent; + opacity: 0.6; + } +`; + +const DateTabsWrapper = styled.div` + border-right: 2px solid ${({ theme }) => theme.colors.disabledBackground}; + border-left: 2px solid ${({ theme }) => theme.colors.disabledBackground}; + + flex: 1; + + text-align: center; + + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const StyledTabs = styled(Tabs)` + display: inline-flex; + gap: 18px; +`; diff --git a/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.test.ts b/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.test.ts new file mode 100644 index 000000000..4a3379989 --- /dev/null +++ b/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.test.ts @@ -0,0 +1,156 @@ +import { renderHook } from '@testing-library/react'; +import { useChallengeCommentDates } from './useChallengeCommentDates'; + +const START_DATE = '2026-07-07'; +const END_DATE = '2026-07-18'; + +const render = (today: string) => renderAt(`${today}T12:00:00`); + +const renderAt = (now: string) => { + jest.setSystemTime(new Date(now)); + + return renderHook(() => + useChallengeCommentDates({ + startDate: START_DATE, + endDate: END_DATE, + }), + ).result.current; +}; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('challengeDates — 날짜 필터 목록', () => { + it('오늘이 챌린지 기간 내 평일이면 오늘까지의 평일을 반환한다', () => { + const { challengeDates } = render('2026-07-09'); + expect(challengeDates).toEqual(['2026-07-07', '2026-07-08', '2026-07-09']); + }); + + it('오늘이 챌린지 기간 내 주말이면 오늘 없이 직전 금요일까지의 평일을 반환한다', () => { + const { challengeDates } = render('2026-07-12'); + expect(challengeDates).toEqual([ + '2026-07-07', + '2026-07-08', + '2026-07-09', + '2026-07-10', + ]); + expect(challengeDates).not.toContain('2026-07-12'); + }); + + it('오늘이 챌린지 종료일 이후면 기간 내 전체 평일을 반환한다', () => { + const { challengeDates } = render('2026-07-20'); + expect(challengeDates).toEqual([ + '2026-07-07', + '2026-07-08', + '2026-07-09', + '2026-07-10', + '2026-07-13', + '2026-07-14', + '2026-07-15', + '2026-07-16', + '2026-07-17', + ]); + expect(challengeDates).not.toContain('2026-07-20'); + }); + + it('오늘이 챌린지 시작일 이전이면 빈 배열을 반환한다', () => { + const { challengeDates } = render('2026-07-05'); + expect(challengeDates).toEqual([]); + }); + + it('startDate/endDate가 없으면 빈 배열을 반환한다', () => { + jest.setSystemTime(new Date('2026-07-09T12:00:00')); + + const { result } = renderHook(() => useChallengeCommentDates({})); + expect(result.current.challengeDates).toEqual([]); + }); +}); + +describe('initialSelectedDate — 날짜 필터 초기 선택 날짜', () => { + it('오늘이 챌린지 기간 내 평일이면 오늘을 선택한다', () => { + const { initialSelectedDate } = render('2026-07-09'); + + expect(initialSelectedDate).toBe('2026-07-09'); + }); + + it('오늘이 챌린지 기간 내 주말이면 직전 유효 챌린지 일을 선택한다', () => { + const { initialSelectedDate } = render('2026-07-12'); + + expect(initialSelectedDate).toBe('2026-07-10'); + }); + + it('오늘이 챌린지 종료일 이후면 마지막 챌린지 일을 선택한다', () => { + const { initialSelectedDate } = render('2026-07-20'); + + expect(initialSelectedDate).toBe('2026-07-17'); + }); +}); + +describe('isRestDay — 오늘 날짜를 필터에 추가할지 여부', () => { + it('오늘이 챌린지 기간 내 주말(토)이면 true를 반환한다', () => { + const { isRestDay } = render('2026-07-11'); + expect(isRestDay).toBe(true); + }); + + it('오늘이 챌린지 기간 내 주말(일)이면 true를 반환한다', () => { + const { isRestDay } = render('2026-07-12'); + expect(isRestDay).toBe(true); + }); + + it('오늘이 챌린지 기간 내 평일이면 false를 반환한다', () => { + const { isRestDay } = render('2026-07-09'); + expect(isRestDay).toBe(false); + }); + + it('오늘이 챌린지 종료일 이후면 false를 반환한다', () => { + const { isRestDay } = render('2026-07-20'); + expect(isRestDay).toBe(false); + }); + + it('오늘이 챌린지 시작일 이전이면 false를 반환한다', () => { + const { isRestDay } = render('2026-07-05'); + expect(isRestDay).toBe(false); + }); +}); + +describe('자정 경계 날짜가 바뀌는 시각의 정책 전환', () => { + it('금요일 자정 직전(23:59:59)에는 금요일이 필터에 포함되고 선택된다', () => { + const { challengeDates, initialSelectedDate, isRestDay } = renderAt( + '2026-07-10T23:59:59', + ); + + expect(challengeDates).toContain('2026-07-10'); + expect(initialSelectedDate).toBe('2026-07-10'); + expect(isRestDay).toBe(false); + }); + + it('토요일 자정 직후(00:00:00)에는 주말 정책이 적용되어 직전 금요일이 선택된다', () => { + const { challengeDates, initialSelectedDate, isRestDay } = renderAt( + '2026-07-11T00:00:00', + ); + + expect(challengeDates).not.toContain('2026-07-11'); + expect(initialSelectedDate).toBe('2026-07-10'); + expect(isRestDay).toBe(true); + }); + + it('챌린지 시작일 전날 자정 직전(23:59:59)에는 빈 배열을 반환한다', () => { + const { challengeDates } = renderAt('2026-07-06T23:59:59'); + + expect(challengeDates).toEqual([]); + }); + + it('챌린지 시작일 자정 직후(00:00:00)부터 시작일이 필터에 포함되고 선택된다', () => { + const { challengeDates, initialSelectedDate } = renderAt( + '2026-07-07T00:00:00', + ); + + expect(challengeDates).toEqual(['2026-07-07']); + expect(initialSelectedDate).toBe('2026-07-07'); + }); +}); diff --git a/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.ts b/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.ts index 6e944b051..35e41a082 100644 --- a/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.ts +++ b/web/src/pages/challenge/comments/hooks/useChallengeCommentDates.ts @@ -14,8 +14,23 @@ export const useChallengeCommentDates = ({ const challengeDates = useMemo(() => { if (!startDate || !endDate) return []; - return filterWeekdays(getDatesInRange(startDate, endDate)); - }, [endDate, startDate]); + const allDates = filterWeekdays(getDatesInRange(startDate, endDate)); + return allDates.filter((date) => today >= date); + }, [endDate, startDate, today]); + + const isTodayInChallengePeriod = useMemo(() => { + return Boolean( + startDate && endDate && today >= startDate && today <= endDate, + ); + }, [startDate, endDate, today]); + + const isRestDay = useMemo(() => { + return isTodayInChallengePeriod && !challengeDates.includes(today); + }, [isTodayInChallengePeriod, challengeDates, today]); + + const initialSelectedDate = useMemo(() => { + return challengeDates[challengeDates.length - 1] ?? today; + }, [challengeDates, today]); const isFirstDay = useCallback( (targetDate: string) => { @@ -34,6 +49,8 @@ export const useChallengeCommentDates = ({ return { today, challengeDates, + initialSelectedDate, + isRestDay, isFirstDay, isChallengeDay, }; diff --git a/web/src/pages/challenge/comments/hooks/useDateFilter.ts b/web/src/pages/challenge/comments/hooks/useDateFilter.ts deleted file mode 100644 index f46c4a169..000000000 --- a/web/src/pages/challenge/comments/hooks/useDateFilter.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { getDisplayDates } from '../utils/date'; - -interface UseDateFilterParams { - today: string; - dates: string[]; - selectedDate: string; - onDateSelect: (date: string) => void; -} - -const DATES_PER_PAGE = 5; - -export const useDateFilter = ({ - today, - dates, - selectedDate, - onDateSelect, -}: UseDateFilterParams) => { - const displayDates = useMemo(() => { - return getDisplayDates(dates, today); - }, [dates, today]); - - const selectedDateIndex = useMemo(() => { - const index = displayDates.indexOf(selectedDate); - return index === -1 ? Math.max(displayDates.length - 1, 0) : index; - }, [displayDates, selectedDate]); - - const weekStartIndex = useMemo(() => { - return Math.floor(selectedDateIndex / DATES_PER_PAGE) * DATES_PER_PAGE; - }, [selectedDateIndex]); - - const weekEndIndex = useMemo(() => { - return Math.min( - weekStartIndex + DATES_PER_PAGE - 1, - displayDates.length - 1, - ); - }, [weekStartIndex, displayDates.length]); - - const canGoPrevWeek = useMemo(() => { - return weekStartIndex > 0; - }, [weekStartIndex]); - - const canGoNextWeek = useMemo(() => { - return weekEndIndex < displayDates.length - 1; - }, [weekEndIndex, displayDates.length]); - - const goToPrevWeek = useCallback(() => { - if (canGoPrevWeek) { - const prevWeekStartIndex = Math.max(weekStartIndex - DATES_PER_PAGE, 0); - const prevWeekDate = displayDates[prevWeekStartIndex]; - if (prevWeekDate) { - onDateSelect(prevWeekDate); - } - } - }, [canGoPrevWeek, weekStartIndex, displayDates, onDateSelect]); - - const goToNextWeek = useCallback(() => { - if (canGoNextWeek) { - const nextWeekStartIndex = weekStartIndex + DATES_PER_PAGE; - const nextWeekDate = displayDates[nextWeekStartIndex]; - if (nextWeekDate) { - onDateSelect(nextWeekDate); - } - } - }, [canGoNextWeek, weekStartIndex, displayDates, onDateSelect]); - - return { - displayDates, - weekStartIndex, - weekEndIndex, - canGoPrevWeek, - canGoNextWeek, - goToPrevWeek, - goToNextWeek, - }; -}; diff --git a/web/src/pages/challenge/comments/hooks/useDateFilterScroll.ts b/web/src/pages/challenge/comments/hooks/useDateFilterScroll.ts new file mode 100644 index 000000000..f35831b1d --- /dev/null +++ b/web/src/pages/challenge/comments/hooks/useDateFilterScroll.ts @@ -0,0 +1,58 @@ +import { + useRef, + useState, + useEffect, + useLayoutEffect, + useCallback, +} from 'react'; + +export const useDateFilterScroll = (dates: string[]) => { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const scrollRef = useRef(null); + const dateString = dates.join(','); + + const updateCanScroll = useCallback(() => { + const filterContainer = scrollRef.current; + if (!filterContainer) return; + + setCanScrollLeft(filterContainer.scrollLeft > 0); + setCanScrollRight( + filterContainer.scrollLeft + filterContainer.clientWidth < + filterContainer.scrollWidth - 1, + ); + }, []); + + const scrollDateFilter = useCallback((direction: 'left' | 'right') => { + const scrollAmount = (scrollRef.current?.clientWidth ?? 0) * 0.5; + scrollRef.current?.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + }, []); + + useLayoutEffect(() => { + const filterContainer = scrollRef.current; + if (!filterContainer) return; + + filterContainer.scrollLeft = Math.max( + filterContainer.scrollWidth - filterContainer.clientWidth, + 0, + ); + updateCanScroll(); + }, [dateString, updateCanScroll]); + + useEffect(() => { + const filterContainer = scrollRef.current; + if (!filterContainer) return; + + updateCanScroll(); + + filterContainer.addEventListener('scroll', updateCanScroll, { + passive: true, + }); + return () => filterContainer.removeEventListener('scroll', updateCanScroll); + }, [updateCanScroll]); + + return { scrollRef, canScrollLeft, canScrollRight, scrollDateFilter }; +}; diff --git a/web/src/pages/challenge/comments/utils/date.ts b/web/src/pages/challenge/comments/utils/date.ts index 0f1642913..3ab10c2e8 100644 --- a/web/src/pages/challenge/comments/utils/date.ts +++ b/web/src/pages/challenge/comments/utils/date.ts @@ -15,8 +15,3 @@ export const convertRelativeTime = (dateString: string) => { if (diffDays < 7) return `${diffDays}일 전`; return formatDate(targetDate); }; - -export const getDisplayDates = (dates: string[], today: string) => { - const latestChallengeDates = dates.filter((date) => today > date); - return [...latestChallengeDates, today]; -}; diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx index b63f62201..ffe9f5195 100644 --- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx +++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReadingCalendar.tsx @@ -157,7 +157,6 @@ const Weekday = styled(Text)` const Divider = styled.div` height: 1px; - background-color: ${COLORS.divider}; `; @@ -168,7 +167,6 @@ const Grid = styled.div` const DayCell = styled.div` position: relative; - height: 28px; display: flex; @@ -186,7 +184,6 @@ const MostReadCircle = styled(Text)` position: absolute; top: 50%; left: 50%; - width: 32px; height: 32px; border-radius: 50%; @@ -204,7 +201,6 @@ const ReadUnderline = styled.span` position: absolute; bottom: 0; left: 50%; - width: 26px; height: 4px; border-radius: 2px; diff --git a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx index 1d97af5ca..59f2bba63 100644 --- a/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx +++ b/web/src/pages/my-page/components/ReadingActivityStats/MonthlyReportStats.tsx @@ -146,9 +146,8 @@ const IconBox = styled.div` height: 40px; border-radius: 10px; - flex-shrink: 0; - display: flex; + flex-shrink: 0; align-items: center; justify-content: center; @@ -191,11 +190,12 @@ const StatUnit = styled(Text)` `; const ChangePill = styled(Text)` - align-self: flex-start; padding: 6px 12px; border: 1px solid ${COLORS.pillBorder}; border-radius: 12px; + align-self: flex-start; + && { color: ${COLORS.pillText}; } @@ -249,9 +249,8 @@ const RankBadge = styled(Text)` height: 18px; border-radius: 50%; - flex-shrink: 0; - display: flex; + flex-shrink: 0; align-items: center; justify-content: center; @@ -259,12 +258,12 @@ const RankBadge = styled(Text)` `; const NewsletterName = styled(Text)` + overflow: hidden; min-width: 0; - overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; - white-space: nowrap; && { color: ${COLORS.newsletterText}; @@ -273,7 +272,6 @@ const NewsletterName = styled(Text)` const NewsletterCount = styled(Text)` margin-left: auto; - flex-shrink: 0; && { diff --git a/web/src/routes/_bombom/_main/challenge/$challengeId/comments.tsx b/web/src/routes/_bombom/_main/challenge/$challengeId/comments.tsx index 98b1c154c..2e6fb6507 100644 --- a/web/src/routes/_bombom/_main/challenge/$challengeId/comments.tsx +++ b/web/src/routes/_bombom/_main/challenge/$challengeId/comments.tsx @@ -1,14 +1,15 @@ import styled from '@emotion/styled'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useParams } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { queries } from '@/apis/queries'; import Button from '@/components/Button/Button'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; import { useDevice } from '@/hooks/useDevice'; import AddCommentModalContent from '@/pages/challenge/comments/components/AddCommentModal/AddCommentModalContent'; -import DateFilter from '@/pages/challenge/comments/components/DateFilter'; +import MobileDateFilter from '@/pages/challenge/comments/components/DateFilter/MobileDateFilter'; +import PCDateFilter from '@/pages/challenge/comments/components/DateFilter/PCDateFilter'; import MobileCommentsContent from '@/pages/challenge/comments/components/MobileCommentsContent'; import PCCommentsContent from '@/pages/challenge/comments/components/PCCommentsContent'; import StreakModalContent from '@/pages/challenge/comments/components/StreakModalContent'; @@ -37,13 +38,20 @@ function ChallengeComments() { queries.challengesInfo(Number(challengeId)), ); - const { today, challengeDates, isFirstDay, isChallengeDay } = - useChallengeCommentDates({ - startDate: challengeInfo?.startDate, - endDate: challengeInfo?.endDate, - }); + const { + today, + challengeDates, + initialSelectedDate, + isFirstDay, + isChallengeDay, + } = useChallengeCommentDates({ + startDate: challengeInfo?.startDate, + endDate: challengeInfo?.endDate, + }); - const [selectedDate, setSelectedDate] = useState(today); + const [selectedDate, setSelectedDate] = useState(null); + + const contentScrollRef = useRef(null); const { data: candidateArticles = [] } = useQuery( queries.challengeCommentCandidateArticles({ date: today }), @@ -60,25 +68,40 @@ function ChallengeComments() { isOpen: isFirstCompletionModalOpen, } = useModal(); + const activeDate = selectedDate ?? initialSelectedDate; + const { baseQueryParams, changePage, page, resetPage } = useCommentsPagination({ challengeId: Number(challengeId), - selectedDate, + selectedDate: activeDate, }); + const selectDate = (date: string) => { + setSelectedDate(date); + }; + return ( - + {isMobile ? ( + + ) : ( + + )} - - {selectedDate === today && isChallengeDay(selectedDate) && ( + + {isChallengeDay(today) && ( 오늘 읽은 뉴스레터, 한 줄만 남겨요. @@ -96,11 +119,11 @@ function ChallengeComments() { )} - {isFirstDay(selectedDate) || !isChallengeDay(selectedDate) ? ( + {isFirstDay(activeDate) || !isChallengeDay(activeDate) ? ( 전체 코멘트 - {isFirstDay(selectedDate) + {isFirstDay(activeDate) ? '첫날에는 코멘트를 작성하지 않아요!' : '오늘은 휴식일이에요. 코멘트를 작성하지 않아요!'} @@ -169,7 +192,7 @@ const FilterWrapper = styled.div<{ isMobile: boolean }>` : 'auto'}; z-index: ${({ isMobile, theme }) => (isMobile ? theme.zIndex.panel : 'auto')}; width: 100%; - padding: ${({ isMobile }) => (isMobile ? '12px 0' : '16px')}; + padding: ${({ isMobile }) => (isMobile ? '12px 0' : '0 0 8px')}; border-bottom: 2px solid ${({ theme }) => theme.colors.dividers}; background-color: ${({ theme }) => theme.colors.white}; @@ -177,6 +200,9 @@ const FilterWrapper = styled.div<{ isMobile: boolean }>` const ContentWrapper = styled.div<{ isMobile: boolean }>` width: 100%; + height: ${({ isMobile }) => + isMobile ? 'calc(100vh - 220px)' : 'calc(100vh - 260px)'}; + min-height: 240px; padding: ${({ isMobile }) => (isMobile ? '20px 0' : '24px')}; border-top: 1px solid ${({ theme }) => theme.colors.dividers}; @@ -186,6 +212,8 @@ const ContentWrapper = styled.div<{ isMobile: boolean }>` background-color: ${({ theme, isMobile }) => isMobile ? 'none' : theme.colors.backgroundHover}; + + overflow-y: auto; `; const AddCommentBox = styled.article` diff --git a/web/src/utils/date.test.ts b/web/src/utils/date.test.ts index bacfc43b7..fb183a6c7 100644 --- a/web/src/utils/date.test.ts +++ b/web/src/utils/date.test.ts @@ -1,4 +1,10 @@ -import { formatDate } from './date'; +import { + compareDates, + filterWeekdays, + formatDate, + getDatesDiff, + getDatesInRange, +} from './date'; describe('formatDate', () => { it('Date 정보를 점(.)으로 구분된 문자열로 변환한다.', () => { @@ -6,3 +12,114 @@ describe('formatDate', () => { expect(formatDate(date)).toBe('2025.07.01'); }); }); + +describe('getDatesInRange', () => { + it('시작일과 종료일이 같으면 해당 날짜 하나만 반환한다.', () => { + expect(getDatesInRange('2025-07-01', '2025-07-01')).toEqual(['2025-07-01']); + }); + + it('시작일부터 종료일까지 모든 날짜를 반환한다.', () => { + expect(getDatesInRange('2025-07-01', '2025-07-03')).toEqual([ + '2025-07-01', + '2025-07-02', + '2025-07-03', + ]); + }); + + it('월을 넘어가는 범위도 올바르게 반환한다.', () => { + expect(getDatesInRange('2025-01-30', '2025-02-01')).toEqual([ + '2025-01-30', + '2025-01-31', + '2025-02-01', + ]); + }); + + it('시작일이 종료일보다 늦으면 빈 배열을 반환한다.', () => { + expect(getDatesInRange('2025-07-03', '2025-07-01')).toEqual([]); + }); +}); + +describe('filterWeekdays', () => { + it('주말을 제외한 평일만 반환한다.', () => { + const week = [ + '2025-07-07', + '2025-07-08', + '2025-07-09', + '2025-07-10', + '2025-07-11', + '2025-07-12', + '2025-07-13', + ]; + expect(filterWeekdays(week)).toEqual([ + '2025-07-07', + '2025-07-08', + '2025-07-09', + '2025-07-10', + '2025-07-11', + ]); + }); + + it('평일만 있으면 그대로 반환한다.', () => { + expect(filterWeekdays(['2025-07-07', '2025-07-08'])).toEqual([ + '2025-07-07', + '2025-07-08', + ]); + }); + + it('주말만 있으면 빈 배열을 반환한다.', () => { + expect(filterWeekdays(['2025-07-12', '2025-07-13'])).toEqual([]); + }); + + it('빈 배열이면 빈 배열을 반환한다.', () => { + expect(filterWeekdays([])).toEqual([]); + }); +}); + +describe('compareDates', () => { + it('date1이 date2보다 이르면 -1을 반환한다.', () => { + expect(compareDates(new Date('2025-07-01'), new Date('2025-07-02'))).toBe( + -1, + ); + }); + + it('date1이 date2보다 늦으면 1을 반환한다.', () => { + expect(compareDates(new Date('2025-07-02'), new Date('2025-07-01'))).toBe( + 1, + ); + }); + + it('날짜가 같으면 0을 반환한다.', () => { + expect(compareDates(new Date('2025-07-01'), new Date('2025-07-01'))).toBe( + 0, + ); + }); + + it('시간이 달라도 날짜가 같으면 0을 반환한다.', () => { + expect( + compareDates( + new Date('2025-07-01T00:00:00'), + new Date('2025-07-01T23:59:59'), + ), + ).toBe(0); + }); +}); + +describe('getDatesDiff', () => { + it('같은 날짜면 0을 반환한다.', () => { + expect(getDatesDiff(new Date('2025-07-01'), new Date('2025-07-01'))).toBe( + 0, + ); + }); + + it('하루 차이면 1을 반환한다.', () => { + expect(getDatesDiff(new Date('2025-07-01'), new Date('2025-07-02'))).toBe( + 1, + ); + }); + + it('인수 순서가 바뀌어도 동일한 값을 반환한다.', () => { + expect(getDatesDiff(new Date('2025-07-10'), new Date('2025-07-01'))).toBe( + 9, + ); + }); +}); diff --git a/web/src/utils/date.ts b/web/src/utils/date.ts index faf3d944a..cf9b3759a 100644 --- a/web/src/utils/date.ts +++ b/web/src/utils/date.ts @@ -6,11 +6,6 @@ export const formatDate = (date: Date, separator: string = '.'): string => { return year + separator + month + separator + day; }; -export const isToday = (date: Date): boolean => { - const today = new Date(); - return formatDate(date) === formatDate(today); -}; - export const getDatesInRange = ( startDate: string, endDate: string,