Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CommentBank from "@/components/comment-bank";
import { Card } from "@/components/ui/card";
import SearchComp from "@/components/search";
import BbsUpdates from "@/components/bbs-updates";
import HomeCommentAnalytics from "@/components/home-comment-analytics";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";

function HomePage() {
Expand Down Expand Up @@ -108,6 +109,9 @@ function HomePage() {
</div>
</div>


<HomeCommentAnalytics />

<div className='max-w-screen-xl mx-auto p-4'>
<div className="py-8">
<div className="text-center text-2xl font-bold pb-8">
Expand Down
147 changes: 147 additions & 0 deletions components/home-comment-analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { ReactNode } from 'react';
import { BarChart3, CalendarDays, GraduationCap, User } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { getHomeCommentAnalytics } from '@/lib/database/get-home-comment-analytics';

const TrendBars = ({
title,
icon,
points
}: {
title: string;
icon: ReactNode;
points: { period: string; count: number }[];
}) => {
const max = Math.max(...points.map((point) => point.count), 1);

return (
<Card className="rounded-2xl border-zinc-200/70 bg-white/90 p-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900/90">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-lg font-semibold">
{icon}
{title}
</div>
<span className="text-xs text-zinc-500">最近 12 個區間 / Last 12 periods</span>
</div>
<div className="space-y-2">
{points.map((point) => (
<div key={point.period} className="grid grid-cols-[90px_1fr_42px] items-center gap-2 text-sm">
<span className="text-zinc-500">{point.period}</span>
<div className="h-2 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-500 via-cyan-500 to-emerald-500"
style={{ width: `${Math.max((point.count / max) * 100, 4)}%` }}
/>
</div>
<span className="text-right font-semibold">{point.count}</span>
</div>
))}
</div>
</Card>
);
};

const RankBars = ({
title,
icon,
points,
emptyText
}: {
title: string;
icon: ReactNode;
points: { name: string; count: number }[];
emptyText: string;
}) => {
const max = Math.max(...points.map((point) => point.count), 1);

return (
<Card className="rounded-2xl border-zinc-200/70 bg-white/90 p-5 shadow-lg dark:border-zinc-700 dark:bg-zinc-900/90">
<div className="mb-4 flex items-center gap-2 text-lg font-semibold">
{icon}
{title}
</div>

{points.length === 0 ? (
<div className="rounded-lg bg-zinc-100 p-4 text-sm text-zinc-500 dark:bg-zinc-800">{emptyText}</div>
) : (
<div className="space-y-3">
{points.map((point) => (
<div key={point.name} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{point.name}</span>
<span className="text-zinc-500">{point.count} 則 / items</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"
style={{ width: `${Math.max((point.count / max) * 100, 6)}%` }}
/>
</div>
</div>
))}
</div>
)}
</Card>
);
};

/**
* Home page statistics section.
*
* This section renders:
* - weekly/monthly new-comment trend
* - top teachers and courses by recent new comments
*/
export default async function HomeCommentAnalytics() {
const analytics = await getHomeCommentAnalytics();

return (
<section className="mx-auto mt-12 max-w-screen-xl px-4">
<div className="overflow-hidden rounded-3xl border border-zinc-200/80 bg-gradient-to-br from-white via-sky-50/50 to-indigo-100/40 p-6 shadow-xl dark:border-zinc-700 dark:from-zinc-900 dark:via-zinc-900 dark:to-zinc-800">
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">評論增長統計看板 / Comment Growth Dashboard</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-300">
按週 / 按月展示新增評論趨勢,並分別統計老師與課程的新增貢獻。 / Weekly & monthly new-comment trends with separate teacher/course contribution rankings.
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-zinc-900 px-3 py-1 text-xs text-white dark:bg-zinc-100 dark:text-zinc-900">
<BarChart3 size={14} />
資料更新時間 / Updated at: {new Date(analytics.generatedAt).toLocaleString('zh-CN', { hour12: false })}
</div>
</div>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<TrendBars title="每週新增評論趨勢 / Weekly New Comments" icon={<CalendarDays size={18} />} points={analytics.weeklyTrend} />
<TrendBars title="每月新增評論趨勢 / Monthly New Comments" icon={<CalendarDays size={18} />} points={analytics.monthlyTrend} />
<RankBars
title="最近 7 天:老師新增評論 Top / Last 7 Days Teacher Top"
icon={<User size={18} />}
points={analytics.weeklyTeacherTop}
emptyText="最近 7 天暫無老師新增評論資料 / No teacher data in last 7 days"
/>
<RankBars
title="最近 30 天:老師新增評論 Top / Last 30 Days Teacher Top"
icon={<User size={18} />}
points={analytics.monthlyTeacherTop}
emptyText="最近 30 天暫無老師新增評論資料 / No teacher data in last 30 days"
/>
<RankBars
title="最近 7 天:課程新增評論 Top / Last 7 Days Course Top"
icon={<GraduationCap size={18} />}
points={analytics.weeklyCourseTop}
emptyText="最近 7 天暫無課程新增評論資料 / No course data in last 7 days"
/>
<RankBars
title="最近 30 天:課程新增評論 Top / Last 30 Days Course Top"
icon={<GraduationCap size={18} />}
points={analytics.monthlyCourseTop}
emptyText="最近 30 天暫無課程新增評論資料 / No course data in last 30 days"
/>
</div>


</div>
</section>
);
}
156 changes: 156 additions & 0 deletions lib/database/get-home-comment-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { unstable_cache } from 'next/cache';
import supabase from '@/lib/database/database';

type CommentRow = {
course_id: number;
pub_time: string;
};

type CourseRefRow = {
id: number;
course_id: string;
prof_id: string;
};

type TimePoint = {
period: string;
count: number;
};

type RankPoint = {
name: string;
count: number;
};

export type HomeCommentAnalytics = {
weeklyTrend: TimePoint[];
monthlyTrend: TimePoint[];
weeklyTeacherTop: RankPoint[];
monthlyTeacherTop: RankPoint[];
weeklyCourseTop: RankPoint[];
monthlyCourseTop: RankPoint[];
generatedAt: string;
};

/**
* Build a UTC week bucket key in `YYYY-Www` format.
*/
const toWeekKey = (isoDate: string): string => {
const date = new Date(isoDate);
const startOfYear = Date.UTC(date.getUTCFullYear(), 0, 1);
const dayIndex = Math.floor((date.getTime() - startOfYear) / 86400000);
const week = Math.floor(dayIndex / 7) + 1;
return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
};

/**
* Build a UTC month bucket key in `YYYY-MM` format.
*/
const toMonthKey = (isoDate: string): string => {
const date = new Date(isoDate);
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
};

const summarizeTop = (counter: Map<string, number>, size: number): RankPoint[] => {
return [...counter.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, size)
.map(([name, count]) => ({ name, count }));
};

/**
* Query and aggregate homepage comment analytics.

*
* Performance notes:
* - bounded time window (last 365 days)
* - select only required columns
* - deduplicated id lookup for relation table
* - wrapped by `unstable_cache` (30 min) to reduce repeated DB workload
*/
const getHomeCommentAnalyticsUncached = async (): Promise<HomeCommentAnalytics> => {
const now = new Date();
const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString();
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();

const { data: commentsData, error: commentsError } = await supabase
.from('comment')
.select('course_id,pub_time')
.gte('pub_time', oneYearAgo)
.neq('hidden', 1)
.is('replyto', null)
.order('pub_time', { ascending: true });

if (commentsError || !commentsData) {
throw new Error(`Failed to fetch comments analytics: ${commentsError?.message ?? 'unknown error'}`);
}

const comments = commentsData as CommentRow[];
const courseRefIds = [...new Set(comments.map((row) => row.course_id))];

const { data: courseRefData, error: courseRefError } = await supabase
.from('prof_with_course')
.select('id,course_id,prof_id')
.in('id', courseRefIds);

if (courseRefError || !courseRefData) {
throw new Error(`Failed to fetch course mapping: ${courseRefError?.message ?? 'unknown error'}`);
}

const courseRefMap = new Map<number, CourseRefRow>();
for (const row of courseRefData as CourseRefRow[]) {
courseRefMap.set(row.id, row);
}

const weeklyCounter = new Map<string, number>();
const monthlyCounter = new Map<string, number>();
const weeklyTeacherCounter = new Map<string, number>();
const monthlyTeacherCounter = new Map<string, number>();
const weeklyCourseCounter = new Map<string, number>();
const monthlyCourseCounter = new Map<string, number>();

for (const row of comments) {
const weekKey = toWeekKey(row.pub_time);
const monthKey = toMonthKey(row.pub_time);

weeklyCounter.set(weekKey, (weeklyCounter.get(weekKey) ?? 0) + 1);
monthlyCounter.set(monthKey, (monthlyCounter.get(monthKey) ?? 0) + 1);

const courseRef = courseRefMap.get(row.course_id);
if (!courseRef) continue;

if (row.pub_time >= oneWeekAgo) {
weeklyTeacherCounter.set(courseRef.prof_id, (weeklyTeacherCounter.get(courseRef.prof_id) ?? 0) + 1);
weeklyCourseCounter.set(courseRef.course_id, (weeklyCourseCounter.get(courseRef.course_id) ?? 0) + 1);
}

if (row.pub_time >= oneMonthAgo) {
monthlyTeacherCounter.set(courseRef.prof_id, (monthlyTeacherCounter.get(courseRef.prof_id) ?? 0) + 1);
monthlyCourseCounter.set(courseRef.course_id, (monthlyCourseCounter.get(courseRef.course_id) ?? 0) + 1);
}
}

const weeklyTrend = [...weeklyCounter.entries()]
.slice(-12)
.map(([period, count]) => ({ period, count }));

const monthlyTrend = [...monthlyCounter.entries()]
.slice(-12)
.map(([period, count]) => ({ period, count }));

return {
weeklyTrend,
monthlyTrend,
weeklyTeacherTop: summarizeTop(weeklyTeacherCounter, 8),
monthlyTeacherTop: summarizeTop(monthlyTeacherCounter, 8),
weeklyCourseTop: summarizeTop(weeklyCourseCounter, 8),
monthlyCourseTop: summarizeTop(monthlyCourseCounter, 8),
generatedAt: now.toISOString()
};
};

export const getHomeCommentAnalytics = unstable_cache(getHomeCommentAnalyticsUncached, ['home-comment-analytics-v1'], {
revalidate: 1800,
tags: ['home-comment-analytics']
});