Files

84 lines
17 KiB
TypeScript
Raw Permalink 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, Share2, 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 todayIndex = Math.max(0, Math.min(89, daysBetween("2026-09-13")));
const [selected, setSelected] = useState(todayIndex);
const [month, setMonth] = useState(new Date(plan[todayIndex].date + "T00:00:00Z").getUTCMonth());
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, isToday = 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) };
const localHour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone: "Asia/Shanghai" }).format(new Date(now)));
const greeting = localHour < 6 ? "夜深了,今天不必追赶进度,稳稳完成一件事就很好。" : localHour < 11 ? "早上好,先从最清晰的一项开始,把节奏带起来。" : localHour < 18 ? "下午好,接着把计划往前推进,专注下一步就够了。" : "晚上好,给今天留一次扎实的收尾,让明天更轻松。";
const taskNarrative = day.tasks.map(task => task.text).join(",接着");
const countdownNarrative = remaining.days > 60 ? `距离六级笔试还有 ${remaining.days} 天,现在最重要的是把每天的复习节奏走稳。` : remaining.days > 30 ? `距离六级笔试还有 ${remaining.days} 天,该让专项练习更有针对性了。` : remaining.days > 14 ? `距离六级笔试还有 ${remaining.days} 天,真题和时间分配会成为接下来的重点。` : remaining.days > 7 ? `距离六级笔试还有 ${remaining.days} 天,开始把易错点收拢成自己的清单。` : remaining.days > 0 ? `距离六级笔试只剩 ${remaining.days} 天,保持手感、相信已完成的积累。` : "考试日已到,按自己的节奏完成最后准备。";
const progressNarrative = done.length === day.tasks.length ? "今天的清单已经全部完成,可以安心收尾。" : done.length === 0 ? "今天还没有开始,先完成第一项就已经在前进。" : `你已经完成 ${done.length} 项,还剩 ${day.tasks.length - done.length} 项。`;
const smartTip = `${greeting}${countdownNarrative}${isToday ? "今天" : `你正在查看 ${fmt(day.date)},当天`}的安排是:${taskNarrative}。${progressNarrative}`;
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; } const previous = records[day.date]; 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) { setRecords(v => { const restored = { ...v }; if (previous) restored[day.date] = previous; else delete restored[day.date]; return restored; }); setStatus(e instanceof Error ? e.message : "保存失败,已恢复上次记录"); } finally { setSaving(false); } }
const toggle = (key: string, on: boolean) => { if (!isFuture && !(isMakeup && done.includes(key))) void save({ ...record, doneTasks: JSON.stringify(on ? [...done, key] : done.filter(x => x !== key)) }); };
const shareCheckinCard = async () => {
const canvas = document.createElement("canvas"), width = 1080, height = 1350;
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rounded = (x: number, y: number, w: number, h: number, r: number) => { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); };
const background = ctx.createLinearGradient(0, 0, width, height); background.addColorStop(0, "#102959"); background.addColorStop(.55, "#1e5dcd"); background.addColorStop(1, "#77a7f5"); ctx.fillStyle = background; ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#ffffff14"; ctx.beginPath(); ctx.arc(945, 145, 265, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(48, 1230, 260, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#dceaff"; ctx.font = "700 30px Arial, \"PingFang SC\", sans-serif"; ctx.fillText("CET-6 · 2026", 82, 106);
ctx.fillStyle = "#ffffff"; ctx.font = "700 68px Arial, \"PingFang SC\", sans-serif"; ctx.fillText("今日打卡完成", 82, 205);
ctx.font = "500 32px Arial, \"PingFang SC\", sans-serif"; ctx.fillStyle = "#dceaff"; ctx.fillText(fmt(day.date), 84, 258);
rounded(82, 320, 916, 238, 34); ctx.fillStyle = "#ffffff"; ctx.fill();
ctx.fillStyle = "#1e5dcd"; ctx.beginPath(); ctx.arc(180, 438, 57, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = "700 72px Arial"; ctx.fillText("✓", 148, 464);
ctx.fillStyle = "#20365f"; ctx.font = "700 42px Arial, \"PingFang SC\", sans-serif"; ctx.fillText(`完成 ${done.length} / ${day.tasks.length} 项今日任务`, 270, 424);
ctx.fillStyle = "#657a9f"; ctx.font = "500 29px Arial, \"PingFang SC\", sans-serif"; ctx.fillText(`${day.phase} · 已累计完整打卡 ${complete} 天`, 270, 478);
rounded(82, 608, 916, 467, 34); ctx.fillStyle = "#f6f9ff"; ctx.fill();
ctx.fillStyle = "#315fd5"; ctx.font = "700 26px Arial, \"PingFang SC\", sans-serif"; ctx.fillText("TODAY'S LIST", 130, 678);
let y = 746; day.tasks.forEach((task, index) => { const summary = `${task.label} · ${task.text.length > 28 ? task.text.slice(0, 28) + "…" : task.text}`; ctx.fillStyle = "#dce9ff"; ctx.beginPath(); ctx.arc(146, y - 10, 18, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#1d4ed8"; ctx.font = "700 22px Arial"; ctx.fillText(String(index + 1), 140, y - 2); ctx.fillStyle = "#20365f"; ctx.font = "500 25px Arial, \"PingFang SC\", sans-serif"; ctx.fillText(summary, 188, y); y += 65; });
rounded(82, 1125, 916, 138, 30); ctx.fillStyle = "#ffffff1c"; ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = "700 33px Arial, \"PingFang SC\", sans-serif"; ctx.fillText(`距离六级笔试还有 ${remaining.days} 天`, 130, 1184); ctx.font = "500 27px Arial, \"PingFang SC\", sans-serif"; ctx.fillStyle = "#dceaff"; ctx.fillText("完成比完美重要 · 明天继续", 130, 1230);
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, "image/png"));
if (!blob) return;
const filename = `cet6-checkin-${day.date}.png`, file = new File([blob], filename, { type: "image/png" });
try { if (navigator.share && navigator.canShare?.({ files: [file] })) { await navigator.share({ files: [file], title: "CET-6 今日打卡完成", text: `我完成了 ${fmt(day.date)} 的六级复习任务。` }); setStatus("打卡卡片已打开分享"); return; } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; }
const url = URL.createObjectURL(blob), link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); setStatus("打卡卡片已生成并下载");
};
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><a className="overview-link" href="/review">复盘与冲刺</a><a className="overview-link" href="/backup">数据备份</a><p className="sync"><Cloud size={16} />{saving ? "正在云端保存…" : status}</p></div></header>
<section className="smart-tip" aria-live="polite"><div><p className="eyebrow">TODAY'S NOTE</p><h2>今天怎么推进</h2></div><p>{smartTip}</p></section>
<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="layout"><article className="card"><div className="hero"><button className="round" aria-label="前一天" 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" aria-label="后一天" 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="task-toolbar"><span>{isToday ? "正在处理今天的任务" : "正在查看 " + fmt(day.date)}</span>{!isToday && <button className="today-button" onClick={() => { setSelected(todayIndex); setMonth(new Date(plan[todayIndex].date + "T00:00:00Z").getUTCMonth()); }}>回到今天</button>}</div><div className="tasks">{day.tasks.map(t => { const locked = isFuture || (isMakeup && done.includes(t.key)); return <label className={(done.includes(t.key) ? "task done " : "task ") + (locked ? "locked" : "")} key={t.key}><Checkbox disabled={locked} checked={done.includes(t.key)} onCheckedChange={v => toggle(t.key, v === true)} /><b>{t.label}</b><span>{t.text}</span>{isMakeup && done.includes(t.key) && <small className="locked-note">已完成,已锁定</small>}</label>; })}</div>{isToday && done.length === day.tasks.length && <section className="completion-card"><div><p className="eyebrow">TODAY COMPLETE</p><h3>今天的任务已全部完成</h3><p>生成一张不含复盘内容的打卡卡片,记录日期、任务清单、阶段与倒计时。</p></div><button className="share-card-button" onClick={() => void shareCheckinCard()}><Share2 size={18} />生成并分享打卡卡片</button></section>}<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><section className="calendar side-calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button aria-label={`查看${m + 1}月`} 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" />补打卡</p></section><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>
{!isToday && !isFuture && done.length === day.tasks.length && <section className="history-share-float"><div><p className="eyebrow">CHECK-IN ARCHIVE</p><b>{fmt(day.date)} 已完整完成</b><span>随时生成分享卡片</span></div><button className="share-card-button" onClick={() => void shareCheckinCard()}><Share2 size={18} />分享这次打卡</button></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>; }