Add exam countdown and task overview

This commit is contained in:
biss committed 2026-09-16 21:27:29 +08:00
1 parent 997ba31e1a
commit f613e25407
4 files changed
+84 -13

No files matched your search

+44 -13
View File
@@ -1,14 +1,45 @@
"use client";
import {useEffect,useMemo,useState} from "react";
import {CalendarDays,CheckCircle2,Cloud,ChevronLeft,ChevronRight} from "lucide-react";
import {Checkbox} from "@/components/ui/checkbox";
import {Progress} from "@/components/ui/progress";
import {daysBetween,plan} from "./plan";
type Rec={doneTasks:string;mood:string;note: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"));
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 day=plan[selected],record=records[day.date]||{doneTasks:"[]",mood:"",note:""};const done=useMemo(()=>{try{return JSON.parse(record.doneTasks)as string[]}catch{return[]}},[record.doneTasks]);const total=plan.reduce((n,p)=>n+p.tasks.length,0),finished=Object.values(records).reduce((n,r)=>{try{return n+JSON.parse(r.doneTasks).length}catch{return n}},0);const complete=plan.filter(p=>{try{return JSON.parse(records[p.date]?.doneTasks||"[]").length===p.tasks.length}catch{return false}}).length;const blanks=Array(new Date(Date.UTC(2026,month,1)).getUTCDay()).fill(null),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"));
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:JSON.parse(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))});
return <main><div className="shell"><header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><p className="sync"><Cloud size={16}/>{saving?"正在云端保存…":status}</p></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 日笔试"/></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];let n=0;try{n=JSON.parse(records[date]?.doneTasks||"[]").length}catch{}const 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></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>}
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { daysBetween, plan } from "./plan";
type Rec = { doneTasks: string; mood: string; note: 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 day = plan[selected], record = records[day.date] || { doneTasks: "[]", mood: "", note: "" };
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-12T09: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(() => { 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)) });
return <main><div className="shell">
<header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><div className="header-actions"><Link className="overview-link" href="/tasks"><LayoutList size={17} />任务总览</Link><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 日 09: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>
</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>; }