From c2aaf91c71f373ea1898ed72ae0761ea1afbec03 Mon Sep 17 00:00:00 2001 From: Box Date: Tue, 24 Mar 2026 13:27:37 +0800 Subject: [PATCH 1/2] refactor(home): remove callchain panel and localize dashboard copy --- app/page.tsx | 4 + components/home-comment-analytics.tsx | 147 +++++++++++++++++++ lib/database/get-home-comment-analytics.ts | 156 +++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 components/home-comment-analytics.tsx create mode 100644 lib/database/get-home-comment-analytics.ts diff --git a/app/page.tsx b/app/page.tsx index c04688b..dc3b824 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { @@ -108,6 +109,9 @@ function HomePage() { + + +
diff --git a/components/home-comment-analytics.tsx b/components/home-comment-analytics.tsx new file mode 100644 index 0000000..b6722da --- /dev/null +++ b/components/home-comment-analytics.tsx @@ -0,0 +1,147 @@ +import { ReactNode } from 'react'; +import { BarChart3, CalendarDays, GraduationCap, UserRound } 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 ( + +
+
+ {icon} + {title} +
+ 最近 12 個區間 / Last 12 periods +
+
+ {points.map((point) => ( +
+ {point.period} +
+
+
+ {point.count} +
+ ))} +
+ + ); +}; + +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 ( + +
+ {icon} + {title} +
+ + {points.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {points.map((point) => ( +
+
+ {point.name} + {point.count} 則 / items +
+
+
+
+
+ ))} +
+ )} + + ); +}; + +/** + * 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 ( +
+
+
+
+

評論增長統計看板 / Comment Growth Dashboard

+

+ 按週 / 按月展示新增評論趨勢,並分別統計老師與課程的新增貢獻。 / Weekly & monthly new-comment trends with separate teacher/course contribution rankings. +

+
+
+ + 資料更新時間 / Updated at: {new Date(analytics.generatedAt).toLocaleString('zh-CN', { hour12: false })} +
+
+ +
+ } points={analytics.weeklyTrend} /> + } points={analytics.monthlyTrend} /> + } + points={analytics.weeklyTeacherTop} + emptyText="最近 7 天暫無老師新增評論資料 / No teacher data in last 7 days" + /> + } + points={analytics.monthlyTeacherTop} + emptyText="最近 30 天暫無老師新增評論資料 / No teacher data in last 30 days" + /> + } + points={analytics.weeklyCourseTop} + emptyText="最近 7 天暫無課程新增評論資料 / No course data in last 7 days" + /> + } + points={analytics.monthlyCourseTop} + emptyText="最近 30 天暫無課程新增評論資料 / No course data in last 30 days" + /> +
+ + +
+
+ ); +} diff --git a/lib/database/get-home-comment-analytics.ts b/lib/database/get-home-comment-analytics.ts new file mode 100644 index 0000000..69a28da --- /dev/null +++ b/lib/database/get-home-comment-analytics.ts @@ -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, 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 => { + 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(); + for (const row of courseRefData as CourseRefRow[]) { + courseRefMap.set(row.id, row); + } + + const weeklyCounter = new Map(); + const monthlyCounter = new Map(); + const weeklyTeacherCounter = new Map(); + const monthlyTeacherCounter = new Map(); + const weeklyCourseCounter = new Map(); + const monthlyCourseCounter = new Map(); + + 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'] +}); From 8bf6b46de057b0339b670110d642dc40fca593ce Mon Sep 17 00:00:00 2001 From: Box Date: Tue, 24 Mar 2026 13:39:42 +0800 Subject: [PATCH 2/2] fix(ui): replace unsupported UserRound icon with User --- components/home-comment-analytics.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/home-comment-analytics.tsx b/components/home-comment-analytics.tsx index b6722da..cf84214 100644 --- a/components/home-comment-analytics.tsx +++ b/components/home-comment-analytics.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { BarChart3, CalendarDays, GraduationCap, UserRound } from 'lucide-react'; +import { BarChart3, CalendarDays, GraduationCap, User } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { getHomeCommentAnalytics } from '@/lib/database/get-home-comment-analytics'; @@ -116,13 +116,13 @@ export default async function HomeCommentAnalytics() { } points={analytics.monthlyTrend} /> } + icon={} points={analytics.weeklyTeacherTop} emptyText="最近 7 天暫無老師新增評論資料 / No teacher data in last 7 days" /> } + icon={} points={analytics.monthlyTeacherTop} emptyText="最近 30 天暫無老師新增評論資料 / No teacher data in last 30 days" />