Oracle3
Open-source trading engine and MCP server for prediction markets. Fee-aware no-arbitrage trading across related contracts, live on Kalshi, Polymarket and Solana or on paper.
管理家庭財務,追蹤資產、負債與現金流
Translation pending
AI summary · README
FinanceJavaScript · PLpgSQLGitHub @kevin1357111317OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
私人家庭財務 PWA。前端部署於 Vercel,正式資料儲存在 Supabase。 安全架構 • 每位家庭成員使用自己的 Supabase Auth 帳號。 • 兩人加入同一 household 後共享財務資料。 • 資產、負債、現金流與歷史資料不存放於 GitHub / 前端原始碼。 • 前端只有 Supabase publishable key;真正資料存取由 JWT + Row Level Security 控制。 • 未登入者無權讀取財務資料。Read the full README ↗
私人家庭財務 PWA。前端部署於 Vercel,正式資料儲存在 Supabase。
index.html:正式 App shell、PWA 與 Apple Web App metadataapp-v3.js:主要前端流程(Auth、家庭、資料載入、行情、Realtime、CRUD、趨勢)financial-core.js:金額正規化、範圍篩選與彙總計算auth-tools.js:密碼重設等 Auth 輔助流程sheet-gesture.js:手機 Sheet 關閉手勢app-version.js:正式版號的唯一來源,版號收在 main.content 最後當頁尾(規則見 VERSIONING.md)loan-ui-fix.js、loan-month-summary.js、loan-month-core.js:貸款畫面的後處理與月份彙總,見下一節v3.css:主要手機優先 UIv3-trends.css:趨勢、分類卡片與目前「布布一二的家」主題樣式health-core.js、health.css:夫妻健檢年度資料整理、重點建議與重要指標趨勢insurance-core.js、insurance.css:保單明細解析、保費/現金價值彙總與保障盤點supabase/functions/:市場行情與每日快照等後端 Edge Functionssupabase/migrations/:已套用或用來重建正式 schema 的 migration 歷史supabase/proposals/:不可直接執行的歷史提案與研究筆記app.js 與 style.css 是舊版保留檔案,目前沒有被正式 index.html 引用。
保險分析沿用 financial_items 的保險資產列,不另外建立一套會重複計價的資產資料。保單明細以
KS_INSURANCE_V1: 開頭的 JSON 存在該列 notes;同一家公司的多張主約可以共用一列,畫面再拆成
逐張保單。這是為了在 migration history 尚未完成 reconciliation 前,不新增正式 schema。
financial_items.amount_twd 只保存解約金/保單現金價值,並繼續進入家庭淨資產;身故、癌症、
醫療、失能與長照保額只在保險分析裡顯示,不能加總進資產。不同事故情境的身故給付也不互加。
缺少保額、保費或附約額度時顯示「待補」,不以 0 代替未知資料。
保險分析畫面先依保險公司整合,再在公司區塊內保留逐張保單明細;公司分組不改變有效主約數、 保費彙總或現金價值計算,也不會把同公司的保額相加成單一保障。
個人健康頁獨立顯示居家體重、體脂率與骨骼肌量趨勢,不混入年度健檢評分或跨儀器比較。
預設收合成顯示最新三項數值的卡片,展開才顯示完整數據與趨勢;行情重繪保留各人的展開狀態。
原始量測及使用者確認的量測條件,以版本化 JSON 保存於該年 health_metrics 的
body_composition_home_v1.value_text,沿用健康資料 RLS,不在 GitHub、測試或前端存個資。
原始時間與各次量測保留;早晨 05:00–11:59 的同日量測取平均作圖,非早晨資料保留並明示
排除筆數。X 軸按實際日期間距,淡點為每日平均,實線為七個日曆日內有量測日的平均;滑桿
可查看各日數值。匯入時只更新居家資料列,不覆蓋年度健檢指標;壞 JSON 不影響其他健康內容。
底部導覽的順序是家庭、老公、老婆、健康。登入後不是落在家庭總覽,而是落在自己的資產頁:
household_members 目前沒有 user 對 owner_scope 的欄位,只有 role,所以用「建立家庭的是老公、
用邀請碼加入的是老婆」來推(memberOwnerScope())。之後要支援反過來的家庭,就把 owner_scope
存進 household_members,只要改那支函式。
第四個分頁(愛心)進入獨立健康模組,可切換老公/老婆與年度。V3.28.0 之前它是家庭頁
最上面那張大卡片,佔掉整個第一屏才進得去;它跟三個資產分頁是同一層的目的地,所以改成分頁。
走 tab 而不是 analysisScreen:底部導覽才會把它標成 on,也不會在歷史多推一筆。
畫面最上方固定用最新年度整理具體行動,年度切換則用來查看當年重點;趨勢只顯示備孕或健康 決策需要優先關注的指標,不把整張檢驗單原樣搬進 App。
健康管理評分存成年度指標,只用來整理已提供資料的管理優先順序,不是疾病風險量表。重要趨勢 會優先挑最新異常且至少有兩個年度的數值,再補上各人需要長期維持的核心指標;不同年度缺少 某項檢驗時只略過該年度,不會讓整個健康頁失效。圖表同時畫出參考下限與上限,不能把偏低項目 錯拿上限當警戒線。
健康頁預設先顯示「夫妻總覽」:並排呈現兩人的總分、六大評分類別、共同檢驗項目與各自優先事項; 個人頁仍可查看各年度趨勢。比較欄位只呈現各自正常/異常狀態,不用單純數字高低判定誰比較健康。
每年健檢換實驗室,同一個指標的參考區間就會跟著換(飯前血糖 70–99 vs 70–100、MCV 80–95 vs
80–100、ALT 5–35 vs 10–40),夫妻並排時圖上會冒出好幾條意義不同的參考線,看起來像兩套標準。
health-reference.js 因此把常用指標統一成國際通用的成人標準,buildHealthModel 讀資料時就
套上去,狀態(正常/偏低/偏高)也依同一組區間重算 —— 不然會出現「參考範圍內」卻標紅字的矛盾。
醫學上本來就男女有別的項目才分開列,其餘男女共用一組數字:血色素、血球容積比、紅血球、
ALT、AST、γ-GT、ALP、肌酸酐、尿酸、HDL、腰圍,以及只有男性適用的精液分析。來源寫在
health-reference.js 檔頭:CBC 用成人常用參考區間、血糖與糖化血色素用 ADA、血脂用
NCEP ATP III/AHA、ALT 用 AASLD 2017 健康人上限、eGFR 用 KDIGO(<60 為腎功能下降)、
精液用 WHO 2021 第 6 版下限值、體位用 WHO BMI 與 IDF 亞洲腰圍切點。
沒收錄的指標保留原報告數值:腫瘤標記(CEA、CA19-9、PSA)、肝炎與感染篩檢、胰島素、HOMA-IR、 G6PD、體脂率這些切點是儀器與方法決定的,硬套別家的標準會把正常判成異常或反過來。AFP 是唯一 的例外:兩人的報告一個寫 0–7、一個寫 <9,圖上會多出兩條意義相同的參考線,所以統一成常見成人 切點 0–10 —— 老公 5.73 仍是正常、老婆 12 仍是偏高,判定沒有變。 統一之後全部資料只有兩筆判定改變,而且都是原本被實驗室較嚴的區間判成偏低的:老公 2026 年 血球容積比 41.9%(國際下限 41,該年實驗室抓 42)與 2025 年 ANC 1.76(國際下限 1.5,該年 實驗室抓 1.85)回到正常。其餘維持原判定:老公白血球 3.58–3.93 偏低、總/直接膽紅素偏高、 2024 年肌酸酐 0.68 偏低;老婆 MCV、MCH、MCHC、RDW、紅血球、2026 年血色素 11.9 與總膽固醇 209/218 仍然是異常。
趨勢圖兩個人用藍(鎧麟)/橘紅(佳軒)兩個色相差最大的顏色,而且點的形狀不同(圓點對菱形), 色弱或黑白列印時一樣分得出來;參考線同色,男女共用同一組區間時只畫一條共用線加底色區間。 卡片不重複固定顯示兩人的最新數值;直接點選或左右滑動圖上的年份,提示框會同時列出兩人該年的值, 缺測的一方顯示「—」。這樣手機畫面不會被摘要框擠滿,也能自行逐年核對。
目前老公有 2023/2024/2025/2026、老婆有 2025/2026 的年度資料:老公優先顯示白血球、ANC、 膽紅素與脊椎發現,老婆優先顯示小球性紅血球型態、AFP、尿液複查與備孕時程。後續新增報告時仍 先人工核對,只把需要追蹤、會影響決策或可形成趨勢的內容放到首頁;其他正常數值保留在資料庫供 年度比較。
一份健檢報告會全部匯入 health_metrics,但畫面只呈現兩種東西:趨勢圖挑「最新年度異常且至少
兩個年度有數值」的指標,個人頁的分類卡片挑 buildHealthDomains 明列的 key。沒被挑到的就只
存在資料庫裡,不會出現在 App —— 這是刻意的,不是漏做。
所以像身體組成細項(除脂體重、骨骼肌肉量、細胞內外水、基礎代謝率、阻抗值、四肢肌肉量與水腫
指數)、白血球分類百分比、眼科視力與眼壓、CYFRA 21-1 這些都只進資料庫。眼科、白血球分類這種
真的沒事的寫 normal,機器推算值(基礎代謝率、代謝年齡、阻抗值、內臟脂肪重量)寫 info;
身體組成的偏低項目仍照 X-SCAN 判定寫 low,不美化數字。
要留意的是「不顯示」目前是靠最新年度沒有這個欄位達成的 —— 趨勢候選只從最新年度的指標裡
挑。哪天新報告也量了體脂機,這些 low 就會變成趨勢候選並自己冒上首頁;到時候再決定是要讓它
顯示(多半該顯示)或把狀態改成 info,不要靠運氣。
健康資料分成 health_checkups(年度主檔)與 health_metrics(指標明細),不寫進前端或 GitHub。
兩張表都啟用 RLS,只允許同一 household 的已登入成員讀取;App 端刻意唯讀,後續報告先經人工
核對再從可信任的管理路徑匯入。健康建議是依數值做的優先排序,不取代醫師診斷。
健康資料載入失敗時不得拖垮財務首頁;健康模組可以暫時顯示空資料,資產、貸款與行情仍要正常。
loan-ui-fix.js 與 loan-month-summary.js 都掛 MutationObserver 在 #root 上,等
app-v3.js 的 render() 畫完再改 DOM。改貸款畫面時只看 app-v3.js 的樣板會對不上實際
畫面:
loan-ui-fix.js:把卡片頭的欄位搬進展開的明細、拿掉重複的格子,而且「貸款年限」是
從已繳期數的分母推出來的(所以排程抓不齊時年限會跟著錯,見「排程要分頁抓」那節)loan-month-summary.js:把摘要換成「本月剩餘還款」,自己帶 publishable key、從
localStorage 讀 auth token(過期就等 SDK 換新再送,見「開 App 的等待時間」),直接打 PostgREST
算當月還沒扣的期數。每輪只批次查一次帳戶與
一次排程,再依成員/貸款類型分桶;同時觸發的更新共用同一個請求。當天快取包含 user id,
換帳號會清除;同步失敗顯示 —,不沿用上一個帳號或過期的數字第二支不走 app-v3.js 的 supabase client,所以測試裡的假 client 攔不到它 —— 瀏覽器測試
看到的那一格會停在「更新中…」,斷言要對著這個狀態寫,不要對著 app-v3.js 的原始樣板寫。
observer 會被自己的改動再次觸發,兩支都各自用「同一個節點不重複改寫」的方式擋住
(dataset.remainingMonthKey、文字比對)。卡片收放的捲動釘位要等到它們都安靜下來才量得準,
所以 pinLoanCard() 是「量到穩定為止」而不是固定幀數。
financial_items.amount_twd 是家庭、個人、配置與歷史快照的台幣計算基準。native_currency=TWD,且 native_amount=amount_twd。native_amount × fx_rate_twd 計算 amount_twd。original_currency / original_amount fallback 讀取,新增與編輯統一寫入 native_*。net_worth_history 保存家庭歷史,financial_scope_history 保存老公/老婆範圍歷史;今日顯示值只在前端即時計算。financial_scope_history
的 husband/net;2026-08-27 起夫妻各自用 asset-liability。個人趨勢永遠只讀自己的 scope,
家庭趨勢才讀 net_worth_history,不在顯示時互相借資料。台股與美股的股數與市值不是手動輸入的,是從 klfan_transactions 推算出來的:交易一有異動,
sync_klfan_financial_item() 觸發器就會重算股數與市值,upsert 回 financial_items
(靠 financial_items.portfolio_stock_key 連結)。所以那一列完全是衍生資料。
因此「新增/編輯財務項目」表單在資產屬性選到台股或美股時:
tw_stock_names 用證交所/櫃買中心的正式中文名,
美股用英文代號2330 或「台積電」都可以),打完會顯示查到的對照;
查不到就照輸入的存owner_scope + market + 裸代號 比對,
前綴不算數)—— 出清過的舊紀錄才不會跟新買的分成兩筆各自為政;夫妻若都持有同一檔,
必須各自保留不同的 klfan_stocks.key,交易與 financial_items 不可互相混用BATS: 過一次,帶冒號的代號
會過不了 refresh-tw-quotes 的 validSymbol,那一檔就靜靜地不再被報價financial_items 那一列,
沒有交易的標的只會出現在「股票投資」、不會出現在資產裡klfan_transactions.note 一律填「股票」/「股息」;表單下方那個「備註(選填)」是
項目自己的 financial_items.notes,兩個放在一起只會搞混klfan_stocks / klfan_transactions,不自己插 financial_items ——
自己插一列會跟觸發器建的那列重複,資產被算兩次klfan_stocks 刪(交易 cascade 跟著走)。只刪 financial_items 沒有用,
下一筆交易或報價更新會讓觸發器把它重建回來報價代號在私帳裡一律帶交易所前綴(TPE:2330、NASDAQ:QQQ),sync_klfan_financial_item()
就是靠這個前綴把裸代號切出來寫進 financial_items.symbol。表單允許只打 2330,會自動補上
並顯示補完的結果。
私帳連動的股票出清後,financial_items 那一列是被更新成 0 股 0 元、不是刪掉 —— 唯一索引建在
portfolio_stock_key 上,再買回來要能接回同一列。所以 getItemsForScope() 在顯示層把 0 股的
私帳部位濾掉;原始的 items 陣列不動,syncPortfolioFinancialItem() 還要靠它找到那一列。
手動建立的 0 元項目不受影響。
股票分析、美金分析與黃金分析在兩個人的頁面上都有,各自只看自己名下的部位:
klfan_stocks.owner_scope 切。這一欄本來就有,只是 klfan_bootstrap() 沒有把它送到
前端 —— s 陣列現在多帶第 7 個元素 owner_scopeusd_transactions.owner_scope 切,在美金分析頁記的那筆就記在當下看的那個人名下financial_items.owner_scope 切,歷史成本照 gold_transactions.owner_scope 切黃金分析把即時部位與 KLFAN「黃金」工作表的成本私帳分開保存:financial_items 繼續負責目前重量、
即時金價與台幣市值,gold_transactions 只保存每筆買進日期、成本、重量與收藏工錢。年化報酬率用每筆
實際投入日期重算 XIRR,不沿用試算表可能過期的快取結果。
成本私帳重量若小於資產頁重量,畫面會明確顯示缺少成本的克數;損益與 XIRR 只計算已有成本且標示 「計入年化」的部位,不把差額或送人的黃金當成零成本獲利。
PAMP 星座的 NT$52,696 是完整買進支出,其中含工錢 NT$23,080。工錢已包含在投入成本中; 依使用者指定的資產估值口徑,目前價值以 6g 即時金價再加回 NT$23,080 工錢。
送父母那兩筆各 2.5g 不是自己的部位了,所以三個地方都要一致:不算年化、不算目前持有、
資產頁的重量也要扣掉。這是 still_held = false(include_in_performance = false 只擋年化,
重量還是留在資產頁上,兩邊會對不起來)。
gold-core.js 把「送出去的一定不計年化」寫死在 normalizeGoldTransaction() 裡
(include_in_performance !== false && still_held !== false),兩個旗標不容許互相矛盾 ——
東西都送人了,期末沒有對應的價值可以配對,XIRR 會把它當成有去無回的虧損。
資產頁的重量跟著從 204.3105 g 改成 199.3105 g(每公克單價不變,市值照比例縮成 882,213)。重量核對只比對還持有的那幾筆。家庭淨資產因此少約 NT$ 22,100 —— 那本來就不該 算進來。
黃金摘要本來有六格,前兩格是「目前黃金部位/目前台幣市值」。送出去的克數扣掉之後, 部位的克數就跟「投入成本」那一格底下標示納入年化的克數是同一個數字,市值也只跟 「目前價值」差一筆工錢 —— 兩格都是重複的,拿掉。現在是四格,全部都在講年化那一組。
「重量已核對」那一塊同樣只在對不起來的時候才畫:對得起來時它只是把上面的克數再寫
一次。缺少買進成本的警告一定要留著,不然差額會靜靜地不見,tests/gold-analysis.test.mjs
兩邊都有守。
portfolioModel 還是要留著:編輯表單與 syncPortfolioFinancialItem() 是照 key
找標的,跟歸屬無關,用切過的那份會找不到美金部位自己一本帳(usd_transactions),跟 financial_items 沒有連動 —— 買賣只在美金分析頁裡進出,
資產頁上的任何一列都不會因此改變。這是刻意的:美金是換匯不是持股,混進資產列表只會兩邊互相蓋。
成本用移動加權平均(usd-core.js),跟股票那邊的 FIFO 不一樣,這是為了對齊 KLFAN 試算表
「美金」工作表原本的算法:
tests/usd-core.test.mjs 有守)分析頁的「平均換匯匯率」就是這個移動加權平均成本(剩餘成本 ÷ 剩餘美元),底下直接寫目前匯率 高/低於它多少,換匯時拿來比。它不是「所有買進的平均」:賣出不改變它,但買進全部平均會把 已經賣掉的那幾筆也算進去(2026-09 兩者是 31.787 對 31.796)。剩餘成本台幣金額移到美元部位那張卡。
tests/fixtures/usd-ledger.json 是試算表那 107 筆真實交易,測試拿它比對試算表自己算出來的
每一個數字(部位、平均成本、市值、淨投入、已實現、未實現、XIRR)。改算法時這支測試會先叫。
一筆交易存三個數字:usd_amount(正買負賣)、rate、twd_amount(正負號跟 usd 相反)。
匯率不是推導出來的而是存下來的 —— 有些單是「買 100,000 台幣」、有些是「買 2,000 美元」,
兩邊都會有零頭,只存兩個數字對不回試算表。
負債分類依序是 房貸/增貸/信貸/質押/信用卡/其他負債。質押是 2026-09 為元大股票質借(台積電擔保)
新增的,跟信貸分開,因為它有擔保品、有追繳線,性質跟無擔保信貸不同。質押只是 financial_items 的一個
分類。
貸款分析也有「質押」分頁(loan_accounts.loan_type = 'pledge',2026-09-30 放寬 CHECK,語法留在
supabase/proposals/20260930_loan_type_pledge.sql,是直接執行、沒寫進 migration history 的一筆)。
質押不按月攤還:排程只有一筆撥款、一筆到期一次還本付息,月付是 0;autopay 一定要關,
到期可以續借,不能讓 apply_due_loan_payments() 在到期日自動把負債扣到 0。
首頁卡片的「下次」只寫利息(下次 03/30 利息 NT$ 19,518 = 到期那期本息 − 目前本金),
不寫 1,019,518 的本息合計 —— 本金到期可以續借,真正要掏出來的是利息(屋主 2026-10-03 指定)。
擔保維持率(2026-10-04)=擔保品市值 ÷ 目前本金。元大證金整戶低於 140% 寄追繳通知、三個營業日 沒補足就處分(一般券商是 130%,證金較嚴)。首頁質押卡片的第一格從「利率」換成「維持率」(利率固定, 貸款分析看得到);貸款分析卡片多「擔保維持率」與「追繳線 140%:股價 X,再跌 Y%」兩格。離追繳線 30 個 百分點內轉橘、跌破轉紅。
擔保品不開新欄位,寫在 loan_accounts.source_note 的一行字裡(pledge-core.js 解析):
擔保品:2330 × 1000 股;追繳維持率 140%。多檔用「、」分開。股價取台股財務項目的即時報價,
任何一檔抓不到價就整個不顯示,不顯示錯的維持率。加借、續借或換擔保品時記得改這行。
個人頁的分析入口依序是「股票分析/貸款分析/黃金分析/美金分析」四個按鈕,美金分析固定在最右邊。貸款分析統整信貸、房屋增貸與房貸。loan_accounts 保存 KLFAN 工作表的原貸款、期程、預估總還款與結清狀態;
進行中貸款以 financial_item_id 連回
financial_items 的既有負債列,畫面上的目前本金、利率與月付以負債列為準。
分析頁以「信貸/增貸/房貸」三段切換,預設顯示信貸;摘要數字、進行中清單與已結清紀錄
都只計算目前選取的貸款類型。進行中的貸款卡片依目前剩餘本金由少到多排列。
這個連結是防止重複計算的邊界:loan_accounts 只供分析,不能再加進資產負債總額。
KLFAN 中已結清的舊信貸保留在分析頁,但餘額固定為零。由於表定利率已無法確認,
資料庫不保存該欄位,已結清信貸卡片也不顯示「表定利率」;實際現金流算出的年化成本仍保留。
夫妻間的責任移轉仍保存在 loan_events 作歷史資料,不再顯示於貸款分析頁,也不會混入銀行貸款現金流。
loan_schedule 是完整的貸款現金流,以 entry_type 區分撥款、費用、還款與調整,並同時保存
應繳日 due_date 和已知的實際扣款日 actual_date。貸款卡片點一下會列出資金流入、所有費用、
過往繳款與未來排程,並用每筆實際現金流做 XIRR,顯示含開辦費的「實際年化成本」。費用不會
再被誤算成還款期數;摘要的原始「每月還款」會由後處理層換成「本月剩餘還款」。排程是
懶載入的(ensureLoanSchedule()),沒點開分析頁就不抓。
240820_KL_LB 已依 2026-09-07 LINE Bank App 畫面核對:本金 350 萬、實收 3,499,112、
開辦費 888、首期 20,523、其後月付 39,763;截至 2026-09-05 已繳 25 次、剩 72 次,
本金餘額 2,663,371。已知的每期繳後本金存於 balance_after_twd,僅供核對,不會重複計入 XIRR。
260323_KL_將來 已依 2026-09-08 將來銀行 App 畫面核對:本金 7,010,000、120 期、
機動利率 2.38%、月付 65,706;截至 2026-08-23 已還本金 259,629,本金餘額 6,750,371,
下次繳款日 2026-09-23,到期日 2036-03-23。KLFAN 記錄的開辦費 2,643 仍納入 XIRR。
250429_KL_CTBC 潤隆增貸依 KLFAN 核對的其他費用共 5 筆、NT$ 25,046:管帳費 9,000、
火災險 66、2,210、2,210,以及代辦費 11,560;其中第二筆 2,210 的火災險日期是
2026-08-28。這些現金流原本都已納入 XIRR,修正的是費用分類、名稱、筆數與合計,
不會重複計入實際年化成本。
匯入排程時順手修了兩個對不起來的數字:連線銀行信貸的預估總還款漏掉最後一期 19,278
(3,798,896 → 3,818,174)。元大的結算日期在試算表裡
是舊的 2033,但排程列走到 2036 —— 10,606 × 120 + 2,888 = 1,275,608 剛好對上,所以 maturity_date
改成 2036-08-07。
新增股票只有一個入口:「新增財務項目」表單。私帳頁不放第二個新增入口,也沒有返回按鈕。
「股票投資」是狀態切換不是換頁,返回手勢預設不會有反應,所以進去時 window.history.pushState()
推一筆、popstate 再把畫面收回來(app-v3.js 有個模組層的 history 變數存淨資產歷史,會遮蔽
window.history,一定要寫全)。底部導覽切分頁也走同一條路 —— render() 在私帳頁會直接回傳
私帳畫面,不先收掉會被困住。頁面上沒有返回按鈕。
「股票投資」清單的卡片是就地展開的,不跳頁:展開後在原位往下長出已實現與未實現損益、 累計股息、目前股價、投資期間、持有股數,以及完整交易紀錄。收合的卡片只顯示市值、累計損益與 XIRR 年化報酬 —— 每個數字只出現在其中一邊,不重複。 一次只開一檔,再點一次收起來。
投資期間看的是「錢投在裡面多久」,不是「買了多久以前」:還握著的部位算到今天,已經出清 的算到最後一次買賣那天。金益鼎 2023-08-04 買、2023-08-09 賣,實際只有 5 天;舊版一律算到 今天,2026 年打開會顯示 3.12 年,看起來像抱了三年才賠。賣光之後才入帳的股息不算進期間 (那只是尾款),但 XIRR 仍然吃它的日期。不滿一年用天顯示 —— 當沖與短線部位用年當單位會 全部擠成「0.00 年」。年化報酬率本來就是用每筆現金流的真實日期算的,不受這個欄位影響。
render() 是整個重建 DOM,捲動位置會掉回頂部,所以展開/收合(以及資產列表的分類展開)
走 renderKeepingAnchor():記下被點元素的文件座標,重繪後先還原捲動位置再量一次、補上差值。
重點是重繪完當下瀏覽器的捲動狀態是未定的,所以不能用相對的 scrollBy,也不能在還原之前量。
已實現與未實現用先進先出配對賣出(splitRealized())—— 賣掉的那幾股要用當初買它們的成本
算損益,不是整體平均;同一檔在不同價位分批買進時兩者差很多。股息沒有對應成本,整筆算已實現。
不變式是「已實現 + 未實現 = 累計損益」,tests/portfolio-core.test.mjs 有守,而且有一組
FIFO 與加權平均會給出不同答案的案例,改回平均成本就會被擋下來。
美股分析頁固定使用單一 USD 口徑,避免同一卡片混入美元、台幣與匯率影響:
全部 分頁與家庭資產仍保留台幣口徑,才能跨台股、美股加總;這些內部計算不出現在美股頁這是呈現口徑的修正,不改交易資料、資產市值或其他頁面的計算定義。
股票分析摘要下方使用「累積 TWR 指數,起始=100」,不拿市值或 XIRR 畫折線:每日股票市值先扣除 當日買進/賣出/股息現金流,再逐日連乘時間加權報酬,避免加碼被誤認成績效。台股比較 0050; 美股可切 VOO/QQQ/SOXX;全部頁可切動態混合或上述三個美股基準。全部頁的單一美股基準換算成 台幣,因此包含 USD/TWD 影響。
Edge Function 不在 Vercel 的部署範圍,推 main 只更新前端,portfolio-performance 要另外
supabase functions deploy。漏掉的時候畫面完全正常,只有數字是舊演算法算的 —— 2026-09-18 就
這樣過一次,當時只能靠人工撈線上原始碼比對才發現。
所以 index.ts 帶一個 FN_VERSION 常數並放進回應的 fnVersion,前端拿它跟
app-v3.js 的 PERFORMANCE_ENGINE_VERSION 比:一致就什麼都不顯示,不一致(或線上還是沒有這個欄位
的舊版)就在績效卡頁尾加一行「⚠ 計算引擎停在 X,畫面要的是 Y」。
引擎版本只在引擎改了才升(2026-10-04 起)。以前是跟 APP_VERSION 綁死,結果前端每改一次版,
就得重新部署一支根本沒改的 Function(10-02~03 兩天多部署四次)。現在 tests/app-version.test.mjs
記一份三個檔案(去掉 FN_VERSION 那行)的 sha256 指紋:程式碼改了卻沒升版號會紅,訊息會告訴你
要把 FN_VERSION、PERFORMANCE_ENGINE_VERSION 改成當次的 APP_VERSION、更新指紋、合併後部署。
引擎版本因此會落後 App 版本(例如 App V3.43.0、引擎 V3.42.0),那是正常的。
注意 Supabase 自己的 function version(v9、v10 那個)是每次部署 +1 的自動序號,API 不給指定, 所以只能用這個常數做對照,沒辦法讓兩個編號真的一致。
portfolio-performance Edge Function 直接從完整交易私帳重建每日持股,配合 Yahoo Chart 的歷史
收盤價與 USD/TWD;因此不再受 2026-09-03 才開始保存每日市值的限制。老公的完整期間會從第一筆
交易 2019-10-23 開始,美股則從第一筆美元交易 2024-05-23 開始。畫面可切「今年以來/近一年/全部」,
每個期間都各自在第一個可比交易日重新設為 100,長期間最多抽樣 280 點,起點與終點一定保留。
同一區塊另列三組不混用的口徑:累積 TWR 是圖表期間的總報酬;期間至少 365 天時,年化 TWR 以
(1 + 累積 TWR) ^ (365 / 實際日數) - 1 計算,投資組合與 Benchmark 共用完全相同的起訖日,差距
以百分點(pp)呈現;未滿一年一律顯示「—」。期間 XIRR 則把第一天市值視為期初投入,再套用期間內
真實的每筆投入/提款金額與日期及期末市值。Benchmark XIRR 使用完全相同的資金時點,只把資金依
當日含息 Benchmark 指數換算為單位;全部頁的混合 Benchmark 仍沿用前一日實際台美股權重,不退化成
固定 50/50。上方摘要的 XIRR 仍是完整交易私帳的實際資金年化報酬,TWR、XIRR 與累計損益率不互換。
自己的資金流由 buildPortfolioCashflows 單獨組出來,不經過 Benchmark。以前兩者共用
buildBenchmarkCashflows,0050 或 VOO 的歷史行情只要有缺口,整個函式回 null,連「自己賺多少」
都跟著變成「—」;那是 Benchmark 的問題,不該讓自己的報酬一起消失。現在 Benchmark 算不出來時
只有 Benchmark XIRR 與差距是空的,期間 XIRR 與入金筆數照常顯示。
Yahoo 的 close 已回溯調整拆股、但不調整現金股息;持股市值繼續使用 close,私帳股息當提款
排除後加回個人報酬。Benchmark 改用 adjclose,把現金股息再投入納入含息總報酬,避免個人含息、
大盤不含息造成超額報酬偏高。歷史交易有兩種
股數口徑:0050 保留拆股前股數,NVDA 已手動換成拆股後股數。不能一律再乘拆股倍率;函式會以
「成交金額 ÷ 股數」和當日拆股調整後收盤價的比例逐筆判斷,測試同時守住這兩個案例。全部頁的
0050+VOO 基準會依前一日實際台美股市值權重動態混合,新增美股後才開始取得美股基準曝險。
倍率同時支援小於 1 的反向調整:長榮 2022 現金減資後,Yahoo 會把 2021 歷史價格回溯調高,而私帳
仍保存減資前股數;舊邏輯因此在 2021-11-08 產生約 +37.4% 的假尖峰。現在以 0.4 倍重建舊股數,並
移除原本直接略過極端單日報酬的遮罩,之後異常資料會在測試與圖表中暴露而不是被靜默隱藏。
每日 TWR 扣掉的當日金流,用「當日收盤價 × 成交股數」計價,不用私帳的成交金額。市值本來就是收盤價 算出來的,拿成交金額去扣,等於把成交價與收盤價的價差(手續費、盤中成交價位)當成當天的報酬;部位 還很小的時候加碼,這個價差除以前一日市值就會炸成幾十個百分點的假單日報酬:
| 日期 | 事件 | 前一日市值 | 成交價 vs 當日收盤 | 舊的單日報酬 | 修正後 |
|---|---|---|---|---|---|
| 2020-07-22 | 買 1 張台積電 381,336 | 10,956 | 381.34 vs 384.00 | +24.57% | +0.25% |
| 2020-08-18 | 買 1 張台積電 440,388 | 23,021 | 440.39 vs 433.00 | −32.66% | −0.57% |
| 2020-11-17 | 買 1 張台積電 502,443 | 63,394 | 502.44 vs 485.50 | −26.02% | +0.71% |
| 2019-12-19 | 買 1 張 0050 95,186 | 23,372 | 95.19 vs 97.65 | +9.98% | −0.56% |
這四天全是 2020-07-17 賣掉 0050 主要部位之後、帳上只剩一萬多元時的加碼,方向與價差符號一致,不是行情
也不是除權息或拆股。這一項單獨讓全期(2019-10-23 ~ 2026-09-17)累積 TWR 從 206.18% 回到 471.24%、
年化 TWR 從 17.59% 回到 28.70%;XIRR 27.09% 完全不變 —— XIRR 是資金加權,本來就該用私帳的真實現金金額,所以
transactionFlows 維持原樣,只有 TWR 那條線改用收盤價計價的金流(buildHistoricalSnapshots 會把它
掛在每日快照的 flow 欄位上)。代價是成交價與收盤價的價差不再計入 TWR,但那段本來就被日收盤口徑的
TWR 攤成雜訊,而且它仍完整反映在 XIRR 與累計損益裡。剩下的單日極值(2025-04-09 +10.35%、2020-07-27
+9.77%、2020-03-20 +7.95%、2025-04-03 −7.52%)當天都沒有金流,是真的行情。
圖表右上角的期間與 X 軸刻度都帶年份(YYYY/M/D):只有 M/D 時「9/17 – 9/17」看起來像同一天。X 軸
只在第一個刻度與跨年處補年份,同年的刻度維持 M/D,380px 下三個刻度都放完整日期也不會重疊或換行。
年化比較區塊在期間未滿一年時標「期間未滿一年,不年化」(annualizeReturn 本來就回 null,畫面顯示
「—」,沒有把短期報酬放大年化過);期間約一年時年化必然等於累積,比較實際值:差距小於 0.005pp(連
兩位小數都一樣)標「期間為一年,年化=累積」,週年日碰到假日使期間多一兩天時標「期間約一年,
年化≈累積」。說明數字一樣不是壞掉,比整塊藏起來動得更少。
同一批還修掉四個會讓 TWR 悄悄失真的口徑問題:
events.dividends 的除息日(差超過 120 天就不算同一次配息,退回私帳日期)。XIRR 用的
transactionFlows 不動:資金加權本來就該用真實入帳時點。buildPerformanceSeries 也一樣,這個 market 當天市值為 0 的日子會把金流寄放到下一天。
目前資料沒踩到(缺價的標的從頭缺到尾),但這是會靜默算錯的定時炸彈。events.splits 有資料,候選只留「1(私帳已換算)」與「成交日之後的累計
倍率(私帳是成交當時股數)」兩個;沒有資料才退回原本的猜測清單。NVDA 2024-06-10 的 10:1 因此
不再有誤判空間。Yahoo 的 chart API 不回報台灣 ETF 的受益權單位分割 —— 它把歷史價格調整了,卻不給 events.splits。
0050 的 2019 收盤直接從 87 元變成 22 元,splits 欄位卻整個不存在(2026-09-19 直接打 API 確認)。
美股與台灣個股都有回報:NVDA、TSLA、SOXX、MUU、長榮 2022 的現金減資(400:1000)都查得到。
所以 TW_ETF_SPLITS 這張表要自己維護,日期用「分割後恢復交易日」,也就是第一個用新單位成交的日子:
| 標的 | 恢復交易日 | 倍率 | 對照 |
|---|---|---|---|
| 0050 | 2025-06-18 | 4 | 188.65 → 47.16 |
| 00631L | 2026-03-31 | 22 | 443.15 → 20.14 |
00631L 是 1 拆 22,不是 20。 舊的猜測清單裡根本沒有 22 這個候選,只能猜成最接近的 20,等於 那段期間的持股一直少算 9%。這就是「用猜的」撐不住的地方 —— 新增一檔時去查投信公告,把價格對照 一起寫進註解,之後才對得起來。
另外「全部」期間兩千多個交易日抽成 280 點,平均每七天才留一點,2025-04-09 那種單日 +10% 會整根被
跳過。downsampleSeries 均勻抽樣之後,額外把單日振幅 2% 以上、最大的 12 天連同前一天補回來
(上限 +24 點)。只影響圖形;累積 TWR、年化與 XIRR 一律用完整序列計算。
四項合計之後的數字(market=all、mixed benchmark,today 取 2026-09-17):
| 期間 | 累積 TWR | 年化 TWR | XIRR | 期間長度 |
|---|---|---|---|---|
| 今年以來 | 31.53% → 34.39% | — | 46.02% → 47.40% | 258 → 260 天 |
| 近一年 | 49.00% → 50.41% | 49.00% → 50.41% | 47.41%(不變) | 365 天 |
| 全部 | 206.18% → 465.84% | 17.59% → 28.52% | 27.09%(不變) | 2,521 天 |
拆開看:金流改用收盤價計價讓全部期間 206.18% → 471.24%,除息日對齊再把它修到 465.84%(年化 28.70% → 28.52%);期間基準點只動到今年以來(32.96% → 34.51%,XIRR 46.02% → 47.40%)。Benchmark 那條線完全沒變 —— 它只吃價格、匯率與前一日台美股權重,不讀任何金流。
行情抓不到的標的不會假造價格;回應會帶缺漏檔數並顯示在圖表期間旁。production migration history
仍未 reconcile,所以這版不新增資料表、不執行 migration,既有每日 portfolio_snapshot 只保留作為
未來資料核對來源。
tests/portfolio-ledger.test.mjs 在真的瀏覽器裡跑這條路徑,supabase client 換成記憶體版
(tests/support/fake-supabase.js,含觸發器與 FK cascade 的行為)。它需要 playwright,沒裝會
自動跳過;裝在專案外面的話用 PLAYWRIGHT_PATH 指到進入點。
tw_stock_names(code / name / board)由 refresh_tw_stock_names() 從證交所 OpenAPI 與
櫃買中心 OpenAPI 各抓一次填入 —— 上市清單不含上櫃,兩邊都要。board 決定代號要配 TPE:
還是 TWO: 前綴。抓取走資料庫的 http 擴充,前端與 Edge Function 都不碰。
這是公開市場資料,登入後可讀;寫入的函式對 public / anon / authenticated 都撤銷執行權限
(否則任何登入者都能觸發對外抓取),要更新清單時用管理連線手動 select refresh_tw_stock_names();。
玉山那筆 23,790,000 是夫妻一起付、一人一半,所以拆成兩筆 loan_accounts(各 11,895,000),
兩邊加起來才是銀行 App 上的數字。負債列本來就已經是分開的兩列,這次只補分析用的主檔與排程。
interest_day_count 記各家銀行的算法:玉山這筆是年利率 ÷ 12,其他信貸是 actual/365。
證據是寬限期每個月不管幾天都扣同一個數字 —— 23,790,000 × 2.18% ÷ 12 = 43,218.5,四捨五入
就是 App 上的 43,219/43,218(半元在跳);actual/365 的話每個月會不一樣(將來、富邦就是那樣)。
連線銀行(LINE Bank)也是年利率 ÷ 12(2026-10-05 改正,原本誤設 actual/365)。證據是還款計畫表 每期利息都剛好等於「期初餘額 × 2.18% ÷ 12」:11/05 2,628,446 → 4,775、12/05 2,593,458 → 4,711, 07/05→08/05 那期 2,733,030 → 4,965 也對得上。用 actual/365 的話 10/05 那期算成 4,772(銀行收 4,838), 本金多扣 66 元,App 的餘額變成 2,628,380、銀行是 2,628,446。那一期已改成銀行實際數字。
扣款紀錄上第一筆不是月付,是 2024-03-05 的 5,633 —— 撥款日 2024-03-01 到首次扣款之間 4 天的零頭利息(23,790,000 × 2.16% × 4/365)。把它算成第 1 期之後,全部對得起來:
| 期別 | 日期 | 內容 | 對照 |
|---|---|---|---|
| 第 1 期 | 2024-03-05 | 4 天零頭利息 5,633 | |
| 第 2~37 期 | 2024-04-05 ~ 2027-03-05 | 36 期只繳息 | = 寬限期間 36 個月 |
| 第 38~361 期 | 2027-04-05 ~ 2054-03-05 | 324 期本息攤還 | = 借款期間 360 個月 |
攤還表說「第 38 期開始還本金」正好吻合,最後一期就是 2054-03-05,不多不少。
(先前一度以為多出來的一個月是繳款日改期造成的 —— 那是錯的,是漏記了開頭那筆零頭利息。)
不是一開始就 43,219。反推每一期(月付 × 12 ÷ 餘額):
| 期間 | 月付 | 反推利率 |
|---|---|---|
| 2024-04-05、2024-05-05 | 40,840 | 2.0600% |
| 2024-06-05 | 41,991 | 2.1181%(過渡月) |
| 2024-07-05 起 | 43,219 | 2.1800% |
跟潤隆房貸同一次升息(央行 2024-03-21 +0.125%),玉山在 2024-06 反映、中信在 2024-05, 差一個月。另外 2024-03-01 還有手續費 3,000(各半 1,500)。
月付 97,207.69 對應324 期的年金(323 期是 97,428、325 期是 96,988,都對不上), 利息 = 餘額 × 2.18% ÷ 12,餘額不四捨五入往下帶。用這組規則重算,攤還表上抽查的 12 列 有 11 列分毫不差(只有第 114 期本金差 1 元,精確值 61,974.64 卡在進位邊界),而且最後一期 餘額剛好歸零 —— 三件事同時成立才敢說算法對了。
拆成兩半各自四捨五入會有誤差,但有界:中段最多差 104 元,到期時回到 0。
中信的「貸款繳息明細」把答案講清楚了。反推每一期的利率(利息 × 365 ÷ 期初餘額 ÷ 天數):
| 期間 | 反推利率 |
|---|---|
| 2023-10-05 ~ 2024-04-05 | 2.0600% |
| 2024-05-05(過渡月) | 2.1085% |
| 2024-06-05 起 | 2.1800% |
央行 2024-03-21 升息 0.125%,中信隔一個月才反映,月付也同步從 29,811 改成 30,287。 過渡月是混合利率,反推大約在 4/23 前後換的。
之前怎麼湊都對不上,是因為我把升息前猜成 2.055%、又假設 4 月就生效 —— 兩個都差一點。
明細也確認了算法:actual/365,而且利息算到「應繳日」不是實際入帳日。2023-11-06 那筆用 11/05 去算才會得到乾淨的 2.06%,跟富邦是同一套。
歷史 36 期已經換成明細上的實際數字(金額、實際入帳日、本金/利息拆分),每一期的
期初 + 利息 − 還款 = 期末 都驗算過。明細只到 2026-07-05,之後兩期用同一套算法推,
算出 7,412,585 跟記錄的 7,412,586 差 1 元。
中信「貸款繳息明細」的 17 期反推利率全部落在 actual/365 × 2.55%、利息算到應繳日, 一分不差 —— 跟潤隆房貸同一套。各月天數對應的利息:
| 天數 | 28 | 29 | 30 | 31 |
|---|---|---|---|---|
| 利息 | 13,693 | 14,182 | 14,671 | 15,160 |
原本從 KLFAN 搬來的排程大多寫死 15,160,30 天跟 28 天的月份就會偏高。現在寬限期的每一期
都用 7,000,000 × 2.55% × 當期天數 ÷ 365 直接算。
期數:App 顯示「16 / 360 期」,但明細到 2026-09-05 為止已經扣了 17 次 —— 差的那次是撥款日到 首次扣款之間的 6 天零頭(2025-05-05 收 2,934),銀行不算一期。所以第 1 期是 2025-06-05、 第 360 期是 2055-05-05。(鼎宇剛好相反,玉山把零頭算成第 1 期。)
寬限期後(2028-06-05 起 324 期)的月付還是推估,只是把 KLFAN 那個整數 30,000 換成依合約條件 算出來的 29,911。兩個都是估的,但這個至少是從條件推出來的;真正的數字要等 2028 年銀行出 攤還表,那時利率也早就變了。
KLFAN 另外記的火災險與代辦費沒有出現在這個帳號的明細裡 —— 那些是在貸款帳戶外付的,
留在 loan_schedule 當 fee 只是為了讓 XIRR 把成本算進去。
卡片的「實際年化成本」優先用排程算出來的 XIRR,排程還沒載入時退回主檔的
effective_annual_cost。那個 fallback 原本是 toFiniteNumber(x) —— 預設吐 0,所以沒存
年化成本的貸款會先顯示「表定利率/實際年化成本 2.50% ; 0.00%」,等排程載進來才跳成真的
數字。一筆 2.5% 的貸款寫 0%,而且還會自己跳掉。
fallback 改成 toFiniteNumber(x, null):算不出來就顯示「—」。
同時把八筆進行中貸款的 effective_annual_cost 全部用實際現金流重算回填(鼎宇兩筆本來是
null、潤隆增貸是舊值),這樣連破折號都不會閃:
| 貸款 | 表定利率 | 實際年化成本 |
|---|---|---|
| 元大銀行信貸 | 3.75% | 3.871% |
| 富邦銀行信貸 | 2.60% | 2.636% |
| 將來銀行信貸 | 2.38% | 2.413% |
| 潤隆增貸 | 2.55% | 2.604% |
| 潤隆房貸 | 2.18% | 2.196% |
| 連線銀行信貸 | 2.18% | 2.208% |
| 鼎宇房貸(各半) | 2.18% | 2.199% |
差額就是開辦費、管理費那些一次性費用攤到全期的效果,數字都合理。回填用的二分法跟前端
loan-core.js 的 XIRR 同一套規則:撥款為正、費用與還款為負,日期取 actual_date。
上面那張表回填之後,潤隆房貸的卡片還是錯的:
| 欄位 | 卡片顯示 | 應該是 |
|---|---|---|
| 表定利率/實際年化成本 | 2.18% ; −8.94% | 2.18% ; 2.20% |
| 貸款年限 | 11 年 11 個月 | 30 年 |
| 已繳期數 | 36 / 143 | 36 / 360 |
三個數字錯在同一件事上:前端只拿到 360 期裡最早的 143 期。loan_schedule 現在有 1,924
列,ensureLoanSchedule() 用 .range(0, 9999) 一次抓 —— 但 PostgREST 伺服器端有
db-max-rows(這個專案是 1000),Range 開再大也沒用,超出的直接砍掉,而且不回錯誤。
照 due_date 排序之後,前 1000 列剛好只包含潤隆房貸的 143 筆繳款,用 SQL 對過完全吻合:
with ranked as (select s.*, a.name, row_number() over (order by s.due_date, s.id) rn
from loan_schedule s join loan_accounts a on a.id = s.loan_account_id)
select name, count(*) filter (where entry_type = 'payment') from ranked where rn <= 1000 group by name;
-- 鼎宇房貸 274、潤隆房貸 143、潤隆增貸 123、將來 112、元大 107…
排程少了尾巴,後面三個數字就一起垮:期數少算 → loan-ui-fix.js 從期數分母推的「貸款年限」
跟著變 11 年 11 個月;現金流缺了 2038 年之後的還款 → XIRR 看起來是「借 800 萬只還回 430 萬」,
算出來當然是負的。
改成 fetchAllRows() 逐頁抓(每頁 1000,拿到不足一頁就停)。淨值快照、財務範圍快照、美金
交易那三支 .range(0, 9999) 也一起換掉 —— 淨值快照一天一列,放個三年就會撞到同一個上限。
測試在 tests/loan-schedule-paging.test.mjs:另一筆貸款塞 900 列日繳排程把 30 年房貸的尾巴
擠出單頁範圍,跟正式站同一個形狀。tests/support/fake-supabase.js 的 range() 也照著補上
1000 列上限,不然這個 bug 在測試裡根本重現不出來。把分頁拿掉會紅成
「已繳期數 36 / 99、貸款年限 8 年 3 個月、年化成本 −20.46%」,確認過。
apply_due_loan_payments() 原本直接用 應繳日 − 已扣到的日期 當天數,沒有下限。新匯入一筆
貸款時很容易變成「主檔說已經扣到今天,但排程從撥款日開始、都還沒扣」—— 天數是負的,利息
跟著變負,本金就爆掉。潤隆房貸第一期就被寫成本金 502,231、利息 −471,944。
餘額沒被寫壞(另一支遷移後來蓋回去了),但那些欄位是垃圾。現在天數與利息都夾在 0 以上,
prev 也只會往前走不會倒退。
apply_due_loan_payments() 跑完一筆貸款之後,如果本金已經歸零而且沒有任何還沒套用的
payment 排程,就把 status 轉成 closed、autopay 關掉、amount_twd 歸零。
closed_on 用最後一筆實際套用的應繳日,不是使用者晚幾天才打開 App 的那天。
兩個條件要同時成立才結清:只看餘額歸零的話,寬限期或資料異常會誤判;只看排程跑完的話, 有殘餘本金也會被清掉。
繳款日一到(台北時間跨過午夜),apply_due_loan_payments() 就把那一期從 financial_items
的負債餘額扣掉,不用每個月手動改。資產負債頁跟貸款分析頁看的是同一列,所以兩邊一起動;
主畫面的負債列也帶一行「下次 10/08 NT$ 10,606」,跟分析頁同一份排程。
拆本金/利息的算法是從實際餘額反推出來的,不是套年金公式:銀行用 actual/365, 利息 = 餘額 × 年利率 × 兩次繳款日相隔天數 ÷ 365,本金 = 月付 − 利息。把每一筆從撥款日 重播到今天跟核對過的餘額對答案 —— 元大、富邦、將來、潤隆完全一樣,連線 25 期差 28 元 (四捨五入累積)。
用 due_date 不是 actual_date:富邦第 2、4 期遇假日順延,用實際扣款日重播會多算 361 元
—— 銀行的利息算到應繳日為止,順延那幾天不多收。哪些列算還款由 entry_type 決定,
撥款與開辦費不會被扣。潤隆是寬限期只繳息(月付幾乎等於當月利息,本金沒動),另外記
grace_until = 2028-05-05,寬限期內本金一律 0。
已扣過的期數記在 loan_schedule.applied_at,所以這支函式呼叫幾次都一樣。搬進來的餘額
本來就反映了今天以前的期數,遷移時把過去的列直接標成已套用,不追溯重扣。
兩個地方會呼叫它:前端開 App 時一天問一次,以及 daily-wealth-snapshot 在寫完快照之後
(那一輪 06:00 記的是昨天的收盤數字,先扣的話今天的還款會被算進昨天那一列)。
點開/收合只換那一張卡片(card.outerHTML = loanAccountCard(...)),不走 render()。
重繪整頁的話收合時頁面變矮,瀏覽器先把捲動量夾到新的底部、renderKeepingAnchor() 的錨點
補正再拉一次,看起來就是跳一下。換的是整張卡片而不是只加減明細節點 —— loan-ui-fix.js
會把卡片上的欄位搬進展開的明細裡,直接把明細拿掉會連那些被搬走的欄位一起刪掉。
光是不重繪還不夠:一次只開一張,所以點下面那張時上面那張會同時收起來,收掉的高度差
會把下面的內容整個往上拉 —— 實測會讓被點的卡片從 371 跳到 −559,直接飛出畫面。
pinLoanCard() 記下被點那張的視窗位置,換完卡片再把捲動量補回去。
補正不能只跑固定次數。loan-ui-fix.js 用 MutationObserver 在後面的 frame 繼續搬 DOM,
而它自己的改動又會再觸發自己一輪,高度要好幾個 frame 才穩。原本固定補三次會漏掉最後一次
改動,測試每三輪就紅一次(371 → 641)。改成一直補到「連兩個 frame 都沒再動」為止,
最多 20 個 frame —— 捲到底或捲不動時不會空轉。
補正補得回來的上限是目前的捲動量。收起上面那張少掉的高度要靠往回捲來抵,而捲動量不能
是負的:捲動量比那個高度差小的時候,捲到 0 就沒得退,剩下的差距是物理上補不回來的
(例如剛把下面那張捲進畫面就點它,捲動量可能只有 950、高度差卻有 1034,卡片就會往上跑 84px)。
pinLoanCard() 已經把能用的捲動量用到見底,這是最好的結果。
測試挑起始捲動位置時要從實際量到的高度差去推,不能寫死。展開後的卡片高度跟字體換行
有關,CI 用 npx playwright install 抓的 Chromium 比本機的 /opt/pw-browsers/chromium-1194
高了約 191px,寫死的 1200 在那個版本上比高度差還少 25px,測到的就會是「捲不回去」而不是補正本身。
.loanList 要 overflow-anchor:none。Chrome 的捲動錨定(scroll anchoring)會在上面的
內容高度變動時自己調整 scrollY,好讓視窗裡看得到的東西不要位移 —— 那跟 pinLoanCard()
是同一件工作的兩套機制,開著就會互搶。實測:測試把捲動量設成 1234、接著點卡片,
pinLoanCard() 第一次量到的 scrollY 是 1565,中間那 331px 沒有任何 scrollTo、
也沒有 scroll 事件,就是錨定自己調的。補正因此是在追一個會自己跑的目標,最後停在哪裡
跟當下的 layout 時機有關;overflow-anchor:none 之後第一次量到的就是 1234。
2026-09-18 CI 為此紅過幾次(被點的卡片停在 670.11,scrollY 停在 0,但當下 maxScroll
有 1835、只需要捲到 200.81 —— 不是捲不動)。查的過程走過兩次錯路,都記在這裡免得再走一次:
先猜「CI 太忙、rAF 來不及補正」,把測試的等待改成「連兩個 frame 沒再動、最多 5 秒」之後
CI 還是紅,證明不是等太短;再猜「捲動量不夠、捲到 0 就沒得退」,本機把捲動量改成 950
的確能重現同一個數字,但那是另一條路徑碰巧停在同一點 —— CI 那次 maxScroll 明明夠。
真正分辨出來的方法是把每一次 scrollTo 連呼叫點、以及每一個 scroll 事件都記下來,
一看就知道那 331px 不是程式捲的。
loan-ui-fix.js 原本有個 keepLoanExpansionDownward(),在展開時把 scrollY 硬鎖回點擊前
的值。那是為了補償舊的整頁重繪,現在會跟 pinLoanCard() 打架(同一個情境變成從 371 跳到
641),所以拿掉了。
iOS 的 apple-mobile-web-app-status-bar-style: default 不會讓網頁蓋到狀態列底下 ——
網頁從安全區下面才開始,狀態列那一條是拿 html 的背景色去填的。所以只要 html 的
背景跟畫面最上緣畫出來的顏色不一樣,那條就會跟畫面接出一條硬邊。
三個畫面最上緣的顏色不一樣,html 要跟著走:
body::before 的漸層起頭 #d9f5f1 → html{background:#d9f5f1}.center,.auth 的漸層起頭 #9fe4df → html:has(#root.center),html:has(#root.auth)改任何一邊的漸層起頭,html 那邊要一起改。tests/status-bar-strip.test.mjs 會把
html 的 computed 背景色跟畫面最上緣的實際像素對起來比,對不上就失敗。
刮動趨勢圖的監聽器不掛在 #root 上。WebKit 會把「有 non-passive pointermove 監聽器」
的範圍整塊標成主執行緒捲動區 —— 捲動前每一個 move 都得先回 JS 問過才交給合成器。掛在 #root
等於整個 App 都是。
現在的做法:
pointerdown(bindTrendChart(),每次 render 重掛)pointermove / pointerup / pointercancel 只在真的在拖曳的那幾百毫秒掛在 window 上,
放開、或判斷成直向手勢,就整組收掉pointermove 裡不呼叫 preventDefault():.trendChart 的 touch-action: pan-y
已經擋掉橫向平移,擋了反而讓監聽器變成 non-passivesetPointerCapture()。抓了瀏覽器就沒辦法把判斷錯的手勢收回去捲頁,整頁會被釘住touchmove 解決:touch-action: pan-y 把直向永遠讓給
瀏覽器,橫著刮時手指只要有一點上下位移,瀏覽器就會接手捲頁並丟一個 pointercancel 過來 ——
提示框當場停住。touchmove 不受這件事影響,會一路發到手指離開,所以觸控時多掛一組
touchmove/touchend/touchcancel,並且忽略 pointercancel(觸控的 pointercancel
幾乎都是瀏覽器接手捲頁發的,不代表手指離開了),由 touchend 決定什麼時候結束。
座標來源有兩種,判斷與更新的邏輯只有 trackTrendDrag() 那一份|dx| > |dy| 的話,
明明是往上滑也會被判成刮動 —— 而且中不中招看那一下的抖動,所以症狀是「有時候會有時候不會」tests/trend-chart.test.mjs 會直接用 CDP 讀 window / #root / 圖表上的監聽器來守這件事,
順便確認刮動本身、以及從圖表上往上滑還捲得動頁面。
financial_items.fx_rate_twd 有兩個寫入者:
refresh-tw-quotes Edge Function —— 一輪只用一個 fxRate(快取夠新就沿用 klfan_quotes
的 USD/TWD,否則打 Twelve Data),美股、美元現金、黃金三條路徑都吃它sync_klfan_financial_item() 觸發器 —— 讀的是 klfan_fx_daily 最新那一列兩邊各自取數,同一輪就會在資料庫裡留下兩個不同的匯率。2026-09-07 08:45 美股是 31.61732、
黃金與美元現金是 31.62785,差 0.03%;而 App 是拿 sort_order 最前面那一列的匯率,
剛好落在黃金那一組。
修法是讓觸發器的來源跟著這一輪走:Edge Function 在更新任何一列之前,先把這一輪要用的
匯率 upsert 進 klfan_fx_daily(台北日期當 key)。之後兩邊讀到的是同一個數字。
klfan_fx_daily 是跟 KLFAN 共用的表,KLFAN 自己也會寫;兩邊寫的都是 Twelve Data 的
USD/TWD,最後寫的那個就是當下的匯率,這正是我們要的「兩個 App 對得起來」。
tests/quote-cache.test.mjs 守兩件事:一輪裡所有 USD 項目的 fx_rate_twd 只能有一個值,
而且 klfan_fx_daily 要拿到同一個值;匯率抓不到時則完全不動那張表(寫 null 會弄壞觸發器的來源)。
保險與其他這兩類沒有行情可抓、金額是手打的,但可能是美金計價,所以表單上有一個
幣別選單(CURRENCY_CHOICE_ATTRIBUTES)。選 USD 就走跟現金一樣的 manual-usd:存
native_currency / native_amount 與當下的 fx_rate_twd,之後 refresh-tw-quotes 會
跟著匯率把 amount_twd 重算。
現金及存款不在這個名單裡 —— 它是靠「台幣」「美金」兩個資產屬性分的,多一個選單只會打架。
要新增別的類別到這個名單,只要把 value 加進 CURRENCY_CHOICE_ATTRIBUTES;儲存那段是照
mode 分支的,不必動。
現金及存款多一個「日圓」屬性(manual-jpy),存法跟美金活存一樣:native_currency='JPY'、
native_amount 是日圓金額、fx_rate_twd 是 JPY/TWD(0.2 上下),卡片右下角顯示 ¥ 金額。
匯率不另外找來源,用 Twelve Data 的 USD/JPY 跟既有的 USD/TWD 交叉:
JPY/TWD = USD/TWD ÷ USD/JPY。這是市場中價,比國泰網銀牌告的買入價高約 0.5%
(2026-10-02:中價約 0.2024、網銀買入 0.2014),所以畫面上的台幣是「市值」,不是「現在換回台幣拿得到的錢」。
USD/JPY 存在 ks_quote_cache(跟 XAU/USD 同一張),快取 10 分鐘 —— Twelve Data 免費每分鐘
只有 8 credits,日圓一天動不了幾次,不值得每輪跟美股搶額度。daily-wealth-snapshot 每天也會用同一個
算法重估一次,沒開 App 的日子淨值走勢也照樣跟著日圓動。
寫回時 quote_currency='JPY'。前端是靠 fx_rate_twd > 1 && quote_currency === 'USD' 找美金匯率的,
日圓列兩個條件都不會中,不會把 0.2 當成美金匯率;編輯表單的日圓換算只讀日圓列自己的匯率。
native_currency 的 CHECK 約束原本只收 TWD/USD,2026-10-02 在 production 直接放寬成多收 JPY
(見 supabase/proposals/20261002_native_currency_jpy.sql)。
資產頁每個分類裡面照金額由大到小排,只有現金及存款例外:活存在前、定存在後,同一類再照幣別
台幣 → 美金 → 日圓 → 歐元(屋主 2026-10-26 指定)。照金額排的話美金定存永遠在最上面,最常動用的台幣活存
反而被擠到中間。活存/定存看名稱有沒有「定存」兩個字;不在名單裡的幣別排在同一類的最後。
tests/cash-order.test.mjs 同時守住「其他分類仍照金額排」。
做法跟日圓一樣:現金及存款多一個「歐元」屬性(manual-eur),native_currency='EUR'、
native_amount 是歐元金額、fx_rate_twd 是 EUR/TWD(36 上下),卡片右下角顯示 € 金額(兩位小數)。
匯率用 Twelve Data 的 EUR/USD 跟 USD/TWD 交叉:EUR/TWD = EUR/USD × USD/TWD。
方向跟日圓相反 —— USD/JPY 是一美元換幾日圓所以用除的,EUR/USD 是一歐元換幾美元所以用乘的;
照抄日圓那行會差一千多倍,tests/eur-cash.test.mjs 擋這個。EUR/USD 一樣放 ks_quote_cache、快取 10 分鐘。
daily-wealth-snapshot 把歐元那一次呼叫排在美股報價之後,Twelve Data 額度不夠時先掉的是這筆小額外幣。
前端找美金匯率靠 quote_currency === 'USD',歐元列寫回時標 quote_currency='EUR',36 這個數字不會被誤當成美金匯率。
native_currency 的 CHECK 約束 2026-10-26 在 production 直接再放寬一次,多收 EUR
(見 supabase/proposals/20261026_native_currency_eur.sql)。
App 開著的時候會用 Screen Wake Lock 讓螢幕不要自己關掉,行情分成兩條快慢不同的路徑, 因為兩邊的成本差很多:
| 間隔 | 來源 | 成本 | 寫資料庫 | |
|---|---|---|---|---|
| 台股 | 5 秒 | Fugle | 免費、沒有 credit | 不寫 |
| 美股(快車道) | 15 秒 | Finnhub | 9 檔 ÷ 15 秒 = 36 次/分,上限 60 | 不寫 |
| 美股(權威值) | 60 秒 | Finnhub(備援 Twelve Data) | 同上 | 寫 |
| 匯率/黃金 | 60 秒 | Twelve Data | 一輪 2 credits,上限每分鐘 8 | 寫 |
美股快車道(scope='us')跟台股那條是同一個模式:只拿價格、不寫資料庫,前端直接套用到畫面。
15 秒不是保守值,是算出來的:Finnhub 免費每分鐘 60 次,9 檔標的 10 秒一輪就是 54 次/分,
太貼邊;15 秒是 36 次/分,留得下重試與手動更新的空間。
快車道不讀 klfan_quotes 的快取 —— 快取最舊可以到 14 秒前,那正好是這條路想擺脫的延遲。
台幣市值由前端用手上的匯率換算;匯率是慢變數(一分鐘動 0.01 上下),沿用 60 秒那輪的值就夠。
前端拿不到匯率時直接不更新,寧可停在舊數字也不要拿 0 乘出一排歸零的市值。
Finnhub 的 /quote 收盤後就停在收盤價,Twelve Data 的 prepost 要付費方案。所以美東盤中以外
(盤前 04:00–09:30、盤後 16:00–20:00、以及收工到隔天盤前、週末),美股報價改抓 Yahoo 的一分鐘線
(v8/finance/chart/{symbol}?interval=1m&range=1d&includePrePost=true),取最後一根非 null 的收盤價;
Yahoo 失敗才退回 Finnhub。盤中維持 Finnhub —— Yahoo 不是正式 API,不拿它扛每 15 秒的主流量。
http_get 從資料庫實測 200,VOO 盤後 705.32 / 收盤 702.35)。regularMarketTime 就用 regularMarketPrice):盤前比昨收、
盤後比今天收盤,跟券商 App 同口徑。不能用 chartPreviousClose —— 盤前時 range=1d 給的是「前天」收盤
(2026-10-02 盤前 VOO:chartPreviousClose 700.86,昨收 702.35),V3.41.0 因此把漲跌多算了一天。session(pre/regular/post,依那根 K 棒的美東時間判斷),卡片上標「盤前」「盤後」。financial_items.amount_twd 與 klfan_quotes 寫的也是這個價。close(正規收盤),
績效用 Yahoo 日線收盤。盤後量小、價格跳,拿來記歷史只會多雜訊。quote_source 有 CHECK 約束,yahoo 不在允許值裡,照 Finnhub 的做法收斂成 twelve_data;
真正來源留在 klfan_quotes.source。匯率與黃金搬不到 Finnhub:2026-09-19 用正式環境的 key 實測,forex/rates、OANDA:USD_TWD、
OANDA:XAU_USD 全部回 403(免費方案只開美股),所以這兩項維持 60 秒。
美股改走 Finnhub 之後,klfan_quotes 的快取 TTL 分成兩段:美股 14 秒、匯率與黃金 59 秒。
14 秒是靠 Finnhub 的額度撐的,所以沒有 FINNHUB_API_KEY 時美股要退回 59 秒 ——
不退的話會退回 Twelve Data 並且每輪重抓,把共用的 credit 燒成四倍。
financial_items.quote_source 有 CHECK 約束,只收 manual/fugle/twelve_data。Finnhub 的價
寫進去會讓整筆 update 被資料庫擋掉、金額停在舊值,所以寫的時候收斂成允許值,真正的上游
來源留在 klfan_quotes.source。要讓 finnhub 直接入庫就得先改約束(提案放 supabase/proposals/)。
台股那一條走 Edge Function 的 scope='tw':只打 Fugle、只把價格回傳,完全不碰資料庫。
碰了的話 financial_items 的 realtime 訂閱會被自己觸發,每 5 秒重載整本私帳(一千多筆交易)
—— 那不是報價快,是把 App 拖垮。前端拿到價之後直接改 items[].amount_twd 再 render,
資料庫的權威值由 60 秒那一輪負責寫。
60 秒是美股那條的上限,不是保守值:一輪 7 credits、每分鐘只有 8,跑兩次就爆。
refreshQuotes() 的節流門檻因此設成間隔的一半,不然計時器早幾毫秒觸發會被自己擋掉。
Fugle 的每分鐘上限沒有查到明文;4 檔 × 每 5 秒 = 48 次/分,貼著一般免費方案的邊。
真的被 429 就把 QUOTE_TW_INTERVAL_MS 調大,狀態列會直接把失敗的標的與原因寫出來。
兩條都綁在「畫面看得到」這個條件上:
visibilitychange 回到前景要重新要一次
(並且掛 release 事件把 wakeLock 清成 null,不然下次會以為還握著)SUBSCRIBED,重建 channel;完整載入超過 60 秒也補抓一次,
避免 iOS 凍結 WebSocket 後一直顯示背景前的資料navigator.wakeLock 拿不到(低電量模式、使用者不給、瀏覽器沒支援)就安靜跳過,照系統原本的
螢幕逾時走。
tests/awake-autorefresh.test.mjs 用 Playwright 的假時鐘把時間快轉掉,守住兩條路徑各自的
節奏;tests/quote-cache.test.mjs 守住 scope='tw' 那一輪一個 credit 都不花、也不寫資料庫。
klfan_bootstrap() 回傳 92 KB,其中 89 KB 是那 1489 筆交易。報價更新只改價格、交易
一筆都沒動,所以那一輪不整包重載:
results[].amountTwd,直接套進 itemsklfan_quotes 撈一次(約 3 KB),重掛到 portfolioStocks[].quote
再重算 portfolioModelrealtime 的 financial_items 事件也不能整包重載 —— 每一輪報價更新都會寫它,事件會送回來
給我們自己,照單全收就等於每分鐘重載一次。那一條改成 reloadItems(),只重抓 financial_items。
交易真的變了會由 klfan_transactions 的事件帶進來,那一條才走完整重載。
完整載入本身使用 single-flight + dirty flag:載入途中再收到存檔或 Realtime 事件,舊請求結束後
會再跑一輪;reloadItems() 則用 generation 只接受最後一個請求的結果,避免亂序回應覆蓋新資料。
行情寫回使用 Edge Function 的 server-side client,且不更新 updated_by/updated_at:行情是
系統衍生值,不應冒充人工編輯,也不應每分鐘灌入 activity_log。人工表單用原始 updated_at
做 compare-and-set,另一台裝置先改過就提示重新開啟,不靜默覆蓋。
(試過用時間窗把「自己的寫入」濾掉,但別的裝置剛好在窗口內改東西就會漏,所以改成分流。)
tests/awake-autorefresh.test.mjs 會數 klfan_bootstrap 被叫了幾次:三輪完整報價更新之後
必須還是 0 次。
開 App 的首屏不等待私帳。資產列的股數與市值是 sync_klfan_financial_item() 觸發器算好存在
financial_items 的;首屏完成後才用 requestIdleCallback(不支援時退回短延遲 timer)在背景預載
92 KB 私帳,讓股票分析一點就能進去:
ensureLedger() 接手,載好後只補 render 一次editItem() 在 item.portfolio_stock_key 有值時才等saveLedgerStock() 一定要先 await ensureLedger():它要靠 portfolioStocks 比對有沒有
同一檔,沒載好就會把已經有的標的再開一筆。這是這個改動唯一真的會壞資料的地方。
ensureLedger() 是單飛行的,載過就不再載;loadData() 只有在已經載過的情況下才重載它。
tests/awake-autorefresh.test.mjs 守住「首屏不等待私帳、背景最多預載一次、切到個人頁與行情更新
不會重複叫 klfan_bootstrap,而且入口按鈕先顯示」。
calculatePortfolio() 要跑每一檔的 XIRR 加上台股/美股/全部三個彙總 XIRR。XIRR 是掃上千個
利率再二分逼近,1496 筆交易實測 ~300ms。
台股報價每 5 秒更新一次,只要有價格變動就 render() —— 在股票分析頁上,以前等於每 5 秒把
整包重算一遍,主執行緒卡 0.3 秒。而且那條輕量更新只改 financial_items 的金額,
portfolioStocks 一個字都沒動,重算的輸入完全一樣。
所以模型走 portfolioModelFor(ownerScope):用 portfolioRevision(私帳重載、報價套用、
換帳號時 +1)加上 fxRate 當快取鍵,同一版之內每個 scope 只算一次。實測一分鐘 12 次重繪
從 3,526ms 降到 0.006ms;匯率一變仍然會重算。
calculatePortfolio() 只准在 portfolioModelFor() 裡呼叫,其他地方一律吃快取 ——
tests/ui-consistency.test.mjs 會數呼叫次數,也會數三個 portfolioRevision += 1 有沒有漏掉。
漏掉任何一個,畫面就會停在舊數字。
Twelve Data 免費方案是每分鐘 8 credits、一個 symbol 算一個。這一輪要 USD/TWD + 美股
XAU/USD,全抓就快貼著上限,所以 refresh-tw-quotes 把 klfan_quotes 當快取:updated_at 會讓沒更新的那幾檔看起來也很新klfan_live_symbolsklfan_quotes 原本由 KLFAN(KLFAN-stock-tracker)那支 refresh-klfan-quotes 維護,
本專案以前每次更新會連它一起呼叫。但兩支抓的是完全一樣的 8 檔,等於自己跟自己搶額度
(2026-09-07 核對過,兩邊的標的一模一樣),所以那個呼叫已經拿掉,整張表由這裡接手。
接手的關鍵是名單要跟著 klfan_live_symbols(仍持有的標的)走,不能看「klfan_quotes
現在有哪些列」:
修剪也是同樣的原因:舊價留著會被當成即時價顯示,一個看起來很新、其實早就過期的價格比沒有 價格更糟。
XAU/USD 不在仍持有名單裡(會被修剪掉),所以金價走自己的 ks_quote_cache,同樣 10 分鐘、
同樣只有 service role 進得來。少了這層,金價每一輪都得重抓,而它又是最後才發出的請求 ——
前端那個 60 秒節流是存在記憶體裡的,換 App 回來或頁面重載就歸零,同一分鐘跑兩輪必定壓死它。
回應裡的 cache(read / write / error)與 gold.cached 可以看出這一輪沿用了幾筆、
有沒有寫回、修剪成不成功。前端狀態列也會把失敗的標的名字與原因寫出來(例如「黃金:行情商
額度用完了」)。
tests/quote-cache.test.mjs 直接跑 Edge Function 原始碼、把 fetch 換成假的來數 credit,
改動這一段時請先跑它。
2026-09 從 Supabase edge log 量過:隔一小時以上再打開(access token 已過期),首屏前是三輪
串起來的網路往返 —— 換 token(含 CORS preflight 約 1 秒)→ 查 household_members
(冷的時候 745 ms)→ 十張表並行(約 0.3 秒),從第一個請求算起 2.3~2.8 秒,還沒算下載程式檔。
資料庫查詢本身不慢,慢在排隊。
localStorage 的 ks-member|<user_id>,只存 household_id
與 role,不存金額)。記得的話直接抓資料,household_members 改在背景重查;查到的不一樣
(被移出、換家庭、角色變了)就寫回正確值並 location.reload(),走沒有快取的那條路。
資料讀不讀得到仍由 RLS 決定,記錯最多是多重來一次。loan-month-summary.js 等 token 換新才送:它自己讀 storage 裡的 token 打 REST,開 App 時
SDK 還沒換好 token,以前每次都先吃一個 401、800 ms 後重試。現在看 expires_at(留 10 秒
餘裕),過期就每 250 ms 看一次 storage,最多等 8 秒。index.html 預先連線 Supabase、預載 supabase-js,讓 TLS 握手與 CDN 下載跟 app-v3.js
並行。預載的網址要跟 app-v3.js/auth-tools.js 的 import 一字不差,否則等於多抓一份。沒做長期快取(Cache-Control: immutable):內部 import 的 ?v= 不是全部跟著版號走
(loan-month-core.js?v=V2P4 之類),設了會讓忘記改字串的檔案在手機上卡一年。
換 token 那一秒省不掉,所以改成把它藏起來:每次線上載入成功,就把 performDataLoad() 拿到的
原始列存進 localStorage 的 ks-home-cache|<user_id>(home-cache-core.js)。下次打開時,在
sb.auth.getSession() 之前就從 SDK 存的 session 讀出 user id、套用快取、畫出首頁,狀態列寫
「顯示上次的資料,同步中…」;線上資料回來後整包換掉。
applyHomeRows(),算法不會分岔。updated_at 做 compare-and-set、
股票表單要私帳,拿舊資料開只會在存檔時撞衝突或寫錯。點下去只會在狀態列提示稍等。HOME_CACHE_VERSION 加一;版本或 user id 對不上、內容壞掉都當沒有。Supabase 各台伺服器的時鐘會差一兩秒。2026-09-28 13:08:52 換的 token,53.1 秒送出的 11 個請求
只有 financial_items 拿到 401 PGRST303「JWT issued at future」,首頁就變成錯誤畫面。以前
「查家庭」那一輪排在前面,剛好把這一秒吸收掉;改成並行之後才浮出來。
兩個 supabase client(auth-tools.js 先建、app-v3.js 沿用 window.KS_SUPABASE_CLIENT)都用
supabase-fetch.js 的 withClockSkewRetry():只認這一種 401,等 1 秒、再 2 秒各重送一次。
401 是在驗 JWT 時就被拒、資料庫沒執行,所以寫入重送也安全。其他 401 原樣回去。
tests/home-cache.test.mjs 守住:卡住線上資料時先畫快取、快取期間不能編輯、回來後整個換掉、別人的快取不畫且被清掉。
tests/startup-latency.test.mjs 守住:記得家庭時 household_members 卡住也照樣開出首屏、
記錯家庭會改正並重來、過期 token 不送出去且換新後補查。
GitHub main 已連接 Vercel Production;合併或 push 到 main 會觸發正式部署。功能變更應先在工作分支完成測試與 diff review,再合併至 main。
Supabase Production schema 與 Git migration history 可能有已知差異。執行 migration 前必須先核對 Production migration history;提案只能放在 supabase/proposals/,不得放進正式 migration 目錄。未完成 reconciliation 前禁止直接對 Production 執行 supabase db push。