Files
digital-pilates/hooks/useWaterData.ts
richarjiang 3e6f55d804 feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
2025-09-30 14:37:15 +08:00

755 lines
24 KiB
TypeScript
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.

import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { logger } from '@/utils/logger';
import { Toast } from '@/utils/toast.utils';
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
// 水分记录数据结构
export interface WaterRecord {
id: string;
amount: number;
recordedAt: string;
createdAt: string;
note?: string;
}
// 水分统计数据结构
export interface WaterStats {
totalAmount: number;
completionRate: number;
recordCount: number;
}
// 将 HealthKit 数据转换为应用数据格式
const convertHealthKitToWaterRecord = (healthKitRecord: any): WaterRecord => {
return {
id: healthKitRecord.id || `${healthKitRecord.startDate}_${healthKitRecord.value}`,
amount: Math.round(healthKitRecord.value), // HealthKit 已经返回毫升数值
recordedAt: healthKitRecord.startDate,
createdAt: healthKitRecord.endDate,
note: healthKitRecord.metadata?.note || undefined,
};
};
// 创建日期范围选项
function createDateRange(date: string): { startDate: string; endDate: string } {
return {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
}
const useWaterChallengeProgressReporter = () => {
const dispatch = useAppDispatch();
const allChallenges = useAppSelector(selectChallengeList);
const joinedWaterChallenges = useMemo(
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined && challenge.status === 'ongoing'),
[allChallenges]
);
return useCallback(
async (value: number) => {
if (!joinedWaterChallenges.length) {
return;
}
for (const challenge of joinedWaterChallenges) {
try {
await dispatch(reportChallengeProgress({ id: challenge.id, value })).unwrap();
} catch (error) {
console.warn('挑战进度上报失败', { error, challengeId: challenge.id });
}
}
},
[dispatch, joinedWaterChallenges]
);
};
export const useWaterData = () => {
// 本地状态管理
const [loading, setLoading] = useState({
records: false,
stats: false,
goal: false
});
const [error, setError] = useState<string | null>(null);
const [dailyWaterGoal, setDailyWaterGoal] = useState<number>(2000);
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
const options = createDateRange(date);
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
// 应用分页逻辑
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedRecords = convertedRecords.slice(startIndex, endIndex);
setWaterRecords(prev => ({
...prev,
[date]: page === 1 ? paginatedRecords : [...(prev[date] || []), ...paginatedRecords]
}));
return paginatedRecords;
} catch (error) {
console.error('获取饮水记录失败:', error);
setError('获取饮水记录失败');
Toast.error('获取饮水记录失败');
return [];
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 加载更多记录占位符HealthKit一次性返回所有数据
const loadMoreWaterRecords = useCallback(async () => {
// HealthKit通常一次性返回所有数据这里保持接口一致性
return;
}, []);
// 获取日期范围的记录
const getWaterRecordsByDateRange = useCallback(async (
startDate: string,
endDate: string,
page = 1,
limit = 20
) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
const options = {
startDate: dayjs(startDate).startOf('day').toDate().toISOString(),
endDate: dayjs(endDate).endOf('day').toDate().toISOString()
};
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
// 按日期分组
const recordsByDate: { [date: string]: WaterRecord[] } = {};
convertedRecords.forEach(record => {
const date = dayjs(record.recordedAt).format('YYYY-MM-DD');
if (!recordsByDate[date]) {
recordsByDate[date] = [];
}
recordsByDate[date].push(record);
});
setWaterRecords(prev => ({ ...prev, ...recordsByDate }));
return recordsByDate;
} catch (error) {
console.error('获取日期范围饮水记录失败:', error);
setError('获取日期范围饮水记录失败');
Toast.error('获取日期范围饮水记录失败');
return {};
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 加载今日数据
const loadTodayData = useCallback(() => {
const today = dayjs().format('YYYY-MM-DD');
getWaterRecordsByDate(today);
}, [getWaterRecordsByDate]);
// 加载指定日期数据
const loadDataByDate = useCallback((date: string) => {
setSelectedDate(date);
getWaterRecordsByDate(date);
}, [getWaterRecordsByDate]);
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try {
const recordTime = recordedAt || dayjs().toISOString();
const date = dayjs(recordTime).format('YYYY-MM-DD');
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(date);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (isToday) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
},
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
try {
// 找到要更新的记录
let recordToUpdate: WaterRecord | null = null;
let recordDate = '';
for (const [date, records] of Object.entries(waterRecords)) {
const record = records.find(r => r.id === id);
if (record) {
recordToUpdate = record;
recordDate = date;
break;
}
}
if (!recordToUpdate) {
Toast.error('找不到要更新的记录');
return false;
}
// 先删除旧记录
await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt);
// 添加新记录
const newAmount = amount ?? recordToUpdate.amount;
const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt;
const success = await addWaterRecord(newAmount, newRecordedAt);
if (success) {
Toast.success('更新饮水记录成功');
}
return success;
} catch (error: any) {
console.error('更新喝水记录失败:', error);
Toast.error(error?.message || '更新喝水记录失败');
return false;
}
}, [waterRecords, addWaterRecord]);
// 删除喝水记录
const removeWaterRecord = useCallback(async (id: string) => {
try {
// 找到要删除的记录
let recordToDelete: WaterRecord | null = null;
let recordDate = '';
for (const [date, records] of Object.entries(waterRecords)) {
const record = records.find(r => r.id === id);
if (record) {
recordToDelete = record;
recordDate = date;
break;
}
}
if (!recordToDelete) {
Toast.error('找不到要删除的记录');
return false;
}
// 从 HealthKit 删除
const healthKitSuccess = await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt);
if (!healthKitSuccess) {
console.warn('从 HealthKit 删除记录失败,但继续更新本地数据');
}
// 更新本地状态
setWaterRecords(prev => ({
...prev,
[recordDate]: prev[recordDate]?.filter(record => record.id !== id) || []
}));
// 如果是今天的数据更新Widget
if (recordDate === dayjs().format('YYYY-MM-DD')) {
const updatedTodayRecords = waterRecords[recordDate]?.filter(record => record.id !== id) || [];
const totalAmount = updatedTodayRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 删除同步错误:', widgetError);
}
}
Toast.success('删除饮水记录成功');
return true;
} catch (error: any) {
console.error('删除喝水记录失败:', error);
Toast.error(error?.message || '删除喝水记录失败');
return false;
}
}, [waterRecords, dailyWaterGoal]);
// 更新喝水目标
const updateWaterGoal = useCallback(async (goal: number) => {
try {
await setWaterGoalToStorage(goal);
setDailyWaterGoal(goal);
// 更新Widget
const today = dayjs().format('YYYY-MM-DD');
const todayRecords = waterRecords[today] || [];
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
try {
const quickAddAmount = await getQuickWaterAmount();
await syncWaterDataToWidget({
dailyGoal: goal,
currentIntake: totalAmount,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 目标同步错误:', widgetError);
}
Toast.success('更新饮水目标成功');
return true;
} catch (error: any) {
console.error('更新喝水目标失败:', error);
Toast.error(error?.message || '更新喝水目标失败');
return false;
}
}, [waterRecords]);
// 计算总喝水量
const getTotalAmount = useCallback((records: WaterRecord[]) => {
return records.reduce((total, record) => total + record.amount, 0);
}, []);
// 按小时分组数据
const getHourlyData = useCallback((records: WaterRecord[]) => {
const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
amount: 0,
}));
records.forEach(record => {
const hour = dayjs(record.recordedAt).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].amount += record.amount;
}
});
return hourlyData;
}, []);
// 计算完成率(返回百分比)
const calculateCompletionRate = useCallback((totalAmount: number, goal: number) => {
if (goal <= 0) return 0;
return Math.min((totalAmount / goal) * 100, 100);
}, []);
// 加载初始数据
useEffect(() => {
const loadInitialData = async () => {
try {
// 加载饮水目标
const goal = await getWaterGoalFromStorage();
setDailyWaterGoal(goal);
// 加载今日数据
loadTodayData();
} catch (error) {
console.error('加载初始数据失败:', error);
}
};
loadInitialData();
}, [loadTodayData]);
// 计算今日统计数据
const todayStats = useMemo(() => {
const today = dayjs().format('YYYY-MM-DD');
const todayRecords = waterRecords[today] || [];
const totalAmount = getTotalAmount(todayRecords);
return {
totalAmount,
completionRate: calculateCompletionRate(totalAmount, dailyWaterGoal),
recordCount: todayRecords.length
};
}, [waterRecords, dailyWaterGoal, getTotalAmount, calculateCompletionRate]);
// 同步初始数据到Widget
useEffect(() => {
const syncInitialDataToWidget = async () => {
if (todayStats && dailyWaterGoal) {
try {
const quickAddAmount = await getQuickWaterAmount();
await syncWaterDataToWidget({
currentIntake: todayStats.totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
} catch (error) {
console.error('初始Widget数据同步失败:', error);
}
}
};
syncInitialDataToWidget();
}, [todayStats, dailyWaterGoal]);
return {
// 数据
todayStats,
dailyWaterGoal,
waterRecords: waterRecords[selectedDate] || [],
waterRecordsMeta: {
total: waterRecords[selectedDate]?.length || 0,
page: 1,
limit: 20,
hasMore: false
},
selectedDate,
loading,
error,
// 方法
loadTodayData,
loadDataByDate,
getWaterRecordsByDate,
loadMoreWaterRecords,
getWaterRecordsByDateRange,
addWaterRecord,
updateWaterRecord,
removeWaterRecord,
updateWaterGoal,
getTotalAmount,
getHourlyData,
calculateCompletionRate,
};
};
// 简化的Hook只返回今日数据
export const useTodayWaterData = () => {
const waterData = useWaterData();
const todayRecords = useMemo(() => {
const today = dayjs().format('YYYY-MM-DD');
return waterData.waterRecords || [];
}, [waterData.waterRecords]);
const todayMeta = useMemo(() => ({
total: todayRecords.length,
page: 1,
limit: 20,
hasMore: false
}), [todayRecords.length]);
const fetchTodayWaterRecords = useCallback(async (page = 1, limit = 20) => {
const today = dayjs().format('YYYY-MM-DD');
await waterData.getWaterRecordsByDate(today, page, limit);
}, [waterData.getWaterRecordsByDate]);
return {
todayStats: waterData.todayStats,
dailyWaterGoal: waterData.dailyWaterGoal,
waterRecords: todayRecords,
waterRecordsMeta: todayMeta,
selectedDate: waterData.selectedDate,
loading: waterData.loading,
error: waterData.error,
fetchTodayWaterRecords,
loadMoreWaterRecords: waterData.loadMoreWaterRecords,
getWaterRecordsByDateRange: waterData.getWaterRecordsByDateRange,
addWaterRecord: waterData.addWaterRecord,
updateWaterRecord: waterData.updateWaterRecord,
removeWaterRecord: waterData.removeWaterRecord,
updateWaterGoal: waterData.updateWaterGoal,
};
};
// 按日期获取饮水数据的 hook
export const useWaterDataByDate = (targetDate?: string) => {
const dateToUse = targetDate || dayjs().format('YYYY-MM-DD');
// 本地状态管理
const [loading, setLoading] = useState({
records: false,
stats: false,
goal: false
});
const [error, setError] = useState<string | null>(null);
const [dailyWaterGoal, setDailyWaterGoal] = useState<number>(2000);
const [waterRecords, setWaterRecords] = useState<WaterRecord[]>([]);
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
setError(null);
try {
logger.debug('🚰 开始获取饮水记录,日期:', date);
const options = createDateRange(date);
logger.debug('🚰 查询选项:', options);
const healthKitRecords = await getWaterIntakeFromHealthKit(options);
logger.debug('🚰 从HealthKit获取到的原始数据:', healthKitRecords);
// 转换数据格式并按时间排序
const convertedRecords = healthKitRecords
.map(convertHealthKitToWaterRecord)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime());
logger.debug('🚰 转换后的记录:', convertedRecords);
logger.debug('🚰 记录数量:', convertedRecords.length);
setWaterRecords(convertedRecords);
return convertedRecords;
} catch (error) {
console.error('🚰 获取饮水记录失败:', error);
setError('获取饮水记录失败');
Toast.error('获取饮水记录失败');
return [];
} finally {
setLoading(prev => ({ ...prev, records: false }));
}
}, []);
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try {
const recordTime = recordedAt || dayjs().toISOString();
// 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
if (!healthKitSuccess) {
Toast.error('保存到 HealthKit 失败');
return false;
}
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 同步错误:', widgetError);
}
}
await reportWaterChallengeProgress(totalAmount);
return true;
} catch (error: any) {
console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败');
return false;
}
},
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
try {
const recordToUpdate = waterRecords.find(r => r.id === id);
if (!recordToUpdate) {
Toast.error('找不到要更新的记录');
return false;
}
// 先删除旧记录
await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt);
// 添加新记录
const newAmount = amount ?? recordToUpdate.amount;
const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt;
const success = await addWaterRecord(newAmount, newRecordedAt);
return success;
} catch (error: any) {
console.error('更新喝水记录失败:', error);
Toast.error(error?.message || '更新喝水记录失败');
return false;
}
}, [waterRecords, addWaterRecord]);
// 删除喝水记录
const removeWaterRecord = useCallback(async (id: string) => {
try {
const recordToDelete = waterRecords.find(r => r.id === id);
if (!recordToDelete) {
Toast.error('找不到要删除的记录');
return false;
}
// 从 HealthKit 删除
await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt);
// 重新获取数据
await getWaterRecordsByDate(dateToUse);
// 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const updatedRecords = waterRecords.filter(record => record.id !== id);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount();
try {
await syncWaterDataToWidget({
currentIntake: totalAmount,
dailyGoal: dailyWaterGoal,
quickAddAmount,
});
await refreshWidget();
} catch (widgetError) {
console.error('Widget 删除同步错误:', widgetError);
}
}
Toast.success('删除饮水记录成功');
return true;
} catch (error: any) {
console.error('删除喝水记录失败:', error);
Toast.error(error?.message || '删除喝水记录失败');
return false;
}
}, [waterRecords, getWaterRecordsByDate, dateToUse, dailyWaterGoal]);
// 更新喝水目标
const updateWaterGoal = useCallback(async (goal: number) => {
try {
await setWaterGoalToStorage(goal);
setDailyWaterGoal(goal);
Toast.success('更新饮水目标成功');
return true;
} catch (error: any) {
console.error('更新喝水目标失败:', error);
Toast.error(error?.message || '更新喝水目标失败');
return false;
}
}, []);
// 计算指定日期的统计数据
const waterStats = useMemo(() => {
logger.debug('🚰 计算waterStats - waterRecords:', waterRecords);
logger.debug('🚰 计算waterStats - dailyWaterGoal:', dailyWaterGoal);
if (!waterRecords || waterRecords.length === 0) {
logger.debug('🚰 没有饮水记录,返回默认值');
return {
totalAmount: 0,
completionRate: 0,
recordCount: 0
};
}
const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0);
const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0;
logger.debug('🚰 计算结果 - totalAmount:', totalAmount);
logger.debug('🚰 计算结果 - completionRate:', completionRate);
logger.debug('🚰 计算结果 - recordCount:', waterRecords.length);
return {
totalAmount,
completionRate,
recordCount: waterRecords.length
};
}, [waterRecords, dailyWaterGoal]);
// 初始化加载指定日期的数据
useEffect(() => {
const loadInitialData = async () => {
try {
// 加载饮水目标
const goal = await getWaterGoalFromStorage();
setDailyWaterGoal(goal);
// 加载指定日期数据
await getWaterRecordsByDate(dateToUse);
} catch (error) {
console.error('加载初始数据失败:', error);
}
};
loadInitialData();
}, [dateToUse, getWaterRecordsByDate]);
return {
waterStats,
dailyWaterGoal,
waterRecords,
waterRecordsMeta: {
total: waterRecords.length,
page: 1,
limit: 20,
hasMore: false
},
loading,
error,
addWaterRecord,
updateWaterRecord,
removeWaterRecord,
updateWaterGoal,
getWaterRecordsByDate,
};
};