iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 10

Day 10|訓練 Dashboard 的初畫面:九天之後,終於有東西可以看

  • 分享至 

  • xImage
  •  

九天了,來細數一下手上目前有什麼。

Garmin 的逐秒紀錄、體重機的 13 bytes、Notion 的重訓文字爬出來,昨天也設定讓它們每天早上自己更新一次,

但有件事一直沒做—這九天的成果,全部停留在終端機的輸出、JSON 檔和 markdown。

身為一個寫畫面的人,做了九天完全沒開過瀏覽器(汗),今天就來處理這部分吧!

https://ithelp.ithome.com.tw/upload/images/20260826/20183598JwFi8fiHv4.png


手上的資料現在長什麼樣

來源 現在的形態 目前的量 接進畫面了嗎
訓練日誌 sessions.json 52 筆 / 49 天
體重 weights.json 3 筆
Garmin FIT 軌跡 解析後 JSON 一場全馬 12,128 筆 留給 Day 27 的 3D
教練課表 還是圖片 每週一份 Day 13 才處理

這就是 Day 2 那張資料地圖,九天後的實際狀態。跟原本想的不一樣—本來以為四個來源會同時到位,實際上課表到今天都還是 LINE 記事本的截圖。

不過該有的都有,要讓這些數字產生意義,得先放在同一個畫面上。


技術選擇:為什麼是 Vite + Vue,不是 Nuxt

原因:這是一個給自己和教練看的本機工具,不是一個網站。

沒有 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 讓每個數字佔一樣寬,跳動時版面不會跑掉。


畫面上有六個項目,但有兩樣待完成

https://ithelp.ithome.com.tw/upload/images/20260826/20183598F2OJaUWLyP.png

畫面上有:臺北馬倒數、本週跑量、體重趨勢折線、訓練負荷曲線、本週訓練清單,還有最近一次間歇的分組明細。

看起來都像完成品,但其中兩個不是

元件 資料來源
體重(72.3 kg/阻抗 457 Ω) weights.json,體重機 BLE 直讀
體重趨勢折線 同上,3 筆
本週訓練清單 sessions.json,52 筆
8/25 間歇分組表 sessions.jsonreps 欄位
訓練負荷 ATL/CTL/TSB Math.sin() 合成的
本週跑量 寫死的

後兩個算不出來,理由是同一個:sessions.json 裝的東西不夠。它只有日期、類型、摘要、動作數,加上跑步才有的分組明細—沒有總距離就算不出跑量,沒有逐秒心率與時長就算不出 TRIMP。

Day 8 那個「schema 對齊目標,不是對齊資料的複雜度」的決定,代價在今天顯現。當時的目標是「顯示哪天練了什麼」,那個目標達成;但想多算一點東西,欄位就不夠用。

Day 17 要接 FIT 檔的心率資料,那時候該筆資料才會是真實的。


環圈為什麼不顯示百分比

https://ithelp.ithome.com.tw/upload/images/20260826/20183598E12wWaWi0Z.png

環圈中間寫的是天數,不是百分比。

原本是百分比。但既然沒有課表可以比對(上一節那個 planned 欄位的問題),那個百分比的分母只能是七天—而休息日是刻意排的,不是沒達成。

改成寫「2 天」之後,那個圈就只是當週運動幾天。等 Day 13 課表接進來、真的算得出達成率,再把百分比加回去。


特別拉出間歇訓練的表格

https://ithelp.ithome.com.tw/upload/images/20260826/20183598090QX6D2ZM.png

最近一次質量課表是 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 本身,之後還會再升級!今天只是它的第一天,敬請期待~



上一篇
Day 9|排程:讓資料自己更新,以及為什麼只跑在自己電腦上
下一篇
Day 11|Telegram Bot 從零開始:為什麼選 long polling,及讓電腦持續開著
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言