<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LifeSync</title>
<style>
:root{
--bg:#0b0f14;
--panel:#0f1621;
--panel2:#0c121b;
--text:#e9f0ff;
--muted:#8ea0bd;
--border:rgba(255,255,255,.08);
--good:#38d39f;
--warn:#ffb020;
--bad:#ff5c5c;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius:18px;
}
*{box-sizing:border-box}
body{
margin:0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang SC","Hiragino Sans GB","Microsoft YaHei", sans-serif;
background:
radial-gradient(900px 600px at 20% -10%, rgba(80,140,255,.18), transparent 55%),
radial-gradient(800px 500px at 95% 10%, rgba(56,211,159,.14), transparent 55%),
radial-gradient(900px 700px at 30% 120%, rgba(255,176,32,.10), transparent 55%),
var(--bg);
color:var(--text);
}
.wrap{
max-width: 1100px;
margin: 0 auto;
padding: 22px 18px 40px;
}
.topbar{
display:flex; align-items:center; justify-content:space-between;
margin-bottom:16px;
}
.brand{
display:flex; gap:12px; align-items:center;
}
.logo{
width:40px; height:40px; border-radius:14px;
background: linear-gradient(135deg, rgba(80,140,255,.9), rgba(56,211,159,.85));
box-shadow: var(--shadow);
}
.title h1{font-size:18px; margin:0}
.title p{margin:2px 0 0; color:var(--muted); font-size:12px}
.actions{display:flex; gap:10px; align-items:center}
.pill{
background: rgba(255,255,255,.06);
border:1px solid var(--border);
color:var(--muted);
padding:8px 10px;
border-radius:999px;
font-size:12px;
display:flex; gap:8px; align-items:center;
}
.btn{
background: rgba(255,255,255,.06);
border:1px solid var(--border);
color:var(--text);
padding:9px 12px;
border-radius:999px;
font-size:12px;
cursor:pointer;
}
.btn:hover{background: rgba(255,255,255,.09)}
.grid{
display:grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap:14px;
margin-top:14px;
}
@media (max-width: 980px){
.grid{grid-template-columns:1fr; }
}
.card{
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
border:1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding:16px;
position:relative;
overflow:hidden;
}
.card:before{
content:"";
position:absolute; inset:-2px;
background: radial-gradient(600px 180px at 30% 0%, rgba(80,140,255,.18), transparent 60%);
pointer-events:none;
}
.card h2{
position:relative;
margin:0 0 10px;
font-size:14px;
letter-spacing:.2px;
color: #dce6ff;
display:flex; align-items:center; justify-content:space-between;
}
.kpi{
position:relative;
display:flex;
gap:14px;
align-items:flex-end;
margin-top:6px;
}
.kpi .big{
font-size:34px;
font-weight:700;
line-height:1;
letter-spacing: .2px;
}
.kpi .unit{
color:var(--muted);
font-size:12px;
padding-bottom:4px;
}
.subgrid{
position:relative;
display:grid;
grid-template-columns: repeat(3, 1fr);
gap:10px;
margin-top:14px;
}
.mini{
background: rgba(0,0,0,.18);
border:1px solid var(--border);
border-radius: 14px;
padding:10px;
}
.mini .label{color:var(--muted); font-size:11px}
.mini .val{margin-top:6px; font-size:16px; font-weight:650}
.mini .val small{color:var(--muted); font-weight:500}
.spark{
position:relative;
margin-top:14px;
background: rgba(0,0,0,.16);
border:1px solid var(--border);
border-radius: 14px;
padding:10px;
}
.spark .row{
display:flex; justify-content:space-between; align-items:center;
margin-bottom:8px;
}
.spark .row .label{color:var(--muted); font-size:11px}
.spark svg{width:100%; height:48px; display:block}
.muted{color:var(--muted)}
.warnbar{
margin-top:14px;
padding:10px 12px;
border-radius: 14px;
border:1px solid rgba(255,176,32,.28);
background: rgba(255,176,32,.08);
color:#ffd79a;
font-size:12px;
position:relative;
}
.errbar{
margin-top:14px;
padding:10px 12px;
border-radius: 14px;
border:1px solid rgba(255,92,92,.28);
background: rgba(255,92,92,.08);
color:#ffb3b3;
font-size:12px;
position:relative;
white-space:pre-wrap;
}
.footer{
margin-top:16px;
color:var(--muted);
font-size:12px;
display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap;
}
code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand">
<div class="logo"></div>
<div class="title">
<h1>LifeSync</h1>
<p id="subtitle">Home · Loading…</p>
</div>
</div>
<div class="actions">
<div class="pill" id="rangePill">—</div>
<button class="btn" id="refreshBtn">刷新</button>
</div>
</div>
<div class="grid">
<!-- Health -->
<div class="card">
<h2>健康概览 <span class="muted">近7天</span></h2>
<div class="kpi">
<div class="big" id="kpiSteps">—</div>
<div class="unit">平均步数/天</div>
</div>
<div class="subgrid">
<div class="mini">
<div class="label">睡眠</div>
<div class="val" id="kpiSleep">— <small>小时</small></div>
</div>
<div class="mini">
<div class="label">饮水</div>
<div class="val" id="kpiWater">— <small>ml</small></div>
</div>
<div class="mini">
<div class="label">HRV(mean)</div>
<div class="val" id="kpiHrv">— <small>ms</small></div>
</div>
</div>
<div class="subgrid">
<div class="mini">
<div class="label">活动消耗</div>
<div class="val" id="kpiKcal">— <small>kcal</small></div>
</div>
<div class="mini">
<div class="label">有效天数</div>
<div class="val" id="kpiDaysWith">— <small>/7</small></div>
</div>
<div class="mini">
<div class="label">数据源</div>
<div class="val"><small id="healthSource">Notion</small></div>
</div>
</div>
<div class="spark">
<div class="row"><div class="label">步数趋势</div><div class="muted" id="stepsTrend">—</div></div>
<svg id="sparkSteps" viewBox="0 0 100 40" preserveAspectRatio="none"></svg>
</div>
<div class="spark">
<div class="row"><div class="label">饮水趋势</div><div class="muted" id="waterTrend">—</div></div>
<svg id="sparkWater" viewBox="0 0 100 40" preserveAspectRatio="none"></svg>
</div>
<div class="warnbar" id="healthHint" style="display:none;"></div>
</div>
<!-- Finance -->
<div class="card">
<h2>财务概览 <span class="muted">近7天</span></h2>
<div class="kpi">
<div class="big" id="kpiSpend">—</div>
<div class="unit">支出合计</div>
</div>
<div class="spark">
<div class="row"><div class="label">支出趋势</div><div class="muted" id="spendTrend">—</div></div>
<svg id="sparkSpend" viewBox="0 0 100 40" preserveAspectRatio="none"></svg>
</div>
<div class="warnbar" id="financeHint" style="display:none;"></div>
</div>
<!-- Tasks -->
<div class="card">
<h2>任务概览 <span class="muted">全量</span></h2>
<div class="kpi">
<div class="big" id="kpiTasksPending">—</div>
<div class="unit">待办</div>
</div>
<div class="subgrid">
<div class="mini">
<div class="label">总任务</div>
<div class="val" id="kpiTasksTotal">—</div>
</div>
<div class="mini">
<div class="label">已完成</div>
<div class="val" id="kpiTasksDone">—</div>
</div>
<div class="mini">
<div class="label">完成率</div>
<div class="val" id="kpiTasksRate">— <small>%</small></div>
</div>
</div>
<div class="warnbar" id="tasksHint" style="display:none;"></div>
</div>
</div>
<div class="errbar" id="errorBox" style="display:none;"></div>
<div class="footer">
<div>API: <code id="apiBase">—</code></div>
<div>Updated: <span id="updatedAt">—</span></div>
</div>
</div>
<script>
// === CONFIG ===
// If your API is on another domain, set it here:
// e.g. const API_BASE = "https://gpt-dashboard.city0920.workers.dev";
const API_BASE = "https://gpt-dashboard.city0920.workers.dev";
document.getElementById("apiBase").textContent = API_BASE;
const $ = (id) => document.getElementById(id);
function fmt(n, digits=0){
if(n === null || n === undefined || Number.isNaN(n)) return "—";
const x = Number(n);
if(!Number.isFinite(x)) return "—";
return x.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits });
}
function pct(a,b){
if(!b || b<=0) return null;
return Math.round((a/b)*1000)/10;
}
function buildSpark(svgEl, values){
// values: array numbers or null
const xs = values.map(v => (Number.isFinite(v) ? v : null));
const valid = xs.filter(v => Number.isFinite(v));
svgEl.innerHTML = "";
if(valid.length < 2){
// draw baseline
svgEl.innerHTML = `<path d="M0,30 L100,30" fill="none" stroke="rgba(255,255,255,.18)" stroke-width="2" />`;
return;
}
let min = Math.min(...valid);
let max = Math.max(...valid);
if(max === min){ max = min + 1; }
const pts = xs.map((v, i) => {
const x = (i/(xs.length-1))*100;
if(!Number.isFinite(v)) return null;
const y = 36 - ((v - min)/(max - min))*30; // 6..36
return {x,y};
}).filter(Boolean);
if(pts.length < 2){
svgEl.innerHTML = `<path d="M0,30 L100,30" fill="none" stroke="rgba(255,255,255,.18)" stroke-width="2" />`;
return;
}
const d = pts.map((p,i)=> (i===0?`M${p.x},${p.y}`:`L${p.x},${p.y}`)).join(" ");
const area = `M${pts[0].x},40 ` + pts.map(p=>`L${p.x},${p.y}`).join(" ") + ` L${pts[pts.length-1].x},40 Z`;
svgEl.innerHTML = `
<path d="${area}" fill="rgba(80,140,255,.12)"></path>
<path d="${d}" fill="none" stroke="rgba(80,140,255,.85)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"></path>
`;
}
function showError(err){
$("errorBox").style.display = "block";
$("errorBox").textContent = err;
}
function clearError(){
$("errorBox").style.display = "none";
$("errorBox").textContent = "";
}
async function load(){
clearError();
$("subtitle").textContent = "Home · Loading…";
// NOTE: 固化 days=7
const url = `${API_BASE}/api/snapshot?days=7&nocache=1`;
try{
const res = await fetch(url, { method:"GET" });
const json = await res.json();
if(!json.ok) throw new Error(json.error || "Snapshot failed");
const d = json.data;
const range = d.range;
$("rangePill").textContent = `${range.start} → ${range.end} · ${range.days}d`;
$("subtitle").textContent = "Home · Ready";
$("updatedAt").textContent = new Date(d.generatedAt).toLocaleString();
// Health
const hs = d.health?.summary || {};
$("kpiSteps").textContent = fmt(hs.steps?.avg, 0);
$("kpiSleep").innerHTML = `${fmt(hs.sleepHours?.avg, 1)} <small>小时</small>`;
$("kpiWater").innerHTML = `${fmt(hs.waterMl?.avg, 0)} <small>ml</small>`;
$("kpiHrv").innerHTML = `${fmt(hs.hrv?.mean?.avg, 1)} <small>ms</small>`;
$("kpiKcal").innerHTML = `${fmt(hs.kcal?.avg, 0)} <small>kcal</small>`;
$("kpiDaysWith").innerHTML = `${fmt(hs.daysWithData ?? 0, 0)} <small>/7</small>`;
$("healthSource").textContent = d.health?.source?.mode === "data_source" ? "Notion DS" : "Notion DB";
const hd = (d.health?.daily || []);
const stepsSeries = hd.map(x => x.steps ?? null);
const waterSeries = hd.map(x => x.waterMl ?? null);
buildSpark($("sparkSteps"), stepsSeries);
buildSpark($("sparkWater"), waterSeries);
$("stepsTrend").textContent = stepsSeries.filter(v=>Number.isFinite(v)).length ? `min ${fmt(Math.min(...stepsSeries.filter(v=>Number.isFinite(v))),0)} · max ${fmt(Math.max(...stepsSeries.filter(v=>Number.isFinite(v))),0)}` : "—";
$("waterTrend").textContent = waterSeries.filter(v=>Number.isFinite(v)).length ? `min ${fmt(Math.min(...waterSeries.filter(v=>Number.isFinite(v))),0)} · max ${fmt(Math.max(...waterSeries.filter(v=>Number.isFinite(v))),0)}` : "—";
// show hints
const hints = [];
if((hs.sleepHours?.avg ?? 0) && hs.sleepHours.avg < 6) hints.push("睡眠均值低于 6 小时,建议优先补足恢复。");
if((hs.waterMl?.avg ?? 0) && hs.waterMl.avg < 1500) hints.push("饮水均值偏低(<1500ml),可尝试分段小口补水。");
$("healthHint").style.display = hints.length ? "block" : "none";
$("healthHint").textContent = hints.join(" ");
// Finance
const fs = d.finance?.summary || {};
$("kpiSpend").textContent = `¥ ${fmt(fs.spending ?? 0, 2)}`;
const fd = (d.finance?.daily || []);
const spendSeries = fd.map(x => x.spending ?? null);
buildSpark($("sparkSpend"), spendSeries);
$("spendTrend").textContent = spendSeries.filter(v=>Number.isFinite(v)).length ? `max ¥${fmt(Math.max(...spendSeries.filter(v=>Number.isFinite(v))),2)}` : "—";
if((fs.spending ?? 0) === 0){
$("financeHint").style.display = "block";
$("financeHint").textContent = "近7天支出为 0:如果你近期未导入账单或今日库中无记录,这是正常现象。";
} else {
$("financeHint").style.display = "none";
}
// Tasks
const ts = d.tasks || {};
$("kpiTasksPending").textContent = fmt(ts.pending ?? 0, 0);
$("kpiTasksTotal").textContent = fmt(ts.total ?? 0, 0);
$("kpiTasksDone").textContent = fmt(ts.completed ?? 0, 0);
const rate = pct(ts.completed ?? 0, ts.total ?? 0);
$("kpiTasksRate").innerHTML = `${fmt(rate ?? 0, 1)} <small>%</small>`;
$("tasksHint").style.display = "block";
$("tasksHint").textContent = `数据源:${ts.source?.dataSourceId ? ("DS " + ts.source.dataSourceId.slice(0,4) + "..." + ts.source.dataSourceId.slice(-4)) : "—"};完成字段:${ts.schema?.doneCheckboxProp || "—"}`;
}catch(e){
showError(String(e && e.message ? e.message : e));
$("subtitle").textContent = "Home · Error";
}
}
$("refreshBtn").addEventListener("click", load);
load();
</script>
</body>
</html>