Initial commit: Schedule Planner

This commit is contained in:
2026-04-06 13:46:31 +00:00
commit b09cefad34
14 changed files with 9534 additions and 0 deletions

623
sleep-buddy.html Normal file
View File

@@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<title>睡眠打卡</title>
<link rel="icon" type="image/png" href="icon-180.png">
<link rel="apple-touch-icon" href="icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,sans-serif;background:#0f0e2a;color:#e0e0e0;min-height:100vh;}
/* 登录 */
.login{display:flex;position:fixed;inset:0;z-index:999;align-items:center;justify-content:center;flex-direction:column;
background:radial-gradient(ellipse at 30% 20%,rgba(102,126,234,0.15),transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(168,85,247,0.1),transparent 50%),#0f0e2a;}
.login.hidden{display:none;}
.login-logo{font-size:56px;margin-bottom:16px;animation:logoFloat 3s ease-in-out infinite;}
@keyframes logoFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
.login h1{font-size:26px;color:white;font-weight:700;}
.login p{font-size:13px;color:rgba(255,255,255,0.35);margin-top:4px;margin-bottom:24px;}
.login-card{background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px 24px;width:300px;backdrop-filter:blur(12px);}
.login-form{display:flex;flex-direction:column;gap:12px;}
.iw{position:relative;}
.login-form input{width:100%;padding:14px 42px 14px 16px;border:1.5px solid rgba(255,255,255,0.1);border-radius:12px;background:rgba(255,255,255,0.04);color:white;font-size:15px;outline:none;transition:border-color 0.2s;}
.login-form input::placeholder{color:rgba(255,255,255,0.25);}
.login-form input:focus{border-color:rgba(167,139,250,0.5);background:rgba(255,255,255,0.06);}
.eye{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:16px;color:rgba(255,255,255,0.3);}
.lmb{width:100%;padding:14px;border:none;border-radius:12px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-size:16px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(102,126,234,0.3);}
.lmb:active{transform:scale(0.98);}
.ltg{background:none;border:none;color:rgba(255,255,255,0.35);font-size:13px;cursor:pointer;margin-top:4px;text-align:center;display:block;width:100%;}
.ltg:hover{color:rgba(255,255,255,0.6);}
.lerr{color:#fca5a5;font-size:13px;margin-top:8px;min-height:20px;text-align:center;}
/* 主体 */
.container{max-width:480px;margin:0 auto;padding:16px;}
.header{display:flex;align-items:center;margin-bottom:14px;}
.header h2{font-size:18px;color:white;flex:1;}
.user-chip{display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:10px;background:rgba(255,255,255,0.08);cursor:pointer;font-size:13px;color:rgba(255,255,255,0.6);position:relative;}
.user-chip:hover{background:rgba(255,255,255,0.12);}
.user-menu{display:none;position:absolute;top:36px;right:0;background:rgba(30,30,60,0.95);border:1px solid rgba(255,255,255,0.1);border-radius:10px;overflow:hidden;z-index:100;min-width:100px;backdrop-filter:blur(12px);}
.user-menu.open{display:block;}
.user-menu button{display:block;width:100%;padding:10px 16px;border:none;background:none;color:rgba(255,255,255,0.7);font-size:13px;cursor:pointer;text-align:left;}
.user-menu button:hover{background:rgba(255,255,255,0.08);}
/* 通知 */
.notif-bar{background:linear-gradient(135deg,#667eea,#764ba2);border-radius:14px;padding:14px;margin-bottom:14px;text-align:center;cursor:pointer;animation:fadeIn 0.4s;}
.notif-bar.mini{padding:8px 14px;border-radius:10px;font-size:12px;text-align:left;display:flex;align-items:center;gap:8px;}
@keyframes fadeIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}
/* 按钮 */
.sleep-btn{display:block;width:100%;padding:20px;border:none;border-radius:18px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-size:20px;font-weight:700;cursor:pointer;margin-bottom:14px;box-shadow:0 6px 24px rgba(102,126,234,0.4);}
.sleep-btn:active{transform:scale(0.97);}
.card{background:rgba(255,255,255,0.06);border-radius:14px;padding:16px;margin-bottom:14px;border:1px solid rgba(255,255,255,0.08);}
.card h3{font-size:14px;color:rgba(255,255,255,0.5);margin-bottom:10px;font-weight:500;}
.ir{display:flex;gap:8px;}
.ir input{flex:1;padding:10px 14px;border:1.5px solid rgba(255,255,255,0.12);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:15px;outline:none;}
.ir input:focus{border-color:#667eea;}
.ir input::placeholder{color:rgba(255,255,255,0.25);}
.sb{padding:10px 16px;border:none;border-radius:10px;font-size:14px;cursor:pointer;background:#667eea;color:white;}
.hint{font-size:12px;min-height:18px;margin-top:6px;color:rgba(255,255,255,0.3);}
/* 用户选择器 */
.user-toggles{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.ut{padding:4px 10px;border-radius:8px;border:1.5px solid rgba(255,255,255,0.15);background:none;color:rgba(255,255,255,0.4);font-size:12px;cursor:pointer;}
.ut.on{border-color:var(--c);color:var(--c);background:rgba(255,255,255,0.06);}
/* 统计 */
.cmp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px;}
.cmp-box{background:rgba(255,255,255,0.04);border-radius:10px;padding:10px;text-align:center;border:1px solid rgba(255,255,255,0.06);}
.cmp-name{font-size:10px;color:rgba(255,255,255,0.3);margin-bottom:4px;}
.cmp-val{font-size:16px;font-weight:700;}
.cmp-label{font-size:10px;color:rgba(255,255,255,0.25);margin-top:2px;}
.chart-canvas{width:100%;height:220px;display:block;}
.record{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px;}
.record:last-child{border-bottom:none;}
.utag{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;}
.ebtn{background:none;border:none;color:rgba(255,255,255,0.2);cursor:pointer;font-size:11px;}
.ebtn:hover{color:#667eea;}
.celebrate{text-align:center;padding:10px;background:rgba(34,197,94,0.1);border-radius:10px;margin-bottom:14px;border:1px solid rgba(34,197,94,0.15);}
</style>
</head>
<body>
<div class="login" id="loginOverlay">
<div class="login-logo">🌙</div>
<h1>睡眠打卡</h1>
<p id="loginSubtitle">和好友一起早睡</p>
<div class="login-card">
<div class="login-form">
<div class="iw"><input type="text" id="loginUser" placeholder="用户名" onkeydown="if(event.key==='Enter'&&!event.isComposing)document.getElementById('loginPwd').focus()"></div>
<div class="iw">
<input type="password" id="loginPwd" placeholder="密码" onkeydown="if(event.key==='Enter'&&!event.isComposing){loginMode==='login'?doLogin():document.getElementById('loginPwd2').focus()}">
<button class="eye" onclick="const p=document.getElementById('loginPwd');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'👁‍🗨'">👁</button>
</div>
<div class="iw" id="pwd2Wrap" style="display:none;">
<input type="password" id="loginPwd2" placeholder="确认密码" onkeydown="if(event.key==='Enter'&&!event.isComposing)doRegister()">
<button class="eye" onclick="const p=document.getElementById('loginPwd2');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'👁‍🗨'">👁</button>
</div>
<button class="lmb" id="loginMainBtn" onclick="doLogin()">登录</button>
<button class="ltg" id="loginToggleBtn" onclick="toggleLoginMode()">没有账号?注册</button>
<div class="lerr" id="loginErr"></div>
</div>
</div>
</div>
<div class="container">
<div class="header">
<h2>🌙 睡眠打卡</h2>
<div class="user-chip" onclick="document.getElementById('userMenu').classList.toggle('open')">
<span id="headerUser"></span>
<div class="user-menu" id="userMenu">
<button onclick="doLogout()">退出登录</button>
</div>
</div>
</div>
<div id="notifArea"></div>
<div id="celebrateArea"></div>
<button class="sleep-btn" id="sleepBtn" onclick="goSleep()">🌙 我去睡觉啦</button>
<div id="notifMini" style="display:none;"></div>
<div class="card" style="padding:12px 16px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;color:rgba(255,255,255,0.4);">我的目标入睡时间</span>
<div style="display:flex;align-items:center;gap:8px;">
<span id="myTargetDisplay" style="font-size:15px;color:#86efac;font-weight:600;">22:00</span>
<button onclick="setMyTarget()" style="padding:4px 10px;border:none;border-radius:8px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.4);font-size:12px;cursor:pointer;">修改</button>
</div>
</div>
</div>
<div class="card">
<h3>记录 / 修改入睡时间</h3>
<div class="ir">
<input id="sleepInput" placeholder="昨晚11点半 / 10:30" onkeydown="if(event.key==='Enter'&&!event.isComposing)addRecord()">
<button class="sb" onclick="addRecord()">记录</button>
</div>
<div class="hint" id="recordHint"></div>
</div>
<!-- 统计对比 -->
<div class="card">
<h3>数据对比</h3>
<div class="cmp-grid" id="cmpGrid"></div>
</div>
<!-- 睡眠曲线 -->
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<h3 style="margin:0;">睡眠趋势</h3>
<div style="display:flex;align-items:center;gap:6px;">
<button onclick="changeChartMonth(-1)" style="width:26px;height:26px;border-radius:50%;border:none;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);cursor:pointer;"></button>
<span id="chartMonthLabel" style="font-size:12px;color:rgba(255,255,255,0.4);min-width:60px;text-align:center;"></span>
<button onclick="changeChartMonth(1)" style="width:26px;height:26px;border-radius:50%;border:none;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);cursor:pointer;"></button>
</div>
</div>
<div class="user-toggles" id="userToggles"></div>
<canvas class="chart-canvas" id="mainChart"></canvas>
</div>
<!-- 最近记录 -->
<div class="card">
<h3>最近记录</h3>
<div id="recordList"></div>
</div>
</div>
<script>
const API='/api/sleep-buddy';
let myName='', loginMode='login';
let selectedUsers=new Set(); // 图表上选中的用户
async function hashStr(s){const buf=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s));return Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');}
const saved=JSON.parse(localStorage.getItem('buddy_session')||'null');
if(saved&&Date.now()<saved.exp){myName=saved.username;document.getElementById('loginOverlay').classList.add('hidden');startApp();}
function toggleLoginMode(){
loginMode=loginMode==='login'?'register':'login';
const isReg=loginMode==='register';
document.getElementById('pwd2Wrap').style.display=isReg?'block':'none';
document.getElementById('loginMainBtn').textContent=isReg?'注册':'登录';
document.getElementById('loginMainBtn').onclick=isReg?doRegister:doLogin;
document.getElementById('loginToggleBtn').textContent=isReg?'已有账号?登录':'没有账号?注册';
document.getElementById('loginSubtitle').textContent=isReg?'创建你的账号':'和好友一起早睡';
document.getElementById('loginUser').placeholder=isReg?'起一个昵称':'用户名';
document.getElementById('loginErr').textContent='';
}
async function doLogin(){
const user=document.getElementById('loginUser').value.trim(),pwd=document.getElementById('loginPwd').value,err=document.getElementById('loginErr');
if(!user||!pwd){err.textContent='请输入用户名和密码';return;}
const r=await fetch('/api/buddy-login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,hash:await hashStr(pwd)})}).then(r=>r.json());
if(r.ok){myName=user;localStorage.setItem('buddy_session',JSON.stringify({username:user,exp:Date.now()+30*86400000}));document.getElementById('loginOverlay').classList.add('hidden');startApp();}
else{err.textContent=r.error||'登录失败';}
}
async function doRegister(){
const user=document.getElementById('loginUser').value.trim(),pwd=document.getElementById('loginPwd').value,pwd2=document.getElementById('loginPwd2').value,err=document.getElementById('loginErr');
err.style.color='#fca5a5';
if(!user){err.textContent='请输入用户名';return;} if(!pwd){err.textContent='请输入密码';return;}
if(pwd.length<4){err.textContent='密码至少4位';return;} if(pwd!==pwd2){err.textContent='两次密码不一致';return;}
const r=await fetch('/api/buddy-register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,hash:await hashStr(pwd)})}).then(r=>r.json());
if(r.ok){err.style.color='#86efac';err.textContent='注册成功!';loginMode='login';setTimeout(()=>doLogin(),500);}
else{err.textContent=r.error||'注册失败';}
}
function doLogout(){localStorage.removeItem('buddy_session');location.reload();}
function startApp(){
document.getElementById('headerUser').textContent=myName;
selectedUsers.add(myName);
loadData();setInterval(loadData,30000);
checkNotifs();setInterval(checkNotifs,10000);
// 切回页面时立即检查通知
document.addEventListener('visibilitychange',()=>{if(!document.hidden){checkNotifs();loadData();}});
}
// 关闭菜单
document.addEventListener('click',e=>{if(!e.target.closest('.user-chip'))document.getElementById('userMenu')?.classList.remove('open');});
function parseSleepTime(text){
const m1=text.match(/(\d{1,2})[点:](\d{1,2})?/);if(!m1)return null;
let h=parseInt(m1[1]),m=m1[2]?parseInt(m1[2]):0;
if(text.includes('半')&&m===0)m=30;
if(h>=9&&h<=11)h+=12;else if(h===12)h=0;
m=Math.round(m/10)*10;if(m===60){m=0;h++;}if(h>=24)h-=24;
const d=new Date();if(/昨/.test(text))d.setDate(d.getDate()-1);
return{date:d.toISOString().slice(0,10),start:`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`};
}
async function addRecord(){
const input=document.getElementById('sleepInput'),hint=document.getElementById('recordHint');
const r=parseSleepTime(input.value);
if(!r){hint.style.color='#ef4444';hint.textContent='无法识别试试昨晚11点半';return;}
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'record',record:r})});
hint.style.color='#86efac';hint.textContent=`${r.date} ${fmtTime(r.start)}`;
input.value='';loadData();
}
async function goSleep(){
const now=new Date();let h=now.getHours(),m=Math.round(now.getMinutes()/10)*10;
if(m===60){m=0;h++;}if(h>=24)h-=24;
const record={date:now.toISOString().slice(0,10),start:`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`};
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'record',record})});
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'sleep-now'})});
const btn=document.getElementById('sleepBtn');
btn.textContent='🌙 晚安!已通知好友';btn.style.opacity='0.6';
setTimeout(()=>{btn.textContent='🌙 我去睡觉啦';btn.style.opacity='1';},3000);
loadData();
}
function showModal(html) {
let m=document.getElementById('sleepModal');
if(!m){m=document.createElement('div');m.id='sleepModal';
m.style.cssText='display:flex;position:fixed;inset:0;z-index:999;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
document.body.appendChild(m);}
m.innerHTML=`<div style="background:#1a1940;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:24px;width:280px;animation:fadeIn 0.2s;">${html}</div>`;
m.style.display='flex';
}
function hideModal(){const m=document.getElementById('sleepModal');if(m)m.style.display='none';}
function editRecord(user,date){
if(user!==myName)return;
showModal(`
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:12px;">修改 ${date.slice(5)} 入睡时间</div>
<input type="time" id="modalTimeInput" style="width:100%;padding:12px;border:1.5px solid rgba(255,255,255,0.15);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:16px;outline:none;margin-bottom:14px;">
<div style="display:flex;gap:8px;">
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
<button onclick="doEditRecord('${user}','${date}')" style="flex:1;padding:10px;border:none;border-radius:10px;background:#667eea;color:white;cursor:pointer;">保存</button>
</div>
`);
}
async function doEditRecord(user,date){
const time=document.getElementById('modalTimeInput')?.value;
if(!time){return;}
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user,action:'record',record:{date,start:time}})});
hideModal();loadData();
}
function deleteRecord(user,date){
if(user!==myName)return;
showModal(`
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:14px;">确定删除 ${date.slice(5)} 的记录?</div>
<div style="display:flex;gap:8px;">
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
<button onclick="doDeleteRecord('${user}','${date}')" style="flex:1;padding:10px;border:none;border-radius:10px;background:#ef4444;color:white;cursor:pointer;">删除</button>
</div>
`);
}
async function doDeleteRecord(user,date){
// 用空记录覆盖来"删除"服务器端只接受record操作
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user,action:'delete-record',date})});
hideModal();loadData();
}
async function checkNotifs(){
try{
const data=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'get-notifications'})}).then(r=>r.json());
const lastSeen=parseFloat(localStorage.getItem('buddy_notif_seen')||'0');
const news=(data.notifications||[]).filter(n=>n.ts>lastSeen);
if(news.length>0){
localStorage.setItem('buddy_notif_seen',String(Math.max(...news.map(n=>n.ts))));
const area=document.getElementById('notifArea');
area.innerHTML=`<div class="notif-bar" onclick="this.remove();document.getElementById('notifMini').style.display='block';document.getElementById('notifMini').innerHTML='<div class=\\'notif-bar mini\\'><span>🌙</span> ${news[0].from} 去睡了</div>'">
<div style="font-size:28px;">🌙</div>
<div style="font-size:14px;color:rgba(255,255,255,0.9);margin-top:6px;">${news[0].msg}</div>
<div style="font-size:11px;color:rgba(255,255,255,0.4);margin-top:4px;">点击收起</div>
</div>`;
if(navigator.vibrate)navigator.vibrate([200,100,200]);
if(Notification.permission==='granted')new Notification('睡眠打卡',{body:news[0].msg});
}
}catch(e){}
}
// 目标时间设置
let userTargets={}; // {username: "22:00"}
function setMyTarget(){
document.getElementById('userMenu')?.classList.remove('open');
const cur=userTargets[myName]||'22:00';
showModal(`
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:12px;">设置你的目标入睡时间</div>
<input type="time" id="modalTargetInput" value="${cur}" style="width:100%;padding:12px;border:1.5px solid rgba(255,255,255,0.15);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:16px;outline:none;margin-bottom:14px;">
<div style="display:flex;gap:8px;">
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
<button onclick="doSetTarget()" style="flex:1;padding:10px;border:none;border-radius:10px;background:#667eea;color:white;cursor:pointer;">保存</button>
</div>
`);
}
async function doSetTarget(){
const t=document.getElementById('modalTargetInput')?.value;
if(!t)return;
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'set-target',target:t})});
userTargets[myName]=t;
const td=document.getElementById('myTargetDisplay');
if(td)td.textContent=t;
hideModal();loadData();
}
function getTargetMins(name){
const t=userTargets[name]||'22:00';
const[h,m]=t.split(':').map(Number);
return(h<12?h+24:h)*60+m-20*60;
}
// 数据
const COLORS=['#a78bfa','#f472b6','#34d399','#fbbf24','#60a5fa'];
let chartViewMonth=new Date(); // 当前查看的月份
function changeChartMonth(delta){
chartViewMonth=new Date(chartViewMonth.getFullYear(),chartViewMonth.getMonth()+delta,1);
const now=new Date();
if(chartViewMonth>now)chartViewMonth=new Date(now.getFullYear(),now.getMonth(),1);
loadData();
}
async function loadData(){
try{
const data=await fetch(API).then(r=>r.json());
const users=data.users||{};
userTargets=data.targets||{};
const td=document.getElementById('myTargetDisplay');
if(td)td.textContent=userTargets[myName]||'22:00';
const names=Object.keys(users);
const sorted=[myName,...names.filter(n=>n!==myName)].filter(n=>users[n]);
// 用户选择器
const tgl=document.getElementById('userToggles');
tgl.innerHTML=sorted.map((n,i)=>{
const c=COLORS[i%COLORS.length];
const on=selectedUsers.has(n);
return`<button class="ut ${on?'on':''}" style="--c:${c}" onclick="toggleUser('${n}')">${n}${n===myName?' (我)':''}</button>`;
}).join('');
// 统计对比
renderCompare(sorted,users);
// 图表
renderChart(sorted,users);
// 早睡庆祝
renderCelebrate(users);
// 记录
renderRecords(users);
}catch(e){}
}
function toggleUser(name){
if(name===myName)return; // 自己始终选中
if(selectedUsers.has(name))selectedUsers.delete(name);else selectedUsers.add(name);
loadData();
}
function sleepMins(s){const[h,m]=s.split(':').map(Number);return(h<12?h+24:h)*60+m-20*60;}
function fmtTime(s){const[h,m]=s.split(':').map(Number);const p=h<6?'凌晨':h<12?'上午':h<18?'下午':'晚上';const dh=h>12?h-12:h===0?12:h;return`${p}${dh}:${m.toString().padStart(2,'0')}`;}
function renderCompare(names,users){
const grid=document.getElementById('cmpGrid');
const active=names.filter(n=>selectedUsers.has(n));
let html='';
active.forEach((name,i)=>{
const recs=users[name]||[];
const c=COLORS[names.indexOf(name)%COLORS.length];
const last7=recs.slice(0,7);
const last30=recs.slice(0,30);
if(!last7.length){html+=`<div class="cmp-box"><div class="cmp-name" style="color:${c}">${name}</div><div style="color:rgba(255,255,255,0.2);font-size:11px;">暂无数据</div></div>`;return;}
const tgt=getTargetMins(name);
const tgtTime=userTargets[name]||'22:00';
const avg7=Math.round(last7.map(r=>sleepMins(r.start)).reduce((a,b)=>a+b,0)/last7.length);
const early7=last7.filter(r=>sleepMins(r.start)<=tgt).length;
const early30=last30.filter(r=>sleepMins(r.start)<=tgt).length;
const toStr=off=>{let t=off+20*60;if(t>=1440)t-=1440;return`${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`;};
html+=`<div class="cmp-box">
<div class="cmp-name" style="color:${c}">${name}${name===myName?' (我)':''}</div>
<div class="cmp-val" style="color:${c}">${fmtTime(toStr(avg7))}</div>
<div class="cmp-label">近7天平均</div>
<div style="margin-top:6px;font-size:12px;color:rgba(255,255,255,0.4);">
${tgtTime}前:${early7}/7天
${last30.length>=14?`<br>月达标:${early30}/${last30.length}`:''}
</div>
</div>`;
});
grid.innerHTML=html;
}
function renderChart(names,users){
const canvas=document.getElementById('mainChart');if(!canvas)return;
const dpr=window.devicePixelRatio||1,rect=canvas.getBoundingClientRect();
canvas.width=rect.width*dpr;canvas.height=rect.height*dpr;
const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=rect.height;ctx.clearRect(0,0,W,H);
const year=chartViewMonth.getFullYear(),month=chartViewMonth.getMonth();
const daysInMonth=new Date(year,month+1,0).getDate();
document.getElementById('chartMonthLabel').textContent=`${year}${month+1}`;
const pL=44,pR=12,pT=22,pB=30,cW=W-pL-pR,cH=H-pT-pB;
const yMax=300; // 21:00=0 → 02:00=300
const yScale=cH/yMax;
// Y轴网格 + 标签
const timeLabels=[{off:0,l:'21:00'},{off:60,l:'22:00'},{off:120,l:'23:00'},{off:180,l:'0:00'},{off:240,l:'1:00'},{off:300,l:'2:00'}];
timeLabels.forEach(tl=>{
const y=pT+tl.off*yScale;
ctx.strokeStyle='rgba(255,255,255,0.06)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(pL,y);ctx.lineTo(W-pR,y);ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.25)';ctx.font='10px -apple-system,sans-serif';ctx.textAlign='right';
ctx.fillText(tl.l,pL-6,y+4);
});
// 目标参考线(用当前用户的目标时间)
const myTgt=getTargetMins(myName);
const myTgtTime=userTargets[myName]||'22:00';
const refOff=myTgt-60; // sleepMins offset from 21:00
const refY=pT+Math.max(0,Math.min(refOff,300))*yScale;
ctx.strokeStyle='rgba(34,197,94,0.35)';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);
ctx.beginPath();ctx.moveTo(pL,refY);ctx.lineTo(W-pR,refY);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle='rgba(34,197,94,0.5)';ctx.font='10px -apple-system,sans-serif';ctx.textAlign='left';
ctx.fillText(`${myTgtTime} 目标`,W-pR-58,refY-5);
// X轴日期标签
ctx.textAlign='center';
for(let d=1;d<=daysInMonth;d++){
if(d%5===1||d===daysInMonth){
const x=pL+(d-0.5)/daysInMonth*cW;
ctx.fillStyle='rgba(255,255,255,0.25)';ctx.font='10px -apple-system,sans-serif';
ctx.fillText(`${d}`,x,H-pB+16);
}
}
const today=new Date();
const todayDay=(today.getFullYear()===year&&today.getMonth()===month)?today.getDate():daysInMonth;
const active=names.filter(n=>selectedUsers.has(n));
const allChartPoints=[]; // for tooltip
// 画每个选中用户
active.forEach((name,ni)=>{
const recs=users[name]||[];
const c=COLORS[names.indexOf(name)%COLORS.length];
const isPrimary=ni===0;
// 建立日期→分钟映射
const dataMap={};
recs.forEach(r=>{
const[ry,rm,rd]=r.date.split('-').map(Number);
if(ry===year&&rm===month+1)dataMap[rd]=sleepMins(r.start);
});
// 预估缺失天(仅主用户)
function estimateForDay(d){
const vals=[];
for(let i=1;i<=5&&vals.length<3;i++){if(dataMap[d-i]!==undefined)vals.push(dataMap[d-i]);}
return vals.length>0?Math.round(vals.reduce((a,b)=>a+b,0)/vals.length):null;
}
const pts=[];
for(let d=1;d<=daysInMonth;d++){
if(d>todayDay)continue;
const x=pL+(d-0.5)/daysInMonth*cW;
let offsetVal,isReal=true;
if(dataMap[d]!==undefined){offsetVal=dataMap[d];}
else if(isPrimary){offsetVal=estimateForDay(d);isReal=false;}
else continue;
if(offsetVal===null)continue;
const off=offsetVal-60;
const clamped=Math.max(0,Math.min(off,yMax));
const y=pT+clamped*yScale;
const dotColor=off<=60?'#22c55e':off<=120?'#f59e0b':'#ef4444';
pts.push({x,y,day:d,offset:offsetVal,real:isReal,color:isPrimary?dotColor:c,userColor:c,name});
}
// 折线
const realPts=pts.filter(p=>p.real);
if(realPts.length>1){
ctx.strokeStyle=c;ctx.lineWidth=isPrimary?2.5:2;ctx.lineJoin='round';
ctx.setLineDash(isPrimary?[]:[4,4]);
ctx.beginPath();realPts.forEach((p,i)=>{if(!i)ctx.moveTo(p.x,p.y);else ctx.lineTo(p.x,p.y);});ctx.stroke();
ctx.setLineDash([]);
}
// 圆点
pts.forEach(p=>{
if(p.real){
ctx.fillStyle='#0f0e2a';ctx.strokeStyle=p.color;ctx.lineWidth=isPrimary?2.5:2;
ctx.beginPath();ctx.arc(p.x,p.y,isPrimary?4.5:3,0,Math.PI*2);ctx.fill();ctx.stroke();
}else{
ctx.strokeStyle='rgba(255,255,255,0.15)';ctx.lineWidth=1.5;ctx.setLineDash([3,3]);
ctx.beginPath();ctx.arc(p.x,p.y,4,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
}
});
allChartPoints.push(...pts);
});
// 图例
ctx.textAlign='left';
active.forEach((name,ni)=>{
const c=COLORS[names.indexOf(name)%COLORS.length];
const x=pL+ni*80;
ctx.fillStyle=c;ctx.fillRect(x,8,12,3);
ctx.fillStyle='rgba(255,255,255,0.4)';ctx.font='10px -apple-system,sans-serif';ctx.fillText(name,x+16,11);
});
// Tooltip
const minsToStr=off=>{let t=off+20*60;if(t>=1440)t-=1440;return`${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`;};
canvas._chartPoints=allChartPoints;
canvas._chartMeta={month,minsToStr};
if(!canvas._tooltipBound){
canvas._tooltipBound=true;
const showTip=(cx,cy)=>{
const r=canvas.getBoundingClientRect(),mx=cx-r.left;
const pts=canvas._chartPoints||[];
let closest=null,minD=30;
pts.forEach(p=>{const d=Math.abs(p.x-mx);if(d<minD){minD=d;closest=p;}});
let tip=document.getElementById('chartTip');
if(!tip){tip=document.createElement('div');tip.id='chartTip';
tip.style.cssText='position:absolute;background:rgba(0,0,0,0.85);color:white;padding:6px 10px;border-radius:8px;font-size:12px;pointer-events:none;z-index:10;white-space:nowrap;transition:opacity 0.15s;';
canvas.parentElement.style.position='relative';canvas.parentElement.appendChild(tip);}
if(closest){
const m=canvas._chartMeta;
const ts=m.minsToStr(closest.offset);
const label=closest.real?ts:`~${ts}(预估)`;
tip.textContent=`${m.month+1}/${closest.day} ${closest.name}: ${label}`;
tip.style.left=Math.min(closest.x,canvas.getBoundingClientRect().width-100)+'px';
tip.style.top=(closest.y-32)+'px';tip.style.opacity='1';
}else{tip.style.opacity='0';}
};
canvas.addEventListener('mousemove',e=>showTip(e.clientX,e.clientY));
canvas.addEventListener('touchstart',e=>showTip(e.touches[0].clientX,e.touches[0].clientY),{passive:true});
canvas.addEventListener('mouseleave',()=>{const t=document.getElementById('chartTip');if(t)t.style.opacity='0';});
}
}
function renderCelebrate(users){
const area=document.getElementById('celebrateArea');
const today=new Date().toISOString().slice(0,10);
const earlyUsers=[];
for(const[name,recs] of Object.entries(users)){
const r=recs.find(r=>r.date===today);
if(r&&sleepMins(r.start)<=getTargetMins(name))earlyUsers.push({name,target:userTargets[name]||'22:00'});
}
area.innerHTML=earlyUsers.length?`<div class="celebrate"><span style="font-size:24px;">🎉</span> <span style="font-size:13px;color:#86efac;">${earlyUsers.map(u=>u.name).join('、')} 今天达标了!</span></div>`:'';
}
function renderRecords(users){
const el=document.getElementById('recordList');
const all=[];
for(const[name,recs] of Object.entries(users))recs.slice(0,14).forEach(r=>all.push({...r,user:name}));
all.sort((a,b)=>b.date.localeCompare(a.date));
if(!all.length){el.innerHTML='<div style="color:rgba(255,255,255,0.2);font-size:13px;">暂无记录</div>';return;}
const names=Object.keys(users);
el.innerHTML=all.slice(0,20).map(r=>{
const isMe=r.user===myName;
const ci=names.indexOf(r.user);
const c=COLORS[ci%COLORS.length];
const myBtns=isMe?`<button class="ebtn" onclick="editRecord('${r.user}','${r.date}')" title="修改">✎</button><button class="ebtn" onclick="deleteRecord('${r.user}','${r.date}')" title="删除" style="color:rgba(255,100,100,0.3);">×</button>`:'';
return`<div class="record">
<span style="color:rgba(255,255,255,0.3);min-width:46px;">${r.date.slice(5)}</span>
<span style="font-weight:500;color:${c};">${fmtTime(r.start)}</span>
${myBtns}
<span class="utag" style="background:${c}20;color:${c};margin-left:auto;">${r.user}</span>
</div>`;
}).join('');
}
if('Notification'in window&&Notification.permission==='default')Notification.requestPermission();
// 自动检测代码更新
(function(){let kv=null;async function ck(){try{const r=await fetch('/api/version');const d=await r.json();if(kv===null){kv=d.v;return;}if(d.v!==kv)location.reload();}catch(e){}}ck();setInterval(ck,60000);})();
</script>
</body>
</html>