feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理
- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知 - 实现基于HRV数据的压力检测和智能鼓励通知 - 添加后台任务处理支持,修改iOS后台模式为processing - 优化营养记录页面使用Redux状态管理,支持实时数据更新 - 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食 - 新增营养目标动态计算功能,基于用户身体数据智能推荐 - 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
This commit is contained in:
@@ -454,6 +454,19 @@ export async function fetchTodayHRV(): Promise<number | null> {
|
||||
return fetchHRVForDate(dayjs().toDate());
|
||||
}
|
||||
|
||||
// 获取最近几小时内的实时HRV数据
|
||||
export async function fetchRecentHRV(hoursBack: number = 2): Promise<number | null> {
|
||||
console.log(`开始获取最近${hoursBack}小时内的HRV数据...`);
|
||||
|
||||
const now = new Date();
|
||||
const options = {
|
||||
startDate: dayjs(now).subtract(hoursBack, 'hour').toDate().toISOString(),
|
||||
endDate: now.toISOString()
|
||||
};
|
||||
|
||||
return fetchHeartRateVariability(options);
|
||||
}
|
||||
|
||||
// 更新healthkit中的体重
|
||||
export async function updateWeight(weight: number) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -12,12 +12,12 @@ export function buildCoachDeepLink(params: {
|
||||
}): string {
|
||||
const baseUrl = '/coach';
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
|
||||
if (params.action) searchParams.set('action', params.action);
|
||||
if (params.subAction) searchParams.set('subAction', params.subAction);
|
||||
if (params.meal) searchParams.set('meal', params.meal);
|
||||
if (params.message) searchParams.set('message', encodeURIComponent(params.message));
|
||||
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
}
|
||||
@@ -286,48 +286,6 @@ export class GoalNotificationHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 心情相关的通知辅助函数
|
||||
*/
|
||||
export class MoodNotificationHelpers {
|
||||
/**
|
||||
* 发送心情打卡提醒
|
||||
*/
|
||||
static async sendMoodCheckinReminder(userName: string) {
|
||||
return notificationService.sendImmediateNotification({
|
||||
title: '心情打卡',
|
||||
body: `${userName},记得记录今天的心情状态哦`,
|
||||
data: { type: 'mood_checkin_reminder' },
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排每日心情打卡提醒
|
||||
*/
|
||||
static async scheduleDailyMoodReminder(userName: string, hour: number = 20, minute: number = 0) {
|
||||
const reminderTime = new Date();
|
||||
reminderTime.setHours(hour, minute, 0, 0);
|
||||
|
||||
// 如果今天的时间已经过了,设置为明天
|
||||
if (reminderTime.getTime() <= Date.now()) {
|
||||
reminderTime.setDate(reminderTime.getDate() + 1);
|
||||
}
|
||||
|
||||
return notificationService.scheduleRepeatingNotification(
|
||||
{
|
||||
title: '每日心情打卡',
|
||||
body: `${userName},记得记录今天的心情状态哦`,
|
||||
data: { type: 'daily_mood_reminder' },
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
},
|
||||
{ days: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养相关的通知辅助函数
|
||||
*/
|
||||
@@ -423,8 +381,8 @@ export class NutritionNotificationHelpers {
|
||||
return notificationService.sendImmediateNotification({
|
||||
title: '午餐记录提醒',
|
||||
body: `${userName},记得记录今天的午餐情况哦!`,
|
||||
data: {
|
||||
type: 'lunch_reminder',
|
||||
data: {
|
||||
type: 'lunch_reminder',
|
||||
meal: '午餐',
|
||||
url: coachUrl
|
||||
},
|
||||
@@ -453,6 +411,82 @@ export class NutritionNotificationHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排每日晚餐提醒
|
||||
* @param userName 用户名
|
||||
* @param hour 小时 (默认18点)
|
||||
* @param minute 分钟 (默认0分)
|
||||
* @returns 通知ID
|
||||
*/
|
||||
static async scheduleDailyDinnerReminder(
|
||||
userName: string,
|
||||
hour: number = 18,
|
||||
minute: number = 0
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// 检查是否已经存在晚餐提醒
|
||||
const existingNotifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
const existingDinnerReminder = existingNotifications.find(
|
||||
notification =>
|
||||
notification.content.data?.type === 'dinner_reminder' &&
|
||||
notification.content.data?.isDailyReminder === true
|
||||
);
|
||||
|
||||
if (existingDinnerReminder) {
|
||||
console.log('晚餐提醒已存在,跳过重复注册:', existingDinnerReminder.identifier);
|
||||
return existingDinnerReminder.identifier;
|
||||
}
|
||||
|
||||
// 创建晚餐提醒通知
|
||||
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
||||
{
|
||||
title: '🍽️ 晚餐时光到啦!',
|
||||
body: `${userName},美好的晚餐时光开始了~记得记录今天的晚餐哦!营养均衡很重要呢 💪`,
|
||||
data: {
|
||||
type: 'dinner_reminder',
|
||||
isDailyReminder: true,
|
||||
meal: '晚餐',
|
||||
url: '/nutrition/records' // 直接跳转到营养记录页面
|
||||
},
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('每日晚餐提醒已安排,ID:', notificationId);
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error('安排每日晚餐提醒失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消晚餐提醒
|
||||
*/
|
||||
static async cancelDinnerReminder(): Promise<void> {
|
||||
try {
|
||||
const notifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (notification.content.data?.type === 'dinner_reminder' &&
|
||||
notification.content.data?.isDailyReminder === true) {
|
||||
await notificationService.cancelNotification(notification.identifier);
|
||||
console.log('已取消晚餐提醒:', notification.identifier);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消晚餐提醒失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排营养记录提醒
|
||||
*/
|
||||
@@ -478,10 +512,10 @@ export class NutritionNotificationHelpers {
|
||||
// 构建深度链接
|
||||
const mealTypeMap: Record<string, string> = {
|
||||
'早餐': 'breakfast',
|
||||
'午餐': 'lunch',
|
||||
'午餐': 'lunch',
|
||||
'晚餐': 'dinner'
|
||||
};
|
||||
|
||||
|
||||
const coachUrl = buildCoachDeepLink({
|
||||
action: 'diet',
|
||||
subAction: 'card',
|
||||
@@ -492,8 +526,8 @@ export class NutritionNotificationHelpers {
|
||||
{
|
||||
title: `${mealTime.meal}提醒`,
|
||||
body: `${userName},记得记录您的${mealTime.meal}情况`,
|
||||
data: {
|
||||
type: 'meal_reminder',
|
||||
data: {
|
||||
type: 'meal_reminder',
|
||||
meal: mealTime.meal,
|
||||
url: coachUrl
|
||||
},
|
||||
@@ -510,6 +544,102 @@ export class NutritionNotificationHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 心情相关的通知辅助函数
|
||||
*/
|
||||
export class MoodNotificationHelpers {
|
||||
/**
|
||||
* 安排每日心情提醒
|
||||
* @param userName 用户名
|
||||
* @param hour 小时 (默认21点)
|
||||
* @param minute 分钟 (默认0分)
|
||||
* @returns 通知ID
|
||||
*/
|
||||
static async scheduleDailyMoodReminder(
|
||||
userName: string,
|
||||
hour: number = 21,
|
||||
minute: number = 0
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// 检查是否已经存在心情提醒
|
||||
const existingNotifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
const existingMoodReminder = existingNotifications.find(
|
||||
notification =>
|
||||
notification.content.data?.type === 'mood_reminder' &&
|
||||
notification.content.data?.isDailyReminder === true
|
||||
);
|
||||
|
||||
if (existingMoodReminder) {
|
||||
console.log('心情提醒已存在,跳过重复注册:', existingMoodReminder.identifier);
|
||||
return existingMoodReminder.identifier;
|
||||
}
|
||||
|
||||
// 创建心情提醒通知
|
||||
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
||||
{
|
||||
title: '🌙 今天过得怎么样呀?',
|
||||
body: `${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕`,
|
||||
data: {
|
||||
type: 'mood_reminder',
|
||||
isDailyReminder: true,
|
||||
url: '/mood-statistics' // 跳转到心情统计页面
|
||||
},
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('每日心情提醒已安排,ID:', notificationId);
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error('安排每日心情提醒失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心情记录提醒
|
||||
*/
|
||||
static async sendMoodReminder(userName: string) {
|
||||
return notificationService.sendImmediateNotification({
|
||||
title: '🌙 今天过得怎么样呀?',
|
||||
body: `${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕`,
|
||||
data: {
|
||||
type: 'mood_reminder',
|
||||
url: '/mood-statistics'
|
||||
},
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消心情提醒
|
||||
*/
|
||||
static async cancelMoodReminder(): Promise<void> {
|
||||
try {
|
||||
const notifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (notification.content.data?.type === 'mood_reminder' &&
|
||||
notification.content.data?.isDailyReminder === true) {
|
||||
await notificationService.cancelNotification(notification.identifier);
|
||||
console.log('已取消心情提醒:', notification.identifier);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消心情提醒失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用通知辅助函数
|
||||
*/
|
||||
@@ -634,11 +764,11 @@ export const NotificationTemplates = {
|
||||
reminder: (userName: string, meal: string) => {
|
||||
const mealTypeMap: Record<string, string> = {
|
||||
'早餐': 'breakfast',
|
||||
'午餐': 'lunch',
|
||||
'午餐': 'lunch',
|
||||
'晚餐': 'dinner',
|
||||
'加餐': 'snack'
|
||||
};
|
||||
|
||||
|
||||
const coachUrl = buildCoachDeepLink({
|
||||
action: 'diet',
|
||||
subAction: 'card',
|
||||
@@ -648,8 +778,8 @@ export const NotificationTemplates = {
|
||||
return {
|
||||
title: `${meal}提醒`,
|
||||
body: `${userName},记得记录您的${meal}情况`,
|
||||
data: {
|
||||
type: 'meal_reminder',
|
||||
data: {
|
||||
type: 'meal_reminder',
|
||||
meal,
|
||||
url: coachUrl
|
||||
},
|
||||
@@ -667,8 +797,8 @@ export const NotificationTemplates = {
|
||||
return {
|
||||
title: '午餐记录提醒',
|
||||
body: `${userName},记得记录今天的午餐情况哦!`,
|
||||
data: {
|
||||
type: 'lunch_reminder',
|
||||
data: {
|
||||
type: 'lunch_reminder',
|
||||
meal: '午餐',
|
||||
url: coachUrl
|
||||
},
|
||||
|
||||
78
utils/nutrition.ts
Normal file
78
utils/nutrition.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export interface NutritionGoals {
|
||||
calories: number;
|
||||
proteinGoal: number;
|
||||
fatGoal: number;
|
||||
carbsGoal: number;
|
||||
fiberGoal: number;
|
||||
sodiumGoal: number;
|
||||
}
|
||||
|
||||
export interface UserProfileForNutrition {
|
||||
weight?: string;
|
||||
height?: string;
|
||||
birthDate?: Date;
|
||||
gender?: 'male' | 'female';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算用户的营养目标
|
||||
* 基于Mifflin-St Jeor公式计算基础代谢率,然后计算各营养素目标
|
||||
*/
|
||||
export const calculateNutritionGoals = (userProfile?: UserProfileForNutrition): NutritionGoals => {
|
||||
const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg
|
||||
const height = parseFloat(userProfile?.height || '170'); // 默认170cm
|
||||
const age = userProfile?.birthDate ?
|
||||
dayjs().diff(dayjs(userProfile.birthDate), 'year') : 25; // 默认25岁
|
||||
const isWoman = userProfile?.gender === 'female';
|
||||
|
||||
// 基础代谢率计算(Mifflin-St Jeor Equation)
|
||||
let bmr;
|
||||
if (isWoman) {
|
||||
bmr = 10 * weight + 6.25 * height - 5 * age - 161;
|
||||
} else {
|
||||
bmr = 10 * weight + 6.25 * height - 5 * age + 5;
|
||||
}
|
||||
|
||||
// 总热量需求(假设轻度活动)
|
||||
const totalCalories = bmr * 1.375;
|
||||
|
||||
// 计算营养素目标
|
||||
const proteinGoal = weight * 1.6; // 1.6g/kg
|
||||
const fatGoal = totalCalories * 0.25 / 9; // 25%来自脂肪,9卡/克
|
||||
const carbsGoal = (totalCalories - proteinGoal * 4 - fatGoal * 9) / 4; // 剩余来自碳水
|
||||
|
||||
// 纤维目标:成人推荐25-35g/天
|
||||
const fiberGoal = 25;
|
||||
|
||||
// 钠目标:WHO推荐<2300mg/天
|
||||
const sodiumGoal = 2300;
|
||||
|
||||
return {
|
||||
calories: Math.round(totalCalories),
|
||||
proteinGoal: Math.round(proteinGoal * 10) / 10,
|
||||
fatGoal: Math.round(fatGoal * 10) / 10,
|
||||
carbsGoal: Math.round(carbsGoal * 10) / 10,
|
||||
fiberGoal,
|
||||
sodiumGoal,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算剩余可摄入卡路里
|
||||
* 公式:还能吃 = 基础代谢 + 运动消耗 - 已摄入饮食
|
||||
*/
|
||||
export const calculateRemainingCalories = (params: {
|
||||
basalMetabolism: number;
|
||||
activeCalories: number;
|
||||
consumedCalories: number;
|
||||
}): number => {
|
||||
const { basalMetabolism, activeCalories, consumedCalories } = params;
|
||||
|
||||
// 总消耗 = 基础代谢 + 运动消耗
|
||||
const totalBurned = basalMetabolism + activeCalories;
|
||||
|
||||
// 剩余可摄入 = 总消耗 - 已摄入
|
||||
return totalBurned - consumedCalories;
|
||||
};
|
||||
Reference in New Issue
Block a user