From 6e814a9bbff3a9ce5cca0251ed650a1c44d5db26 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 16:25:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(split-meow):=20=E6=90=B6=E6=95=91=E6=AF=8F?= =?UTF-8?q?=E7=AD=86=E6=94=AF=E5=87=BA=E5=B9=A3=E5=88=A5=E8=88=87=E5=8C=AF?= =?UTF-8?q?=E7=8E=87=E5=BF=AB=E7=85=A7=EF=BC=88=E5=BE=9E=E8=A2=AB=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E7=9A=84=E6=B2=BB=E7=90=86=E5=88=86=E6=94=AF=E9=81=B7?= =?UTF-8?q?=E7=A7=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 從被 PR433 取代的 governance 分支搶救 split-meow 幣別快照功能(原 commit 87fcbe30) - ExpenseRecord 保存記帳當下 currency 與 exchangeRateKrwPerTwd 快照 - HistoryTab 個別金額用各筆快照幣別、KRW 副標即時換算 TWD、彙總結算採 trip 主導幣別 - currencies.test 合併 main 完整版(detectCurrencyFromTimezone)與 formatKrwAsTwd 測試 - 補 002:split-meow 幣別快照與 PR426 SW bounded-nav 稽核條目(本次 +2,累計 +68) 測試:split-meow vitest 175/175 通過、typecheck 通過 Generated with Claude Code via Happy Co-Authored-By: Claude Co-Authored-By: Happy --- .../split-meow-expense-currency-snapshot.md | 5 +++ apps/split-meow/src/components/HistoryTab.tsx | 41 +++++++++++++------ .../src/config/__tests__/currencies.test.ts | 27 +++++++++++- apps/split-meow/src/config/currencies.ts | 9 ++++ .../src/store/__tests__/useStore.test.ts | 17 ++++++++ apps/split-meow/src/store/useStore.ts | 7 ++++ .../dev/002_development_reward_penalty_log.md | 12 +++++- 7 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 .changeset/split-meow-expense-currency-snapshot.md diff --git a/.changeset/split-meow-expense-currency-snapshot.md b/.changeset/split-meow-expense-currency-snapshot.md new file mode 100644 index 000000000..7e7ea1dfd --- /dev/null +++ b/.changeset/split-meow-expense-currency-snapshot.md @@ -0,0 +1,5 @@ +--- +'@app/split-meow': patch +--- + +每筆支出記錄記帳當下的幣別與匯率,切換顯示幣別後歷史金額不再被錯誤換算;以韓元(₩)記帳的支出會同時顯示對應的台幣參考金額。 diff --git a/apps/split-meow/src/components/HistoryTab.tsx b/apps/split-meow/src/components/HistoryTab.tsx index 22c953f27..438605538 100644 --- a/apps/split-meow/src/components/HistoryTab.tsx +++ b/apps/split-meow/src/components/HistoryTab.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useStore, type Member, type ExpenseRecord } from '../store/useStore'; -import { formatAmount, getCurrencySymbol } from '../config/currencies'; +import { formatAmount, getCurrencySymbol, formatKrwAsTwd } from '../config/currencies'; +import type { CurrencyCode } from '../config/currencies'; import { format } from 'date-fns'; import { useRef, useState } from 'react'; import { cn } from '../lib/utils'; @@ -177,6 +178,12 @@ export function HistoryTab() { }); const totalSpent = tripExpenses.reduce((sum, e) => sum + e.totalAmount, 0); + // trip 主導幣別:採用該行程最舊一筆記錄的幣別(trip 建立時的幣別), + // 舊資料無幣別時 fallback 至當前全域幣別。彙總與結算統一以此幣別顯示。 + const tripCurrency: CurrencyCode = tripExpenses[tripExpenses.length - 1]?.currency ?? currency; + // 取得單筆記錄的顯示幣別(優先使用記帳當下快照,舊資料 fallback 主導幣別)。 + const expenseCurrency = (exp: ExpenseRecord): CurrencyCode => exp.currency ?? tripCurrency; + // 計算各人餘額 const balances: Record = {}; tripExpenses.forEach((exp) => { @@ -215,7 +222,7 @@ export function HistoryTab() { const lines: string[] = [ `🐾 喵喵分帳 — ${tripName}`, `${'─'.repeat(24)}`, - `💰 總花費:${formatAmount(totalSpent, currency)}`, + `💰 總花費:${formatAmount(totalSpent, tripCurrency)}`, '', ]; if (tripExpenses.length > 0) { @@ -226,7 +233,9 @@ export function HistoryTab() { const label = exp.note || (exp.type === 'split_evenly' ? t('history.split_evenly') : t('history.itemized')); - lines.push(`${emoji} ${label} ${formatAmount(exp.totalAmount, currency)}(${payer} 付)`); + lines.push( + `${emoji} ${label} ${formatAmount(exp.totalAmount, expenseCurrency(exp))}(${payer} 付)`, + ); }); lines.push(''); } @@ -235,7 +244,7 @@ export function HistoryTab() { settlements.forEach((s) => { const from = members.find((m) => m.id === s.from)?.name ?? s.from; const to = members.find((m) => m.id === s.to)?.name ?? s.to; - lines.push(`${from} → ${to} ${formatAmount(s.amount, currency)}`); + lines.push(`${from} → ${to} ${formatAmount(s.amount, tripCurrency)}`); }); lines.push(''); } @@ -273,7 +282,7 @@ export function HistoryTab() { {t('history.total_spent')}

