九天了,來細數一下手上目前有什麼。
Garmin 的逐秒紀錄、體重機的 13 bytes、Notion 的重訓文字爬出來,昨天也設定讓它們每天早上自己更新一次,
但有件事一直沒做—這九天的成果,全部停留在終端機的輸出、JSON 檔和 markdown。
身為一個寫畫面的人,做了九天完全沒開過瀏覽器(汗),今天就來處理這部分吧!

| 來源 | 現在的形態 | 目前的量 | 接進畫面了嗎 |
|---|---|---|---|
| 訓練日誌 | sessions.json |
52 筆 / 49 天 | 是 |
| 體重 | weights.json |
3 筆 | 是 |
| Garmin FIT 軌跡 | 解析後 JSON | 一場全馬 12,128 筆 | 留給 Day 27 的 3D |
| 教練課表 | 還是圖片 | 每週一份 | Day 13 才處理 |
這就是 Day 2 那張資料地圖,九天後的實際狀態。跟原本想的不一樣—本來以為四個來源會同時到位,實際上課表到今天都還是 LINE 記事本的截圖。
不過該有的都有,要讓這些數字產生意義,得先放在同一個畫面上。
原因:這是一個給自己和教練看的本機工具,不是一個網站。
沒有 SEO 需求(沒有人會 Google 我的訓練紀錄^^)、沒有效能載入焦慮(使用者只有兩個^0^)、沒有多人協作。Nuxt 帶來的 SSR、路由約定、伺服器端渲染這些能力,在這個情境下全部用不到,卻要付出額外的架構複雜度。
更實際的理由是:後面要塞 Three.js 和 WebSocket,需要的是一個單純的 client 環境。 3D 場景本來就只能在瀏覽器裡跑,SSR 對它沒有意義,反而要處理一堆「這段程式碼不能在伺服器端執行」的例外。
那什麼時候我會選 Nuxt?如果這個 dashboard 之後要公開、要被搜尋引擎找到、要有多個使用者各自登入看自己的資料—那 SSR 和後端路由就變成必需品,那時候換過去才划算,但不是現在。
畫面剛做的時候,前端吃的是靜態資料—src/data/fixtures.ts,手動從 coach/logs/ 抄出來的真實數值。
先把畫面做對,等管線接上來再換掉資料來源。 反過來先接管線再做畫面,會變成一邊 debug 資料一邊 debug 版面,兩邊的錯相互混在一起。
現在管線通了(Day 8 的 parse_logs.py 產出、Day 9 的排程每天更新),可以將靜態資料移除,載入的部分很短:
async function getJSON<T>(url: string): Promise<T[]> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
return res.json();
}
export const loadSessions = () => getJSON<Session>('/sessions.json');
export const loadWeighings = () => getJSON<Weighing>('/weights.json');
因為 JSON 就放在 public/,跟前端同源,不需要 CORS、不需要後端 API。排程每天早上重寫那兩個檔案,重新整理頁面就是新的。
換的時候發現,原本的靜態資料裡有個欄位對不上。fixtures.ts 的本週清單長這樣:
{ day: '週四', kind: 'run', planned: '間歇 1000×2 + 600×2', actual: '6.2K 達標 3.22K', done: true }
有 planned(教練開的)也有 actual(實際做的),兩相對照才算得出「達成」。
但 sessions.json 只有實際做的:
{ date: '2026-08-11', kind: 'run', summary: '跑步(中山區田徑場 間歇:1000m×2 + 600m×2)', moves: 0, reps: [ /* 分組明細 */ ] }
因為教練課表到現在都還是圖片。 沒有 planned 可以比,「達成率」就無從算起。
所以那張卡片從「本週課表達成」改成「本週訓練」—顯示這週練了什麼,而不是比對做到幾成。等 Day 13 把課表讀進來,再把課表對照加回去。
色票用 CSS 自訂屬性定義,深色為底、單一強調色(青綠)。這邊主要都是用暗色調,因為這個頁面之後會放 3D 軌跡和圖表,深色背景讓淺色軌跡可以更明顯好看。
有個小細節值得一提:
@utility tnum {
font-variant-numeric: tabular-nums;
}
數字用等寬字身。因為儀表板上的數值會變動(倒數天數、本週跑量、體重),如果數字寬度不一致,更新時整排字會左右抖動。這行 CSS 讓每個數字佔一樣寬,跳動時版面不會跑掉。

畫面上有:臺北馬倒數、本週跑量、體重趨勢折線、訓練負荷曲線、本週訓練清單,還有最近一次間歇的分組明細。
看起來都像完成品,但其中兩個不是:
| 元件 | 資料來源 |
|---|---|
| 體重(72.3 kg/阻抗 457 Ω) | weights.json,體重機 BLE 直讀 |
| 體重趨勢折線 | 同上,3 筆 |
| 本週訓練清單 | sessions.json,52 筆 |
| 8/25 間歇分組表 | sessions.json 的 reps 欄位 |
| 訓練負荷 ATL/CTL/TSB | Math.sin() 合成的 |
| 本週跑量 | 寫死的 |
後兩個算不出來,理由是同一個:sessions.json 裝的東西不夠。它只有日期、類型、摘要、動作數,加上跑步才有的分組明細—沒有總距離就算不出跑量,沒有逐秒心率與時長就算不出 TRIMP。
Day 8 那個「schema 對齊目標,不是對齊資料的複雜度」的決定,代價在今天顯現。當時的目標是「顯示哪天練了什麼」,那個目標達成;但想多算一點東西,欄位就不夠用。
Day 17 要接 FIT 檔的心率資料,那時候該筆資料才會是真實的。

環圈中間寫的是天數,不是百分比。
原本是百分比。但既然沒有課表可以比對(上一節那個 planned 欄位的問題),那個百分比的分母只能是七天—而休息日是刻意排的,不是沒達成。
改成寫「2 天」之後,那個圈就只是當週運動幾天。等 Day 13 課表接進來、真的算得出達成率,再把百分比加回去。

最近一次質量課表是 8/25,分組明細擺出來是這樣:
| 組 | 距離 | 時間 | 每 400m | 最高心率 |
|---|---|---|---|---|
| 1 | 1010m | 4:06.5 | 1:37.6 | 186 |
| 2 | 990m | 4:01.6 | 1:37.6 | 192 |
| 3 | 800m | 3:15.8 | 1:37.9 | 187 |
| 4 | 800m | 3:15.7 | 1:37.8 | 191 |
| 5 | 800m | 3:08.8 | 1:34.4 | 192 |
間歇跟長跑不一樣,看總距離和平均配速沒有意義—五組跑完的平均值,會把開太快和末段掉速抵銷掉。要知道每一趟的狀態,得把距離、時間、心率分開列。
而且每一組的距離都不一樣,所以還要一欄每 400m 把配速正規化,否則第 1 組的 4:06.5 和第 3 組的 3:15.8 沒得比。
Day 8 花了一整篇講「解析成功不等於數字有意義」,這裡是同一件事的下一層:檢查發現不一致之後,還是得回去看原始來源,不能挑一邊當真。 所以對於讓AI來整理數據還是要謹慎檢視,自己的訓練資料是不是正確地被導入及使用。
畫面有了,可是它現在做的事只有一件:把訓練數字對整齊。
它不知道前四組漂移不到 0.3 秒代表什麼,也不知道末組快出來的三秒是不是該和教練討論。它只是直接地把資料呈現出來,剩下的判斷還是得靠人。
明天開始第二階段,要處理的就是這件事:讓這些資料轉成教練能明白的內容。
而這個 dashboard 本身,之後還會再升級!今天只是它的第一天,敬請期待~