經過前幾天的努力,KAKERU 已經能一口氣吐出 24 週的龐大訓練計畫。但如果 UI 上只顯示:「第 13 週,週二:輕鬆跑 5km」,看到這個畫面心裡可能會問:「所以這到底是幾月幾號啦?!」
如果不能把抽象的「週次」對齊真實世界的「日曆」,這個 APP 就永遠只是個半成品,跑者甚至可能會在星期一不小心跑了週末的 LSD 長距離。
今天,不僅要實作「日期映射演算」,還要加入「當週自動定位」與「未來課表時間鎖定」,並將 Day 22 開發的「多模態圖片解析」完美串接,讓每一次的完賽打卡,都成為帶有 AI 專屬評語的真實日記!
要達成這個目標,需要解決四個核心關鍵:
((weekNumber - 1) * 7) + (dayOfWeek - 1) 公式,算出每一天的絕對日期(如 10/15 (二)),並推算當前所在的「真實本週」。workoutDate > today),卡片標註「未開始」並反灰「尚未開放打卡」,防止跑者提早偷跑打卡。我們在 src/utils/dateUtils.ts 封裝日期推算與未來日期檢驗的核心函式:
const WEEK_DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
/**
* 取得基準日所在週的週一 (00:00:00)
*/
export const getMondayOfWeek = (baseDate?: Date | string | number | null): Date => {
const dateObj = baseDate ? new Date(baseDate) : new Date();
const currentDay = dateObj.getDay(); // 0 是週日, 1 是週一
const diffToMonday = currentDay === 0 ? -6 : 1 - currentDay;
const monday = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0);
monday.setDate(monday.getDate() + diffToMonday);
return monday;
};
/**
* 根據計畫起始日,推算「第 W 週 的 星期 D」是幾月幾號
*/
export const calculateWorkoutDate = (
startDate?: Date | string | number | null,
weekNumber: number = 1,
dayOfWeek: number = 1
): string => {
const monday = getMondayOfWeek(startDate);
const daysToAdd = ((Math.max(1, weekNumber) - 1) * 7) + (Math.min(Math.max(1, dayOfWeek), 7) - 1);
const targetDate = new Date(monday);
targetDate.setDate(targetDate.getDate() + daysToAdd);
const month = targetDate.getMonth() + 1;
const date = targetDate.getDate();
const dayName = WEEK_DAY_NAMES[targetDate.getDay()];
return `${month}/${date} (${dayName})`;
};
/**
* 計算今日落在計畫的第幾週 (1 ~ totalWeeks)
*/
export const getCurrentPlanWeek = (
startDate?: Date | string | number | null,
totalWeeks: number = 24
): number => {
const monday = getMondayOfWeek(startDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffDays = Math.floor((today.getTime() - monday.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return 1;
const weekNum = Math.floor(diffDays / 7) + 1;
return Math.min(Math.max(1, weekNum), Math.max(1, totalWeeks));
};
/**
* 判斷指定課表日期是否在「今天之後」(未來課表尚未到達)
*/
export const isWorkoutInFuture = (
startDate?: Date | string | number | null,
weekNumber: number = 1,
dayOfWeek: number = 1
): boolean => {
const monday = getMondayOfWeek(startDate);
const daysToAdd = ((Math.max(1, weekNumber) - 1) * 7) + (Math.min(Math.max(1, dayOfWeek), 7) - 1);
const workoutDate = new Date(monday);
workoutDate.setDate(workoutDate.getDate() + daysToAdd);
workoutDate.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return workoutDate.getTime() > today.getTime();
};
在 src/store/useStore.ts 中擴充打卡狀態,讓每一次打卡都能寫入實際配速、里程與 AI 教練評語:
export interface WorkoutResult {
actualDistance?: number | string;
actualPace?: string;
averageHeartRate?: number;
aiFeedback?: string;
coachFeedback?: string;
feedback?: string;
imageUri?: string;
}
// 在 Store Actions 中注入:
markWorkoutCompleted: (weekNumber: number, dayOfWeek: number, result: WorkoutResult) =>
set((state) => {
const key = `${weekNumber}-${dayOfWeek}`;
const finalFeedback = result.aiFeedback || result.coachFeedback || result.feedback || '';
const newRecord = {
isCompleted: true,
actualDistance: result.actualDistance,
actualPace: result.actualPace,
averageHeartRate: result.averageHeartRate,
aiFeedback: finalFeedback,
coachFeedback: finalFeedback,
imageUri: result.imageUri,
completedAt: new Date().toISOString(),
};
// 同步更新 scheduleData 陣列中的對應項目
const updateItem = (item: any) => {
if (item.weekNumber === weekNumber && item.dayOfWeek === dayOfWeek) {
return { ...item, isCompleted: true, ...newRecord };
}
return item;
};
return {
completedWorkouts: { ...state.completedWorkouts, [key]: newRecord },
aiPlan: state.aiPlan ? {
...state.aiPlan,
scheduleData: state.aiPlan.scheduleData?.map(updateItem),
} : null,
};
}),
我們獨立出 DailyWorkoutCard.tsx,解決「輕鬆跑」被誤判為休息日的痛點,並加入時間鎖定保護:
export function DailyWorkoutCard({ workout, planStartDate, onPressCheckIn, onPressRestCheckIn, onPressStrengthCheckIn }) {
const displayDate = calculateWorkoutDate(planStartDate, workout.weekNumber, workout.dayOfWeek);
const isFuture = isWorkoutInFuture(planStartDate, workout.weekNumber, workout.dayOfWeek);
const isDone = Boolean(workout.isCompleted);
const title = workout.title || workout.workoutType || '訓練';
// 1. 精確判斷運動類別:跑步項目優先
const lowerTitle = title.toLowerCase();
const textDesc = `${title} ${workout.description || ''}`.toLowerCase();
const isRun = lowerTitle.includes('跑') || lowerTitle.includes('run') || lowerTitle.includes('lsd') ||
lowerTitle.includes('間歇') || lowerTitle.includes('配速') || lowerTitle.includes('輕鬆') ||
Boolean(workout.distanceKm && workout.distanceKm > 0);
const isRest = !isRun && (lowerTitle.includes('休息') || lowerTitle.includes('rest'));
const isStrength = !isRun && !isRest && (textDesc.includes('肌力') || textDesc.includes('核心'));
const handleCardPress = () => {
if (isDone) return;
if (isFuture) {
Alert.alert('尚未開放打卡', '此為未來日期的訓練課表,請於訓練當天完成後再進行打卡或上傳跑錶數據!');
return;
}
if (isRest) onPressRestCheckIn?.(workout);
else if (isStrength) onPressStrengthCheckIn?.(workout);
else onPressCheckIn?.(workout); // 喚起 Day 22 的 Gemini Vision 截圖上傳 Modal
};
return (
// ... 篇幅太長省略 ....
);
}
完成後如下:
防止對未來打卡:
clinic.tsx):
今日進度:第 1 週 · 週五),送出微調請求時以此基準回報給後端 AI。schedule.tsx):
const handleResetPlan = () => {
Alert.alert(
'確認重新設定目標與課表?',
'重新設定將會清除目前的備賽課表與訓練紀錄,確定要重新開始規劃嗎?',
[
{ text: '取消', style: 'cancel' },
{
text: '確定清除',
style: 'destructive',
onPress: () => {
setCurrentPlan(null);
setAiPlan(null);
},
},
]
);
};
完成後如下:
今天我們解決了一些 UX 痛點:
明天(Day 25)將克服一項問題。如果跑者在第 5 週的時候,因為受傷去「AI 診療室」按下了「重新調整課表」,後端回傳了新計畫覆蓋掉 Zustand……等等!那前 4 週辛苦上傳的跑錶截圖、打卡紀錄跟 AI 評語,不就跟著被整包洗掉清空了嗎?!因此將解決此歷史課表變取代掉的問題,敬請期待!