Add checkin limits and progress statistics

This commit is contained in:
biss committed 2026-09-16 22:08:29 +08:00
1 parent cf94b1ba32
commit 8b2d1275cf
7 files changed
+83 -10

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
import StatsClient from "./stats-client";
export default function StatsPage() { return <StatsClient />; }
+40
View File
@@ -0,0 +1,40 @@
"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>; }