From dc8cc27c3c59a2aa1adf5f312d36c31cdf96ed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Tue, 23 Jun 2026 09:11:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=EB=B6=84=EC=84=9D=ED=83=AD?= =?UTF-8?q?=20=EC=9D=B8=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DailyVisitsChart.tsx | 41 ++-- src/i18n/translations.ts | 51 +++++ src/lib/analytics-data.ts | 62 ++++++ src/pages/AnalyticsPage.tsx | 307 ++++++++++++++++++++++++++-- 4 files changed, 415 insertions(+), 46 deletions(-) create mode 100644 src/lib/analytics-data.ts diff --git a/src/components/DailyVisitsChart.tsx b/src/components/DailyVisitsChart.tsx index 29272bf..87ef3f9 100644 --- a/src/components/DailyVisitsChart.tsx +++ b/src/components/DailyVisitsChart.tsx @@ -7,8 +7,9 @@ import { type ChartConfig, } from "@/components/ui/chart" import { Skeleton } from "@/components/ui/skeleton" -import { usePageViews, type DailyData } from "@/hooks/usePageViews" +import type { DailyData } from "@/hooks/usePageViews" import { useT } from "@/i18n" +import { buildDailySeries, sumViews } from "@/lib/analytics-data" const chartConfig = { views: { @@ -17,31 +18,23 @@ const chartConfig = { }, } satisfies ChartConfig -function generateLast30Days(): DailyData[] { - const days: DailyData[] = [] - const today = new Date() - for (let i = 29; i >= 0; i--) { - const d = new Date(today) - d.setDate(d.getDate() - i) - const yyyy = d.getFullYear() - const mm = String(d.getMonth() + 1).padStart(2, "0") - const dd = String(d.getDate()).padStart(2, "0") - days.push({ date: `${yyyy}-${mm}-${dd}`, views: 0 }) - } - return days -} - -function mergeData(base: DailyData[], fetched: DailyData[]): DailyData[] { - const map = new Map(fetched.map((d) => [d.date, d.views])) - return base.map((d) => ({ ...d, views: map.get(d.date) ?? 0 })) +interface DailyVisitsChartProps { + totalViews: number | null + daily: DailyData[] + isLoading: boolean + rangeDays?: number } -export function DailyVisitsChart() { - const { totalViews, daily, isLoading } = usePageViews() +export function DailyVisitsChart({ totalViews, daily, isLoading, rangeDays = 30 }: DailyVisitsChartProps) { const t = useT() - const data = mergeData(generateLast30Days(), daily) - const monthlyTotal = data.reduce((sum, d) => sum + d.views, 0) + const data = buildDailySeries(rangeDays, daily) + const rangeTotal = sumViews(data) + const rangeLabel = rangeDays === 7 + ? t.analytics.range7d + : rangeDays === 14 + ? t.analytics.range14d + : t.analytics.range30d if (isLoading) { return ( @@ -77,10 +70,10 @@ export function DailyVisitsChart() {

- {t.components.last30Days} + {rangeLabel}

- {monthlyTotal.toLocaleString()} + {rangeTotal.toLocaleString()}

diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index f2ebea5..f6addb3 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -93,6 +93,23 @@ export interface Translations { loadError: string showingCachedData: string refresh: string + range7d: string + range14d: string + range30d: string + trafficMomentum: string + viewsInRange: string + avgDailyViews: string + peakDay: string + periodChange: string + compareUnavailable: string + contentInsights: string + fastestPosts: string + tagPerformance: string + seriesPerformance: string + viewsPerDay: string + averageViews: string + allTimeBasis: string + noSeriesData: string } about: { description: string @@ -233,6 +250,23 @@ export const ko: Translations = { loadError: "조회수 데이터를 불러올 수 없습니다", showingCachedData: "캐시된 데이터를 표시합니다", refresh: "새로고침", + range7d: "7일", + range14d: "14일", + range30d: "30일", + trafficMomentum: "트래픽 모멘텀", + viewsInRange: "선택 기간 조회수", + avgDailyViews: "일평균 조회수", + peakDay: "최고 유입일", + periodChange: "이전 기간 대비", + compareUnavailable: "비교 데이터 부족", + contentInsights: "콘텐츠 인사이트", + fastestPosts: "빠르게 읽히는 글", + tagPerformance: "태그별 성과", + seriesPerformance: "시리즈별 성과", + viewsPerDay: "일평균", + averageViews: "평균 조회수", + allTimeBasis: "전체 기간 기준", + noSeriesData: "시리즈 데이터 없음", }, about: { description: "비즈니스 흐름을 제품 기능으로 만들고, 안정적으로 확장되는 서비스 구조까지 설계하는 백엔드 엔지니어입니다.", @@ -373,6 +407,23 @@ export const en: Translations = { loadError: "Could not load view data", showingCachedData: "Showing cached data", refresh: "Refresh", + range7d: "7 days", + range14d: "14 days", + range30d: "30 days", + trafficMomentum: "Traffic Momentum", + viewsInRange: "Views in range", + avgDailyViews: "Daily average", + peakDay: "Peak day", + periodChange: "vs previous period", + compareUnavailable: "Not enough comparison data", + contentInsights: "Content Insights", + fastestPosts: "Fastest Posts", + tagPerformance: "Tag Performance", + seriesPerformance: "Series Performance", + viewsPerDay: "views/day", + averageViews: "Avg views", + allTimeBasis: "All-time basis", + noSeriesData: "No series data", }, about: { description: "Backend engineer who turns business flows into product features and designs service structures that scale reliably.", diff --git a/src/lib/analytics-data.ts b/src/lib/analytics-data.ts new file mode 100644 index 0000000..1c7aa8d --- /dev/null +++ b/src/lib/analytics-data.ts @@ -0,0 +1,62 @@ +import type { DailyData } from "@/hooks/usePageViews" + +export interface DailySeriesPoint extends DailyData { + label: string +} + +function formatDateKey(date: Date): string { + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, "0") + const dd = String(date.getDate()).padStart(2, "0") + return `${yyyy}-${mm}-${dd}` +} + +export function formatShortDate(dateKey: string): string { + const [, month = "0", day = "0"] = dateKey.split("-") + return `${Number(month)}/${Number(day)}` +} + +export function buildDailySeries(days: number, fetched: DailyData[]): DailySeriesPoint[] { + const map = new Map(fetched.map((d) => [d.date, d.views])) + const today = new Date() + + return Array.from({ length: days }, (_, index) => { + const d = new Date(today) + d.setDate(d.getDate() - (days - 1 - index)) + const date = formatDateKey(d) + + return { + date, + label: formatShortDate(date), + views: map.get(date) ?? 0, + } + }) +} + +export function sumViews(data: Pick[]): number { + return data.reduce((sum, d) => sum + d.views, 0) +} + +export function averageViews(data: Pick[]): number { + if (data.length === 0) return 0 + return sumViews(data) / data.length +} + +export function getPeakDay(data: DailySeriesPoint[]): DailySeriesPoint | null { + if (data.length === 0) return null + return data.reduce((peak, d) => (d.views > peak.views ? d : peak), data[0]!) +} + +export function getPreviousComparableRange(days: number, fetched: DailyData[]): DailySeriesPoint[] { + if (fetched.length < days * 2) return [] + + const sorted = [...fetched].sort((a, b) => a.date.localeCompare(b.date)) + return sorted + .slice(Math.max(0, sorted.length - days * 2), sorted.length - days) + .map((d) => ({ ...d, label: formatShortDate(d.date) })) +} + +export function getPercentChange(current: number, previous: number): number | null { + if (previous <= 0) return null + return ((current - previous) / previous) * 100 +} diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index ca3e7c9..f0cf3b6 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,7 +1,8 @@ -import { useMemo } from "react" +import { useMemo, useState } from "react" import { Link } from "react-router-dom" -import { AlertCircle, Eye, FileText, Library, PenLine, RefreshCw, Tags } from "lucide-react" +import { Activity, AlertCircle, CalendarDays, Eye, FileText, Gauge, Library, PenLine, RefreshCw, Tags, TrendingUp } from "lucide-react" import { Alert, AlertDescription } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Bar, BarChart, CartesianGrid, Label, Pie, PieChart, XAxis, YAxis } from "recharts" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -13,8 +14,10 @@ import { } from "@/components/ui/chart" import { DailyVisitsChart } from "@/components/DailyVisitsChart" import { PageContainer } from "@/components/PageContainer" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { usePageViews } from "@/hooks/usePageViews" import { useMetaTags } from "@/hooks/useMetaTags" +import { averageViews, buildDailySeries, getPeakDay, getPercentChange, getPreviousComparableRange, sumViews } from "@/lib/analytics-data" import { getAllPosts, getAllSeries, getAllTags } from "@/lib/posts" import { useLanguage } from "@/i18n" import { localizePath, postPath } from "@/lib/i18n-routing" @@ -32,30 +35,111 @@ const TAG_COLORS = [ "oklch(0.7 0.15 60)", // amber ] +type RangeDays = 7 | 14 | 30 + +const RANGE_OPTIONS: RangeDays[] = [7, 14, 30] +const DAY_MS = 24 * 60 * 60 * 1000 + +function getPostViews(allPageViews: Record | null, slug: string, language: "ko" | "en") { + if (!allPageViews) return 0 + const localizedPath = language === "en" ? `/en/posts/${slug}` : `/posts/${slug}` + return allPageViews[localizedPath] ?? allPageViews[`/posts/${slug}`] ?? 0 +} + +function getDaysSince(date: string) { + const publishedAt = new Date(`${date}T00:00:00`).getTime() + if (Number.isNaN(publishedAt)) return 1 + return Math.max(1, Math.ceil((Date.now() - publishedAt) / DAY_MS)) +} + +function formatMetric(value: number, fractionDigits = 0) { + return value.toLocaleString(undefined, { + maximumFractionDigits: fractionDigits, + }) +} + +function formatPercent(value: number | null) { + if (value === null) return null + const prefix = value > 0 ? "+" : "" + return `${prefix}${value.toFixed(1)}%` +} + export function AnalyticsPage() { const { language, t } = useLanguage() + const [rangeDays, setRangeDays] = useState(14) useMetaTags({ title: t.common.analytics, description: t.analytics.description, url: localizePath("/analytics", language) }) - const { totalViews, allPageViews, isError, isLoading, lastUpdated, refresh } = usePageViews() + const { totalViews, allPageViews, daily, isError, isLoading, lastUpdated, refresh } = usePageViews() const posts = getAllPosts(language) const series = getAllSeries(language) const tags = getAllTags(language) + const postPerformance = useMemo(() => { + return posts.map((post) => { + const views = getPostViews(allPageViews, post.slug, language) + const daysLive = getDaysSince(post.publishDate ?? post.date) + return { + ...post, + views, + daysLive, + viewsPerDay: views / daysLive, + } + }) + }, [allPageViews, language, posts]) + const popularPosts = useMemo(() => { - if (!allPageViews) return [] - const postsPrefix = language === "en" ? "/en/posts/" : "/posts/" - return Object.entries(allPageViews) - .filter(([path]) => path.startsWith(postsPrefix)) - .map(([path, views]) => { - const slug = path.replace(postsPrefix, "").replace(/\/$/, "") - const post = posts.find((p) => p.slug === slug) - if (!post) return null - return { slug, title: post.title, views } - }) - .filter((p): p is NonNullable => p !== null) + return [...postPerformance] + .filter((post) => post.views > 0) .sort((a, b) => b.views - a.views) .slice(0, 10) - }, [allPageViews, language, posts]) + }, [postPerformance]) + + const fastestPosts = useMemo(() => { + return [...postPerformance] + .filter((post) => post.views > 0) + .sort((a, b) => b.viewsPerDay - a.viewsPerDay || b.views - a.views) + .slice(0, 5) + }, [postPerformance]) + + const tagPerformance = useMemo(() => { + const map = new Map() + for (const post of postPerformance) { + for (const tag of post.tags) { + const current = map.get(tag) ?? { tag, count: 0, views: 0 } + current.count += 1 + current.views += post.views + map.set(tag, current) + } + } + return [...map.values()] + .map((item) => ({ ...item, averageViews: item.views / item.count })) + .sort((a, b) => b.views - a.views || b.averageViews - a.averageViews) + .slice(0, 8) + }, [postPerformance]) + + const seriesPerformance = useMemo(() => { + const map = new Map() + for (const post of postPerformance) { + if (!post.series) continue + const current = map.get(post.series) ?? { series: post.series, count: 0, views: 0 } + current.count += 1 + current.views += post.views + map.set(post.series, current) + } + return [...map.values()] + .map((item) => ({ ...item, averageViews: item.views / item.count })) + .sort((a, b) => b.views - a.views || b.averageViews - a.averageViews) + .slice(0, 5) + }, [postPerformance]) + + const selectedDaily = useMemo(() => buildDailySeries(rangeDays, daily), [daily, rangeDays]) + const previousDaily = useMemo(() => getPreviousComparableRange(rangeDays, daily), [daily, rangeDays]) + const rangeViews = useMemo(() => sumViews(selectedDaily), [selectedDaily]) + const previousRangeViews = useMemo(() => sumViews(previousDaily), [previousDaily]) + const percentChange = formatPercent(getPercentChange(rangeViews, previousRangeViews)) + const peakDay = useMemo(() => getPeakDay(selectedDaily), [selectedDaily]) + const dailyAverage = averageViews(selectedDaily) + const maxTagViews = Math.max(...tagPerformance.map((item) => item.views), 1) const tagDistribution = useMemo(() => { const map = new Map() @@ -124,16 +208,60 @@ export function AnalyticsPage() { : []), ] + const momentumCards = [ + { + label: t.analytics.viewsInRange, + value: formatMetric(rangeViews), + detail: `${rangeDays}${language === "ko" ? "일" : "d"}`, + icon: TrendingUp, + }, + { + label: t.analytics.avgDailyViews, + value: formatMetric(dailyAverage, 1), + detail: t.analytics.views, + icon: Activity, + }, + { + label: t.analytics.peakDay, + value: peakDay ? formatMetric(peakDay.views) : "-", + detail: peakDay?.label ?? t.analytics.noData, + icon: CalendarDays, + }, + { + label: t.analytics.periodChange, + value: percentChange ?? "-", + detail: percentChange ? t.analytics.periodChange : t.analytics.compareUnavailable, + icon: Gauge, + }, + ] + return (

{t.common.analytics}

-
+

{t.analytics.description}

- +
+ { + if (value) setRangeDays(Number(value) as RangeDays) + }} + > + {RANGE_OPTIONS.map((days) => ( + + {days === 7 ? t.analytics.range7d : days === 14 ? t.analytics.range14d : t.analytics.range30d} + + ))} + + +
{isError && ( @@ -149,7 +277,7 @@ export function AnalyticsPage() {
{/* Summary Cards */} -
+
{summaryCards.map((card) => ( @@ -165,7 +293,142 @@ export function AnalyticsPage() { {/* Daily Visits Chart */}
- + +
+ + {/* Traffic Momentum */} +
+
+

+ {t.analytics.trafficMomentum} +

+ {rangeDays === 7 ? t.analytics.range7d : rangeDays === 14 ? t.analytics.range14d : t.analytics.range30d} +
+
+ {momentumCards.map((card) => ( + + +
+

{card.label}

+ +
+

{card.value}

+

{card.detail}

+
+
+ ))} +
+
+ + {/* Content Insights */} +
+
+

+ {t.analytics.contentInsights} +

+ {t.analytics.allTimeBasis} +
+
+ + + + {t.analytics.fastestPosts} + + + + {fastestPosts.length === 0 ? ( +

{t.analytics.noData}

+ ) : ( +
+ {fastestPosts.map((post, i) => ( + + + {i + 1} + + + {post.title} + + {formatMetric(post.viewsPerDay, 1)} {t.analytics.viewsPerDay} + + + + + {post.views.toLocaleString()} + + + ))} +
+ )} +
+
+ + + + + {t.analytics.tagPerformance} + + + + {tagPerformance.length === 0 ? ( +

{t.analytics.noData}

+ ) : ( +
+ {tagPerformance.map((item) => ( +
+
+ {item.tag} + + {formatMetric(item.views)} · {formatMetric(item.averageViews, 1)} {t.analytics.averageViews} + +
+
+
+
+
+ ))} +
+ )} + + + + + + + {t.analytics.seriesPerformance} + + + + {seriesPerformance.length === 0 ? ( +

{t.analytics.noSeriesData}

+ ) : ( +
+ {seriesPerformance.map((item) => ( +
+

{item.series}

+

{formatMetric(item.views)}

+

+ {item.count} {t.analytics.postsCount} · {formatMetric(item.averageViews, 1)} {t.analytics.averageViews} +

+
+ ))} +
+ )} +
+
+
{/* Popular Posts Top 10 */}