多幣別才上線 14 小時,就被我自己收回去——v0.17.2 / v0.17.3 的反向收斂日
老實說——昨天凌晨 00:37 才 ship 完 v0.17.1、把 CurrencySelector 接到 AddSheet 上、自己看著 ¥5,000 ≈ NT$1,110 即時 render 還很感動。結果今天中午自己 dogfood 第一筆主帳本記錄,第一個動作就是:「我幹嘛在記帳的時候要選幣別?我又不出國」。
每筆 transaction 多一個 picker、就算 default 是 base currency 也要看著它意識到自己可以選——這個認知負擔對日常記帳是純粹的噪音。為了支援 5% 出國場景,把 95% 日常場景變吵——這個交換不划算。
所以這天決定:多幣別 picker 從主帳本拿掉、只留在旅行子帳本。這不是退一步——這是把架構釐清成「主帳本單幣別、旅行子帳本多幣別」兩層。發了 v0.17.2 / v0.17.3 兩版,35 個 commit、整天都在做這件「把昨天 ship 的東西收一半回來」的事。
早上:v0.17.2 phase 3 / 4 把旅行子帳本接到底
接著昨天 TripExpenses 表跟 server actions 鋪好的地皮,phase 3 把 TripExpense UI wire 起來、phase 4 寫了 trip-end summary CashTransactions——這條才是 trip sub-ledger 真正的核心。
問題是:旅行進行中 TripExpenses 表自己玩自己的——多幣別、splitRatioA 可以每筆不同、不影響 GroupBalance。但旅行結束的時候,這趟旅行的「淨額」必須回到主帳本——不然 balance 永遠對不齊。怎麼 fold?
答:最多兩條 CashTransactions(每個 paying member 一條),代表「整趟旅行的綜合結果」。實作 lib/tripSummary.ts 的 buildTripSummaries():
- 對每個 paying member 算 out-of-pocket total + A-share total
- 暴力搜尋 integer
splitRatioA——逐一試0..100、看哪個比例下lib/balance.ts算出的 delta 最接近真正的 per-expense exact delta - 邊界 collapse:0% →
all_theirs、100% →all_mine、其他用weighted - 回 0–2 rows
為什麼暴力搜尋 ratio? 因為旅行裡 100 筆 transaction 每筆都可能用不同 splitRatioA、不同幣別、不同 paying member。真實的「平均」是無理數,但 splitRatioA 在 schema 是 0–100 integer——所以挑「最接近 exact delta 的整數比例」、誤差吸進那條 summary 的 amount。整數空間只有 101 個,暴力搜尋比寫精算公式快寫又好懂——這種「有界整數空間直接窮舉」的思路在實務上常常贏過精緻演算法。
endTrip 寫成 atomic transaction:
UPDATE Trips SET status='ended' WHERE status='active' -- conditional, idempotent
↓ (若 0 rows updated → throw '找不到進行中的旅行')
SELECT live TripExpenses
↓
INSERT 0-2 summary CashTransactions
↓
COMMIT → trigger recalcGroupBalance
第一行的 WHERE status='active' 是 idempotent guard——同一個 trip 被 race condition 連續 endTrip 兩次、第二次 affected rows = 0、throw error、後面 select / insert 完全不會跑。單一 SQL conditional 取代分布式鎖,因為 DB row update 本身就是 atomic。
中午 13:15:trip detail FAB + currency/per-side review
把旅行 detail 頁的 FAB 接上去——點 ➕ 直接開 AddSheet 帶 prefilledTripId、currency 也預填成 trip 的 defaultCurrency。同時補 per-side review(每個 member 自己這趟花多少、A 應分多少、結算 delta)—— endTrip 那條 fold-back 邏輯先讓使用者預覽再 commit,避免「按下去才知道結果」的恐懼感。
14:16:把 CurrencySelector 從 AddSheet 拿掉
第一個版本——激進刀:CurrencySelector 直接 drop、currencyManuallySet state 整片刪。Currency 改成完全 cascade from context:
- 有 trip selected → trip 的
defaultCurrency - 沒有 trip → group 的
baseCurrency
backend currency field 還是會送,只是 UI 沒有 picker、沒有「我手動覆寫了 currency」這條 state。這版邏輯簡單但有點過頭——旅行中也選不到 JPY 5000 跟 USD 30 混在同一個 trip 的情境。
14:42:收斂版——picker only in trip-sub-ledger path
26 分鐘後 ship 第二版,這版才是對的形狀:
tripIdset(記到 TripExpenses)→ renderCurrencySelector,可以每筆 overridetripIdnull(記到主帳本)→ picker 完全不存在、amount hero 直接顯示 baseCurrency 符號
「不該決定的時候不要假裝給你決定權」——這條 UX 原則寫起來簡單、做起來常常違反。AddSheet 在 trip 跟非 trip 場景變成兩種不同的 form,這是分歧、不是 inconsistency——硬要統一只會兩邊都難用。
13:41 那條 docs commit:幣別視角刻意分層立場
寫完上面兩個 picker 改動的中間,補了一個 docs commit——product-design.md 新增「設計立場」section,把這個取捨用一句話定住:
Complexity at the boundary, simplicity in daily use——守住記錄要素低認知負擔,日常每筆不必做幣別決定。
把這條哲學寫進 spec 的價值在於——將來再有「為什麼不在主帳本也加 picker」這種建議時,這條 spec 是擋下來的理由。設計決策不留書面紀錄,三個月後就會被自己(或下一個人)以「合理」之名再撤一次。
18:23:兩份 spec 重新拆分——按「核心哲學」分組
接著做了一個更大膽的 docs refactor——把 multi-currency-trip-design.md 跟 i18n-locales.md 兩份 spec 重新切,分成兩條核心哲學:
- 「邊界複雜」:多幣別、跨章節歷史、leave/swap、PartnerQuiz——產品外圍接 reality 的部分一定會有複雜度,這層該複雜就複雜
- 「保持簡單」:日常記帳、AddSheet、dashboard、records feed——使用者每天碰的入口必須認知負擔極低
spec 的組織方式不再按「功能模組」分(currency.md / trip.md / i18n.md),改按「這個決策守護的是哪一條哲學」分組。一份 spec 同時涉及兩條哲學就拆。spec 是給未來自己看的決策邏輯,不是給程式碼 mirror 的目錄樹——這條 framing 我自己也是第一次寫的時候才想清楚。
18:45 / 18:50:Settings 也跟著重整
v0.17.2 上線後使用者馬上反饋——「貨幣」這顆 Setting Row 在每個 user 面前都存在、但 99% 人從不碰。
兩個改動:
#365 語言 & 幣別合一:把 language switcher 跟 base currency 合進新的 sectionDisplay。邏輯上它們都是「這個帳本是誰的、用哪種視角」的基礎設定——之前散在「應用」跟「資料」兩個 section,使用者第一次找 base currency 平均要點 3 個 section 才找到。i18n 4 語齊備。
#366 心理匯率搬進 trip context:/settings/currency 整個頁面保留(onboarding 時還是需要設 base currency),但心理匯率編輯從 Settings top-level 拔掉——心理匯率只在記帳時用得到、平常看完全是噪音。改成 TripDetailClient 加一條 tertiary link「調整心理匯率」、放在 edit/end-trip CTA 下面。使用者剛好想著「這趟旅行的匯率合不合理」的瞬間、入口就在那。
19:05 又補一條 ActiveTripBanner——dashboard 已經 SSR-fetch activeTrips 給 AddSheet 的 TripSelector 用、但沒有任何視覺 surface。要知道哪趟旅行進行中只能去 Settings 翻。banner 補上、點進去直接 trip detail。這就是「資料已經在 client 上、只是沒有對應 UI」的典型 leftover——v0.17.0 衝架構先行的代價、第二天回來收。
18:57:AddSheet asset picker 拆愛物/守護 tabs
Guardian beta on 的時候、AddSheet 的 asset picker 要怎麼處理保險?v0.16.0 把保險從愛物拆出來變獨立模組——但 AddSheet 還是把所有 asset 攤平在同一個 picker,「車、貓、植物、那張壽險保單」混在一起選。Guardian on 的時候 picker 拆成兩個 tab(愛物 / 守護),off 的時候維持單 tab。
這條改動 50 行 UI 但結構意義超過 v0.16.0 的 tab 拆分——v0.16.0 是「看清單時分」、這次是「選的時候也分」。一個產品的 mental model 一旦在 list view 切兩半,每個 entry point 都得跟著切——picker、stats、filter、search、notification……漏掉任一個都會造成「視覺上是分的、但操作上又混在一起」的精神分裂。
20:14 v0.17.3 release + 20:55 修 i18n key regression
v0.17.3 20:14 release。41 分鐘後撞到 regression——#366 把 Settings 「貨幣」Row 拿掉的時候連帶刪了 settings.currency 這個 i18n key,但 onboarding flow 還有別的地方 reference 它、頁面跑出來顯示 raw key string。Hotfix 一行:把 key restore 回來、只是不再 mount UI Row 上。i18n key removal 是這種「靜默 break」的高發地——TypeScript 對 i18n key string 看不到、grep 又會被各種 dynamic key compose 騙過去。
收尾的兩個小東西
#352 LCP perf——public pages(/、/sign-in、/terms、/privacy)的 Largest Contentful Paint 優化。CSS critical path、image priority、font-display: swap 整套。SEO core web vitals 直接拉上去。
llms-full.txt(接昨天 llms.txt)——比 llms.txt 更詳細,給 AI crawler 完整 context、提升 AI citation accuracy。llms.txt 是 robots.txt 等級的 metadata、llms-full.txt 是 sitemap.xml 等級的 full content。新一代 SEO 兩條都要。
收尾
35 個 commit、v0.17.2 + v0.17.3 兩個 release、把多幣別 UI 從主帳本拿掉、把 trip sub-ledger fold-back 邏輯寫穿、Settings 重組、spec 按哲學重拆。
最有戲的對照——v0.17.0 凌晨 ship、14 小時後我自己 dogfood 把它的核心 UI 收一半回來。這聽起來像失敗、但其實是 architecture-first sprint 的設計正確性反證:schema 是對的(original_currency / trip_id / rate_snapshot 全都保留沒動)、只是最初的 UI 假設太貪心。把 picker 收掉、schema 不動、未來任何時候要重開都是 5 行 UI 的事。這就是「架構先行」的真實價值——UI 可以反復試錯、schema 不用反復遷移。
至於那個從 14:16 → 14:42 兩版迭代——26 分鐘內 ship → 再 ship——我自己看著 git log 也覺得這節奏該寫進什麼「LLM-augmented 開發節奏」的文章裡。一邊 ship 一邊就意識到上一版過頭、立刻再 ship 一版收回去——傳統工時 model 完全描述不了這種「邊想邊改」的密度。
5 月每日變動 line 數(更新到 5/15)
濾掉 lock / build artifacts / 圖片字型之後:
| 日期 | 新增 | 刪除 | net | 檔案 |
|---|---|---|---|---|
| 5/2 | +8,820 | −245 | +8,575 | 84 |
| 5/3 | +18,504 | −11,701 | +6,803 | 245 |
| 5/4 | +251 | −5 | +246 | 3 |
| 5/5 | +12,171 | −1,042 | +11,129 | 240 |
| 5/6 | +5,519 | −2,859 | +2,660 | 234 |
| 5/7 | +8,169 | −1,403 | +6,766 | 128 |
| 5/8 | +8,585 | −3,836 | +4,749 | 256 |
| 5/9 | +7,460 | −975 | +6,485 | 205 |
| 5/10 | +8,709 | −2,792 | +5,917 | 296 |
| 5/11 | +7,846 | −493 | +7,353 | 196 |
| 5/12 | +10,160 | −3,944 | +6,216 | 282 |
| 5/13 | +9,716 | −3,853 | +5,863 | 420 |
| 5/14 | +7,453 | −883 | +6,570 | 223 |
| 5/15 | +4,883 | −1,154 | +3,729 | 95 |
| 總計 | +126,246 | −35,185 | +91,061 | — |
14 天 net +91,061 行,平均每天 +6,504。v0.1.0 → v0.17.3 累計 20 版——平均 1.07 天一版,看起來像個發版機關槍。
5/15 觀察:
- net +3,729 是 5 月以來第二低(僅高於 5/4 的休息日 +246)—— 不是因為慢,是因為今天的工作 60% 是「拿掉昨天加的東西」,net 看起來保守、實質決策密度很高
- 檔案數只有 95——典型的「收斂日」:sub-ledger fold-back、AddSheet picker 簡化、Settings 重組、spec 重拆,動少地方但每處改得深
- 對照 5/13(420 檔)跟 5/14(223 檔)—— sprint 從「鋪地皮」收斂到「對齊哲學」的曲線很清楚
這段 code 寫於 2026 年 5 月,文章整理於 2026 年 5 月。