一天發四個版本、外加把多幣別跟旅行帳本一次寫穿——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 又指 /dashboardGoogle 完全不知道你想被 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 個核心架構決策

  1. Snapshot rate semantics —— 歷史 record 鎖定當時匯率,未來改匯率不會回頭改 record。CashTransactionsoriginal_currency / original_amount / rate_snapshot 三欄、CHECK constraint 強制 all-or-nothing(NULL 代表 base 幣別 native record)。balance 計算永遠看 base 幣別 amount、外幣只是顯示語意
  2. Full codebase currency-aware refactor via lib/currency.ts —— 之前散在 13 個地方的 NT$ ${amount} hardcode 全部換成 formatAmount(amount, currency)這條是最枯燥但最值錢的鋪路
  3. Trip 強制單一 epoch —— Trips.epoch_id FK to GroupEpochsstart_date >= currentEpochStartedAt、進行中 trip 阻止 epoch 結束leaveGroup 偵測到 active trip 直接 reject「請先結束旅行」)。Trip 不能跨章節——「2023 那段關係的東京之旅」跟「2024 這段關係」是兩件事,不能混。
  4. Settlement base-only; base_currency 在當前 epoch 無 record 時可改 —— 結算永遠用主幣別,免得「我給你 100 USD 還 350 TWD」這種事務局面。base_currency 只在 epoch 還沒任何 record 的時候可改、寫成 lock rule 在 setBaseCurrency action。
  5. 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、formatAmountconvertAmount(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_idOikosGroups.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 + TripListTripSheet 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–#326 Currency UX pass —— Settings 貨幣頁 rate 編輯介面、base picker 鎖規則的提示文案、未啟用幣別的 dimmed 顯示、convert preview 即時更新、4 語 i18n 完整覆蓋
  • #327–#331 Trips UX polish —— list / sheet / detail 三個 surface 一起對齊:trip status badge、active trip pinned to top、ended trip 顯示 date range、TripSheet validation message 更白話、detail page 空狀態文案
  • #314 Vercel edge cache override for public pages —— //terms/privacyCache-Control: public, s-maxage=86400, stale-while-revalidate、邊緣 cache 真的命中
  • #315 #316 a11y 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 #319 build perf —— browserslist field 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 之後我盯著它看了一下——CurrencyRatesTrips 兩張新表、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 月。