"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>({}); 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 = {}; 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(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
{ if (!open) closeCountdown(); }}>

CET-6 · 考试倒计时

今天也离目标更近一步2026 年 12 月 12 日 15:00 笔试
{remaining.days}天{String(remaining.hours).padStart(2, "0")}时{String(remaining.minutes).padStart(2, "0")}分

今天的任务,完成一项就是一次推进。

CET-6 · 2026

大学英语六级 · 90天打卡

从今天的一件事开始,完成比完美重要。

任务总览数据统计复盘与冲刺

{saving ? "正在云端保存…" : status}

TODAY'S NOTE

今天怎么推进

{smartTip}

} value={Math.round(finished / total * 100) + "%"} label="总体完成度" detail={finished + " / " + total + " 项任务"} />} value={complete + " 天"} label="完整完成" detail="目标共 90 天" />} value={day.phase} label="当前阶段" detail="12 月 12 日笔试" />

距离六级笔试

{remaining.days}天 {remaining.hours}时 {remaining.minutes}分2026 年 12 月 12 日 15:00

{day.phase} · {day.week}

{fmt(day.date)}

{isFuture ? "未来日期,暂不可打卡" : isMakeup ? "补打卡 · 已完成项目不可取消" : "今日打卡"} · 已完成 {done.length} / {day.tasks.length} 项
{isToday ? "正在处理今天的任务" : "正在查看 " + fmt(day.date)}{!isToday && }
{day.tasks.map(t => { const locked = isFuture || (isMakeup && done.includes(t.key)); return ; })}
{isToday && done.length === day.tasks.length &&

TODAY COMPLETE

今天的任务已全部完成

生成一张不含复盘内容的打卡卡片,记录日期、任务清单、阶段与倒计时。

}

今日状态

{["专注", "平稳", "有点累", "需要调整"].map(m => )}