把保險從愛物裡搬走、再給愛物加上第七種——v0.16.0 的兩件事
老實說——「保險是愛物嗎」這題我糾結很久。前面六種愛物:車、房、孩子、寵物、植物,再加保險,我們的 spec 本來都歸在同一張 Assets 表底下。從 schema 看起來理直氣壯——一張 base table + 1:1 detail table per type,很乾淨。
可是從使用者角度看,保險根本不是「愛物」——它是承諾、是守護、是「萬一有事」的那條兜底線。把「我家的橘貓」跟「我的醫療險」放在同一個 tab 滑來滑去,那個 mental model 一直怪怪的——像是在 IKEA 看完沙發然後下一頁是骨灰罈。第二個尷尬:我自己這幾天在 dogfood,每次想加新東西按 ➕ 按鈕,TypePicker 跳出來六個 tile——除了我家現有的 6 種愛物之外,剩下 99% 想記的東西都沒地方放。「我新買的 AirPods」、「客廳那個 Dyson」、「我媽送的那台烤箱」——通通卡在「沒有對應的 type」這個技術門檻外。
所以這天做了兩件事:把保險拆出來變成獨立模組「守護」、幫愛物加上第七種叫「物品」的萬用 type。順便把過去章節(epoch)變成 read-only,發了三個版本(v0.15.3 → v0.16.0 → v0.16.1)。17 個 commit、收 sprint 收得很有交代。
守護模組獨立化:一個 boolean、一個 helper、一條 redirect
最直接的解法是保險不刪、UI 拆開。OikosGroups 加一個 guardian_beta_enabled boolean NOT NULL DEFAULT false(migration 0036),新增 canAccessGuardian(group) helper——目前只看 beta flag,未來付費層 cut-over 改成 hasSubscription || isBetaEnabled 是一行的事。Feature flag 的價值不在切流量、在於把「將來決策的 commit diff 」縮成一行。
/assets 頁長出兩個 tab——「愛物」跟「守護(Beta)」——但只有 canAccessGuardian 為 true 才看得到第二個。TypePicker 的保險 tile 在 beta off 的時候完全藏起來(不是 disabled、是不存在,這條 UX 比 disabled 更乾淨)。設定頁開一個「守護(Beta)」section 給使用者自己 toggle、4 語齊備。
最有趣的踩坑是 stale URL 怎麼處理——使用者收藏了 /assets?tab=guardian 在書籤、然後 beta 被關掉(或者根本沒開)。原本會 render 一個空 tab。改成 fall back 回愛物 tab——?tab=guardian 在 server side 直接 collapse 掉。/assets/[id] 也加了 redirect:開保險 asset 但 beta off → 直接 server-side redirect 回 /dashboard,資料還在 DB、只是 UI 拿不到入口。
最後一道保險(雙關):createInsurance action 直接 throw guardian_disabled 當 safety net——萬一前端某個 path 沒檢查到、server action 也擋下。「UI 藏起來」是禮貌、「server 擋下來」才是規則。
愛物模板系統 v1:物品作為第七種 type
「物品」這個 type 的設計關鍵是——故意做得最弱。
之前 6 種 type 每種都接後端自動化:車有 fuel log、房有保險、孩子有健保卡、寵物有疫苗紀錄、植物有澆水週期、保險有 recurring income。每加一個 type 就是寫一整套 schema + action + UI——這是為什麼 backlog 累了一堆「新增 XX type」的 issue 卻都不敢動,每個都要 ~2 週工時。
item 反過來——純文字 + 備註、不接任何後端自動化、不開子表。Assets 加兩欄:template_key enum 跟 template_fields jsonb——item type 永遠走這條 path、list / detail 用 type='item' 這個 invariant 分流。v1 範圍縮到極小:只有一個 general 模板——使用者「物品」拿來記什麼隨意,schema 不管。
TypePicker 從 6 個變 7 個 tile(保險還在愛物 list 裡但 beta off 看不到,所以實際是 5 + 1 + 物品 = 7)。選「物品」會 dispatch 到新的 TemplateSheetBody + createTemplateAsset action——舊 6 種 type 完全不動,這是我給自己訂的約束:「新東西的代價不能是動到舊東西」。
為什麼這條約束重要?因為 5/12 才剛把 AssetSheet 從 1517 行切成 6 個獨立 SheetBody——那條重構的全部價值就是「每個 type 不認識其他 type」。item 是第 7 個 SheetBody,沒碰其他六個一行。重構的真正驗收,是下次加新東西時不痛。如果這次 item 還要動到 CarSheetBody,那 5/12 那條重構就白做了。
過去章節 read-only(v0.15.3)
5/11 ship 了 GroupEpochs、5/12 把 past-times 跨 group 接好——這天把最後一塊拼好:過去章節變成 read-only。
從昨天 dogfood 抓到的 bug——pin 到一個 closed epoch、然後 dashboard 上點「結算」、彈出 SettlementForm、按確認——這條 path 之前是 work 的,但邏輯上荒謬:你怎麼能在「2024 那段日子」幫已經結束的關係結算?或者把 transaction 編輯成「比那個 epoch started_at 還早」的日期?
修法是兩件事:
- 加一個
epochWindow型別 把所有 transaction query 的 input 包進去——{ kind: 'current' }/{ kind: 'closed', startedAt, endedAt }。Compile-time 強制所有 read query 都要明確說「我看的是現在還是過去」——這條型別防呆比 runtime check 強得多,型別系統幫你抓那些你以為自己不會忘記的事。 - closed epoch view 全部 mutation 都 disable——settle、edit、delete、recurring rule on/off 通通拿掉。UI 不會出現按鈕、就算硬 call server action 也會 throw
epoch_closed。
最戲劇化的是 #208 那個 bug——「結算後 projection」這個 view 把 settled + pending 加總當 actionable debt 顯示。使用者按結算 → SettlementForm 預填了 inflated total → createSettlement 真的拿這個數字寫進去 → GroupBalance.balance 被推開 ±pendingDelta。最慘的 case 是從 3,610 元的 projection、一次按到 3600 萬的 settlement——cache 一旦髒掉 snowball 滾起來。修法:derive 一個 isProjectionView flag、canSettle 在 projection view 直接 false。view 跟 actionable 分開——看得到不等於做得到。
投資型保單接 RecurringIncome(#166 Phase B)
Assets.account_value 欄位早就在 schema 裡(Phase A 偷偷加的、UI 沒接),這天把它接到 SavingsView 上——hero 上方專屬區塊顯示帳戶價值、「更新」CTA 直接編。
接著做了一個 UX 上的小決策——SavingsView inline 列出綁定這張保單的 RecurringIncome 規則。原本「投資型保單每月配息 X 元」這條要去設定頁 → 定期收入 → 新增 → 選對應 asset,四步。現在 SavingsView 底部直接「定期進帳」section + 「建立定期進帳」CTA,一鍵打開 RecurringRuleSheet 帶 prefill(assetId / category / source)。
關鍵是 RecurringRuleSheet 接受 create-mode prefill 這個改動——這個元件之前只支援 edit-mode prefill(編輯既有規則)。把它的 prop signature 統一成 { mode: 'create' | 'edit', initial: Partial<Rule> },create 跟 edit 共用同一個 prefill mechanism——下次要做「從 X surface 預填 Y 欄位開 Z sheet」就不用再加 prop。
Realtime auto-refresh:SavingsView 訂閱 recurring-income-changed event——使用者在別處改規則、這頁自動 reflect。這條訂閱是現成的、沒加新 channel——能不開 socket 就不開、PWA socket 維護成本一定要省。
v0.16.1 收尾的細節
v0.16.0 凌晨 release 之後當天又補了 5 個 polish:
- 保險被保人支援 group member(#237)——
insured_user_idFK 跟insured_type='user'discriminator 在 v0.6.0 就鋪好了、但 form 從沒接過——picker 之前只有 Child 愛物或自由文字。人身險 / 旅平險 / 失能扶助這類常常保自己或對方,之前只能打字、丟掉 FK。Picker chip row 改成「我 / 對方 / [children…] / 自行輸入」四選一,三個 source 互斥(child > member > text),enforced 在resolveInsuredFieldsaction 層,picker 上選一個就清掉另外兩條 branch。 - member_a/b avatar 角色色(#238)—— member_a 用
--ink(#3A2419 深咖啡)、member_b 用--accent(#E08856 橘)—— 跟 FutariMark 那個愛心 icon 同一組色。before:所有人頭像都一樣灰,after:你 vs 對方一眼分辨。 - dashboard income mode 也顯示 filter button(#242)—— 之前 income tab 切過去 filter 按鈕直接不見、必須切回支出才能設 filter。一條 conditional render 漏掉的 bug、補 1 行修掉。
- transaction row 拿掉 "my share" badge(#239)—— 那顆 badge 在每一列都跳出來,feed 看起來像加滿 sticker——拿掉之後 feed 終於安靜下來。
- TypePicker 隱藏保險 tile(#236)—— v0.16.0 已經把保險拆到守護 tab、但 TypePicker 還保留入口、選下去會 render 一個 broken UI。直接隱藏。
Sprint 收成:兩週 12 天的全部變動
從 5/2 第一個 commit 到 5/13 v0.16.1 收尾——一個傳統 sprint 的範圍:
| 日期 | 新增 | 刪除 | 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,038 | −3,935 | +6,103 | 272 |
| 5/13 | +3,896 | −620 | +3,276 | 198 |
| 總計 | +108,968 | −29,906 | +79,062 | — |
兩週 net +79,062 行——濾掉 lock / build / 圖片字型之後。平均每天 +6,588 行進 main。換算成「人力工時」是離譜的——這個數字撐得起來的唯一原因是 LLM-augmented 工作流;老式手寫一週能 +5,000 net 就要慶祝。
從 v0.1.0 一路走到 v0.16.1 一共 17 個版本——平均每 1.4 天一版。當然每版範圍有大有小(v0.16.x 是「概念重整 + 第七種愛物」、v0.11.4 只是分色 polish),但 **「兩週發 17 版、每版有 changelog 條目」**這個節奏感是過去寫 code 從沒體會過的。
收尾
17 個 commit、三版收 sprint、把保險從愛物拆出來成獨立模組、給愛物加上第七種開放 type、過去章節變 read-only、修了一個「3,610 → 3,600 萬」的爆掉 settlement bug。
回頭看 5/2 到 5/13——這 12 天從 v0.1.0 一路到 v0.16.1。我看 git log 的時候有種錯覺:這像不是同一個 codebase 的歷史。但 commit author 都是我。對著鏡子。
至於下個 sprint 第一條?大概是「物品」模板系統的 v2——加幾個常見子模板(書、電器、首飾),不過那是另一天的事。
這段 code 寫於 2026 年 5 月,文章整理於 2026 年 5 月。