Files
cet6-90-day-checkin/app/stats/stats-client.tsx
T

41 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
import { BarChart3, CalendarCheck2, Flame, ListChecks } from "lucide-react";
import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { plan, TaskKey } from "../plan";
type Rec = { doneTasks: string; checkinType?: "normal" | "makeup" };
const groups: { key: TaskKey; name: string; color: string }[] = [
{ key: "vocab", name: "词汇", color: "#2563eb" }, { key: "listening", name: "听力", color: "#7c3aed" }, { key: "reading", name: "阅读", color: "#0f766e" }, { key: "output", name: "输出", color: "#ea580c" }, { key: "review", name: "复盘", color: "#be123c" },
];
const read = (value?: string) => { try { return JSON.parse(value || "[]") as string[]; } catch { return []; } };
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
export default function StatsClient() {
const [records, setRecords] = useState<Record<string, Rec>>({});
const [status, setStatus] = useState("正在读取统计数据…");
useEffect(() => { fetch("/api/progress").then(async r => { const x = await r.json(); if (!r.ok) throw Error(x.error); const all: Record<string, Rec> = {}; x.progress.forEach((p: Rec & { planDate: string }) => all[p.planDate] = p); setRecords(all); setStatus("统计数据已同步"); }).catch(e => setStatus(e.message)); }, []);
const data = useMemo(() => {
const due = plan.filter(day => day.date <= today());
const total = due.reduce((n, day) => n + day.tasks.length, 0);
const completed = due.reduce((n, day) => n + read(records[day.date]?.doneTasks).length, 0);
const fullDays = due.filter(day => read(records[day.date]?.doneTasks).length === day.tasks.length).length;
const makeupDays = due.filter(day => records[day.date]?.checkinType === "makeup" && read(records[day.date]?.doneTasks).length > 0).length;
const types = groups.map(group => { const tasks = due.flatMap(day => day.tasks.filter(task => task.key === group.key)); const done = due.reduce((n, day) => n + (read(records[day.date]?.doneTasks).includes(group.key) ? 1 : 0), 0); return { name: group.name, value: tasks.length ? Math.round(done / tasks.length * 100) : 0, done, total: tasks.length, color: group.color }; });
const weeks = Array.from({ length: Math.ceil(due.length / 7) }, (_, i) => { const days = due.slice(i * 7, i * 7 + 7); const tasks = days.reduce((n, day) => n + day.tasks.length, 0), done = days.reduce((n, day) => n + read(records[day.date]?.doneTasks).length, 0); return { week: `第${i + 1}周`, rate: tasks ? Math.round(done / tasks * 100) : 0 }; });
let streak = 0; for (let i = due.length - 1; i >= 0; i--) { if (read(records[due[i].date]?.doneTasks).length === due[i].tasks.length) streak++; else break; }
return { total, completed, fullDays, makeupDays, types, weeks, streak, normalDays: Math.max(0, fullDays - makeupDays) };
}, [records]);
const rate = data.total ? Math.round(data.completed / data.total * 100) : 0;
return <main><div className="shell stats-page"><header><div><p className="eyebrow">LEARNING INSIGHTS</p><h1>数据统计</h1><p className="sub">查看投入是否稳定,并据此调整下一个阶段。</p></div><a className="overview-link" href="/"><ListChecks size={17} />返回打卡</a></header><p className="overview-status">{status}</p>
<section className="insight-cards"><Insight icon={<BarChart3 />} value={`${rate}%`} label="任务打卡率" detail={`${data.completed} / ${data.total} 项应完成任务`} /><Insight icon={<CalendarCheck2 />} value={`${data.fullDays} 天`} label="完整完成" detail={`其中补打卡 ${data.makeupDays} 天`} /><Insight icon={<Flame />} value={`${data.streak} 天`} label="当前连续完成" detail="连续完整完成才计入" /><Insight icon={<CalendarCheck2 />} value={`${data.normalDays} / ${data.makeupDays}`} label="正常 / 补打卡" detail="完整完成日期的构成" /></section>
<section className="charts"><article className="chart-card"><div><p className="eyebrow">BY TYPE</p><h2>各类型完成率</h2></div><ResponsiveContainer width="100%" height={270}><BarChart data={data.types} margin={{ top: 20, right: 8, left: -18, bottom: 0 }}><CartesianGrid strokeDasharray="3 3" vertical={false} /><XAxis dataKey="name" tickLine={false} axisLine={false} /><YAxis unit="%" domain={[0, 100]} tickLine={false} axisLine={false} /><Tooltip formatter={(value: number, _name, item) => [`${value}%(${item.payload.done}/${item.payload.total})`, "完成率"]} /><Bar dataKey="value" radius={[7, 7, 0, 0]}>{data.types.map(item => <Cell key={item.name} fill={item.color} />)}</Bar></BarChart></ResponsiveContainer></article>
<article className="chart-card"><div><p className="eyebrow">WEEKLY RHYTHM</p><h2>每周完成趋势</h2></div><ResponsiveContainer width="100%" height={270}><LineChart data={data.weeks} margin={{ top: 20, right: 12, left: -18, bottom: 0 }}><CartesianGrid strokeDasharray="3 3" vertical={false} /><XAxis dataKey="week" tickLine={false} axisLine={false} /><YAxis unit="%" domain={[0, 100]} tickLine={false} axisLine={false} /><Tooltip formatter={(value: number) => [`${value}%`, "完成率"]} /><Line type="monotone" dataKey="rate" stroke="#2563eb" strokeWidth={3} dot={{ r: 4, fill: "#2563eb" }} activeDot={{ r: 6 }} /></LineChart></ResponsiveContainer></article>
</section>
<section className="stat-detail"><div><p className="eyebrow">CHECK-IN QUALITY</p><h2>打卡方式</h2><p>正常打卡反映当天节奏;补打卡会保留实际保存时间,以便你更准确地判断复习节奏。</p></div><ResponsiveContainer width={260} height={170}><PieChart><Pie data={[{ name: "正常完成", value: data.normalDays, color: "#2563eb" }, { name: "补打卡", value: data.makeupDays, color: "#f59e0b" }]} dataKey="value" nameKey="name" innerRadius={48} outerRadius={70} paddingAngle={3}>{[{ color: "#2563eb" }, { color: "#f59e0b" }].map((x, i) => <Cell key={i} fill={x.color} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer><div className="legend"><span><i className="normal-dot" />正常完成 {data.normalDays} 天</span><span><i className="makeup-dot" />补打卡 {data.makeupDays} 天</span></div></section>
</div></main>;
}
function Insight({ icon, value, label, detail }: { icon: React.ReactNode; value: string; label: string; detail: string }) { return <div className="insight"><span>{icon}</span><p>{label}</p><strong>{value}</strong><small>{detail}</small></div>; }