实现完整的周期性断食计划系统,支持每日自动续订和通知管理: - 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory) - 实现周期性断食会话的自动完成和续订逻辑 - 添加独立的周期性断食通知系统,避免与单次断食通知冲突 - 支持暂停/恢复周期性断食计划 - 添加周期性断食数据持久化和水合功能 - 优化断食界面,优先显示周期性断食信息 - 新增空状态引导界面,提升用户体验 - 保持单次断食功能向后兼容
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting';
|
|
import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting';
|
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
import dayjs from 'dayjs';
|
|
import type { RootState } from './index';
|
|
|
|
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start' | 'auto';
|
|
|
|
// 保持向后兼容的单次断食计划
|
|
export type FastingSchedule = {
|
|
planId: string;
|
|
startISO: string;
|
|
endISO: string;
|
|
createdAtISO: string;
|
|
updatedAtISO: string;
|
|
origin: FastingScheduleOrigin;
|
|
};
|
|
|
|
// 新的周期性断食计划
|
|
export type FastingCycle = {
|
|
planId: string;
|
|
startHour: number;
|
|
startMinute: number;
|
|
enabled: boolean;
|
|
createdAtISO: string;
|
|
lastUpdatedISO: string;
|
|
};
|
|
|
|
// 周期性断食的单次会话
|
|
export type FastingCycleSession = {
|
|
planId: string;
|
|
startISO: string;
|
|
endISO: string;
|
|
cycleDate: string; // YYYY-MM-DD
|
|
completed: boolean;
|
|
};
|
|
|
|
type FastingState = {
|
|
// 保持向后兼容的单次计划
|
|
activeSchedule: FastingSchedule | null;
|
|
history: FastingSchedule[];
|
|
|
|
// 新的周期性计划
|
|
activeCycle: FastingCycle | null;
|
|
currentCycleSession: FastingCycleSession | null;
|
|
cycleHistory: FastingCycleSession[];
|
|
};
|
|
|
|
const initialState: FastingState = {
|
|
activeSchedule: null,
|
|
history: [],
|
|
activeCycle: null,
|
|
currentCycleSession: null,
|
|
cycleHistory: [],
|
|
};
|
|
|
|
const fastingSlice = createSlice({
|
|
name: 'fasting',
|
|
initialState,
|
|
reducers: {
|
|
hydrateActiveSchedule: (
|
|
state,
|
|
action: PayloadAction<FastingSchedule | null>
|
|
) => {
|
|
const incoming = action.payload;
|
|
if (!incoming) {
|
|
state.activeSchedule = null;
|
|
return;
|
|
}
|
|
|
|
if (state.activeSchedule) {
|
|
const currentUpdated = dayjs(state.activeSchedule.updatedAtISO ?? state.activeSchedule.startISO);
|
|
const incomingUpdated = dayjs(incoming.updatedAtISO ?? incoming.startISO);
|
|
if (currentUpdated.isSame(incomingUpdated) || currentUpdated.isAfter(incomingUpdated)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
state.activeSchedule = incoming;
|
|
},
|
|
scheduleFastingPlan: (
|
|
state,
|
|
action: PayloadAction<{ planId: string; start: string; origin?: FastingScheduleOrigin }>
|
|
) => {
|
|
const plan = getPlanById(action.payload.planId);
|
|
if (!plan) return;
|
|
|
|
const startDate = new Date(action.payload.start);
|
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
|
const nowISO = new Date().toISOString();
|
|
state.activeSchedule = {
|
|
planId: plan.id,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString(),
|
|
createdAtISO: nowISO,
|
|
updatedAtISO: nowISO,
|
|
origin: action.payload.origin ?? 'manual',
|
|
};
|
|
},
|
|
rescheduleActivePlan: (
|
|
state,
|
|
action: PayloadAction<{ start: string; origin?: FastingScheduleOrigin }>
|
|
) => {
|
|
if (!state.activeSchedule) return;
|
|
const plan = getPlanById(state.activeSchedule.planId);
|
|
if (!plan) return;
|
|
|
|
const startDate = new Date(action.payload.start);
|
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
|
state.activeSchedule = {
|
|
...state.activeSchedule,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString(),
|
|
updatedAtISO: new Date().toISOString(),
|
|
origin: action.payload.origin ?? state.activeSchedule.origin,
|
|
};
|
|
},
|
|
setRecommendedSchedule: (
|
|
state,
|
|
action: PayloadAction<{ planId: string; recommendedStart: string }>
|
|
) => {
|
|
const plan = getPlanById(action.payload.planId);
|
|
if (!plan) return;
|
|
const startDate = new Date(action.payload.recommendedStart);
|
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
|
const nowISO = new Date().toISOString();
|
|
state.activeSchedule = {
|
|
planId: plan.id,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString(),
|
|
createdAtISO: nowISO,
|
|
updatedAtISO: nowISO,
|
|
origin: 'recommended',
|
|
};
|
|
},
|
|
completeActiveSchedule: (state) => {
|
|
if (!state.activeSchedule) return;
|
|
const phase = getFastingPhase(
|
|
new Date(state.activeSchedule.startISO),
|
|
new Date(state.activeSchedule.endISO)
|
|
);
|
|
|
|
if (phase === 'fasting') {
|
|
// Allow manual completion only when fasting window已经结束
|
|
state.activeSchedule = {
|
|
...state.activeSchedule,
|
|
endISO: dayjs().toISOString(),
|
|
updatedAtISO: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
state.history.unshift(state.activeSchedule);
|
|
state.activeSchedule = null;
|
|
},
|
|
clearActiveSchedule: (state) => {
|
|
state.activeSchedule = null;
|
|
},
|
|
|
|
// 周期性断食计划相关的 actions
|
|
startFastingCycle: (
|
|
state,
|
|
action: PayloadAction<{
|
|
planId: string;
|
|
startHour: number;
|
|
startMinute: number;
|
|
}>
|
|
) => {
|
|
const plan = getPlanById(action.payload.planId);
|
|
if (!plan) return;
|
|
|
|
const nowISO = new Date().toISOString();
|
|
state.activeCycle = {
|
|
planId: plan.id,
|
|
startHour: action.payload.startHour,
|
|
startMinute: action.payload.startMinute,
|
|
enabled: true,
|
|
createdAtISO: nowISO,
|
|
lastUpdatedISO: nowISO,
|
|
};
|
|
|
|
// 创建今天的断食会话,增加最小提前时间检查
|
|
const today = dayjs();
|
|
const todayStart = today.hour(action.payload.startHour).minute(action.payload.startMinute).second(0).millisecond(0);
|
|
|
|
// 要求至少提前10分钟设置周期性断食
|
|
const minAdvanceTime = 10; // 分钟
|
|
const minStartTime = today.add(minAdvanceTime, 'minute');
|
|
|
|
// 如果今天的开始时间已过或太接近,则从明天开始
|
|
const sessionStart = todayStart.isBefore(minStartTime)
|
|
? todayStart.add(1, 'day')
|
|
: todayStart;
|
|
|
|
const { start, end } = calculateFastingWindow(sessionStart.toDate(), plan.fastingHours);
|
|
|
|
state.currentCycleSession = {
|
|
planId: plan.id,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString(),
|
|
cycleDate: sessionStart.format('YYYY-MM-DD'),
|
|
completed: false,
|
|
};
|
|
},
|
|
|
|
pauseFastingCycle: (state) => {
|
|
if (state.activeCycle) {
|
|
state.activeCycle.enabled = false;
|
|
state.activeCycle.lastUpdatedISO = new Date().toISOString();
|
|
}
|
|
},
|
|
|
|
resumeFastingCycle: (state) => {
|
|
if (state.activeCycle) {
|
|
state.activeCycle.enabled = true;
|
|
state.activeCycle.lastUpdatedISO = new Date().toISOString();
|
|
}
|
|
},
|
|
|
|
stopFastingCycle: (state) => {
|
|
// 完成当前会话
|
|
if (state.currentCycleSession) {
|
|
state.cycleHistory.unshift(state.currentCycleSession);
|
|
}
|
|
|
|
// 清除周期性计划
|
|
state.activeCycle = null;
|
|
state.currentCycleSession = null;
|
|
},
|
|
|
|
updateFastingCycleTime: (
|
|
state,
|
|
action: PayloadAction<{ startHour: number; startMinute: number }>
|
|
) => {
|
|
if (!state.activeCycle) return;
|
|
|
|
state.activeCycle.startHour = action.payload.startHour;
|
|
state.activeCycle.startMinute = action.payload.startMinute;
|
|
state.activeCycle.lastUpdatedISO = new Date().toISOString();
|
|
},
|
|
|
|
completeCurrentCycleSession: (state) => {
|
|
if (!state.currentCycleSession) return;
|
|
|
|
// 标记当前会话为已完成
|
|
state.currentCycleSession.completed = true;
|
|
|
|
// 添加到历史记录
|
|
state.cycleHistory.unshift(state.currentCycleSession);
|
|
|
|
// 创建下一个周期的会话
|
|
if (state.activeCycle && state.activeCycle.enabled) {
|
|
const plan = getPlanById(state.activeCycle.planId);
|
|
if (plan) {
|
|
const nextDate = dayjs(state.currentCycleSession.cycleDate).add(1, 'day');
|
|
const nextStart = nextDate.hour(state.activeCycle.startHour).minute(state.activeCycle.startMinute).second(0).millisecond(0);
|
|
const { start, end } = calculateFastingWindow(nextStart.toDate(), plan.fastingHours);
|
|
|
|
state.currentCycleSession = {
|
|
planId: plan.id,
|
|
startISO: start.toISOString(),
|
|
endISO: end.toISOString(),
|
|
cycleDate: nextDate.format('YYYY-MM-DD'),
|
|
completed: false,
|
|
};
|
|
}
|
|
} else {
|
|
state.currentCycleSession = null;
|
|
}
|
|
},
|
|
|
|
hydrateFastingCycle: (
|
|
state,
|
|
action: PayloadAction<{
|
|
activeCycle: FastingCycle | null;
|
|
currentCycleSession: FastingCycleSession | null;
|
|
cycleHistory: FastingCycleSession[];
|
|
}>
|
|
) => {
|
|
state.activeCycle = action.payload.activeCycle;
|
|
state.currentCycleSession = action.payload.currentCycleSession;
|
|
state.cycleHistory = action.payload.cycleHistory;
|
|
},
|
|
},
|
|
});
|
|
|
|
export const {
|
|
hydrateActiveSchedule,
|
|
scheduleFastingPlan,
|
|
rescheduleActivePlan,
|
|
setRecommendedSchedule,
|
|
completeActiveSchedule,
|
|
clearActiveSchedule,
|
|
// 周期性断食相关的 actions
|
|
startFastingCycle,
|
|
pauseFastingCycle,
|
|
resumeFastingCycle,
|
|
stopFastingCycle,
|
|
updateFastingCycleTime,
|
|
completeCurrentCycleSession,
|
|
hydrateFastingCycle,
|
|
} = fastingSlice.actions;
|
|
|
|
export default fastingSlice.reducer;
|
|
|
|
export const selectFastingRoot = (state: RootState) => state.fasting;
|
|
export const selectActiveFastingSchedule = (state: RootState) => state.fasting.activeSchedule;
|
|
export const selectFastingPlans = () => FASTING_PLANS;
|
|
export const selectActiveFastingPlan = (state: RootState): FastingPlan | undefined => {
|
|
const schedule = state.fasting.activeSchedule;
|
|
if (!schedule) return undefined;
|
|
return getPlanById(schedule.planId);
|
|
};
|
|
|
|
// 周期性断食相关的 selectors
|
|
export const selectActiveFastingCycle = (state: RootState) => state.fasting.activeCycle;
|
|
export const selectCurrentCycleSession = (state: RootState) => state.fasting.currentCycleSession;
|
|
export const selectCycleHistory = (state: RootState) => state.fasting.cycleHistory;
|
|
export const selectActiveCyclePlan = (state: RootState): FastingPlan | undefined => {
|
|
const cycle = state.fasting.activeCycle;
|
|
if (!cycle) return undefined;
|
|
return getPlanById(cycle.planId);
|
|
};
|
|
export const selectCurrentCyclePlan = (state: RootState): FastingPlan | undefined => {
|
|
const session = state.fasting.currentCycleSession;
|
|
if (!session) return undefined;
|
|
return getPlanById(session.planId);
|
|
};
|
|
|
|
// 获取当前应该显示的断食信息(优先显示周期性,其次显示单次)
|
|
export const selectCurrentFastingPlan = (state: RootState): FastingPlan | undefined => {
|
|
// 优先显示周期性断食
|
|
const cyclePlan = selectCurrentCyclePlan(state);
|
|
if (cyclePlan) return cyclePlan;
|
|
|
|
// 其次显示单次断食
|
|
return selectActiveFastingPlan(state);
|
|
};
|
|
|
|
// 获取当前应该显示的断食时间
|
|
export const selectCurrentFastingTimes = (state: RootState) => {
|
|
// 优先显示周期性断食
|
|
const cycleSession = state.fasting.currentCycleSession;
|
|
if (cycleSession) {
|
|
return {
|
|
startISO: cycleSession.startISO,
|
|
endISO: cycleSession.endISO,
|
|
};
|
|
}
|
|
|
|
// 其次显示单次断食
|
|
const schedule = state.fasting.activeSchedule;
|
|
if (schedule) {
|
|
return {
|
|
startISO: schedule.startISO,
|
|
endISO: schedule.endISO,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// 判断是否处于周期性断食模式
|
|
export const selectIsInCycleMode = (state: RootState) => {
|
|
return !!state.fasting.activeCycle;
|
|
};
|