- {formatAmount(totalSpent, currency)} + {formatAmount(totalSpent, tripCurrency)}

@@ -376,7 +385,7 @@ export function HistoryTab() { check_circle ) : ( - formatAmount(s.amount, currency) + formatAmount(s.amount, tripCurrency) )}
@@ -451,7 +460,7 @@ export function HistoryTab() { )} > {isOwed ? '+' : '-'} - {formatAmount(Math.abs(amount), currency)} + {formatAmount(Math.abs(amount), tripCurrency)} - +{formatAmount(exp.totalAmount, currency)} + +{formatAmount(exp.totalAmount, expenseCurrency(exp))} )} @@ -528,7 +537,7 @@ export function HistoryTab() { {t('history.balance_share')} - -{formatAmount(share, currency)} + -{formatAmount(share, expenseCurrency(exp))} )} @@ -549,7 +558,7 @@ export function HistoryTab() { )} > {net > 0.01 ? '+' : net < -0.01 ? '-' : ''} - {formatAmount(Math.abs(net), currency)} + {formatAmount(Math.abs(net), expenseCurrency(exp))} @@ -568,7 +577,7 @@ export function HistoryTab() { )} > {isOwed ? '+' : '-'} - {formatAmount(Math.abs(amount), currency)} + {formatAmount(Math.abs(amount), tripCurrency)} @@ -708,8 +717,14 @@ export function HistoryTab() {

- {formatAmount(exp.totalAmount, currency)} + {formatAmount(exp.totalAmount, expenseCurrency(exp))}

+ {expenseCurrency(exp) === 'KRW' && + formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd) && ( +

+ ≈ {formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd)} +

+ )}

{t('history.participants', { count: exp.participantIds.length })}

