SteveChuang · Personal Hub

一支手錶到一個網站,
中間沒有任何伺服器

這個專案把 Garmin 手錶錄下來的原始 FIT 檔,變成一整套訓練分析網站。 全程零後端、零推論成本、沒有 build step —— 排程跑完 git commit,那就是線上版。 以下三張圖照著資料的流向排:先看它怎麼進來,再看機器算了什麼,最後看畫面怎麼長出來。

01資料怎麼進來

手錶錄完自己上傳到 Garmin Connect,之後不需要任何手動操作。 真正的主線是 intervals.icu —— 它免費、有開放 API、而且會把原始 FIT 檔完整交出來, 所以逐秒的功率、心率、踏頻、齒比才拿得到。

Strava 那條線刻意畫成虛線:它現在只剩年度統計和歷史成績, 留著是因為總覽的年度數字還住在 data/strava.json。室內騎乘尤其不能信它 —— Rouvy 會自己上傳一份虛擬距離進去,那份不是訓練資料。

資料怎麼進來 架構圖:Garmin 手錶的 FIT 檔上傳到 Garmin Connect,由 intervals.icu 自動同步後開放 API 供 GitHub Actions 取用;Garmin Connect 同時推送到 Strava,但 Strava 現在只提供年度統計與歷史資料。 自動同步 自動推送 fetch-strava.js Garmin 手錶 Forerunner 原始 FIT 檔 Garmin Connect 原廠雲端 唯一的上傳出口 intervals.icu 開放 API · 免費 訓練資料主線 GitHub Actions 每天 10:30 / 22:30 零自架伺服器 Strava 只剩年度統計與歷史 訓練資料主線 外部服務 只剩歷史相依

選 intervals.icu 不是因為它比較好看,是因為它肯把原始檔還給你。
資料的所有權決定了這整條管線能做到多深。

02CI 算了什麼

兩班排程,一天各跑兩到三次。主線 fit-sync.yml 依序跑六支腳本, 順序不能換 —— 後面每一支都吃前面的產出。

橘色那支是這個專案真正的自建部分:不採用 Strava 給的路段成績, 而是從自己的 FIT 逐秒重算閘門通過時間。同理,坡度也不讀 Strava 的高程 —— 路段 altitude stream 的均坡是對的,但在山壁窄路上局部坡度會高估兩到三倍。

CI 算了什麼 流程圖:fit-sync.yml 依序執行六支 Python 腳本,從抓取 FIT 到推導代辦,其中自建 ITT 閘門計時是核心;另一條 strava-sync.yml 只補年度統計;兩者的產出都以 git commit 版本化,commit 完即上線。 唯一還沒斷乾淨的 Strava 相依 fit-sync.yml 每天 10:30 與 22:30 各跑一次 · Python 3.12 sync-intervals.py 抓 FIT 與 wellness build-ride-reports.py 逐秒解析 · 產單日報告 backfill-itt-efforts.py 自建 ITT 閘門計時 不信 Strava 的成績 estimate-indoor-distance.py 室內等效平路里程 build-coach-context.py 給外部 AI 的脈絡檔 build-tasks.py 推導代辦,不手動維護 strava-sync.yml fetch-strava.js · fetch-segment-terrain.js 每天 10:00 / 18:00 / 22:00 git commit data/*.json (22 個) rides/*.html (72 頁) commit 完就是線上版 自建演算法 CI 腳本 版本化產出 待退場

最後一支 build-tasks.py 比較特別:它不維護狀態,而是從現況推導代辦。 該做的事做完了,重跑一次它自己就消失。

03前端怎麼呈現

五個視覺世界共用同一份 data/*.json,但各自是一個獨立單檔, CSS 和 JS 全部 inline,彼此不共用 bundle。這是刻意的:每個世界有自己的設計語言, 硬要抽共用元件會把它們拉成同一種長相。

右邊那 72 頁單日報告是另一種東西 —— 它們由 CI 直接烤成靜態 HTML, 打開後不 fetch 任何資料。所以報告模版改了不會自動傳播到舊頁面, 要重生得逐日指定。

前端怎麼呈現 巢狀架構圖:linkTreeIndex.html 是唯一入口,通往五個各自單檔的視覺世界,它們在瀏覽器端讀同一份 data 目錄;另有 72 頁單日報告由 CI 直接產生、不做任何 runtime fetch,以及五支已退出導覽的舊主題頁。 單日報告索引 導覽 fetch() 五個現行視覺世界 各自一個單檔 · 不共用 bundle · 讀同一份資料 strava.html 深空觀測站 · 9 個頁籤 · 讀 14 個資料檔 strava_cinema.html Taiwan Ride Cinema strava_pitwall.html 賽車維修站 strava_helicorder.html 記震紙 strava_opus5_max.html 遙測 OPUS ARCHIVE · 5 支舊主題頁 不在導覽,直接開網址仍可用 linkTreeIndex.html 首頁 · 唯一入口 data/*.json 22 個檔 · 2.2 MB 瀏覽器端 fetch rides/*.html 72 頁 · CI 直接烤好 零 runtime fetch 主力頁 現行世界 靜態產出 已退場

沒有 build step 是一個功能,不是一個還沒做的待辦。
它換到的是:任何一頁都能單獨存檔、單獨打開、單獨活過十年。