milestone 9
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { logout } from '@/actions/auth'
|
||||
import { getStatistics, getLearningSessions } from '@/actions/progress'
|
||||
import Link from 'next/link'
|
||||
|
||||
async function logoutAction() {
|
||||
@@ -18,6 +19,14 @@ export default async function DashboardPage() {
|
||||
|
||||
const user = session.user as any
|
||||
|
||||
// Get dashboard statistics
|
||||
const statsResult = await getStatistics()
|
||||
const stats = statsResult.success ? statsResult.data : null
|
||||
|
||||
// Get recent learning sessions
|
||||
const sessionsResult = await getLearningSessions(5)
|
||||
const recentSessions = sessionsResult.success ? sessionsResult.data : []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
@@ -39,6 +48,12 @@ export default async function DashboardPage() {
|
||||
>
|
||||
Search Hanzi
|
||||
</Link>
|
||||
<Link
|
||||
href="/progress"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Progress
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
@@ -95,10 +110,10 @@ export default async function DashboardPage() {
|
||||
Due Cards
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
||||
0
|
||||
{stats?.dueNow || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No cards due right now
|
||||
{stats?.dueNow === 0 ? "No cards due right now" : `${stats?.dueToday || 0} due today`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -107,10 +122,10 @@ export default async function DashboardPage() {
|
||||
Total Learned
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
|
||||
0
|
||||
{stats?.totalLearned || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Characters mastered
|
||||
{stats?.streak ? `${stats.streak} day streak!` : "Characters in progress"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +134,7 @@ export default async function DashboardPage() {
|
||||
Daily Goal
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
|
||||
0/50
|
||||
{stats?.reviewedToday || 0}/{stats?.dailyGoal || 50}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Cards reviewed today
|
||||
@@ -167,6 +182,52 @@ export default async function DashboardPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{recentSessions && recentSessions.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<Link
|
||||
href="/progress"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{recentSessions.map((session) => (
|
||||
<div key={session.id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{session.collectionName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{session.cardsReviewed} cards • {session.accuracyPercent}% accuracy
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(session.startedAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{new Date(session.startedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
316
src/app/(app)/progress/page.tsx
Normal file
316
src/app/(app)/progress/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
import { getUserProgress, getLearningSessions } from "@/actions/progress"
|
||||
|
||||
export default function ProgressPage() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [progressData, setProgressData] = useState<any>(null)
|
||||
const [sessions, setSessions] = useState<any[]>([])
|
||||
const [dateRange, setDateRange] = useState("30") // days
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress()
|
||||
}, [dateRange])
|
||||
|
||||
const loadProgress = async () => {
|
||||
setLoading(true)
|
||||
|
||||
const days = parseInt(dateRange)
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - days)
|
||||
|
||||
const [progressResult, sessionsResult] = await Promise.all([
|
||||
getUserProgress(startDate, endDate),
|
||||
getLearningSessions(20),
|
||||
])
|
||||
|
||||
if (progressResult.success) {
|
||||
setProgressData(progressResult.data)
|
||||
}
|
||||
|
||||
if (sessionsResult.success) {
|
||||
setSessions(sessionsResult.data || [])
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading progress...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = progressData?.dailyActivity || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<Link href="/dashboard">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white cursor-pointer">
|
||||
MemoHanzi <span className="text-sm font-normal text-gray-500">记汉字</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
<Link
|
||||
href="/hanzi"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Search Hanzi
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Learning Progress
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Track your learning journey and review statistics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mr-3">
|
||||
Time Period:
|
||||
</label>
|
||||
<select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="365">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Cards Reviewed
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{progressData?.cardsReviewed || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Accuracy
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||
{progressData?.accuracyPercent || 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Total Cards
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{progressData?.totalCards || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Avg Session
|
||||
</h3>
|
||||
<p className="text-3xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{progressData?.averageSessionLength || 0}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Activity Chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Daily Activity
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#9CA3AF"
|
||||
tick={{ fill: '#9CA3AF' }}
|
||||
/>
|
||||
<YAxis stroke="#9CA3AF" tick={{ fill: '#9CA3AF' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#F3F4F6'
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#9CA3AF' }} />
|
||||
<Bar dataKey="correct" fill="#10B981" name="Correct" stackId="a" />
|
||||
<Bar dataKey="incorrect" fill="#EF4444" name="Incorrect" stackId="a" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accuracy Trend Chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Accuracy Trend
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#9CA3AF"
|
||||
tick={{ fill: '#9CA3AF' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9CA3AF"
|
||||
tick={{ fill: '#9CA3AF' }}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#F3F4F6'
|
||||
}}
|
||||
formatter={(value: any, name: any) => {
|
||||
if (name === "Accuracy") return [`${value.toFixed(1)}%`, name]
|
||||
return [value, name]
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#9CA3AF' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={(data: any) => {
|
||||
const total = data.correct + data.incorrect
|
||||
return total > 0 ? (data.correct / total) * 100 : 0
|
||||
}}
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={2}
|
||||
name="Accuracy"
|
||||
dot={{ fill: '#3B82F6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session History */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Recent Sessions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Collection
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cards
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Correct
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Incorrect
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Accuracy
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No learning sessions yet. Start learning to see your progress!
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr key={session.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{new Date(session.startedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{session.collectionName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{session.cardsReviewed}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
|
||||
{session.correctAnswers}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-600 dark:text-red-400">
|
||||
{session.incorrectAnswers}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{session.accuracyPercent}%
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user