過去的 22 天,我們單人用戶玩得很開心,功能也都順利的進行,但當 KAKERU 實際上線,100 個使用者同時點擊「生成課表」時(哪來的自信這麼多?!),後端該怎麼區分誰是誰?
今天要引入一個方法 Local-First 架構,利用 UUID 讓手機在背景配發一組全球唯一的身分證。同時,將原本死板的 4 週課表升級為根據目標賽事日期自動推算的 24 週動態備賽週期引擎!
user_8f4c2...),並透過 Zustand Persist 持久化儲存在手機硬碟中。UI 畫面上完全不干擾使用者(無須顯示冷冰冰的 UUID),但發給後端的每一次 API 請求都會默默帶上。ProfileScreen):專注於暱稱、身高、體重與 BMI 評估。ScheduleScreen):未生成課表前提供目標賽事與日期選擇;生成後專注於課表執行。DailyWorkoutList):跑步課表呼叫 AI 跑錶截圖辨識;遇到「完全休息」或「肌力訓練」時提供專屬的一鍵完成打卡,體驗更自然順暢!為了讓 APP 初次開啟時自動配發唯一識別碼,我們使用 react-native-uuid 套件,並在 Zustand Store 初始化的瞬間生成 uuid.v4()。透過 persist 中介軟體,這個 ID 會被存進手機的 AsyncStorage 中,即便跑者滑掉 APP 或重開機,這組身分證也永遠不會改變。
安裝所需套件:
npx expo install react-native-uuid @react-native-community/datetimepicker
在 Zustand Store 初始化並持久化 userId:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import uuid from 'react-native-uuid';
interface AppState {
userId: string;
selectedWeek: number;
// ... 其他狀態定義
}
export const useStore = create<AppState>()(
persist(
(set) => ({
// 如果還沒有 userId,就在初始化的瞬間配發一個全球唯一的 UUID
userId: uuid.v4() as string,
selectedWeek: 1,
// ... 下半部 Actions 與 Reducer 實作省略
}),
{
name: 'kakeru-storage',
storage: createJSONStorage(() => AsyncStorage),
// ... 下半部持久化設定省略
}
)
);
如此一來,前端每次發起 API 請求時,只需從 useStore 取出 userId 放到 Payload 裡,後端的 Firestore 就能精準識別跑者,達成真正的免登入多用戶隔離。
進入課表生成流程時,我們導入 @react-native-community/datetimepicker 讓跑者挑選預計參賽的日期。接著透過時間差計算公式算出距離今日的週數,並設定最少 4 週(急行軍模式)、最多 24 週(完整半年週期)的彈性防呆限制。
在 ScheduleScreen 中加入賽事日期選擇與動態週數計算:
// src/app/schedule.tsx (節錄)
export default function ScheduleScreen() {
const [raceDate, setRaceDate] = useState(new Date(Date.now() + 120 * 24 * 60 * 60 * 1000));
// 計算距離今天還有幾週 (最少 4 週,最多 24 週)
const calculateTotalWeeks = (targetDate: Date) => {
const today = new Date();
const diffTime = Math.max(0, targetDate.getTime() - today.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const weeks = Math.ceil(diffDays / 7);
return Math.min(Math.max(weeks, 4), 24);
};
const totalWeeks = calculateTotalWeeks(raceDate);
// ... 下半部表單與生成 API 呼叫省略
}
透過將算出的 totalWeeks 傳送給後端,AI 教練便能知道這次備賽到底有多少時間可以安排,避免出現「下個月要比賽卻給了 20 週課表」的尷尬情況。

當課表長達 24 週時,按鈕會超出螢幕寬度。我們將 WeekSelector 升級為橫向可滑動的 ScrollView,並利用 React 的 useRef 與 useEffect 監聽目前選取的週次,當跑者點選不同週次時,按鈕列會自動平滑滾動到可視正中央。
升級可滑動與自動對焦的週次選擇元件:
// src/components/WeekSelector.tsx (節錄)
import React, { useRef, useEffect } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useStore } from '../store/useStore';
export function WeekSelector({ totalWeeks = 4 }: { totalWeeks?: number }) {
const scrollViewRef = useRef<ScrollView>(null);
const selectedWeek = useStore((state) => state.selectedWeek);
const setSelectedWeek = useStore((state) => state.setSelectedWeek);
const weeksArray = Array.from({ length: totalWeeks }, (_, i) => i + 1);
// 選取週次時,自動平滑滾動至可視區域
useEffect(() => {
if (scrollViewRef.current && selectedWeek > 0) {
scrollViewRef.current.scrollTo({ x: Math.max(0, (selectedWeek - 2) * 90), animated: true });
}
}, [selectedWeek]);
// ... 下半部 JSX 渲染與 Stylesheet 樣式省略
}
頂部保留「第 X 週 / 共 Y 週」的進度提示,讓跑者隨時掌握目前在整個半年大週期中的訓練位置。

