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

51 lines
9.9 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, 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; 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 []; } };
export default function CheckinClient() {
const [selected, setSelected] = useState(Math.max(0, Math.min(89, daysBetween("2026-09-13"))));
const [month, setMonth] = useState(8);
const [records, setRecords] = useState<Record<string, Rec>>({});
const [status, setStatus] = useState("正在读取云端进度…");
const [saving, setSaving] = useState(false);
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);
const complete = plan.filter(p => parseTasks(records[p.date]?.doneTasks).length === p.tasks.length).length;
const blanks = Array(new Date(Date.UTC(2026, month, 1)).getUTCDay()).fill(null);
const length = new Date(Date.UTC(2026, month + 1, 0)).getUTCDate();
const dates = Array.from({ length }, (_, i) => "2026-" + String(month + 1).padStart(2, "0") + "-" + String(i + 1).padStart(2, "0"));
const seconds = Math.max(0, Math.floor((new Date("2026-12-12T15:00:00+08:00").getTime() - now) / 1000));
const remaining = { days: Math.floor(seconds / 86400), hours: Math.floor(seconds % 86400 / 3600), minutes: Math.floor(seconds % 3600 / 60) };
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) { 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><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, 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>;
}
function Stat({ icon, value, label, detail }: { icon: React.ReactNode; value: string; label: string; detail: string }) { return <div className="stat"><span>{icon}</span><p>{label}</p><strong>{value}</strong><small>{detail}</small></div>; }