This commit is contained in:
@@ -29,10 +29,17 @@ import {
|
||||
Category,
|
||||
DashboardPostStat,
|
||||
DashboardRange,
|
||||
DashboardTrafficPoint,
|
||||
PageMeta,
|
||||
Post,
|
||||
} from '@/types';
|
||||
|
||||
const dashboardRangeDays: Record<DashboardRange, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
const countCategories = (categories: Category[] = []): number => {
|
||||
return categories.reduce((count, category) => {
|
||||
return count + 1 + countCategories(category.children || []);
|
||||
@@ -89,6 +96,46 @@ const toPostStat = (post: Post): DashboardPostStat => ({
|
||||
createdAt: post.createdAt,
|
||||
});
|
||||
|
||||
const formatKstDateKey = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getFallbackTraffic = (
|
||||
posts: Post[],
|
||||
range: DashboardRange,
|
||||
): DashboardTrafficPoint[] => {
|
||||
const days = dashboardRangeDays[range];
|
||||
const today = new Date();
|
||||
const points = Array.from({ length: days }, (_, index) => {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - (days - 1 - index));
|
||||
|
||||
return {
|
||||
date: formatKstDateKey(date),
|
||||
views: 0,
|
||||
};
|
||||
});
|
||||
const pointMap = new Map(points.map((point) => [point.date, point]));
|
||||
|
||||
posts.forEach((post) => {
|
||||
const createdAt = new Date(post.createdAt);
|
||||
if (Number.isNaN(createdAt.getTime())) return;
|
||||
|
||||
const dateKey = formatKstDateKey(createdAt);
|
||||
const point = pointMap.get(dateKey);
|
||||
if (!point) return;
|
||||
|
||||
point.views += post.viewCount;
|
||||
});
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
function RecentPostRow({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
@@ -249,6 +296,11 @@ export default function AdminPage() {
|
||||
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
|
||||
[dashboard?.topPosts, popularData?.content],
|
||||
);
|
||||
const fallbackTraffic = useMemo(
|
||||
() => getFallbackTraffic([...(latestData?.content ?? []), ...(popularData?.content ?? [])], range),
|
||||
[latestData?.content, popularData?.content, range],
|
||||
);
|
||||
const trafficPoints = dashboard?.traffic?.length ? dashboard.traffic : fallbackTraffic;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -301,10 +353,10 @@ export default function AdminPage() {
|
||||
/>
|
||||
|
||||
<AdminDashboardTrafficChart
|
||||
points={dashboard?.traffic ?? []}
|
||||
points={trafficPoints}
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
isFallback={isFallback}
|
||||
isFallback={isFallback || !dashboard?.traffic?.length}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function AdminDashboardTrafficChart({
|
||||
isFallback,
|
||||
}: AdminDashboardTrafficChartProps) {
|
||||
const maxViews = Math.max(...points.map((point) => point.views), 1);
|
||||
const hasTraffic = points.some((point) => point.views > 0);
|
||||
|
||||
return (
|
||||
<Surface as="section" className="p-5">
|
||||
@@ -44,7 +45,9 @@ export default function AdminDashboardTrafficChart({
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">트래픽 흐름</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
최근 기간별 조회수 흐름을 확인합니다.
|
||||
{isFallback
|
||||
? '대시보드 집계 전에는 게시글 누적 조회수로 임시 흐름을 표시합니다.'
|
||||
: '최근 기간별 조회수 흐름을 확인합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
@@ -56,24 +59,37 @@ export default function AdminDashboardTrafficChart({
|
||||
</div>
|
||||
|
||||
{points.length > 0 ? (
|
||||
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
|
||||
{points.map((point) => {
|
||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||
<>
|
||||
<div className="h-64 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
|
||||
<div className="flex h-full min-w-full items-stretch gap-1.5">
|
||||
{points.map((point) => {
|
||||
const height = Math.max((point.views / maxViews) * 100, 4);
|
||||
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||
/>
|
||||
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||
{formatDay(point.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
|
||||
<div className="flex min-h-0 w-full flex-1 items-end">
|
||||
<div
|
||||
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
|
||||
style={{ height: `${height}%` }}
|
||||
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden h-4 text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
|
||||
{formatDay(point.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{isFallback && (
|
||||
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{hasTraffic
|
||||
? '임시 그래프는 글 발행일과 누적 조회수를 기준으로 계산됩니다.'
|
||||
: '집계 API가 연결되면 실제 일별 조회수로 대체됩니다.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="통계 API 연결 전"
|
||||
|
||||
Reference in New Issue
Block a user