iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Build on Google AI

單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?系列 第 25

Day 25 | 矛盾大對決:保留與清除跑者的血汗打卡紀錄,進階智慧合併與清除

  • 分享至 

  • xImage
  •  

前言:AI 一句話,跑者的心血瞬間歸零?

昨天我們將「AI 圖片解析」與「真實日曆」結合,跑者終於能驕傲地在 APP 上留下包含配速與教練評語的訓練日記。

但實際可怕的往往不是寫出新功能,而是「新舊狀態的衝突」:

  1. 災難情境 1(微調覆蓋):跑者在第 5 週時大腿拉傷,進入「AI 診療室」求救。Gemini 回傳了一份微調過的課表。如果前端只是無腦執行 setAiPlan(newPlan),那前 4 週辛苦上傳的跑錶截圖、汗水紀錄與 AI 講評將在瞬間被全新 JSON 完全抹除!
  2. 災難情境 2(新課表殘留):跑者比完一場賽事後,點擊「生成全新賽事課表」,結果舊賽事的打卡紀錄竟然自動被套到新課表的「第 1 週第 1 天」!

今天要實作一套的「狀態全生命週期防護」:既要在微調時無損保留紀錄,又要在全新生成時乾淨隔離重設

觀念解說:微調課表 vs 全新生成的狀態生命週期

業務情境 觸發位置 正確行為 狀態處理方法
受傷/疲勞微調 AI 診療室 (clinic.tsx) 保留同賽事週期已打卡的血汗紀錄 呼叫 adjustAiPlan() 執行 Map 比對智慧無損合併
重新規劃新目標 課表頁 (schedule.tsx) 開啟全新備賽週期,打卡紀錄全部歸零 呼叫 setAiPlan() 或 resetPlan() 重置打卡字典

動手實作 1:進階智慧合併演算法 (Pure Function)

Prompt

建立一個純函數模組 src/utils/scheduleMerge.ts

  1. 設計 advancedMergeSchedules(oldSchedule, newSchedule) 函數,以 ${weekNumber}-${dayOfWeek} 作為唯一 Key。
  2. 以新課表為基底,若對應的舊課表已被打卡 (isCompleted === true),則保留舊課表的實跑里程、均速、心率、AI 講評、截圖與打卡時間。
  3. 兼容處理扁平陣列 (scheduleData) 與巢狀結構 (schedule: [{ week, days }])。

**產出內容與程式碼,**在 src/utils/scheduleMerge.ts 建立純函數合併器:

export interface MergedWorkoutItem {
  weekNumber: number;
  dayOfWeek: number;
  workoutType: string;
  distanceKm: number;
  description: string;
  isCompleted?: boolean;
  actualDistance?: number | string;
  actualPace?: string;
  averageHeartRate?: number;
  aiFeedback?: string;
  imageUri?: string;
  completedAt?: string;
}

export function advancedMergeSchedules(
  oldSchedule: MergedWorkoutItem[],
  newSchedule: MergedWorkoutItem[]
): MergedWorkoutItem[] {
  // 建立舊課表打卡資料 Map
  const oldCompletedMap = new Map<string, MergedWorkoutItem>();
  for (const item of oldSchedule) {
    if (item.isCompleted) {
      oldCompletedMap.set(`${item.weekNumber}-${item.dayOfWeek}`, item);
    }
  }

  // 遍歷新課表,若舊課表已打卡則無損保留
  return newSchedule.map((newItem) => {
    const key = `${newItem.weekNumber}-${newItem.dayOfWeek}`;
    const oldCompleted = oldCompletedMap.get(key);

    if (oldCompleted) {
      return {
        ...newItem,
        isCompleted: true,
        actualDistance: oldCompleted.actualDistance,
        actualPace: oldCompleted.actualPace,
        averageHeartRate: oldCompleted.averageHeartRate,
        aiFeedback: oldCompleted.aiFeedback,
        imageUri: oldCompleted.imageUri,
        completedAt: oldCompleted.completedAt,
      };
    }
    return { ...newItem, isCompleted: false };
  });
}
// ... extractWorkoutList 輔助工具函式略 ...

動手實作 2:Zustand 雙向動作升級 (微調保留 vs 生成重設)

Prompt

