From ac954d67f6fc4010ab57d82330c4ddd944928b53 Mon Sep 17 00:00:00 2001 From: sandy Date: Sun, 8 Feb 2026 21:42:59 +0900 Subject: [PATCH 1/8] =?UTF-8?q?design:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20ui=20=EC=88=98=EC=A0=95=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/InsightPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index 277226d9..6588ffac 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -373,7 +373,7 @@ export default function InsightPage() {

가장 많은 피드백을 받은 슬라이드

-
+
{topSlides.map(({ slideId, slide, slideIndex, title }, index) => { const summary = topSlideReactionSummaries?.[index]; const baseReactions = createDefaultReactions(); From e5e3a6ca9fec2e6b19bc9c1731efd8f1292555cf Mon Sep 17 00:00:00 2001 From: sandy Date: Sun, 8 Feb 2026 23:24:56 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20404=EC=97=90=EB=9F=AC=20=EC=9E=AC=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insight/RecentCommentsSection.tsx | 59 +++ src/components/insight/RetentionSection.tsx | 15 + .../insight/charts/RetentionChartCard.tsx | 97 ++++ .../insight/charts/RetentionChartTooltip.tsx | 28 ++ src/components/insight/types.ts | 46 ++ src/hooks/queries/usePresentations.ts | 8 + src/hooks/queries/useSlides.ts | 19 +- src/hooks/useInsightPageModel.ts | 231 ++++++++++ src/pages/InsightPage.tsx | 419 +----------------- 9 files changed, 523 insertions(+), 399 deletions(-) create mode 100644 src/components/insight/RecentCommentsSection.tsx create mode 100644 src/components/insight/RetentionSection.tsx create mode 100644 src/components/insight/charts/RetentionChartCard.tsx create mode 100644 src/components/insight/charts/RetentionChartTooltip.tsx create mode 100644 src/components/insight/types.ts create mode 100644 src/hooks/useInsightPageModel.ts diff --git a/src/components/insight/RecentCommentsSection.tsx b/src/components/insight/RecentCommentsSection.tsx new file mode 100644 index 00000000..75bab465 --- /dev/null +++ b/src/components/insight/RecentCommentsSection.tsx @@ -0,0 +1,59 @@ +// src/pages/insight/sections/RecentCommentsSection.tsx +import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto'; +import { RecentCommentItem } from '@/components/insight'; +import { formatVideoTimestamp } from '@/utils/format'; + +const thumbBase = 'bg-gray-100 rounded-lg aspect-video'; + +export function RecentCommentsSection({ + hasVideo, + recentCommentsData, +}: { + hasVideo: boolean; + recentCommentsData?: ReadRecentCommentListResponseDto; +}) { + return ( +
+
+
+

최근 댓글 피드백

+ + {recentCommentsData?.comments && recentCommentsData.comments.length > 0 ? ( + recentCommentsData.comments.map((comment) => ( + + )) + ) : ( +
+ 아직 등록된 댓글이 없습니다. +
+ )} +
+ + {!hasVideo && ( +
+
+

+ 영상을 녹화하면 더 자세한 분석을 받을 수 있어요 +

+
    +
  • • 시청 구간별 이탈률 분석
  • +
  • • 영상 잔존율 그래프
  • +
  • • 타임라인 기반 댓글 피드백
  • +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/insight/RetentionSection.tsx b/src/components/insight/RetentionSection.tsx new file mode 100644 index 00000000..936bce32 --- /dev/null +++ b/src/components/insight/RetentionSection.tsx @@ -0,0 +1,15 @@ +// src/pages/insight/sections/RetentionSection.tsx +import { RetentionChartCard } from './charts/RetentionChartCard'; +import type { ChartDataPoint } from './types'; + +export function RetentionSection({ + title, + data, + isVideo, +}: { + title: string; + data: ChartDataPoint[]; + isVideo: boolean; +}) { + return ; +} diff --git a/src/components/insight/charts/RetentionChartCard.tsx b/src/components/insight/charts/RetentionChartCard.tsx new file mode 100644 index 00000000..eb224851 --- /dev/null +++ b/src/components/insight/charts/RetentionChartCard.tsx @@ -0,0 +1,97 @@ +// src/pages/insight/charts/RetentionChartCard.tsx +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDataPoint } from '../types'; +import { RetentionChartTooltip } from './RetentionChartTooltip'; + +interface Props { + title: string; + data: ChartDataPoint[]; + isVideo: boolean; +} + +export function RetentionChartCard({ title, data, isVideo }: Props) { + return ( +
+

{title}

+ +
+ {data.length > 0 ? ( + + + + + + + + + + + + + + + + } + cursor={{ stroke: 'var(--color-error)', strokeDasharray: '4 4', strokeWidth: 1 }} + /> + + + + + ) : ( +
+

데이터를 분석 중이거나 결과가 없습니다.

+
+ )} +
+
+ ); +} diff --git a/src/components/insight/charts/RetentionChartTooltip.tsx b/src/components/insight/charts/RetentionChartTooltip.tsx new file mode 100644 index 00000000..577941b7 --- /dev/null +++ b/src/components/insight/charts/RetentionChartTooltip.tsx @@ -0,0 +1,28 @@ +// src/pages/insight/charts/RetentionChartTooltip.tsx +import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; +import type { TooltipContentProps } from 'recharts/types/component/Tooltip'; + +import type { ChartDataPoint } from '../types'; + +export function RetentionChartTooltip({ + active, + payload, + label, + hasVideo, +}: TooltipContentProps & { hasVideo: boolean }) { + if (active && payload && payload.length) { + const data = payload[0].payload as ChartDataPoint; + return ( +
+

+ {hasVideo ? `재생 시간: ${label}` : `슬라이드: ${data.tooltipTitle}`} +

+
+

잔존율 {data.value}%

+ ({data.sessionCount}명) +
+
+ ); + } + return null; +} diff --git a/src/components/insight/types.ts b/src/components/insight/types.ts new file mode 100644 index 00000000..70cfd757 --- /dev/null +++ b/src/components/insight/types.ts @@ -0,0 +1,46 @@ +// src/components/insight/types.ts +import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto'; +import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; +import type { ReactionType } from '@/types/script'; +import type { SlideListItem } from '@/types/slide'; + +// 기존 공용 타입 재사용 + +export interface ChartDataPoint { + label: string; + value: number; + tooltipTitle: string; + sessionCount: number; + originalTime?: number; +} + +export type InsightTopSlide = { + slideId: string; + slide?: SlideListItem; + slideIndex: number; + title: string; + commentCount: number; + feedbackCount: number; +}; + +export type InsightModel = { + projectIdStr: string; + projectIdNum: number; + + hasVideo: boolean; + + summaryStats: SummaryStat[]; + + dropOffSlides: DropOffSlide[]; + dropOffTimes: DropOffTime[]; + + retentionTitle: string; + retentionData: ChartDataPoint[]; + retentionIsVideo: boolean; + + topSlides: InsightTopSlide[]; + topSlideReactionSummaries?: Array>; + getThumb: (slideIndex: number) => string | undefined; + + recentCommentsData: ReadRecentCommentListResponseDto | undefined; +}; diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index 234bf9d1..d86c21d9 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -5,6 +5,7 @@ * @description 프로젝트 조회 훅 */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; import { queryKeys } from '@/api'; import type { UpdateProjectDto } from '@/api/dto'; @@ -14,6 +15,7 @@ import { getPresentation, updatePresentation, } from '@/api/endpoints/presentations'; +import { MAX_RETRIES } from '@/api/queryClient'; import type { Presentation } from '@/types/presentation'; import { showToast } from '@/utils/toast'; @@ -31,6 +33,12 @@ export function usePresentation(projectId: string) { queryKey: queryKeys.presentations.detail(projectId), queryFn: () => getPresentation(projectId), enabled: !!projectId, + retry: (failureCount, error) => { + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + return failureCount < MAX_RETRIES; + }, }); } diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 4147b789..3548e204 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -1,11 +1,12 @@ -/** +/** * 슬라이드 관련 TanStack Query 훅 */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; import type { UpdateSlideTitleRequestDto } from '@/api/dto'; import { getSlides, updateSlide } from '@/api/endpoints/slides'; -import { queryKeys } from '@/api/queryClient'; +import { MAX_RETRIES, queryKeys } from '@/api/queryClient'; /** 슬라이드 목록 조회 */ export function useSlides(projectId: string) { @@ -13,9 +14,21 @@ export function useSlides(projectId: string) { queryKey: queryKeys.slides.list(projectId), queryFn: () => getSlides(projectId), enabled: !!projectId, + retry: (failureCount, error) => { + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + return failureCount < MAX_RETRIES; + }, // 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가 // TODO: 서버에서 broadcastNewComment 호출 후 제거 - refetchInterval: 3000, // 3초마다 자동 갱신 + refetchInterval: (query) => { + const error = query.state.error; + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + return 3000; + }, // 3초마다 자동 갱신 refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤 }); } diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts new file mode 100644 index 00000000..650fcdfd --- /dev/null +++ b/src/hooks/useInsightPageModel.ts @@ -0,0 +1,231 @@ +// src/pages/insight/useInsightPageModel.ts +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import type { ChartDataPoint, InsightModel, InsightTopSlide } from '@/components/insight/types'; +import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; +import { useSlides } from '@/hooks/queries/useSlides'; +import { + useProjectAnalyticsSummary, + useRecentComments, + useSlideAnalytics, + useSlideRetention, + useVideoAnalytics, + useVideoRetention, +} from '@/hooks/useAnalytics'; +import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; +import type { SlideListItem } from '@/types/slide'; +import { formatVideoTimestamp } from '@/utils/format'; +import { getSlideIndexFromTime } from '@/utils/video'; + +const FALLBACK_SLIDE_DURATION_SECONDS = 10; +const summaryStatLabels = ['총 조회수', '완료율', '받은 피드백', '평균 체류 시간'] as const; + +const emptySummaryStats: SummaryStat[] = summaryStatLabels.map((label) => ({ + label, + value: '-', + sub: '', +})); + +const normalizeRate = (rate: number) => (rate <= 1 ? rate * 100 : rate); + +export function useInsightPageModel(): InsightModel { + const { projectId: projectIdStrRaw } = useParams<{ projectId: string }>(); + const projectIdStr = projectIdStrRaw ?? ''; + const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; + + const { data: slides } = useSlides(projectIdStr); + const { data: slideAnalytics } = useSlideAnalytics(projectIdNum); + const { data: summaryAnalytics } = useProjectAnalyticsSummary(projectIdNum); + const { data: recentCommentsData } = useRecentComments(projectIdNum); + + const videoIdStr = summaryAnalytics?.videoIds?.[0] ?? ''; + const videoIdNum = videoIdStr ? Number(videoIdStr) : 0; + const hasVideo = !!videoIdNum; + + const { data: videoExitAnalytics } = useVideoAnalytics(videoIdNum); + + // ---- Summary stats ---- + const computedSummaryStats = useMemo(() => { + if (!summaryAnalytics) return emptySummaryStats; + + const completionRate = + summaryAnalytics.completionRate <= 1 + ? summaryAnalytics.completionRate * 100 + : summaryAnalytics.completionRate; + + return [ + { label: summaryStatLabels[0], value: String(summaryAnalytics.totalViews), sub: '' }, + { label: summaryStatLabels[1], value: `${Math.round(completionRate)}%`, sub: '' }, + { label: summaryStatLabels[2], value: String(summaryAnalytics.totalFeedbackCount), sub: '' }, + { + label: summaryStatLabels[3], + value: formatVideoTimestamp(summaryAnalytics.avgDurationSeconds), + sub: '', + }, + ]; + }, [summaryAnalytics]); + + const summaryStats = useMemo( + () => + hasVideo + ? computedSummaryStats + : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]), + [computedSummaryStats, hasVideo], + ); + + // ---- Slides map / thumbs ---- + const slideList = useMemo(() => (Array.isArray(slides) ? slides : []), [slides]); + + const slideDataMaps = useMemo(() => { + const slideIndexById = new Map(); + const slideById = new Map(); + + slideList.forEach((slide, index) => { + slideIndexById.set(slide.slideId, index); + slideById.set(slide.slideId, slide); + }); + + return { slideIndexById, slideById }; + }, [slideList]); + + const toPublicUrl = (url?: string) => + url?.startsWith('gs://') ? `https://storage.googleapis.com/${url.slice(5)}` : url; + + const getThumb = (slideIndex: number) => toPublicUrl(slideList[slideIndex]?.imageUrl); + + // ---- Top slides ---- + const topSlides = useMemo(() => { + const analyticsSlides = slideAnalytics?.slides ?? []; + if (!analyticsSlides.length) return []; + + const { slideIndexById, slideById } = slideDataMaps; + + return analyticsSlides + .slice() + .sort((a, b) => b.feedbackCount - a.feedbackCount) + .slice(0, 3) + .map((item) => { + const slide = slideById.get(item.slideId); + const slideIndex = slideIndexById.get(item.slideId) ?? Math.max(0, item.slideNum - 1); + const title = slide?.title || item.title || `슬라이드 ${slideIndex + 1}`; + + return { + slideId: item.slideId, + slide, + slideIndex, + title, + commentCount: item.commentCount, + feedbackCount: item.feedbackCount, + }; + }); + }, [slideAnalytics, slideDataMaps]); + + const topSlideIds = useMemo(() => topSlides.map((item) => item.slideId), [topSlides]); + const { data: topSlideReactionSummaries } = useSlideReactionSummaries(topSlideIds); + + // ---- Drop-off ---- + const slideChangeTimes = useMemo(() => { + if (!slides?.length) return []; + return slides.map((slide, index) => + Number.isFinite(slide.startTime) + ? (slide.startTime ?? 0) + : index * FALLBACK_SLIDE_DURATION_SECONDS, + ); + }, [slides]); + + const dropOffSlides: DropOffSlide[] = useMemo(() => { + const items = slideAnalytics?.slides ?? []; + return items + .slice() + .sort((a, b) => b.exitCount - a.exitCount) + .slice(0, 3) + .map((item) => ({ + label: `슬라이드 ${item.slideNum}`, + desc: `${item.exitCount}명 이탈`, + percent: Math.min( + 100, + Math.round( + item.exitRate <= 1 + ? item.exitRate * 100 + : item.exitRate > 100 + ? item.exitRate / 100 + : item.exitRate, + ), + ), + slideIndex: Math.max(0, item.slideNum - 1), + })); + }, [slideAnalytics]); + + const dropOffTimes: DropOffTime[] = useMemo(() => { + const items = videoExitAnalytics?.exits ?? []; + return items + .slice() + .sort((a, b) => b.exitCount - a.exitCount) + .slice(0, 3) + .map((item) => { + const seconds = item.timestampMs / 1000; + const slideIndex = + slides?.length && slideChangeTimes.length + ? getSlideIndexFromTime(seconds, slideChangeTimes, slides.length - 1) + : 0; + + return { + time: formatVideoTimestamp(seconds), + desc: slides?.length ? `슬라이드 ${slideIndex + 1}` : '슬라이드', + count: item.exitCount, + slideIndex, + }; + }); + }, [videoExitAnalytics, slideChangeTimes, slides]); + + // ---- Retention(잔존율) ---- + const { data: videoRetentionRes } = useVideoRetention(videoIdNum); + const { data: slideRetentionRes } = useSlideRetention(projectIdNum); + + const videoChartData = useMemo(() => { + if (!videoRetentionRes?.videoRetention) return []; + return videoRetentionRes.videoRetention.map((item) => ({ + label: formatVideoTimestamp(item.timestampMs / 1000), // x축: 00:00 + value: Math.round(normalizeRate(item.retentionRate)), // y축: 0~100% + tooltipTitle: formatVideoTimestamp(item.timestampMs / 1000), + sessionCount: item.sessionCount, + originalTime: item.timestampMs, + })); + }, [videoRetentionRes]); + + const slideChartData = useMemo(() => { + if (!slideRetentionRes?.slideRetention) return []; + return slideRetentionRes.slideRetention.map((item) => ({ + label: `S${item.slideNum}`, // x축: S1, S2 + value: Math.round(normalizeRate(item.retentionRate)), + tooltipTitle: item.title || `슬라이드 ${item.slideNum}`, // 툴팁: 제목 + sessionCount: item.sessionCount, + })); + }, [slideRetentionRes]); + + const retentionTitle = hasVideo ? '영상 시청 잔존률' : '슬라이드별 청중 잔존률'; + const retentionData = hasVideo ? videoChartData : slideChartData; + + return { + projectIdStr, + projectIdNum, + hasVideo, + + summaryStats, + + dropOffSlides, + dropOffTimes, + + retentionTitle, + retentionData, + retentionIsVideo: hasVideo, + + topSlides, + topSlideReactionSummaries, + + getThumb, + + recentCommentsData, + }; +} diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index f374afb0..3d0b90d2 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -1,339 +1,15 @@ -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; - -// 1. Recharts 컴포넌트 임포트 -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; -import type { TooltipContentProps } from 'recharts/types/component/Tooltip'; - -import { - DropOffAnalysisSection, - FeedbackDistributionSection, - RecentCommentItem, - SummaryStatsSection, - TopSlideCard, -} from '@/components/insight'; +// src/pages/insight/InsightPage.tsx +import { SummaryStatsSection } from '@/components/insight'; +import { DropOffAnalysisSection } from '@/components/insight'; +import { FeedbackDistributionSection } from '@/components/insight'; +import { TopSlideCard } from '@/components/insight'; +import { RecentCommentsSection } from '@/components/insight/RecentCommentsSection'; +import { RetentionSection } from '@/components/insight/RetentionSection'; import { createDefaultReactions } from '@/constants/reaction'; -import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; -import { useSlides } from '@/hooks/queries/useSlides'; -import { - useProjectAnalyticsSummary, - useRecentComments, - useSlideAnalytics, - useSlideRetention, - useVideoAnalytics, - useVideoRetention, -} from '@/hooks/useAnalytics'; -import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; -import type { SlideListItem } from '@/types/slide'; -import { formatVideoTimestamp } from '@/utils/format'; -import { getSlideIndexFromTime } from '@/utils/video'; - -// --- 타입 및 스타일 정의 --- -// 툴팁용 타입 -interface ChartDataPoint { - label: string; - value: number; - tooltipTitle: string; - sessionCount: number; - originalTime?: number; // 영상 시간 계산용 -} - -// 디자인을 위해 스타일을 조금 더 부드럽게 조정함 -const thumbBase = 'bg-gray-100 rounded-lg aspect-video'; -const FALLBACK_SLIDE_DURATION_SECONDS = 10; - -// --- 더미 데이터 (사진 수치 반영) --- -const summaryStatLabels = ['총 조회수', '완료율', '받은 피드백', '평균 체류 시간'] as const; -const emptySummaryStats: SummaryStat[] = summaryStatLabels.map((label) => ({ - label, - value: '-', - sub: '', -})); - -// --- 커스텀 툴팁 컴포넌트 --- -const CustomTooltip = ({ - active, - payload, - label, - hasVideo, -}: TooltipContentProps & { hasVideo: boolean }) => { - if (active && payload && payload.length) { - const data = payload[0].payload as ChartDataPoint; - return ( -
-

