File: //proc/2131/cwd/20260313/src/App.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { GoogleGenAI } from "@google/genai";
import {
Activity,
Moon,
Zap,
RefreshCw,
ChevronRight,
Heart,
Brain,
Sparkles,
AlertCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { OuraData } from './types';
import {
StatCard,
HealthWarnings,
SleepStageChart,
formatDuration,
ContributorSection,
HealthScoreGauge
} from './components/HealthComponents';
export default function App() {
const [loading, setLoading] = useState(false);
const [ouraData, setOuraData] = useState<OuraData | null>(null);
const [report, setReport] = useState<string | null>(null);
const [healthScore, setHealthScore] = useState<number | null>(null);
const [reportRange, setReportRange] = useState<'today' | 'yesterday' | 'week'>('today');
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/oura/data');
if (!res.ok) throw new Error('Failed to fetch data');
const data = await res.json();
setOuraData(data);
generateReport(data, reportRange);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [reportRange]);
useEffect(() => {
fetchData();
}, [fetchData]);
const generateReport = async (data: OuraData, range: 'today' | 'yesterday' | 'week') => {
setLoading(true);
try {
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
let prompt = '';
// Helper to get date string in YYYY-MM-DD
const getDateStr = (offset: number) => {
const d = new Date();
d.setDate(d.getDate() - offset);
return d.toISOString().split('T')[0];
};
const formatInstruction = `
请按以下严格格式输出(不要包含 Markdown 代码块标记):
### 综合评分
[仅输出一个 0-100 的整数数字,代表当天的综合健康得分]
### 状态总结
[一句话总结当前状态]
### 核心洞察
- [洞察1]
- [洞察2]
### 行动建议
- [建议1]
- [建议2]
- [建议3]
注意:
1. 请称呼用户为“杨哥”。
2. 在提及年龄相关建议时,请使用“您这年纪”来表述(用户实际年龄为 43 岁)。
3. 请根据该年龄段的生理特点(如代谢水平、恢复速度、心血管健康重点)提供针对性的专业建议。
`;
if (range === 'today' || range === 'yesterday') {
const targetDate = range === 'today' ? getDateStr(0) : getDateStr(1);
const readiness = data.readiness.find(d => d.day === targetDate) || data.readiness[data.readiness.length - (range === 'today' ? 1 : 2)];
const sleep = data.sleep.find(d => d.day === targetDate) || data.sleep[data.sleep.length - (range === 'today' ? 1 : 2)];
const activity = data.activity.find(d => d.day === targetDate) || data.activity[data.activity.length - (range === 'today' ? 1 : 2)];
if (!readiness && !sleep && !activity) {
throw new Error(`未能找到${range === 'today' ? '今天' : '昨天'}的健康数据,请确保您的 Oura Ring 已同步。`);
}
prompt = `
作为健康专家,分析 Oura 数据(${range === 'today' ? '今天' : '昨天'},日期:${readiness?.day || targetDate})。
数据:
- 准备程度: ${readiness?.score || 'N/A'} (贡献因素: ${JSON.stringify(readiness?.contributors)})
- 睡眠分数: ${sleep?.score || 'N/A'} (效率: ${sleep?.efficiency}%, 时长: ${Math.round(sleep?.total_sleep_duration / 3600)}小时)
- 活动分数: ${activity?.score || 'N/A'} (步数: ${activity?.steps}, 消耗: ${activity?.active_calories}卡路里)
${formatInstruction}
`;
} else {
if (data.readiness.length === 0) throw new Error('过去一周暂无足够数据。');
prompt = `
作为健康专家,分析过去一周的 Oura 数据趋势(共 ${data.readiness.length} 天数据)。
每周数据:
- 准备程度分数序列: ${data.readiness.map(d => `${d.day}: ${d.score}`).join(', ')}
- 睡眠分数序列: ${data.sleep.map(d => `${d.day}: ${d.score}`).join(', ')}
- 活动分数序列: ${data.activity.map(d => `${d.day}: ${d.score}`).join(', ')}
${formatInstruction.replace('状态总结', '本周趋势').replace('核心洞察', '亮点与不足').replace('行动建议', '下周建议')}
`;
}
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
});
const text = response.text || '无法生成报告内容。';
setReport(text);
// Extract health score
const scoreMatch = text.match(/### 综合评分\s*(\d+)/);
if (scoreMatch) {
setHealthScore(parseInt(scoreMatch[1]));
} else {
// Fallback calculation if AI fails to provide score
let lastReadiness, lastSleep, lastActivity;
if (range === 'today' || range === 'yesterday') {
const targetDate = range === 'today' ? getDateStr(0) : getDateStr(1);
lastReadiness = data.readiness.find(d => d.day === targetDate) || data.readiness[data.readiness.length - (range === 'today' ? 1 : 2)];
lastSleep = data.sleep.find(d => d.day === targetDate) || data.sleep[data.sleep.length - (range === 'today' ? 1 : 2)];
lastActivity = data.activity.find(d => d.day === targetDate) || data.activity[data.activity.length - (range === 'today' ? 1 : 2)];
} else {
lastReadiness = data.readiness[data.readiness.length - 1];
lastSleep = data.sleep[data.sleep.length - 1];
lastActivity = data.activity[data.activity.length - 1];
}
const rScore = lastReadiness?.score || 0;
const sScore = lastSleep?.score || 0;
const aScore = lastActivity?.score || 0;
setHealthScore(Math.round((rScore + sScore + aScore) / 3));
}
} catch (err: any) {
console.error('Gemini error', err);
setReport(`无法生成AI报告: ${err.message}`);
} finally {
setLoading(false);
}
};
const handleRangeChange = (range: 'today' | 'yesterday' | 'week') => {
setReportRange(range);
if (ouraData) {
generateReport(ouraData, range);
}
};
return (
<div className="min-h-screen bg-stone-50 text-stone-900 font-sans selection:bg-emerald-100">
{/* Header */}
<header className="max-w-4xl mx-auto px-4 md:px-6 py-4 md:py-8 flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-8 h-8 md:w-10 md:h-10 bg-emerald-600 rounded-lg md:rounded-xl flex items-center justify-center text-white">
<Activity size={20} className="md:w-6 md:h-6" />
</div>
<div>
<h1 className="text-lg md:text-xl font-semibold tracking-tight">Oura 健康日报</h1>
<p className="text-[10px] text-stone-400 font-medium">杨哥 · 专属定制版</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
<button
onClick={fetchData}
disabled={loading}
className={`text-stone-400 hover:text-emerald-600 transition-all p-2 rounded-lg hover:bg-emerald-50 ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
title="更新数据"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 md:px-6 pb-12 md:pb-20">
<div className="space-y-4 md:space-y-8">
{/* Overall Health Score */}
<HealthScoreGauge score={healthScore} />
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2 md:gap-6">
<StatCard
title="准备程度"
value={ouraData?.readiness[ouraData.readiness.length - 1]?.score || '--'}
icon={<RefreshCw className="text-blue-500 w-4 h-4 md:w-5 md:h-5" />}
color="bg-blue-50"
/>
<StatCard
title="睡眠质量"
value={ouraData?.sleep[ouraData.sleep.length - 1]?.score || '--'}
icon={<Moon className="text-indigo-500 w-4 h-4 md:w-5 md:h-5" />}
color="bg-indigo-50"
/>
<StatCard
title="活动指数"
value={ouraData?.activity[ouraData.activity.length - 1]?.score || '--'}
icon={<Zap className="text-orange-500 w-4 h-4 md:w-5 md:h-5" />}
color="bg-orange-50"
/>
</div>
{/* Health Warnings */}
<HealthWarnings data={ouraData} />
{/* Detailed Insights (Optional) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
<div className="flex items-center justify-between mb-2 md:mb-4">
<div className="flex items-center gap-2 text-stone-500">
<Heart size={16} className="md:w-[18px] md:h-[18px]" />
<span className="text-xs md:text-sm font-medium">心率变异性 (HRV)</span>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${
(ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || 0) >= (ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length || 0)
? 'bg-emerald-50 text-emerald-600' : 'bg-orange-50 text-orange-600'
}`}>
{(ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || 0) >= (ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length || 0) ? '高于平均' : '低于平均'}
</span>
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl md:text-2xl font-bold">
{ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || '--'} <span className="text-xs font-normal text-stone-400">ms</span>
</div>
<div className="text-[10px] md:text-xs text-stone-400">
/ 周均 {Math.round(ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length) || '--'} ms
</div>
</div>
<p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">HRV 是身体恢复和压力水平的关键科学指标</p>
</div>
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
<div className="flex items-center justify-between mb-2 md:mb-4">
<div className="flex items-center gap-2 text-stone-500">
<Activity size={16} className="md:w-[18px] md:h-[18px]" />
<span className="text-xs md:text-sm font-medium">心血管年龄</span>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full font-medium bg-emerald-50 text-emerald-600">
状态良好
</span>
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl md:text-2xl font-bold">
-2 <span className="text-xs font-normal text-stone-400">岁</span>
</div>
<div className="text-[10px] md:text-xs text-stone-400">
比实际年龄更年轻
</div>
</div>
<p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">基于动脉硬化程度和静息心率估算</p>
</div>
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
<div className="flex items-center justify-between mb-2 md:mb-4">
<div className="flex items-center gap-2 text-stone-500">
<Activity size={16} className="md:w-[18px] md:h-[18px]" />
<span className="text-xs md:text-sm font-medium">今日步数</span>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${
(ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) >= (ouraData?.activity[ouraData.activity.length - 1]?.target_steps || 10000)
? 'bg-emerald-50 text-emerald-600' : 'bg-stone-50 text-stone-500'
}`}>
{Math.round(((ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) / (ouraData?.activity[ouraData.activity.length - 1]?.target_steps || 10000)) * 100)}% 完成
</span>
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl md:text-2xl font-bold">
{ouraData?.activity[ouraData.activity.length - 1]?.steps?.toLocaleString() || '0'}
</div>
<div className="text-[10px] md:text-xs text-stone-400">
/ 目标 {ouraData?.activity[ouraData.activity.length - 1]?.target_steps?.toLocaleString() || '10,000'}
</div>
</div>
<p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">
较昨日 { (ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) > (ouraData?.activity[ouraData.activity.length - 2]?.steps || 0) ? '增加' : '减少' }
{ Math.abs((ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) - (ouraData?.activity[ouraData.activity.length - 2]?.steps || 0)).toLocaleString() } 步
</p>
</div>
</div>
{/* Score Contributors Breakdown */}
<div className="space-y-6">
<ContributorSection
title="准备程度分析"
icon={<RefreshCw size={18} className="text-blue-500" />}
contributors={ouraData?.readiness[ouraData.readiness.length - 1]?.contributors}
labels={{
activity_balance: '活动平衡',
body_temperature: '体温',
hrv_balance: 'HRV 平衡',
previous_day_activity: '昨日活动',
previous_night: '昨晚睡眠',
recovery_index: '恢复指数',
resting_heart_rate: '静息心率',
sleep_balance: '睡眠平衡'
}}
/>
<ContributorSection
title="睡眠质量分析"
icon={<Moon size={18} className="text-indigo-500" />}
contributors={ouraData?.sleep[ouraData.sleep.length - 1]?.contributors}
labels={{
deep_sleep: '深睡',
efficiency: '睡眠效率',
latency: '入睡潜伏期',
rem_sleep: 'REM 睡眠',
restfulness: '安稳度',
timing: '睡眠时机',
total_sleep: '总睡眠时长'
}}
/>
<ContributorSection
title="活动指数分析"
icon={<Zap size={18} className="text-orange-500" />}
contributors={ouraData?.activity[ouraData.activity.length - 1]?.contributors}
labels={{
meet_daily_targets: '目标达成',
move_every_hour: '每小时活动',
recovery_time: '恢复时间',
stay_active: '保持活跃',
training_frequency: '训练频率',
training_volume: '训练量'
}}
/>
</div>
{/* Sleep Depth Analysis */}
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-8 shadow-sm border border-stone-200/60">
<div className="flex items-center gap-2 text-indigo-600 font-semibold mb-6">
<Moon size={18} className="md:w-5 md:h-5" />
<span className="text-sm md:text-base">睡眠深度分析</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<div className="h-[200px] md:h-[250px]">
<SleepStageChart data={ouraData?.sleep[ouraData.sleep.length - 1]} />
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-indigo-50/50 p-3 rounded-xl">
<p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">深睡时长</p>
<p className="text-lg font-bold text-indigo-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.deep_sleep_duration)}</p>
</div>
<div className="bg-blue-50/50 p-3 rounded-xl">
<p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">REM 时长</p>
<p className="text-lg font-bold text-blue-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.rem_sleep_duration)}</p>
</div>
<div className="bg-stone-50 p-3 rounded-xl">
<p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">浅睡时长</p>
<p className="text-lg font-bold text-stone-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.light_sleep_duration)}</p>
</div>
<div className="bg-emerald-50/50 p-3 rounded-xl">
<p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">总睡眠</p>
<p className="text-lg font-bold text-emerald-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.total_sleep_duration)}</p>
</div>
</div>
<p className="text-xs text-stone-500 leading-relaxed italic">
* 理想的睡眠结构中,深睡应占 15-25%,REM 应占 20-25%。
</p>
</div>
</div>
</div>
{/* AI Report */}
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-8 shadow-sm border border-stone-200/60 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 md:p-8 opacity-5">
<Sparkles size={80} className="md:w-[120px] md:h-[120px]" />
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 md:gap-4 mb-6 md:mb-8">
<div className="flex items-center gap-2 text-emerald-600 font-semibold">
<Brain size={18} className="md:w-5 md:h-5" />
<span className="text-sm md:text-base">AI 健康分析</span>
</div>
<div className="flex bg-stone-100 p-1 rounded-lg md:rounded-xl">
<button
onClick={() => handleRangeChange('today')}
className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'today' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
>
今天
</button>
<button
onClick={() => handleRangeChange('yesterday')}
className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'yesterday' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
>
昨天
</button>
<button
onClick={() => handleRangeChange('week')}
className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'week' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
>
过去一周
</button>
</div>
</div>
{loading ? (
<div className="py-12 flex flex-col items-center justify-center text-stone-400 gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 2, ease: "linear" }}
>
<RefreshCw size={32} />
</motion.div>
<p>正在分析您的健康数据...</p>
</div>
) : error ? (
<div className="py-8 text-center text-red-500 flex flex-col items-center gap-2">
<AlertCircle size={32} />
<p>{error}</p>
<button onClick={fetchData} className="text-stone-500 underline text-sm mt-2">重试</button>
</div>
) : (
<div className="space-y-6">
{report ? (
<div className="grid grid-cols-1 gap-6">
{report.replace(/\*\*/g, '').split('###').filter(s => s.trim() && !s.includes('综合评分')).map((section, i) => {
const [title, ...content] = section.trim().split('\n');
return (
<div key={i} className="group">
<h3 className="text-sm font-bold text-emerald-600 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-emerald-500 rounded-full"></span>
{title.trim()}
</h3>
<div className="bg-stone-50 rounded-xl md:rounded-2xl p-4 md:p-5 border border-stone-100 group-hover:border-emerald-100 transition-colors">
{content.map((line, j) => {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith('-')) {
return (
<div key={j} className="flex gap-2 md:gap-3 mb-1.5 md:mb-2 last:mb-0">
<span className="text-emerald-500 mt-1 md:mt-1.5">•</span>
<p className="text-stone-700 text-sm md:text-base leading-relaxed">{trimmed.replace(/^-/, '').trim()}</p>
</div>
);
}
return <p key={j} className="text-stone-700 text-sm md:text-base leading-relaxed mb-1.5 md:mb-2 last:mb-0">{trimmed}</p>;
})}
</div>
</div>
);
})}
</div>
) : (
<p className="text-stone-400 italic text-center py-8">暂无报告数据</p>
)}
</div>
)}
</div>
</div>
</main>
{/* Footer */}
<footer className="max-w-4xl mx-auto px-6 py-12 text-center text-stone-400 text-sm border-t border-stone-200/40">
<p>© 2026 Oura 健康日报 AI. 数据来源于 Oura API.</p>
</footer>
{/* Back to Top Button */}
<AnimatePresence>
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-6 right-6 bg-white border border-stone-200 p-3 rounded-full shadow-lg text-stone-500 hover:text-emerald-600 transition-all z-50"
>
<ChevronRight size={20} className="-rotate-90" />
</motion.button>
</AnimatePresence>
</div>
);
}