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 e26493a..203d9d5 100644
--- a/src/i18n/translations.ts
+++ b/src/i18n/translations.ts
@@ -129,6 +129,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
@@ -305,6 +322,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: "비즈니스 흐름을 제품 기능으로 만들고, 안정적으로 확장되는 서비스 구조까지 설계하는 백엔드 엔지니어입니다.",
@@ -481,6 +515,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.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 */}