iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Build on Google AI

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

Day 24 | 所以今天是幾月幾號?告別「第 X 週」,日曆對齊與打卡實戰

  • 分享至 

  • xImage
  •  

前言:AI 算得很開心,但跑者看得很痛苦

經過前幾天的努力,KAKERU 已經能一口氣吐出 24 週的龐大訓練計畫。但如果 UI 上只顯示:「第 13 週,週二:輕鬆跑 5km」,看到這個畫面心裡可能會問:「所以這到底是幾月幾號啦?!」

如果不能把抽象的「週次」對齊真實世界的「日曆」,這個 APP 就永遠只是個半成品,跑者甚至可能會在星期一不小心跑了週末的 LSD 長距離。

今天,不僅要實作「日期映射演算」,還要加入「當週自動定位」「未來課表時間鎖定」,並將 Day 22 開發的「多模態圖片解析」完美串接,讓每一次的完賽打卡,都成為帶有 AI 專屬評語的真實日記!

觀念解說:日期時間對齊與狀態擴充

要達成這個目標,需要解決四個核心關鍵:

  1. 時間軸推算 (Date Mapping):以「計畫起始週週一」為基準點,透過 ((weekNumber - 1) * 7) + (dayOfWeek - 1) 公式,算出每一天的絕對日期(如 10/15 (二)),並推算當前所在的「真實本週」。
  2. 未來課表防呆鎖定 (Time-Gated Lock):尚未到達的未來日期(workoutDate > today),卡片標註「未開始」並反灰「尚未開放打卡」,防止跑者提早偷跑打卡。
  3. 精確運動分類 (Smart Classification):確保「輕鬆跑」、「恢復跑」、「LSD」等所有跑步項目在開放時間內均能透過多模態 Gemini Vision 上傳截圖打卡。
  4. AI 診療室進度校正 (Clinic Alignment):診療室的「今日進度」與發送微調請求時的基準,改為 follow 今日真實日曆。

動手實作 1:打造日期校正推算器

我們在 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();
};

動手實作 2:Zustand 打卡狀態擴充

在 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,
    };
  }),

動手實作 3:精準分類與日曆打卡卡片

我們獨立出 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 (
   // ... 篇幅太長省略 ....
  );
}

完成後如下:
https://ithelp.ithome.com.tw/upload/images/20260902/20165043DeYvAxtiCJ.jpg

防止對未來打卡:
https://ithelp.ithome.com.tw/upload/images/20260902/20165043xnZ4Lm9Wcr.png

動手實作 4:診療室連動與重設防呆機制

  1. AI 診療室 (clinic.tsx)
    • 進入診療室時,進度卡片改由真實日曆推算(例如:今日進度:第 1 週 · 週五),送出微調請求時以此基準回報給後端 AI。
  2. 重設課表防呆對話框 (schedule.tsx)
    • 點擊「重新設定目標賽事與課表」時加入確認 Alert,避免誤觸清除跑者辛辛苦苦累積的打卡紀錄。
const handleResetPlan = () => {
  Alert.alert(
    '確認重新設定目標與課表?',
    '重新設定將會清除目前的備賽課表與訓練紀錄,確定要重新開始規劃嗎?',
    [
      { text: '取消', style: 'cancel' },
      {
        text: '確定清除',
        style: 'destructive',
        onPress: () => {
          setCurrentPlan(null);
          setAiPlan(null);
        },
      },
    ]
  );
};

完成後如下:
https://ithelp.ithome.com.tw/upload/images/20260902/20165043ZfuczkXSB6.jpg

今日總結與明日預告

今天我們解決了一些 UX 痛點:

  1. 24 週的課表真正對齊了跑者的真實日曆 (MM/DD)
  2. 進入頁面自動定位當週,並透過時間鎖定防止未來課表偷跑打卡。
  3. 支援輕鬆跑等全跑種的跑錶截圖上傳與 Gemini Vision 視覺解析
  4. 診療室與重設課表皆具備即時日曆連動防呆確認機制

明天(Day 25)將克服一項問題。如果跑者在第 5 週的時候,因為受傷去「AI 診療室」按下了「重新調整課表」,後端回傳了新計畫覆蓋掉 Zustand……等等!那前 4 週辛苦上傳的跑錶截圖、打卡紀錄跟 AI 評語,不就跟著被整包洗掉清空了嗎?!因此將解決此歷史課表變取代掉的問題,敬請期待!


上一篇
Day 23 | UUID 影分身術!免登入資料隔離與 24 週動態引擎!
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言