Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/split-meow-expense-currency-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/split-meow': patch
---

每筆支出記錄記帳當下的幣別與匯率,切換顯示幣別後歷史金額不再被錯誤換算;以韓元(₩)記帳的支出會同時顯示對應的台幣參考金額。
41 changes: 28 additions & 13 deletions apps/split-meow/src/components/HistoryTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Comment on lines +183 to +185

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 將舊費用缺幣別時回退為 TWD

這裡把沒有 currency 快照的舊資料回退到目前全域 currency,因此既有使用者升級後只要在設定切到 KRW,升級前以 TWD 記下的費用仍會被顯示/分享為 ₩,與 ExpenseRecord 註解「舊資料缺此欄位時視為 TWD」相反,也沒有真正修復這次要救回的舊 TWD→切 KRW 場景。請將 legacy fallback 固定為 TWD(或在 persist migrate 補上 TWD 快照)。

Useful? React with 👍 / 👎.


// 計算各人餘額
const balances: Record<string, number> = {};
tripExpenses.forEach((exp) => {
Expand Down Expand Up @@ -215,7 +222,7 @@ export function HistoryTab() {
const lines: string[] = [
`🐾 喵喵分帳 — ${tripName}`,
`${'─'.repeat(24)}`,
`💰 總花費:${formatAmount(totalSpent, currency)}`,
`💰 總花費:${formatAmount(totalSpent, tripCurrency)}`,
'',
];
if (tripExpenses.length > 0) {
Expand All @@ -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('');
}
Expand All @@ -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('');
}
Expand Down Expand Up @@ -273,7 +282,7 @@ export function HistoryTab() {
{t('history.total_spent')}
</span>
<h2 className="text-4xl font-medium text-on-surface tracking-tight">
{formatAmount(totalSpent, currency)}
{formatAmount(totalSpent, tripCurrency)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 先把不同幣別換算後再計算彙總

當同一行程先用 TWD 記 NT$100、再切到 KRW 用匯率快照 43.5 記 ₩30,000 時,totalSpent/balances 仍直接相加原始數字,這行只把 30,100 格式化成 tripCurrency(例如 NT$30,100),結算也用同一組 raw balances;正確應該先依每筆 currency/exchangeRateKrwPerTwd 換算到行程主幣別後再加總與結算,否則混合幣別行程會產生錯誤債務。

Useful? React with 👍 / 👎.

</h2>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
Expand Down Expand Up @@ -376,7 +385,7 @@ export function HistoryTab() {
check_circle
</span>
) : (
formatAmount(s.amount, currency)
formatAmount(s.amount, tripCurrency)
)}
</span>
</div>
Expand Down Expand Up @@ -451,7 +460,7 @@ export function HistoryTab() {
)}
>
{isOwed ? '+' : '-'}
{formatAmount(Math.abs(amount), currency)}
{formatAmount(Math.abs(amount), tripCurrency)}
</span>
<span
className="material-symbols-outlined text-on-surface-variant text-lg transition-transform duration-300"
Expand Down Expand Up @@ -513,7 +522,7 @@ export function HistoryTab() {
{t('history.balance_paid')}
</span>
<span className="font-semibold text-secondary">
+{formatAmount(exp.totalAmount, currency)}
+{formatAmount(exp.totalAmount, expenseCurrency(exp))}
</span>
</div>
)}
Expand All @@ -528,7 +537,7 @@ export function HistoryTab() {
{t('history.balance_share')}
</span>
<span className="font-semibold text-error">
-{formatAmount(share, currency)}
-{formatAmount(share, expenseCurrency(exp))}
</span>
</div>
)}
Expand All @@ -549,7 +558,7 @@ export function HistoryTab() {
)}
>
{net > 0.01 ? '+' : net < -0.01 ? '-' : ''}
{formatAmount(Math.abs(net), currency)}
{formatAmount(Math.abs(net), expenseCurrency(exp))}
</span>
</div>
</div>
Expand All @@ -568,7 +577,7 @@ export function HistoryTab() {
)}
>
{isOwed ? '+' : '-'}
{formatAmount(Math.abs(amount), currency)}
{formatAmount(Math.abs(amount), tripCurrency)}
</span>
</div>
</div>
Expand Down Expand Up @@ -708,8 +717,14 @@ export function HistoryTab() {
<div className="text-right flex items-center gap-2 sm:gap-3 shrink-0">
<div className="shrink-0">
<p className="font-bold text-on-surface whitespace-nowrap text-base sm:text-lg">
{formatAmount(exp.totalAmount, currency)}
{formatAmount(exp.totalAmount, expenseCurrency(exp))}
</p>
{expenseCurrency(exp) === 'KRW' &&
formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd) && (
<p className="text-[10px] font-medium text-on-surface-variant/70 whitespace-nowrap">
≈ {formatKrwAsTwd(exp.totalAmount, exp.exchangeRateKrwPerTwd)}
</p>
)}
<p className="text-[10px] font-medium text-secondary uppercase tracking-wider">
{t('history.participants', { count: exp.participantIds.length })}
</p>
Expand Down Expand Up @@ -830,7 +845,7 @@ export function HistoryTab() {
<span className="font-medium text-on-surface">{m.name}</span>
</div>
<span className="font-semibold text-on-surface">
{formatAmount(amt, currency)}
{formatAmount(amt, expenseCurrency(exp))}
</span>
</div>
);
Expand Down
27 changes: 26 additions & 1 deletion apps/split-meow/src/config/__tests__/currencies.test.ts
Original file line number Diff line number Diff line change
@@ -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$ 前綴格式化', () => {
Expand Down Expand Up @@ -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();
});
});
9 changes: 9 additions & 0 deletions apps/split-meow/src/config/currencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
17 changes: 17 additions & 0 deletions apps/split-meow/src/store/__tests__/useStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
7 changes: 7 additions & 0 deletions apps/split-meow/src/store/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -266,6 +270,9 @@ export const useStore = create<AppState>()(
note: state.expenseNote.trim(),
...(state.expenseCategory ? { category: state.expenseCategory } : {}),
createdAt: Date.now(),
// 記帳當下的幣別與匯率快照,確保歷史金額不受日後切換幣別影響並可回溯換算
currency: state.currency,
exchangeRateKrwPerTwd: state.krwPerTwd,
};

return {
Expand Down
12 changes: 11 additions & 1 deletion docs/dev/002_development_reward_penalty_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> 版本:outline-v2-ultra
> 原則:每筆只保留日期、ID、原因、解法。
> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+66
> 本次分數變化:+2(reward 2、penalty 0)|累計總分:+68

## 新增模板(4 行)

Expand All @@ -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 欄位。
Expand Down
Loading