- {hasVideo ? `재생 시간: ${label}` : `슬라이드: ${data.tooltipTitle}`} -

-
-

잔존율 {data.value}%

- ({data.sessionCount}명) -
-
- ); - } - return null; -}; - -// --- 컴포넌트 시작 --- - -const normalizeRate = (rate: number) => (rate <= 1 ? rate * 100 : rate); +import { useInsightPageModel } from '@/hooks/useInsightPageModel'; export default function InsightPage() { - const { projectId: projectIdStr } = useParams<{ projectId: string }>(); - const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; - const { data: slides } = useSlides(projectIdStr ?? ''); - const { data: slideAnalytics } = useSlideAnalytics(projectIdNum); - const { data: summaryAnalytics } = useProjectAnalyticsSummary(projectIdNum); - const { data: recentCommentsData } = useRecentComments(projectIdNum); - const videoIdStr = summaryAnalytics?.videoIds?.[0] ?? ''; - const videoIdNum = videoIdStr ? Number(videoIdStr) : 0; - const hasVideo = !!videoIdNum; - const { data: videoExitAnalytics } = useVideoAnalytics(videoIdNum); - const computedSummaryStats = useMemo(() => { - if (!summaryAnalytics) return emptySummaryStats; - - const completionRate = - summaryAnalytics.completionRate <= 1 - ? summaryAnalytics.completionRate * 100 - : summaryAnalytics.completionRate; - - return [ - { label: summaryStatLabels[0], value: String(summaryAnalytics.totalViews), sub: '' }, - { label: summaryStatLabels[1], value: `${Math.round(completionRate)}%`, sub: '' }, - { - label: summaryStatLabels[2], - value: String(summaryAnalytics.totalFeedbackCount), - sub: '', - }, - { - label: summaryStatLabels[3], - value: formatVideoTimestamp(summaryAnalytics.avgDurationSeconds), - sub: '', - }, - ]; - }, [summaryAnalytics]); - - const visibleSummaryStats = hasVideo - ? computedSummaryStats - : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]); - - const slideList = useMemo(() => (Array.isArray(slides) ? slides : []), [slides]); - - const slideDataMaps = useMemo(() => { - const slideIndexById = new Map(); - const slideById = new Map(); - - slideList.forEach((slide, index) => { - slideIndexById.set(slide.slideId, index); - slideById.set(slide.slideId, slide); - }); - - return { slideIndexById, slideById }; - }, [slideList]); - - const topSlides = useMemo(() => { - const analyticsSlides = slideAnalytics?.slides ?? []; - if (!analyticsSlides.length) return []; - const { slideIndexById, slideById } = slideDataMaps; - - return analyticsSlides - .slice() - .sort((a, b) => b.feedbackCount - a.feedbackCount) - .slice(0, 3) - .map((item) => { - const slide = slideById.get(item.slideId); - const slideIndex = slideIndexById.get(item.slideId) ?? Math.max(0, item.slideNum - 1); - const title = slide?.title || item.title || `슬라이드 ${slideIndex + 1}`; - - return { - slideId: item.slideId, - slide, - slideIndex, - title, - commentCount: item.commentCount, - feedbackCount: item.feedbackCount, - }; - }); - }, [slideAnalytics, slideDataMaps]); - - const topSlideIds = useMemo(() => topSlides.map((item) => item.slideId), [topSlides]); - const { data: topSlideReactionSummaries } = useSlideReactionSummaries(topSlideIds); - - const toPublicUrl = (url?: string) => - url?.startsWith('gs://') ? `https://storage.googleapis.com/${url.slice(5)}` : url; - const getThumb = (slideIndex: number) => toPublicUrl(slideList[slideIndex]?.imageUrl); - - const slideChangeTimes = useMemo(() => { - if (!slides?.length) return []; - - return slides.map((slide, index) => - Number.isFinite(slide.startTime) - ? (slide.startTime ?? 0) - : index * FALLBACK_SLIDE_DURATION_SECONDS, - ); - }, [slides]); - - const dropOffSlides: DropOffSlide[] = useMemo(() => { - const items = slideAnalytics?.slides ?? []; - return items - .slice() - .sort((a, b) => b.exitCount - a.exitCount) - .slice(0, 3) - .map((item) => ({ - label: `슬라이드 ${item.slideNum}`, - desc: `${item.exitCount}명 이탈`, - percent: Math.min( - 100, - Math.round( - item.exitRate <= 1 - ? item.exitRate * 100 - : item.exitRate > 100 - ? item.exitRate / 100 - : item.exitRate, - ), - ), - slideIndex: Math.max(0, item.slideNum - 1), - })); - }, [slideAnalytics]); - - const dropOffTimes: DropOffTime[] = useMemo(() => { - const items = videoExitAnalytics?.exits ?? []; - return items - .slice() - .sort((a, b) => b.exitCount - a.exitCount) - .slice(0, 3) - .map((item) => { - const seconds = item.timestampMs / 1000; - const slideIndex = - slides?.length && slideChangeTimes.length - ? getSlideIndexFromTime(seconds, slideChangeTimes, slides.length - 1) - : 0; - return { - time: formatVideoTimestamp(seconds), - desc: slides?.length ? `슬라이드 ${slideIndex + 1}` : '슬라이드', - count: item.exitCount, - slideIndex, - }; - }); - }, [videoExitAnalytics, slideChangeTimes, slides]); - - // 잔존율 데이터 Fetching - // 1. 영상 잔존율 (영상이 있을 때만 호출) - const { data: videoRetentionRes } = useVideoRetention(videoIdNum); - - // 2. 슬라이드 잔존율 (영상이 없을 때 호출) - const { data: slideRetentionRes } = useSlideRetention(projectIdNum); - - // --- Chart Data 가공 --- - const videoChartData = useMemo(() => { - if (!videoRetentionRes?.videoRetention) return []; - return videoRetentionRes.videoRetention.map((item) => ({ - label: formatVideoTimestamp(item.timestampMs / 1000), // x축: 00:00 - value: Math.round(normalizeRate(item.retentionRate)), // y축: 0~100% - tooltipTitle: formatVideoTimestamp(item.timestampMs / 1000), - sessionCount: item.sessionCount, - originalTime: item.timestampMs, - })); - }, [videoRetentionRes]); - - const slideChartData = useMemo(() => { - if (!slideRetentionRes?.slideRetention) return []; - return slideRetentionRes.slideRetention.map((item) => ({ - label: `S${item.slideNum}`, // x축: S1, S2 - value: Math.round(normalizeRate(item.retentionRate)), - tooltipTitle: item.title || `슬라이드 ${item.slideNum}`, // 툴팁: 제목 - sessionCount: item.sessionCount, - })); - }, [slideRetentionRes]); - - const renderRetentionChart = (title: string, data: ChartDataPoint[], isVideo: boolean) => ( -
-