重構 Zustand Store (useStore.ts):

  1. 區分微調動作 adjustAiPlan 與全新生成動作 setAiPlan
  2. adjustAiPlan:使用 advancedMergeSchedules 結合本地持久化的 completedWorkouts 字典進行合併。
  3. setAiPlan 與 resetPlan:當跑者建立新目標賽事時,自動將 completedWorkouts: {} 歸零,防止舊賽事打卡影響新課表。

**產出內容與程式碼,**在 src/store/useStore.ts 實作狀態隔離:

export const useStore = create<StoreState>()(
  persist(
    (set, get) => ({
      aiPlan: null,
      currentPlan: null,
      completedWorkouts: {},

      // 1. 生成全新課表:自動清空打卡歷史字典
      setAiPlan: (plan) =>
        set((state) => ({
          aiPlan: plan,
          completedWorkouts: {}, // 👈 全新課表強制歸零
          currentPlan: plan ? { ...plan, data: plan.data || plan } : null,
        })),

      // 2. AI 診療室微調:啟動無損智慧合併
      adjustAiPlan: (newPlanFromBackend) =>
        set((state) => {
          const oldSchedule = extractWorkoutList(state.currentPlan || state.aiPlan);
          const newSchedule = extractWorkoutList(newPlanFromBackend);
          const merged = advancedMergeSchedules(oldSchedule, newSchedule);

          return {
            aiPlan: { ...newPlanFromBackend, scheduleData: merged },
            currentPlan: { ...state.currentPlan, data: { ...newPlanFromBackend, scheduleData: merged } },
          };
        }),

      // 3. 重新設定課表與目標
      resetPlan: () =>
        set({ aiPlan: null, currentPlan: null, completedWorkouts: {}, sessionId: '' }),

      // ... 其他狀態略 ...
    }),
    { name: 'kakeru-storage', storage: createJSONStorage(() => AsyncStorage) }
  )
);

微調之前:
https://ithelp.ithome.com.tw/upload/images/20260903/20165043dTOqTpFrbT.png

微調之後:
https://ithelp.ithome.com.tw/upload/images/20260903/20165043qUn96YRPSU.png

重新產生課表:
https://ithelp.ithome.com.tw/upload/images/20260903/20165043vqRBNj3urQ.png

動手實作 3:渲染層單一真實來源 (Single Source of Truth)

Prompt

在 DailyWorkoutList.tsx 渲染每日訓練卡片時,請以 completedWorkouts 字典作為打卡狀態的唯一依據 (isCompleted: Boolean(record?.isCompleted)),不要使用 Boolean(record || item.isCompleted),避免舊物件快取中的 isCompleted: true 殘留導致誤判。

**產出內容與程式碼,**在 src/components/DailyWorkoutList.tsx 規範打卡屬性來源:

const recordKey = `${selectedWeek}-${dayIdx}`;
const record = completedWorkouts[recordKey];

return {
  weekNumber: selectedWeek,
  dayOfWeek: dayIdx,
  title: item.workoutType || item.title || '訓練',
  distance: dist,
  description: item.description || '',
  // 嚴格以 completedWorkouts 作為唯一真實來源
  isCompleted: Boolean(record?.isCompleted),
  actualDistance: record?.actualDistance,
  actualPace: record?.actualPace,
  aiFeedback: record?.aiFeedback || record?.coachFeedback,
};

今日總結與明日預告

今天我們解決了前端狀態管理中棘手的難題:

  1. 進階智慧合併 (advancedMergeSchedules):讓 AI 診療室在微調課表時,100% 守護跑者過往的汗水紀錄;
  2. 生命週期邊界隔離:生成全新目標時完全清空打卡歷史,防止舊紀錄穿透至新賽事;
  3. 單一真實來源:列表狀態嚴格由 completedWorkouts 掌控,完全隔絕物件快取殘留問題。

明天(Day 26)將進行上線前的最後一次細節打磨!我們將實作「API 載入鎖定防呆機制」來防止連點問題,並為 KAKERU 導入「深淺色模式」,提升不同使用體驗,為本地端開發畫下完美的句點。我們明天見!


上一篇
Day 24 | 所以今天是幾月幾號?告別「第 X 週」,日曆對齊與打卡實戰
下一篇
Day 26 | 自動部署前的細節打磨:實作深淺色模式與 API 載入鎖定防呆
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言