Files
schedule-planner/sleep-buddy.html
2026-04-06 13:46:31 +00:00

624 lines
32 KiB
HTML
Raw 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.
<!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>