前端把 userId 與 totalWeeks 傳到後端後,Spring Boot 的 Gemini Prompt 必須變得更有彈性,不能再寫死 4 週了!我們改寫 Prompt 範本,動態將週數注入提示詞,要求 Gemini 依時間長度嚴格進行週期化訓練規劃。
修改 Prompt 範本 (generate-marathon.md):
<!-- prompts/generate-marathon.md (節錄) -->
你是一位嚴格且專業的馬拉松教練。
評估學員資料:
- 目標賽事:{{TARGET_RACE}}
- 備賽週期:{{TOTAL_WEEKS}} 週
- 目前體能:{{CURRENT_LEVEL}}
【週期化訓練規劃】
請根據學員的備賽長度(共 {{TOTAL_WEEKS}} 週),嚴格進行「週期化訓練 (Periodization)」編排(依序規劃:基礎期 -> 強化期 -> 巔峰期 -> 減量期)。
JSON 的 scheduleData 必須包含長度為 {{TOTAL_WEEKS}} 週的完整每日訓練課表(weekNumber: 1 至 {{TOTAL_WEEKS}})。
<!-- ... 下半部 JSON 欄位規範與領域防禦守則省略 -->
在 Java 服務層動態組合 Prompt 並隔離儲存歷史:
@Override
public GenerateScheduleResponseDto generateSchedule(GenerateScheduleRequest request) {
int totalWeeks = (request.getTotalWeeks() != null && request.getTotalWeeks() > 0)
? Math.min(Math.max(request.getTotalWeeks(), 4), 24)
: 4;
String prompt = template
.replace("{{TARGET_RACE}}", request.getTargetRace() != null ? request.getTargetRace() : "")
.replace("{{TOTAL_WEEKS}}", String.valueOf(totalWeeks))
.replace("{{CURRENT_LEVEL}}", request.getCurrentLevel() != null ? request.getCurrentLevel() : "");
String rawJson = callGeminiApiWithFallback(List.of(new GeminiRequest.Part(prompt)));
String cleanedJson = cleanJsonResponse(rawJson);
// 儲存對話歷史時帶入 userId 達成資料庫多租戶隔離
saveHistory(request.getUserId(), request.getSessionId(), prompt, cleanedJson);
// ... 下半部 JSON 反序列化與 Guardrail 驗證省略
}
這樣一來,不論跑者是下個月就要上陣(急行軍 4 週),還是半年後才比賽(完整週期 24 週),Gemini 都能依據時長產出最合適的每週配速與跑量堆疊計畫。
今天在友克鑫市完成了 KAKERU 系統底層的升級!
uuid.v4(),省去了幾百行枯燥的登入/註冊 UI 與 Token 刷新邏輯,完美實現了零門檻的多用戶隔離。目前課表上顯示的「第 1 週 週一」太過抽象。明天(Day 24)我們將實作「真實日曆映射系統」,將相對週次精準轉換為實際日期(例如:2026/08/31),並整合 Day 22 的 AI 視覺解析與智慧打卡分流,打造專屬跑者的運動日記!