{title}

-
- {data.length > 0 ? ( - - - - - - - - - - - - - - - - } - cursor={{ stroke: 'var(--color-error)', strokeDasharray: '4 4', strokeWidth: 1 }} - /> - - - - - ) : ( -
-

데이터를 분석 중이거나 결과가 없습니다.

-
- )} -
-
- ); + const m = useInsightPageModel(); return (
- {/* 헤더 */}

발표 인사이트

발표 자료 분석 결과를 확인하세요

- {/* 통계 카드 */}
- + - {/* 이탈 분석 */} - {/* 잔존률 차트 */} - {renderRetentionChart( - hasVideo ? '영상 시청 잔존률' : '슬라이드별 청중 잔존률', - hasVideo ? videoChartData : slideChartData, - hasVideo, - )} +
- +

가장 많은 피드백을 받은 슬라이드

- {topSlides.map(({ slideId, slide, slideIndex, title }, index) => { - const summary = topSlideReactionSummaries?.[index]; + {m.topSlides.map(({ slideId, slide, slideIndex, title }, index) => { + const summary = m.topSlideReactionSummaries?.[index]; const baseReactions = createDefaultReactions(); const summaryReactions = summary ? baseReactions.map((reaction) => ({ @@ -389,7 +60,7 @@ export default function InsightPage() { ); @@ -397,51 +68,7 @@ export default function InsightPage() {
- -
-
-
-

최근 댓글 피드백

- {recentCommentsData?.comments && recentCommentsData.comments.length > 0 ? ( - recentCommentsData.comments.map((comment) => ( - - )) - ) : ( - /* 댓글이 없을 경우 표시할 UI (선택 사항) */ -
- 아직 등록된 댓글이 없습니다. -
- )} -
- - {/* 오버레이: 이 영역 안에서만 덮음 */} - {!hasVideo && ( -
-
-

- 영상을 녹화하면 더 자세한 분석을 받을 수 있어요 -

-
    -
  • • 시청 구간별 이탈률 분석
  • -
  • • 영상 잔존율 그래프
  • -
  • • 타임라인 기반 댓글 피드백
  • -
-
-
- )} -
-
+
From ec2136eae714b405a2cbfcc0f398e8fb5731b246 Mon Sep 17 00:00:00 2001 From: sandy Date: Mon, 9 Feb 2026 00:19:36 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=20=EB=B0=8F=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EB=AA=85=EC=B9=AD=20=EC=A0=95=EB=A6=AC=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insight/RecentCommentsSection.tsx | 4 ++-- src/components/insight/RetentionSection.tsx | 15 ------------- src/components/insight/index.ts | 2 ++ src/hooks/useInsightPageModel.ts | 22 ++++++++----------- src/pages/InsightPage.tsx | 4 ++-- 5 files changed, 15 insertions(+), 32 deletions(-) delete mode 100644 src/components/insight/RetentionSection.tsx diff --git a/src/components/insight/RecentCommentsSection.tsx b/src/components/insight/RecentCommentsSection.tsx index 75bab465..b6a03b90 100644 --- a/src/components/insight/RecentCommentsSection.tsx +++ b/src/components/insight/RecentCommentsSection.tsx @@ -1,4 +1,4 @@ -// src/pages/insight/sections/RecentCommentsSection.tsx +// src/components/insight/RecentCommentsSection.tsx import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto'; import { RecentCommentItem } from '@/components/insight'; import { formatVideoTimestamp } from '@/utils/format'; @@ -16,7 +16,7 @@ export function RecentCommentsSection({

최근 댓글 피드백

diff --git a/src/components/insight/RetentionSection.tsx b/src/components/insight/RetentionSection.tsx deleted file mode 100644 index 936bce32..00000000 --- a/src/components/insight/RetentionSection.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// src/pages/insight/sections/RetentionSection.tsx -import { RetentionChartCard } from './charts/RetentionChartCard'; -import type { ChartDataPoint } from './types'; - -export function RetentionSection({ - title, - data, - isVideo, -}: { - title: string; - data: ChartDataPoint[]; - isVideo: boolean; -}) { - return ; -} diff --git a/src/components/insight/index.ts b/src/components/insight/index.ts index 5e0b356e..3eb5769c 100644 --- a/src/components/insight/index.ts +++ b/src/components/insight/index.ts @@ -4,3 +4,5 @@ export { default as RecentCommentItem } from './RecentCommentItem'; export { default as SummaryStatsSection } from './SummaryStatsSection'; export { default as DropOffAnalysisSection } from './DropOffAnalysisSection'; export { default as FeedbackDistributionSection } from './FeedbackDistributionSection'; +export { RecentCommentsSection } from './RecentCommentsSection'; +export * from './types'; diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index 650fcdfd..2cd2cafe 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -1,4 +1,4 @@ -// src/pages/insight/useInsightPageModel.ts +// src/hooks/useInsightPageModel.ts import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -30,8 +30,8 @@ const emptySummaryStats: SummaryStat[] = summaryStatLabels.map((label) => ({ const normalizeRate = (rate: number) => (rate <= 1 ? rate * 100 : rate); export function useInsightPageModel(): InsightModel { - const { projectId: projectIdStrRaw } = useParams<{ projectId: string }>(); - const projectIdStr = projectIdStrRaw ?? ''; + const { projectId } = useParams<{ projectId: string }>(); + const projectIdStr = projectId ?? ''; const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; const { data: slides } = useSlides(projectIdStr); @@ -66,13 +66,9 @@ export function useInsightPageModel(): InsightModel { ]; }, [summaryAnalytics]); - const summaryStats = useMemo( - () => - hasVideo - ? computedSummaryStats - : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]), - [computedSummaryStats, hasVideo], - ); + const summaryStats = hasVideo + ? computedSummaryStats + : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]); // ---- Slides map / thumbs ---- const slideList = useMemo(() => (Array.isArray(slides) ? slides : []), [slides]); @@ -89,10 +85,10 @@ export function useInsightPageModel(): InsightModel { return { slideIndexById, slideById }; }, [slideList]); - const toPublicUrl = (url?: string) => - url?.startsWith('gs://') ? `https://storage.googleapis.com/${url.slice(5)}` : url; + const convertGcsToHttpUrl = (gcsUrl?: string) => + gcsUrl?.startsWith('gs://') ? `https://storage.googleapis.com/${gcsUrl.slice(5)}` : gcsUrl; - const getThumb = (slideIndex: number) => toPublicUrl(slideList[slideIndex]?.imageUrl); + const getThumb = (slideIndex: number) => convertGcsToHttpUrl(slideList[slideIndex]?.imageUrl); // ---- Top slides ---- const topSlides = useMemo(() => { diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index 3d0b90d2..10fd0ed6 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -4,7 +4,7 @@ import { DropOffAnalysisSection } from '@/components/insight'; import { FeedbackDistributionSection } from '@/components/insight'; import { TopSlideCard } from '@/components/insight'; import { RecentCommentsSection } from '@/components/insight/RecentCommentsSection'; -import { RetentionSection } from '@/components/insight/RetentionSection'; +import { RetentionChartCard } from '@/components/insight/charts/RetentionChartCard'; import { createDefaultReactions } from '@/constants/reaction'; import { useInsightPageModel } from '@/hooks/useInsightPageModel'; @@ -33,7 +33,7 @@ export default function InsightPage() { getThumb={m.getThumb} /> - Date: Mon, 9 Feb 2026 00:32:19 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EC=9D=B4=ED=83=88=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=93=9C/=EC=98=81=EC=83=81=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EB=B9=88=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insight/DropOffAnalysisSection.tsx | 61 +++++++++++-------- src/hooks/useInsightPageModel.ts | 3 +- src/types/insight.ts | 1 + 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/components/insight/DropOffAnalysisSection.tsx b/src/components/insight/DropOffAnalysisSection.tsx index 8da63e1d..addc4ff3 100644 --- a/src/components/insight/DropOffAnalysisSection.tsx +++ b/src/components/insight/DropOffAnalysisSection.tsx @@ -1,4 +1,4 @@ -import type { DropOffSlide, DropOffTime } from '@/types/insight'; +import type { DropOffSlide, DropOffTime } from '@/types/insight'; import SlideThumb from './SlideThumb'; @@ -16,13 +16,20 @@ export default function DropOffAnalysisSection({ showVideoDropOff = true, }: DropOffAnalysisSectionProps) { const isSlideOnly = !showVideoDropOff; // !hasVideo 일때 + const noDataMessage = '데이터를 분석 중이거나 결과가 없습니다.'; + const hasSlideDropOff = dropOffSlides.some((item) => item.count > 0); + const hasVideoDropOff = dropOffTimes.some((item) => item.count > 0); return (
{/* 슬라이드 이탈 */}

가장 많이 이탈한 슬라이드

- {isSlideOnly ? ( + {!hasSlideDropOff ? ( +
+

{noDataMessage}

+
+ ) : isSlideOnly ? ( // !hasVideo UI
{dropOffSlides.map((item, idx) => ( @@ -74,29 +81,35 @@ export default function DropOffAnalysisSection({ {showVideoDropOff && (

가장 많이 이탈한 영상 구간

- {dropOffTimes.map((item, idx) => ( -
- -
- {item.time} - {item.desc} -
-
- {item.count}명 - 이탈 -
+ {!hasVideoDropOff ? ( +
+

{noDataMessage}

- ))} + ) : ( + dropOffTimes.map((item, idx) => ( +
+ +
+ {item.time} + {item.desc} +
+
+ {item.count}명 + 이탈 +
+
+ )) + )}
)}
diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index 2cd2cafe..a8c2984d 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -1,4 +1,4 @@ -// src/hooks/useInsightPageModel.ts +// src/hooks/useInsightPageModel.ts import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -150,6 +150,7 @@ export function useInsightPageModel(): InsightModel { ), ), slideIndex: Math.max(0, item.slideNum - 1), + count: item.exitCount, })); }, [slideAnalytics]); diff --git a/src/types/insight.ts b/src/types/insight.ts index 522ad324..7eea2a2e 100644 --- a/src/types/insight.ts +++ b/src/types/insight.ts @@ -11,6 +11,7 @@ export type DropOffSlide = { desc: string; percent: number; slideIndex: number; + count: number; }; export type DropOffTime = { From 72c13948c404dc1fad691217558bc13bfc0fc206 Mon Sep 17 00:00:00 2001 From: sandy Date: Mon, 9 Feb 2026 00:35:57 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=98=81=EC=83=81=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=EB=95=8C=20=EC=98=81=EC=83=81=EC=9D=B4=ED=83=88=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EB=B0=95=EC=8A=A4=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/insight/charts/RetentionChartTooltip.tsx | 2 +- src/pages/InsightPage.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/insight/charts/RetentionChartTooltip.tsx b/src/components/insight/charts/RetentionChartTooltip.tsx index 577941b7..ca5b25c7 100644 --- a/src/components/insight/charts/RetentionChartTooltip.tsx +++ b/src/components/insight/charts/RetentionChartTooltip.tsx @@ -15,7 +15,7 @@ export function RetentionChartTooltip({ return (

- {hasVideo ? `재생 시간: ${label}` : `슬라이드: ${data.tooltipTitle}`} + {hasVideo ? `재생 시간: ${label}` : `${data.tooltipTitle}`}

잔존율 {data.value}%

diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index 10fd0ed6..156fe389 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -31,6 +31,7 @@ export default function InsightPage() { dropOffSlides={m.dropOffSlides} dropOffTimes={m.dropOffTimes} getThumb={m.getThumb} + showVideoDropOff={m.hasVideo} /> Date: Mon, 9 Feb 2026 01:13:24 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20404=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20=EC=9B=90=EB=B3=B5=20?= =?UTF-8?q?(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/usePresentations.ts | 8 -------- src/hooks/queries/useSlides.ts | 17 ++--------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index d86c21d9..234bf9d1 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -5,7 +5,6 @@ * @description 프로젝트 조회 훅 */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; import { queryKeys } from '@/api'; import type { UpdateProjectDto } from '@/api/dto'; @@ -15,7 +14,6 @@ import { getPresentation, updatePresentation, } from '@/api/endpoints/presentations'; -import { MAX_RETRIES } from '@/api/queryClient'; import type { Presentation } from '@/types/presentation'; import { showToast } from '@/utils/toast'; @@ -33,12 +31,6 @@ export function usePresentation(projectId: string) { queryKey: queryKeys.presentations.detail(projectId), queryFn: () => getPresentation(projectId), enabled: !!projectId, - retry: (failureCount, error) => { - if (isAxiosError(error) && error.response?.status === 404) { - return false; - } - return failureCount < MAX_RETRIES; - }, }); } diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 3548e204..18ccccb4 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -2,11 +2,10 @@ * 슬라이드 관련 TanStack Query 훅 */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; import type { UpdateSlideTitleRequestDto } from '@/api/dto'; import { getSlides, updateSlide } from '@/api/endpoints/slides'; -import { MAX_RETRIES, queryKeys } from '@/api/queryClient'; +import { queryKeys } from '@/api/queryClient'; /** 슬라이드 목록 조회 */ export function useSlides(projectId: string) { @@ -14,21 +13,9 @@ export function useSlides(projectId: string) { queryKey: queryKeys.slides.list(projectId), queryFn: () => getSlides(projectId), enabled: !!projectId, - retry: (failureCount, error) => { - if (isAxiosError(error) && error.response?.status === 404) { - return false; - } - return failureCount < MAX_RETRIES; - }, // 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가 // TODO: 서버에서 broadcastNewComment 호출 후 제거 - refetchInterval: (query) => { - const error = query.state.error; - if (isAxiosError(error) && error.response?.status === 404) { - return false; - } - return 3000; - }, // 3초마다 자동 갱신 + refetchInterval: 3000, // 3초마다 자동 갱신 refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤 }); } From 22882e729340c9657a4e2fdd9c50e4e38d1858c3 Mon Sep 17 00:00:00 2001 From: sandy Date: Mon, 9 Feb 2026 20:38:43 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useInsightPageModel.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index a8c2984d..524d539f 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -2,6 +2,12 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; +import type { + SlideAnalyticsDto, + SlideRetentionDto, + VideoExitAnalyticsDto, + VideoRetentionDto, +} from '@/api/dto/analytics.dto'; import type { ChartDataPoint, InsightModel, InsightTopSlide } from '@/components/insight/types'; import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; import { useSlides } from '@/hooks/queries/useSlides'; @@ -12,7 +18,7 @@ import { useSlideRetention, useVideoAnalytics, useVideoRetention, -} from '@/hooks/useAnalytics'; +} from '@/hooks/useAnalytics.ts'; import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; import type { SlideListItem } from '@/types/slide'; import { formatVideoTimestamp } from '@/utils/format'; @@ -92,7 +98,7 @@ export function useInsightPageModel(): InsightModel { // ---- Top slides ---- const topSlides = useMemo(() => { - const analyticsSlides = slideAnalytics?.slides ?? []; + const analyticsSlides: SlideAnalyticsDto[] = slideAnalytics?.slides ?? []; if (!analyticsSlides.length) return []; const { slideIndexById, slideById } = slideDataMaps; @@ -131,7 +137,7 @@ export function useInsightPageModel(): InsightModel { }, [slides]); const dropOffSlides: DropOffSlide[] = useMemo(() => { - const items = slideAnalytics?.slides ?? []; + const items: SlideAnalyticsDto[] = slideAnalytics?.slides ?? []; return items .slice() .sort((a, b) => b.exitCount - a.exitCount) @@ -155,7 +161,7 @@ export function useInsightPageModel(): InsightModel { }, [slideAnalytics]); const dropOffTimes: DropOffTime[] = useMemo(() => { - const items = videoExitAnalytics?.exits ?? []; + const items: VideoExitAnalyticsDto[] = videoExitAnalytics?.exits ?? []; return items .slice() .sort((a, b) => b.exitCount - a.exitCount) @@ -182,7 +188,7 @@ export function useInsightPageModel(): InsightModel { const videoChartData = useMemo(() => { if (!videoRetentionRes?.videoRetention) return []; - return videoRetentionRes.videoRetention.map((item) => ({ + return videoRetentionRes.videoRetention.map((item: VideoRetentionDto) => ({ label: formatVideoTimestamp(item.timestampMs / 1000), // x축: 00:00 value: Math.round(normalizeRate(item.retentionRate)), // y축: 0~100% tooltipTitle: formatVideoTimestamp(item.timestampMs / 1000), @@ -193,7 +199,7 @@ export function useInsightPageModel(): InsightModel { const slideChartData = useMemo(() => { if (!slideRetentionRes?.slideRetention) return []; - return slideRetentionRes.slideRetention.map((item) => ({ + return slideRetentionRes.slideRetention.map((item: SlideRetentionDto) => ({ label: `S${item.slideNum}`, // x축: S1, S2 value: Math.round(normalizeRate(item.retentionRate)), tooltipTitle: item.title || `슬라이드 ${item.slideNum}`, // 툴팁: 제목 From fddb03a5d5b1443518c887d0a24b0aaf5b684ff9 Mon Sep 17 00:00:00 2001 From: sandy Date: Mon, 9 Feb 2026 20:45:39 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=9E=AC=EC=88=98=EC=A0=95=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useInsightPageModel.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index 524d539f..b7db19ba 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -9,8 +9,6 @@ import type { VideoRetentionDto, } from '@/api/dto/analytics.dto'; import type { ChartDataPoint, InsightModel, InsightTopSlide } from '@/components/insight/types'; -import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; -import { useSlides } from '@/hooks/queries/useSlides'; import { useProjectAnalyticsSummary, useRecentComments, @@ -18,7 +16,9 @@ import { useSlideRetention, useVideoAnalytics, useVideoRetention, -} from '@/hooks/useAnalytics.ts'; +} from '@/hooks/queries/useAnalytics'; +import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; +import { useSlides } from '@/hooks/queries/useSlides'; import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; import type { SlideListItem } from '@/types/slide'; import { formatVideoTimestamp } from '@/utils/format';