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
+82 -9

No files matched your search

+25 -2
View File
@@ -1,6 +1,29 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "../../../db";
import { progress } from "../../../db/schema";
const user = (r: Request) => r.headers.get("oai-authenticated-user-id");
export async function GET(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再使用打卡功能。"},{status:401});try{return Response.json({progress:await getDb().select().from(progress).where(eq(progress.userId,id))});}catch{return Response.json({error:"暂时无法读取云端进度。"},{status:500});}}
export async function PUT(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再保存打卡。"},{status:401});const b=await r.json() as {date?:string;doneTasks?:string[];mood?:string;note?:string};if(!/^2026-(09|10|11|12)-\d{2}$/.test(b.date||"")||!Array.isArray(b.doneTasks))return Response.json({error:"提交的数据格式不正确。"},{status:400});try{const db=getDb(), values={userId:id,planDate:b.date!,doneTasks:JSON.stringify(b.doneTasks),mood:(b.mood||"").slice(0,20),note:(b.note||"").slice(0,1000),updatedAt:new Date().toISOString()};await db.insert(progress).values(values).onConflictDoUpdate({target:[progress.userId,progress.planDate],set:values});const row=await db.select().from(progress).where(and(eq(progress.userId,id),eq(progress.planDate,b.date!)));return Response.json({progress:row[0]});}catch{return Response.json({error:"暂时无法保存到云端,请稍后重试。"},{status:500});}}
const todayInChina = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
export async function GET(r: Request) {
const id = user(r);
if (!id) return Response.json({ error: "请登录后再使用打卡功能。" }, { status: 401 });
try { return Response.json({ progress: await getDb().select().from(progress).where(eq(progress.userId, id)) }); }
catch { return Response.json({ error: "暂时无法读取云端进度。" }, { status: 500 }); }
}
export async function PUT(r: Request) {
const id = user(r);
if (!id) return Response.json({ error: "请登录后再保存打卡。" }, { status: 401 });
const b = await r.json() as { date?: string; doneTasks?: string[]; mood?: string; note?: string };
if (!/^2026-(09|10|11|12)-\d{2}$/.test(b.date || "") || !Array.isArray(b.doneTasks)) return Response.json({ error: "提交的数据格式不正确。" }, { status: 400 });
const today = todayInChina();
if (b.date! > today) return Response.json({ error: "未来日期暂不能打卡。" }, { status: 400 });
try {
const db = getDb(), now = new Date().toISOString(), checkinType = b.date! < today ? "makeup" : "normal";
const values = { userId: id, planDate: b.date!, doneTasks: JSON.stringify(b.doneTasks), mood: (b.mood || "").slice(0, 20), note: (b.note || "").slice(0, 1000), checkinType, checkedAt: now, updatedAt: now };
await db.insert(progress).values(values).onConflictDoUpdate({ target: [progress.userId, progress.planDate], set: values });
const row = await db.select().from(progress).where(and(eq(progress.userId, id), eq(progress.planDate, b.date!)));
return Response.json({ progress: row[0] });
} catch { return Response.json({ error: "暂时无法保存到云端,请稍后重试。" }, { status: 500 }); }
}
+9 -7
View File
@@ -1,13 +1,13 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react";
import { BarChart3, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { daysBetween, plan } from "./plan";
type Rec = { doneTasks: string; mood: string; note: string };
type Rec = { doneTasks: string; mood: string; note: string; checkinType?: "normal" | "makeup"; checkedAt?: string };
const fmt = (s: string) => new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric", weekday: "short", timeZone: "UTC" }).format(new Date(s + "T00:00:00Z"));
const parseTasks = (value?: string) => { try { return JSON.parse(value || "[]") as string[]; } catch { return []; } };
@@ -20,6 +20,8 @@ export default function CheckinClient() {
const [now, setNow] = useState(Date.now());
const [showCountdown, setShowCountdown] = useState(false);
const day = plan[selected], record = records[day.date] || { doneTasks: "[]", mood: "", note: "" };
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
const isFuture = day.date > today, isMakeup = day.date < today;
const done = useMemo(() => parseTasks(record.doneTasks), [record.doneTasks]);
const total = plan.reduce((n, p) => n + p.tasks.length, 0);
const finished = Object.values(records).reduce((n, r) => n + parseTasks(r.doneTasks).length, 0);
@@ -33,15 +35,15 @@ export default function CheckinClient() {
useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 30000); return () => window.clearInterval(id); }, []);
useEffect(() => { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); if (localStorage.getItem("cet6-countdown-notice") !== today) setShowCountdown(true); }, []);
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)); }, []);
async function save(next: Rec) { setRecords(v => ({ ...v, [day.date]: next })); setSaving(true); try { const r = await fetch("/api/progress", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date: day.date, doneTasks: parseTasks(next.doneTasks), mood: next.mood, note: next.note }) }), x = await r.json(); if (!r.ok) throw Error(x.error); setRecords(v => ({ ...v, [day.date]: x.progress })); setStatus("已保存到云端"); } catch (e) { setStatus(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } }
const toggle = (key: string, on: boolean) => void save({ ...record, doneTasks: JSON.stringify(on ? [...done, key] : done.filter(x => x !== key)) });
async function save(next: Rec) { if (isFuture) { setStatus("未来日期暂不能打卡"); return; } setRecords(v => ({ ...v, [day.date]: next })); setSaving(true); try { const r = await fetch("/api/progress", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date: day.date, doneTasks: parseTasks(next.doneTasks), mood: next.mood, note: next.note }) }), x = await r.json(); if (!r.ok) throw Error(x.error); setRecords(v => ({ ...v, [day.date]: x.progress })); setStatus(isMakeup ? "补打卡已保存" : "已保存到云端"); } catch (e) { setStatus(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } }
const toggle = (key: string, on: boolean) => { if (!isFuture) void save({ ...record, doneTasks: JSON.stringify(on ? [...done, key] : done.filter(x => x !== key)) }); };
const closeCountdown = () => { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); localStorage.setItem("cet6-countdown-notice", today); setShowCountdown(false); };
return <main><Dialog open={showCountdown} onOpenChange={open => { if (!open) closeCountdown(); }}><DialogContent className="countdown-dialog" showCloseButton={false}><DialogHeader><p className="dialog-kicker">CET-6 · 考试倒计时</p><DialogTitle>今天也离目标更近一步</DialogTitle><DialogDescription>2026 年 12 月 12 日 15:00 笔试</DialogDescription></DialogHeader><div className="dialog-time"><b>{remaining.days}</b><span>天</span><b>{String(remaining.hours).padStart(2, "0")}</b><span>时</span><b>{String(remaining.minutes).padStart(2, "0")}</b><span>分</span></div><p className="dialog-note">今天的任务,完成一项就是一次推进。</p><button className="dialog-button" onClick={closeCountdown}>开始今天的打卡</button></DialogContent></Dialog><div className="shell">
<header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><div className="header-actions"><a className="overview-link" href="/tasks"><LayoutList size={17} />任务总览</a><p className="sync"><Cloud size={16} />{saving ? "正在云端保存…" : status}</p></div></header>
<header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><div className="header-actions"><a className="overview-link" href="/tasks"><LayoutList size={17} />任务总览</a><a className="overview-link" href="/stats"><BarChart3 size={17} />数据统计</a><p className="sync"><Cloud size={16} />{saving ? "正在云端保存…" : status}</p></div></header>
<section className="stats"><Stat icon={<CheckCircle2 />} value={Math.round(finished / total * 100) + "%"} label="总体完成度" detail={finished + " / " + total + " 项任务"} /><Stat icon={<CalendarDays />} value={complete + " 天"} label="完整完成" detail="目标共 90 天" /><Stat icon={<CheckCircle2 />} value={day.phase} label="当前阶段" detail="12 月 12 日笔试" /><div className="countdown"><span><Timer size={19} /></span><p>距离六级笔试</p><strong>{remaining.days}<em>天</em> {remaining.hours}<em>时</em> {remaining.minutes}<em>分</em></strong><small>2026 年 12 月 12 日 15:00</small></div></section>
<section className="calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN CALENDAR</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button className={month === m ? "active" : ""} onClick={() => setMonth(m)} key={m}>{m + 1}月</button>)}</div></div><div className="weekdays">{["日", "一", "二", "三", "四", "五", "六"].map(x => <span key={x}>{x}</span>)}</div><div className="calendar-grid">{blanks.map((_, i) => <span key={"e" + i} />)}{dates.map(date => { const idx = plan.findIndex(p => p.date === date), p = plan[idx], n = parseTasks(records[date]?.doneTasks).length, full = !!p && n === p.tasks.length, partial = !!p && n > 0 && !full; return <button disabled={!p} key={date} onClick={() => { if (p) { setSelected(idx); setMonth(new Date(date + "T00:00:00Z").getUTCMonth()); } }} className={"calendar-day " + (idx === selected ? "chosen " : "") + (full ? "full " : partial ? "partial " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? "完成" : partial ? n + "/" + p.tasks.length : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />全部完成 <i className="partial-dot" />进行中 <span>点击日期即可查看并打卡</span></p></section>
<section className="layout"><article className="card"><div className="hero"><button className="round" onClick={() => setSelected(n => Math.max(0, n - 1))} disabled={!selected}><ChevronLeft /></button><div><p>{day.phase} · {day.week}</p><h2>{fmt(day.date)}</h2><small>已完成 {done.length} / {day.tasks.length} 项</small></div><button className="round" onClick={() => setSelected(n => Math.min(89, n + 1))} disabled={selected === 89}><ChevronRight /></button><Progress value={done.length / day.tasks.length * 100} /></div><div className="body"><div className="tasks">{day.tasks.map(t => <label className={done.includes(t.key) ? "task done" : "task"} key={t.key}><Checkbox checked={done.includes(t.key)} onCheckedChange={v => toggle(t.key, v === true)} /><b>{t.label}</b><span>{t.text}</span></label>)}</div><div className="review"><p>今日状态</p><div>{["专注", "平稳", "有点累", "需要调整"].map(m => <button className={record.mood === m ? "pill active" : "pill"} key={m} onClick={() => void save({ ...record, mood: record.mood === m ? "" : m })}>{m}</button>)}</div><label>一句复盘(可选)<textarea value={record.note} onChange={e => setRecords(x => ({ ...x, [day.date]: { ...record, note: e.target.value } }))} onBlur={() => void save(record)} placeholder="今天的收获、卡住的地方,或明天要调整的一点。" /></label></div></div></article><aside><div className="dark"><p>当前阶段</p><h2>{day.phase}</h2><p>每天稳步推进,答案会慢慢变得清楚。</p></div><div className="side"><b>90 天阶段</b>{["Ⅰ 恢复期 · 9/13–10/5", "Ⅱ 专项期 · 10/6–11/2", "Ⅲ 真题期 · 11/3–11/29", "Ⅳ 冲刺期 · 11/30–12/11"].map(x => <p key={x}>{x}</p>)}</div></aside></section>
<section className="calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN CALENDAR</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button className={month === m ? "active" : ""} onClick={() => setMonth(m)} key={m}>{m + 1}月</button>)}</div></div><div className="weekdays">{["日", "一", "二", "三", "四", "五", "六"].map(x => <span key={x}>{x}</span>)}</div><div className="calendar-grid">{blanks.map((_, i) => <span key={"e" + i} />)}{dates.map(date => { const idx = plan.findIndex(p => p.date === date), p = plan[idx], n = parseTasks(records[date]?.doneTasks).length, full = !!p && n === p.tasks.length, partial = !!p && n > 0 && !full, makeup = records[date]?.checkinType === "makeup"; return <button disabled={!p} key={date} onClick={() => { if (p) { setSelected(idx); setMonth(new Date(date + "T00:00:00Z").getUTCMonth()); } }} className={"calendar-day " + (idx === selected ? "chosen " : "") + (full ? "full " : partial ? "partial " : "") + (makeup ? "makeup " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? makeup ? "补打卡" : "完成" : partial ? n + "/" + p.tasks.length : date > today ? "未到日期" : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />全部完成 <i className="partial-dot" />进行中 <i className="makeup-dot" />补打卡 <span>未来日期不可修改</span></p></section>
<section className="layout"><article className="card"><div className="hero"><button className="round" onClick={() => setSelected(n => Math.max(0, n - 1))} disabled={!selected}><ChevronLeft /></button><div><p>{day.phase} · {day.week}</p><h2>{fmt(day.date)}</h2><small>{isFuture ? "未来日期,暂不可打卡" : isMakeup ? "补打卡 · 将记录实际补打时间" : "今日打卡"} · 已完成 {done.length} / {day.tasks.length} 项</small></div><button className="round" onClick={() => setSelected(n => Math.min(89, n + 1))} disabled={selected === 89}><ChevronRight /></button><Progress value={done.length / day.tasks.length * 100} /></div><div className="body"><div className="tasks">{day.tasks.map(t => <label className={(done.includes(t.key) ? "task done " : "task ") + (isFuture ? "locked" : "")} key={t.key}><Checkbox disabled={isFuture} checked={done.includes(t.key)} onCheckedChange={v => toggle(t.key, v === true)} /><b>{t.label}</b><span>{t.text}</span></label>)}</div><div className="review"><p>今日状态</p><div>{["专注", "平稳", "有点累", "需要调整"].map(m => <button disabled={isFuture} className={record.mood === m ? "pill active" : "pill"} key={m} onClick={() => void save({ ...record, mood: record.mood === m ? "" : m })}>{m}</button>)}</div><label>一句复盘(可选)<textarea disabled={isFuture} value={record.note} onChange={e => setRecords(x => ({ ...x, [day.date]: { ...record, note: e.target.value } }))} onBlur={() => void save(record)} placeholder={isFuture ? "未到此日期,暂不能修改。" : "今天的收获、卡住的地方,或明天要调整的一点。"} /></label></div></div></article><aside><div className="dark"><p>{isFuture ? "日期限制" : isMakeup ? "补打卡" : "当前阶段"}</p><h2>{isFuture ? "暂未开放" : isMakeup ? "记录为补打卡" : day.phase}</h2><p>{isFuture ? "当天到来后即可正常打卡。" : isMakeup ? "补做的任务会与当天完成情况分开展示。" : "每天稳步推进,答案会慢慢变得清楚。"}</p></div><div className="side"><b>90 天阶段</b>{["Ⅰ 恢复期 · 9/13–10/5", "Ⅱ 专项期 · 10/6–11/2", "Ⅲ 真题期 · 11/3–11/29", "Ⅳ 冲刺期 · 11/30–12/11"].map(x => <p key={x}>{x}</p>)}</div></aside></section>
</div></main>;
}
+1
View File
@@ -144,3 +144,4 @@ body {
@media(max-width:760px){.header-actions{align-items:flex-start;flex-direction:column}.type-cards{grid-template-columns:repeat(2,1fr)}.task-list-head{align-items:flex-start;flex-direction:column}.type-progress{text-align:left}.task-row{grid-template-columns:24px 1fr;padding:14px}.task-row small{grid-column:2}.countdown{grid-column:span 2}}
@media(max-width:420px){.type-cards{grid-template-columns:1fr}.countdown{grid-column:auto}.task-row p{font-size:.9rem}}
.countdown-dialog{max-width:420px!important;border:0!important;padding:30px!important;text-align:center!important;background:linear-gradient(145deg,#172554,#2563eb)!important;color:#fff!important;box-shadow:0 28px 70px #17255488!important}.dialog-kicker{margin:0;color:#bfdbfe;font-size:.78rem;font-weight:700;letter-spacing:.13em}.countdown-dialog [data-slot=dialog-title]{color:#fff;font-size:1.55rem}.countdown-dialog [data-slot=dialog-description]{color:#dbeafe;font-size:.9rem}.dialog-time{display:flex;justify-content:center;align-items:baseline;gap:7px;margin:12px 0 4px}.dialog-time b{font-size:2.7rem;line-height:1;color:#fff}.dialog-time span{font-size:.86rem;color:#bfdbfe;margin-right:3px}.dialog-note{margin:12px 0 20px;color:#dbeafe;font-size:.9rem}.dialog-button{width:100%;border:0;border-radius:12px;background:#fff;color:#1d4ed8;padding:12px;font:inherit;font-weight:700;cursor:pointer}.dialog-button:hover{background:#eff6ff}
.calendar-day.makeup{background:#fff7ed;border-color:#fdba74}.calendar-day.makeup small{color:#c2410c}.makeup-dot{background:#f97316}.task.locked{cursor:not-allowed;opacity:.52;background:#f8fafc}.task.locked:hover{border-color:#e2e8f0;background:#f8fafc}.pill:disabled,.review textarea:disabled{cursor:not-allowed;opacity:.55}.stats-page{max-width:1100px}.insight-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.insight{background:#fff;border:1px solid #dbeafe;border-radius:18px;padding:18px;box-shadow:0 7px 22px #dbeafe66}.insight span{float:right;color:#2563eb}.insight p,.insight small{display:block;margin:0;color:#64748b;font-size:.82rem}.insight strong{display:block;margin:12px 0 4px;font-size:1.7rem}.charts{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:24px}.chart-card,.stat-detail{background:#fff;border:1px solid #dbeafe;border-radius:18px;padding:22px;box-shadow:0 10px 26px #dbeafe66}.chart-card h2,.stat-detail h2{margin:5px 0;font-size:1.3rem}.stat-detail{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px}.stat-detail>div:first-child{max-width:400px}.stat-detail p:not(.eyebrow){color:#64748b;line-height:1.6;margin:8px 0 0}.legend{display:grid;gap:8px;min-width:115px;color:#475569;font-size:.85rem}.legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}.legend .normal-dot{background:#2563eb}@media(max-width:820px){.insight-cards{grid-template-columns:repeat(2,1fr)}.charts{grid-template-columns:1fr}.stat-detail{align-items:flex-start;flex-wrap:wrap}}@media(max-width:480px){.insight-cards{grid-template-columns:1fr}.chart-card{padding:16px}.stat-detail{padding:16px}}
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more