|
|
@@ -0,0 +1,114 @@
|
|
|
+import { AppDataSource } from '../config/database';
|
|
|
+import { DailyHealthMetric } from '../entities/DailyHealthMetric';
|
|
|
+import { Between, IsNull, Not } from 'typeorm';
|
|
|
+
|
|
|
+export class HealthMetricService {
|
|
|
+ private repo = AppDataSource.getRepository(DailyHealthMetric);
|
|
|
+
|
|
|
+ /** 最近 N 天的每日指标 */
|
|
|
+ async getRecent(days: number) {
|
|
|
+ const since = new Date();
|
|
|
+ since.setDate(since.getDate() - days);
|
|
|
+ const sinceStr = since.toISOString().slice(0, 10);
|
|
|
+ return this.repository.find({
|
|
|
+ where: { metricDate: Between(sinceStr, new Date().toISOString().slice(0, 10)) },
|
|
|
+ order: { metricDate: 'ASC' }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 最新一天(dashboard 用) */
|
|
|
+ async getLatest() {
|
|
|
+ const row = await this.repository.findOne({
|
|
|
+ where: { hrvSdnn: Not(IsNull()) },
|
|
|
+ order: { metricDate: 'DESC' }
|
|
|
+ });
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 恢复分:基于 HRV 相对 30 天基线的比值
|
|
|
+ * >1.0 状态好 / 0.85-1.0 一般 / <0.85 疲劳
|
|
|
+ */
|
|
|
+ async getRecovery() {
|
|
|
+ const rows = await this.repository.find({
|
|
|
+ where: { hrvSdnn: Not(IsNull()) },
|
|
|
+ order: { metricDate: 'DESC' },
|
|
|
+ take: 30
|
|
|
+ });
|
|
|
+ if (rows.length < 7) {
|
|
|
+ return { enough: false, days: rows.length };
|
|
|
+ }
|
|
|
+ const baseline = rows.reduce((s, r) => s + Number(r.hrvSdnn), 0) / rows.length;
|
|
|
+ const today = rows[0];
|
|
|
+ const todayHrv = Number(today.hrvSdnn);
|
|
|
+ const ratio = todayHrv / baseline;
|
|
|
+ let status: 'good' | 'ok' | 'fatigued' = 'ok';
|
|
|
+ if (ratio >= 1.0) status = 'good';
|
|
|
+ else if (ratio < 0.85) status = 'fatigued';
|
|
|
+
|
|
|
+ return {
|
|
|
+ enough: true,
|
|
|
+ today: {
|
|
|
+ date: today.metricDate,
|
|
|
+ hrv: todayHrv,
|
|
|
+ restingHr: today.restingHeartRate,
|
|
|
+ sleepHours: today.sleepDurationHours,
|
|
|
+ deepSleepHours: today.deepSleepHours,
|
|
|
+ },
|
|
|
+ baseline30d: Math.round(baseline * 10) / 10,
|
|
|
+ ratio: Math.round(ratio * 100) / 100,
|
|
|
+ status,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 经期 profile:最近 6 次经期前后 7 天的均值 */
|
|
|
+ async getMenstrualProfile() {
|
|
|
+ // 查所有行按时间排序,找经期跳变
|
|
|
+ const rows = await this.repository.find({
|
|
|
+ select: ['metricDate', 'isMenstruating', 'restingHeartRate', 'hrvSdnn', 'sleepDurationHours', 'workoutCount'],
|
|
|
+ order: { metricDate: 'ASC' }
|
|
|
+ });
|
|
|
+ const starts: string[] = [];
|
|
|
+ let prev = false;
|
|
|
+ for (const r of rows) {
|
|
|
+ const cur = !!r.isMenstruating;
|
|
|
+ if (cur && !prev) starts.push(r.metricDate);
|
|
|
+ prev = cur;
|
|
|
+ }
|
|
|
+ // 最近 6 次
|
|
|
+ const recentStarts = starts.slice(-6);
|
|
|
+ if (recentStarts.length === 0) return { periods: 0, profile: [] };
|
|
|
+
|
|
|
+ // 对每次经期,聚合 offset -7..+7 的平均指标
|
|
|
+ const byOffset: Record<number, { hr: number[]; hrv: number[]; sleep: number[]; workouts: number }> = {};
|
|
|
+ for (const start of recentStarts) {
|
|
|
+ const startDate = new Date(start);
|
|
|
+ for (let off = -7; off <= 7; off++) {
|
|
|
+ const d = new Date(startDate);
|
|
|
+ d.setDate(d.getDate() + off);
|
|
|
+ const dStr = d.toISOString().slice(0, 10);
|
|
|
+ const row = await this.repository.findOneBy({ metricDate: dStr });
|
|
|
+ if (!row) continue;
|
|
|
+ if (!byOffset[off]) byOffset[off] = { hr: [], hrv: [], sleep: [], workouts: 0 };
|
|
|
+ if (row.restingHeartRate) byOffset[off].hr.push(Number(row.restingHeartRate));
|
|
|
+ if (row.hrvSdnn) byOffset[off].hrv.push(Number(row.hrvSdnn));
|
|
|
+ if (row.sleepDurationHours) byOffset[off].sleep.push(Number(row.sleepDurationHours));
|
|
|
+ byOffset[off].workouts += row.workoutCount;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const profile = Object.entries(byOffset).map(([offset, v]) => ({
|
|
|
+ offset: Number(offset),
|
|
|
+ avgRestingHr: v.hr.length ? Math.round(v.hr.reduce((a,b)=>a+b,0)/v.hr.length * 10) / 10 : null,
|
|
|
+ avgHrv: v.hrv.length ? Math.round(v.hrv.reduce((a,b)=>a+b,0)/v.hrv.length * 10) / 10 : null,
|
|
|
+ avgSleepHours: v.sleep.length ? Math.round(v.sleep.reduce((a,b)=>a+b,0)/v.sleep.length * 100) / 100 : null,
|
|
|
+ totalWorkouts: v.workouts,
|
|
|
+ samples: Math.min(v.hr.length, v.hrv.length, v.sleep.length),
|
|
|
+ }));
|
|
|
+ return { periods: recentStarts.length, profile };
|
|
|
+ }
|
|
|
+
|
|
|
+ private get repository() {
|
|
|
+ return this.repo;
|
|
|
+ }
|
|
|
+}
|