Add interactive check-in calendar

This commit is contained in:
Codex committed 2026-09-13 23:54:09 +00:00
1 parent 5c63f9f120
commit 997ba31e1a
2 files changed
+7 -5

No files matched your search

+6 -5
View File
@@ -1,13 +1,14 @@
"use client"; "use client";
import {useEffect,useMemo,useState} from "react"; import {useEffect,useMemo,useState} from "react";
import {CalendarDays,CheckCircle2,Cloud,Flame,ChevronLeft,ChevronRight} from "lucide-react"; import {CalendarDays,CheckCircle2,Cloud,ChevronLeft,ChevronRight} from "lucide-react";
import {Checkbox} from "@/components/ui/checkbox"; import {Checkbox} from "@/components/ui/checkbox";
import {Progress} from "@/components/ui/progress"; import {Progress} from "@/components/ui/progress";
import {daysBetween,plan} from "./plan"; import {daysBetween,plan} from "./plan";
type Record={doneTasks:string;mood:string;note:string}; 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 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 [records,setRecords]=useState<Record<string,Record>>({});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);const finished=Object.values(records).reduce((n,r)=>{try{return n+JSON.parse(r.doneTasks).length}catch{return n}},0);const completeDays=plan.filter(p=>{try{return JSON.parse(records[p.date]?.doneTasks||"[]").length===p.tasks.length}catch{return false}}).length;useEffect(()=>{fetch("/api/progress").then(async r=>{const x=await r.json();if(!r.ok)throw Error(x.error);const next:Record<string,Record>={};x.progress.forEach((p:Record&{planDate:string})=>next[p.planDate]=p);setRecords(next);setStatus("云端同步已开启");}).catch(e=>setStatus(e.message));},[]); 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"));
async function save(next:Record){setRecords(x=>({...x,[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})});const 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);}} 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))}); 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={completeDays+" 天"} label="完整完成" detail="目标共 90 天"/><Stat icon={<Flame/>} value={completeDays?"保持中":"0 天"} label="连续打卡" detail="按自己的节奏来"/><Stat icon={<CheckCircle2/>} value={day.phase} label="当前阶段" detail="12 月 12 日笔试"/></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>{day.phase==="Ⅰ 恢复期"?"找回节奏,把基础重新接上。":day.phase==="Ⅱ 专项期"?"按模块补短板,练清楚错因。":day.phase==="Ⅲ 真题期"?"在计时和复盘中稳定能力。":"回炉核心内容,稳定发挥。"}</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><p className="tip">忙的时候,优先完成一项最核心的任务;不断档,比补作业更重要。</p></aside></section></div></main>} 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>} 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>}
+1
View File
@@ -131,6 +131,7 @@
} }
body{margin:0;background:#f5f8ff;color:#172554;font-family:Arial,"PingFang SC","Microsoft YaHei",sans-serif}.shell{max-width:1120px;margin:auto;padding:32px 20px}header{display:flex;justify-content:space-between;align-items:end;gap:16px;margin-bottom:28px}.eyebrow{color:#2563eb;font-weight:700;letter-spacing:.14em;font-size:.8rem}h1{font-size:clamp(1.8rem,4vw,2.5rem);margin:8px 0}.sub{color:#64748b;margin:0}.sync{display:flex;gap:8px;align-items:center;background:#fff;padding:10px 14px;border-radius:999px;font-size:.85rem;color:#475569;box-shadow:0 1px 4px #bfdbfe}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.stat,.card,.side{background:#fff;border:1px solid #dbeafe;border-radius:18px;padding:18px}.stat span{float:right;color:#2563eb}.stat p,.stat small{display:block;margin:0;color:#64748b;font-size:.82rem}.stat strong{display:block;margin:12px 0 4px;font-size:1.65rem}.layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:24px;margin-top:24px}.card{padding:0;overflow:hidden;box-shadow:0 16px 50px #dbeafe}.hero{display:grid;grid-template-columns:44px 1fr 44px;align-items:center;text-align:center;gap:10px;padding:24px;background:linear-gradient(120deg,#eaf2ff,#fff)}.hero p{margin:0;color:#2563eb;font-weight:700;font-size:.9rem}.hero h2{margin:6px 0;font-size:1.55rem}.hero small{color:#64748b}.hero [role=progressbar]{grid-column:1/-1;margin-top:10px;height:10px;background:#dbeafe}.hero [data-slot=progress-indicator]{background:#2563eb}.round{width:40px;height:40px;border-radius:50%;border:1px solid #bfdbfe;background:#fff;color:#2563eb}.body{padding:24px}.tasks{display:grid;gap:10px}.task{display:flex;align-items:flex-start;gap:12px;border:1px solid #e2e8f0;border-radius:14px;padding:14px;cursor:pointer}.task:hover{border-color:#93c5fd;background:#f8fbff}.task.done{background:#eff6ff;color:#64748b}.task b{font-size:.78rem;background:#e2e8f0;border-radius:999px;padding:3px 8px;white-space:nowrap}.task.done b{background:#bfdbfe;color:#1d4ed8}.task span{line-height:1.5}.review{border-top:1px solid #e2e8f0;margin-top:22px;padding-top:20px}.review>p{font-weight:700}.pill{border:1px solid #cbd5e1;border-radius:999px;padding:7px 12px;background:white;margin:0 7px 10px 0}.pill.active{background:#2563eb;color:#fff;border-color:#2563eb}.review label{display:block;font-size:.9rem;font-weight:700;margin-top:12px}.review textarea{display:block;width:100%;min-height:92px;margin-top:8px;border:1px solid #cbd5e1;border-radius:12px;padding:12px;font:inherit;outline:none;background:#f8fafc}.review textarea:focus{border-color:#60a5fa;background:white}.dark{border-radius:18px;padding:22px;background:#172554;color:#fff}.dark h2{margin:8px 0}.dark p{color:#cbd5e1;line-height:1.6}.side{margin-top:14px}.side p{font-size:.85rem;color:#64748b;border-left:3px solid #dbeafe;padding-left:10px}.tip{font-size:.85rem;color:#64748b;line-height:1.6;padding:0 8px}@media(max-width:760px){header{align-items:flex-start;flex-direction:column}.stats{grid-template-columns:repeat(2,1fr)}.layout{grid-template-columns:1fr}.shell{padding:22px 14px}.body{padding:16px}}@media(max-width:420px){.stats{grid-template-columns:1fr}.task{gap:8px}.task b{display:none}} body{margin:0;background:#f5f8ff;color:#172554;font-family:Arial,"PingFang SC","Microsoft YaHei",sans-serif}.shell{max-width:1120px;margin:auto;padding:32px 20px}header{display:flex;justify-content:space-between;align-items:end;gap:16px;margin-bottom:28px}.eyebrow{color:#2563eb;font-weight:700;letter-spacing:.14em;font-size:.8rem}h1{font-size:clamp(1.8rem,4vw,2.5rem);margin:8px 0}.sub{color:#64748b;margin:0}.sync{display:flex;gap:8px;align-items:center;background:#fff;padding:10px 14px;border-radius:999px;font-size:.85rem;color:#475569;box-shadow:0 1px 4px #bfdbfe}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.stat,.card,.side{background:#fff;border:1px solid #dbeafe;border-radius:18px;padding:18px}.stat span{float:right;color:#2563eb}.stat p,.stat small{display:block;margin:0;color:#64748b;font-size:.82rem}.stat strong{display:block;margin:12px 0 4px;font-size:1.65rem}.layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:24px;margin-top:24px}.card{padding:0;overflow:hidden;box-shadow:0 16px 50px #dbeafe}.hero{display:grid;grid-template-columns:44px 1fr 44px;align-items:center;text-align:center;gap:10px;padding:24px;background:linear-gradient(120deg,#eaf2ff,#fff)}.hero p{margin:0;color:#2563eb;font-weight:700;font-size:.9rem}.hero h2{margin:6px 0;font-size:1.55rem}.hero small{color:#64748b}.hero [role=progressbar]{grid-column:1/-1;margin-top:10px;height:10px;background:#dbeafe}.hero [data-slot=progress-indicator]{background:#2563eb}.round{width:40px;height:40px;border-radius:50%;border:1px solid #bfdbfe;background:#fff;color:#2563eb}.body{padding:24px}.tasks{display:grid;gap:10px}.task{display:flex;align-items:flex-start;gap:12px;border:1px solid #e2e8f0;border-radius:14px;padding:14px;cursor:pointer}.task:hover{border-color:#93c5fd;background:#f8fbff}.task.done{background:#eff6ff;color:#64748b}.task b{font-size:.78rem;background:#e2e8f0;border-radius:999px;padding:3px 8px;white-space:nowrap}.task.done b{background:#bfdbfe;color:#1d4ed8}.task span{line-height:1.5}.review{border-top:1px solid #e2e8f0;margin-top:22px;padding-top:20px}.review>p{font-weight:700}.pill{border:1px solid #cbd5e1;border-radius:999px;padding:7px 12px;background:white;margin:0 7px 10px 0}.pill.active{background:#2563eb;color:#fff;border-color:#2563eb}.review label{display:block;font-size:.9rem;font-weight:700;margin-top:12px}.review textarea{display:block;width:100%;min-height:92px;margin-top:8px;border:1px solid #cbd5e1;border-radius:12px;padding:12px;font:inherit;outline:none;background:#f8fafc}.review textarea:focus{border-color:#60a5fa;background:white}.dark{border-radius:18px;padding:22px;background:#172554;color:#fff}.dark h2{margin:8px 0}.dark p{color:#cbd5e1;line-height:1.6}.side{margin-top:14px}.side p{font-size:.85rem;color:#64748b;border-left:3px solid #dbeafe;padding-left:10px}.tip{font-size:.85rem;color:#64748b;line-height:1.6;padding:0 8px}@media(max-width:760px){header{align-items:flex-start;flex-direction:column}.stats{grid-template-columns:repeat(2,1fr)}.layout{grid-template-columns:1fr}.shell{padding:22px 14px}.body{padding:16px}}@media(max-width:420px){.stats{grid-template-columns:1fr}.task{gap:8px}.task b{display:none}}
.calendar{margin-top:24px;padding:22px;background:#fff;border:1px solid #dbeafe;border-radius:18px;box-shadow:0 8px 25px #dbeafe}.calendar-head{display:flex;justify-content:space-between;align-items:center;gap:16px}.calendar-head h2{margin:5px 0;font-size:1.4rem}.month-tabs{display:flex;gap:6px}.month-tabs button{border:1px solid #dbeafe;background:#fff;color:#475569;border-radius:999px;padding:7px 11px}.month-tabs button.active{background:#2563eb;color:#fff;border-color:#2563eb}.weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:7px}.weekdays{margin:20px 0 8px;text-align:center;color:#94a3b8;font-size:.78rem}.calendar-day{min-height:68px;border:1px solid #e2e8f0;background:#fff;border-radius:12px;color:#334155;padding:7px;display:flex;flex-direction:column;align-items:flex-start;justify-content:space-between}.calendar-day:hover:not(:disabled){border-color:#60a5fa;background:#f8fbff}.calendar-day small{font-size:.68rem;color:#94a3b8}.calendar-day.full{background:#eff6ff;border-color:#93c5fd}.calendar-day.full small{color:#1d4ed8}.calendar-day.partial{background:#fffbeb;border-color:#fde68a}.calendar-day.partial small{color:#a16207}.calendar-day.chosen{outline:3px solid #2563eb;outline-offset:1px}.calendar-day.outside{visibility:hidden}.calendar-note{margin:15px 0 0;color:#64748b;font-size:.8rem}.calendar-note i{display:inline-block;width:9px;height:9px;border-radius:50%;margin:0 5px 0 12px}.calendar-note i:first-child{margin-left:0}.full-dot{background:#2563eb}.partial-dot{background:#f59e0b}.calendar-note span{margin-left:14px}@media(max-width:520px){.calendar{padding:16px}.calendar-head{align-items:flex-start;flex-direction:column}.calendar-day{min-height:52px;padding:5px}.calendar-day small{font-size:.6rem}.weekdays,.calendar-grid{gap:4px}.calendar-note span{display:block;margin:8px 0 0}}
body { body {
background: var(--background); background: var(--background);