一天發四個版本、外加把多幣別跟旅行帳本一次寫穿——v0.16.2 到 v0.17.1 的長日
說真的,「Futari 不支援多幣別」這個事實一直讓我有點難解釋。我自己住台北、用台幣,邏輯上沒問題——可是兩個禮拜前測試使用者一句「我們下個月要去東京,所有 JPY 開銷怎麼記?」當場把我問住。寫成台幣 ÷ 4.5、心算成日圓?再認真的伴侶過兩個月都會放棄、回去用 Excel。Futari 一直定位是「兩個人的家計簿」、可是**「兩個人」的常見場景之一就是一起出國**,這條 schema-level 的缺口卡很久了。
旅行子帳本(#42)也是類似的痛點——「這趟北海道五天花了多少」之類,對著一筆一筆 transaction 翻日期區間心算這種事不應該存在於 2026 的軟體裡。
兩個 issue 各自我都評估過、各自單獨做都不便宜。然後上週 brainstorm 的時候靈光一閃:它們應該綁在一起做——旅行 99% 時候用外幣、外幣 95% 場景就是旅行;schema 跟 form 都共用同一片地皮。單獨做兩次等於把同一個 migration window 開兩次,會痛兩次。
所以這天決定:一個 sprint 寫穿 #68 + #42,發 v0.17.0 標記「架構先行」、第二天 v0.17.1 收 UX。順便把上週積的 v0.16.x 收尾(perf / SEO / a11y)一次清掉。
12 個 PR、62 個 commit、四個 release tag(v0.16.2 / v0.16.3 / v0.17.0 / v0.17.1)落在同一天。
上午:v0.16.2 收 perf + 三種 toggle 統一
開場是 #289 font subset——之前 Noto Sans TC weight 600 整個塞進 render-blocking CSS、−93 KB。直接拔掉 weight 600(只保留 400 / 500 / 700),ship。
接著 #290 React.cache() 去重 + dashboard Promise.all 合併——dashboard 那一頁 SSR 期間同一個 getCurrentUser / resolveViewerEpochContext 被四五個地方各自 call,同一份資料每個 request 重抓五次。包進 React.cache() per-request 去重、剩下獨立 query 用 Promise.all 攤平。這條改完 RSC time 砍掉 ~40%——之前慢不是 query 慢,是被自己 cascade 拖死。
然後是 #293 三種 toggle 統一——switch / chip / segment 三種視覺長得不一樣、但語意上都是「選一個」。設計層收斂成同一組 token 之後好看多了。接著 ship 完一個小時後就發現 #293 regression——Switch 元件背景色 token 接錯、暗色背景上整顆 switch 直接隱形。#294 hotfix、release v0.16.2 才能 ship。這是這天第一次「ship → hotfix → ship」的循環,當天還會再來幾次。
v0.16.3:SEO 收斂 + records dual-currency 預備
#305 #306 #307 #308 一次 close 四個 SEO issue——Google Search Console 的 LHR 一直 fail to render。挖根因找到一個經典的「訊號矛盾」:
sitemap.ts沒列/、/sign-in卻是 priority 1.0- root layout 全站 canonical:
/ - middleware matcher 沒排除
/sw.js、/manifest.json、fonts —— PWA 註冊每次都被 307 到/sign-in - middleware 對 public path 沒覆寫
Cache-Control、邊緣快取吃不到 - manifest
start_url: /dashboard—— PWA installable audit 因 redirect chain 降級
這五個 signal 互相打架:crawler 看 sitemap 指 /sign-in、看 canonical 又指 /、看 middleware 被 307、看 manifest 又指 /dashboard。Google 完全不知道你想被 index 的是哪一頁,乾脆放棄。修法是把所有訊號對齊:/ 升 priority 1.0、canonical 從全站宣告改成每頁各自宣告、middleware 排除 SW / manifest / fonts、public path Cache-Control 覆寫成 public, s-maxage=...、start_url 改 /。SEO 修起來 80% 是把 signal 對齊、20% 才是加東西。
順手 ship 一條 records list dual-currency display preview——#68 Phase 1 預備、把基礎設施先擺到 production 上待用。
下午到晚上:v0.17.0 — 多幣別 × 旅行子帳本架構先行 bundle
這是整天主菜。架構先行 bundle 一份 design doc(docs/superpowers/specs/multi-currency-trip-design.md,342 行),鎖了 5 個核心架構決策:
- Snapshot rate semantics —— 歷史 record 鎖定當時匯率,未來改匯率不會回頭改 record。
CashTransactions加original_currency/original_amount/rate_snapshot三欄、CHECK constraint 強制 all-or-nothing(NULL 代表 base 幣別 native record)。balance 計算永遠看 base 幣別 amount、外幣只是顯示語意。 - Full codebase currency-aware refactor via
lib/currency.ts—— 之前散在 13 個地方的NT$ ${amount}hardcode 全部換成formatAmount(amount, currency)。這條是最枯燥但最值錢的鋪路。 - Trip 強制單一 epoch ——
Trips.epoch_idFK toGroupEpochs、start_date >= currentEpochStartedAt、進行中 trip 阻止 epoch 結束(leaveGroup偵測到 active trip 直接 reject「請先結束旅行」)。Trip 不能跨章節——「2023 那段關係的東京之旅」跟「2024 這段關係」是兩件事,不能混。 - Settlement base-only;
base_currency在當前 epoch 無 record 時可改 —— 結算永遠用主幣別,免得「我給你 100 USD 還 350 TWD」這種事務局面。base_currency只在 epoch 還沒任何 record 的時候可改、寫成 lock rule 在setBaseCurrencyaction。 - Income multi-currency schema 加、UI 暫不接 ——
IncomeTransactions同步加original_*三欄、留架構缺口;UI 這版只動支出表單,避免把 sprint 撐爆。
實作分 6 個 Phase,全部當天 ship:
Phase 1:lib/currency.ts foundation(4 個 commit)—— CurrencyCode type、CURRENCIES constant(TWD/CNY/USD/JPY symbols + precision)、currencyPrecision helper、formatAmount、convertAmount(cent-aware precision handling——日圓沒小數、美金兩位、台幣整數,每種幣別的 rounding 規則寫死在 helper、callsite 不用知道)。
Phase 2:formatAmount callsites migration(1 個 commit)—— 14 個 inline NT$ 字串通通換成 formatAmount。
Phase 3:Schema migration 0038(1 個 commit)—— currency_code enum、trip_status enum、CurrencyRates 表(per-group 心理匯率表、只存當前匯率不存歷史)、Trips 表、CashTransactions / IncomeTransactions 加 original_* 三欄、CashTransactions.trip_id、OikosGroups.base_currency default ‘twd’、CHECK constraint 強制 original_* 三欄 all-or-nothing。additive only,不動既有資料——backfill 邏輯藏在 formatAmount 看到 NULL 就 fall back base,DB 不動。
Phase 4:Currency Settings 頁(4 個 commit)—— defaultRatesFor / listRatesForGroup / upsertRate query、setBaseCurrency lock rule action、setRate action、/settings/currency 頁面(rate 編輯 + base picker)、Settings nav 加「貨幣」entry + 4 語 i18n。
Phase 5:Trip CRUD(7 個 commit)—— lib/db/queries/trip.ts query helpers、createTrip / endTrip / updateTrip / softDeleteTrip 4 個 server action(290 行測試先寫)、leaveGroup 加 active trip guard、/trips list page + TripList、TripSheet create form、/trips/[id] detail page、Settings nav + i18n。
Phase 6:Wire 進 AddSheet(3 個 commit)—— CurrencySelector + TripSelector 兩個元件、createTransaction 接 currency + trip 兩個 param、AddSheet wire 進去 + dashboard prop drilling + i18n。
Phase 7:E2E golden path test(1 個 commit)—— multi-currency × trip 從 create transaction 一路測到 balance / records / trip detail 全鏈路,這條是 v0.17.0 release 之前的 gatekeeper。
v0.17.0 23:31 release。
凌晨:v0.17.1 把 UX 補齊
v0.17.0 ship 完之後立刻 dogfood 撞出一堆「能動但不順手」的小細節,連續 4 個 PR 包成 v0.17.1:
#322–#326Currency UX pass —— Settings 貨幣頁 rate 編輯介面、base picker 鎖規則的提示文案、未啟用幣別的 dimmed 顯示、convert preview 即時更新、4 語 i18n 完整覆蓋#327–#331Trips UX polish —— list / sheet / detail 三個 surface 一起對齊:trip status badge、active trip pinned to top、ended trip 顯示 date range、TripSheet validation message 更白話、detail page 空狀態文案#314Vercel edge cache override for public pages ——/、/terms、/privacy走Cache-Control: public, s-maxage=86400, stale-while-revalidate、邊緣 cache 真的命中#315 #316a11y color contrast +llms.txt—— 兩個 chip variant 的對比比沒過 WCAG AA、補對;新增/llms.txt給 AI crawler(Anthropic / OpenAI / Google AI Overview)讀——這是新一代 SEO,AI agent crawl 你的網站時讀的不是 sitemap、是 llms.txt#317 #318 #319build perf ——browserslistfield targeting modern browsers(Chrome/Firefox/Edge ≥ 100、Safari/iOS ≥ 16)讓 SWC 跳過 ES2019+ polyfill、grep 確認 7 條 polyfill 拔掉 6 條;Noto Sans TC 設preload: false——保留 @font-face metadata 但拔掉<link rel="preload">storm(之前並行 fetch ~11 個 unicode-range woff2 共 ~770 KiB)。fold above 不應該被 font preload 卡 770 KiB 才開始 render。
v0.17.1 00:37(已經是 5/15 凌晨)release。
收尾
62 個 commit、4 個 release、一個架構先行 bundle、多幣別跟旅行子帳本一次到位。寫完 migration 0038 那條 SQL 之後我盯著它看了一下——CurrencyRates 跟 Trips 兩張新表、CashTransactions 三欄外加 trip_id FK、CHECK constraint 鎖緊 invariant——整段 schema 第一次看就 self-consistent。設計先行的便宜在這裡:寫的時候沒有「啊我忘了一個 case」那種卡頓,因為 design doc 已經把 5 個架構決策鎖死了。
最後在 dashboard 點開 AddSheet、切到 JPY、選旅行「東京 2026」、輸入 5000、看著「¥5,000 ≈ NT$1,110」即時 render——那一刻覺得這個 app 終於是「兩個人的」,不只是兩個台灣人的。
至於那連續四個 release tag?v0.16.2 → v0.16.3 → v0.17.0 → v0.17.1 都在同一個自然日內(最後一個跨 5/15 凌晨 37 分)。git log 看下去像個發版機關槍。我自己看著也有點不真實。
這段 code 寫於 2026 年 5 月,文章整理於 2026 年 5 月。