@@ -830,7 +845,7 @@ export function HistoryTab() { {m.name}
- {formatAmount(amt, currency)} + {formatAmount(amt, expenseCurrency(exp))}
); diff --git a/apps/split-meow/src/config/__tests__/currencies.test.ts b/apps/split-meow/src/config/__tests__/currencies.test.ts index 6602d79e1..4d32f2509 100644 --- a/apps/split-meow/src/config/__tests__/currencies.test.ts +++ b/apps/split-meow/src/config/__tests__/currencies.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { formatAmount, detectCurrencyFromTimezone, getCurrencySymbol } from '../currencies'; +import { + formatAmount, + detectCurrencyFromTimezone, + getCurrencySymbol, + formatKrwAsTwd, +} from '../currencies'; describe('formatAmount', () => { it('TWD:以 NT$ 前綴格式化', () => { @@ -59,3 +64,23 @@ describe('detectCurrencyFromTimezone', () => { expect(detectCurrencyFromTimezone()).toBeNull(); }); }); + +describe('formatKrwAsTwd', () => { + it('依匯率將 KRW 換算為 TWD 顯示字串', () => { + // 30000 KRW / 43.5 (KRW per TWD) ≈ 689.66 → NT$ 690 + expect(formatKrwAsTwd(30000, 43.5)).toBe('NT$ 690'); + }); + + it('匯率為 null 時回傳 null(無法換算)', () => { + expect(formatKrwAsTwd(30000, null)).toBeNull(); + }); + + it('匯率為 0 或負值時回傳 null(避免除以零)', () => { + expect(formatKrwAsTwd(30000, 0)).toBeNull(); + expect(formatKrwAsTwd(30000, -1)).toBeNull(); + }); + + it('匯率為 undefined(舊資料)時回傳 null', () => { + expect(formatKrwAsTwd(30000, undefined)).toBeNull(); + }); +}); diff --git a/apps/split-meow/src/config/currencies.ts b/apps/split-meow/src/config/currencies.ts index 6b969e3d1..da2fc146e 100644 --- a/apps/split-meow/src/config/currencies.ts +++ b/apps/split-meow/src/config/currencies.ts @@ -36,3 +36,12 @@ export function formatAmount(amount: number, currency: CurrencyCode): string { export function getCurrencySymbol(currency: CurrencyCode): string { return CURRENCIES[currency].symbol; } + +/** + * 將 KRW 金額依匯率快照換算為 TWD 顯示字串(用於 KRW 記帳時的副標)。 + * rate 為 1 TWD = rate KRW(賣出價);rate 無效時回傳 null 表示無法換算。 + */ +export function formatKrwAsTwd(krwAmount: number, rate: number | null | undefined): string | null { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null; + return formatAmount(krwAmount / rate, 'TWD'); +} diff --git a/apps/split-meow/src/store/__tests__/useStore.test.ts b/apps/split-meow/src/store/__tests__/useStore.test.ts index c41ebe0dc..40004e5d7 100644 --- a/apps/split-meow/src/store/__tests__/useStore.test.ts +++ b/apps/split-meow/src/store/__tests__/useStore.test.ts @@ -207,6 +207,23 @@ describe('useStore', () => { expect(useStore.getState().activeTab).toBe('history'); }); + it('保存記帳當下的幣別與匯率快照(KRW)', () => { + useStore.setState({ calculatorValue: '30000', currency: 'KRW', krwPerTwd: 43.5 }); + act(() => useStore.getState().saveExpense()); + const exp = useStore.getState().expenses[0]!; + expect(exp.currency).toBe('KRW'); + expect(exp.exchangeRateKrwPerTwd).toBe(43.5); + }); + + it('幣別快照不受日後切換幣別影響', () => { + useStore.setState({ calculatorValue: '1000', currency: 'TWD', krwPerTwd: null }); + act(() => useStore.getState().saveExpense()); + // 記帳後切換全域幣別到 KRW + act(() => useStore.getState().setCurrency('KRW')); + const exp = useStore.getState().expenses[0]!; + expect(exp.currency).toBe('TWD'); + }); + it('儲存後清空計算機', () => { useStore.setState({ calculatorValue: '100', expenseNote: '午餐' }); act(() => useStore.getState().saveExpense()); diff --git a/apps/split-meow/src/store/useStore.ts b/apps/split-meow/src/store/useStore.ts index adc8b47c8..547dd0204 100644 --- a/apps/split-meow/src/store/useStore.ts +++ b/apps/split-meow/src/store/useStore.ts @@ -32,6 +32,10 @@ export interface ExpenseRecord { note: string; category?: ExpenseCategory; createdAt: number; + /** 記帳當下的幣別快照;舊資料缺此欄位時視為 TWD(記帳幣別於 KRW 上線前僅有 TWD)。 */ + currency?: CurrencyCode; + /** 記帳當下的匯率快照(1 TWD = X KRW 賣出價);供 KRW 金額回溯換算 TWD,舊資料為 null。 */ + exchangeRateKrwPerTwd?: number | null; } export interface Trip { @@ -266,6 +270,9 @@ export const useStore = create()( note: state.expenseNote.trim(), ...(state.expenseCategory ? { category: state.expenseCategory } : {}), createdAt: Date.now(), + // 記帳當下的幣別與匯率快照,確保歷史金額不受日後切換幣別影響並可回溯換算 + currency: state.currency, + exchangeRateKrwPerTwd: state.krwPerTwd, }; return { diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 446712615..6d3689137 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+66 +> 本次分數變化:+2(reward 2、penalty 0)|累計總分:+68 ## 新增模板(4 行) @@ -13,6 +13,16 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:reward-split-meow-expense-currency-rate-snapshot +- 原因:split-meow 的 ExpenseRecord 只存裸數字、未保存記帳當下幣別;使用者先以 TWD 記帳後切換 KRW(或韓國時區自動偵測)時,歷史金額會被當前全域幣別重新格式化,NT$1,000 被誤顯示為 ₩1,000。 +- 解法:ExpenseRecord 增加 currency 與 exchangeRateKrwPerTwd 快照欄位,記帳時保存;HistoryTab 個別金額改用各筆快照幣別、KRW 副標即時換算 TWD、彙總/結算採 trip 主導幣別 fallback;補 store/currencies 單元測試與 Playwright 瀏覽器驗收(切回 TWD 後 KRW 記錄仍正確)。 + +- 日期:2026-06-26 +- ID:reward-pr426-sw-bounded-nav-case3-002-audit +- 原因:PR 426 review P1 指出 SW case-3 有界網路 fallback 與測試 lint 修改的 commit 未含 002 稽核軌跡;該功能已隨 #433 生產治理進 main,但稽核條目隨被取代的 #411 分支遺失。 +- 解法:補本條目搶救稽核軌跡;SW case-3 setCatchHandler 有界 fallback 本體已於 #433 收斂進 main。 + - 日期:2026-06-26 - ID:reward-pr435-code-reviewer-hardening - 原因:Codex 配額用盡無法 review PR 435,改派 code-reviewer 代理把關,發現 i18n 探測在 removeItem 丟錯時會誤判 localStorage 不可寫(可寫性結論不應依賴清除步驟),且 smoke 測試 BASE_STATE 仍缺部分 test-mutable 欄位。