Skip to content
Closed
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/rw6-moneybox-seoul-date-rollover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

修正換錢所匯率資料:首爾掛牌日跨日時即使牌價未變也刷新快照,讓趨勢與歷史日期正確對齊,並讓離線時也能服務換錢所趨勢圖。
19 changes: 18 additions & 1 deletion .github/workflows/update-moneybox-rates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,20 @@ jobs:
id: save-history
if: steps.fetch-rates.outcome == 'success'
run: |
CURRENT_DATE=$(TZ=Asia/Taipei date +%Y-%m-%d)
# history 檔名 SSOT:採資料本身宣告的首爾掛牌日(updateTime),與 fetch script 的
# date-rollover 判斷 (extractSeoulSnapshotDate) 同源,避免 runner wall-clock 與資料日期
# 在跨日視窗不一致。提取失敗時 fallback 首爾 wall-clock。
CURRENT_DATE=$(node --input-type=module -e "
import { readFileSync } from 'node:fs';
const { extractSeoulSnapshotDate } = await import('./scripts/fetch-moneybox-rates.js');
const data = JSON.parse(readFileSync(process.env.MONEYBOX_FETCH_OUTPUT_FILE, 'utf8'));
const d = extractSeoulSnapshotDate(data);
if (d) process.stdout.write(d);
" 2>/dev/null)
if [[ ! "$CURRENT_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
CURRENT_DATE=$(TZ=Asia/Seoul date +%Y-%m-%d)
echo "⚠️ 無法從資料提取首爾 snapshot date,fallback 至首爾 wall-clock: ${CURRENT_DATE}"
fi
MONEYBOX_HISTORY_FILE="${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json"
mkdir -p "$MONEYBOX_HISTORY_DIR"
if [[ ! -f "$MONEYBOX_FETCH_OUTPUT_FILE" ]]; then
Expand Down Expand Up @@ -133,6 +146,10 @@ jobs:
if [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_LATEST_FILE")" ]]; then
echo "changed=true" >> $GITHUB_OUTPUT
fi
# aggregate 首次生成(檔案不存在)也應觸發 commit,避免 history-30d.json 遺失卻不開 PR
if [[ ! -f "$MONEYBOX_AGGREGATE_FILE" ]]; then
echo "changed=true" >> $GITHUB_OUTPUT
fi
if [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_HISTORY_DIR/")" ]]; then
echo "changed=true" >> $GITHUB_OUTPUT
fi
Expand Down
7 changes: 5 additions & 2 deletions apps/ratewise/src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,13 @@ registerRoute(new NavigationRoute(handleNavigationRequest));
registerRoute(
({ url }: { url: URL }) =>
url.pathname.includes('/public/rates/history-30d.json') ||
url.pathname.includes('/public/rates/providers/moneybox/history-30d.json') ||
Comment thread
s123104 marked this conversation as resolved.
(url.origin === 'https://cdn.jsdelivr.net' &&
url.pathname.includes('/public/rates/history-30d.json')) ||
(url.pathname.includes('/public/rates/history-30d.json') ||
url.pathname.includes('/public/rates/providers/moneybox/history-30d.json'))) ||
(url.origin === 'https://raw.githubusercontent.com' &&
url.pathname.includes('/public/rates/history-30d.json')),
(url.pathname.includes('/public/rates/history-30d.json') ||
url.pathname.includes('/public/rates/providers/moneybox/history-30d.json'))),
new StaleWhileRevalidate({
cacheName: 'history-aggregate-cache',
plugins: [
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、neutral 0)|累計總分:+89
> 本次分數變化:+2(reward 2、penalty 0、neutral 0)|累計總分:+91

## 新增模板(4 行)

Expand All @@ -13,6 +13,16 @@

## 條目(新→舊)

- 日期:2026-06-30
- ID:reward-rw6b-moneybox-workflow-sw-history
- 原因:MoneyBox history 檔名用台北 wall-clock(跨日與首爾掛牌日不一致)、aggregate 首次缺失不觸發 commit、SW 未快取換錢所 history-30d(離線趨勢圖無資料)
- 解法:workflow 改用 extractSeoulSnapshotDate 命名 history(fallback 首爾 wall-clock)+ aggregate 缺失補 changed=true;sw.ts history-30d 路由加 providers/moneybox 路徑

- 日期:2026-06-30
- ID:reward-rw6-moneybox-seoul-date-rollover
- 原因:MoneyBox 換錢所牌價不變但首爾掛牌日跨日時,latest.json 未刷新→與每日 history 日期不對齊
- 解法:加 extractSeoulSnapshotDate/shouldRefreshLatestSnapshot(rate-changed 或 date-rollover 才刷新),整合進 hasRateChanges;保留 main 的 needsSchemaMigration/enrich(紅線);補 3 測試並納入 test:root

- 日期:2026-06-30
- ID:reward-rw2-nitro-theme-contrast-fix
- 原因:Nitro 深色主題 textMuted 為 slate-500(深底對比過低,次要文字看不清)、primary 為過亮 cyan(按鈕白字對比不足);themes.ts chartLine 與 index.css 漂移
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"format:fix": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test && pnpm test:root",
"test:root": "vitest run scripts/__tests__/lighthouse-production.test.ts",
"test:root": "vitest run scripts/__tests__/lighthouse-production.test.ts scripts/__tests__/fetch-moneybox-rates.test.ts",
"test:unit": "pnpm -r test && pnpm test:root",
"test:integration": "pnpm --filter @app/ratewise exec vitest run integration",
"test:e2e": "pnpm --filter @app/ratewise exec playwright test && pnpm --filter @app/nihonname exec playwright test",
Expand Down
60 changes: 60 additions & 0 deletions scripts/__tests__/fetch-moneybox-rates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';

import { shouldRefreshLatestSnapshot } from '../fetch-moneybox-rates.js';

const baseRates = {
TWD: { sell: 46.1, buy: 46.7, base: 47.95, spbuy: 54.23, spsell: 43.16 },
};

describe('fetch-moneybox-rates / shouldRefreshLatestSnapshot', () => {
it('跨到首爾新的一天時,即使牌價未變也刷新 latest snapshot', () => {
const oldData = {
timestamp: '2026-05-31T11:56:44.177Z',
updateTime: '2026/05/31 20:56:44',
rates: baseRates,
};
const newData = {
timestamp: '2026-05-31T16:36:06.583Z',
updateTime: '2026/06/01 01:36:06',
rates: baseRates,
};

expect(shouldRefreshLatestSnapshot(oldData, newData)).toMatchObject({
shouldUpdate: true,
reason: 'date-rollover',
oldSnapshotDate: '2026-05-31',
newSnapshotDate: '2026-06-01',
});
});

it('同一首爾日且牌價未變時不刷新', () => {
const oldData = {
timestamp: '2026-05-31T11:56:44.177Z',
updateTime: '2026/05/31 20:56:44',
rates: baseRates,
};
const newData = {
timestamp: '2026-05-31T12:01:44.177Z',
updateTime: '2026/05/31 21:01:44',
rates: baseRates,
};

expect(shouldRefreshLatestSnapshot(oldData, newData)).toMatchObject({
shouldUpdate: false,
reason: 'unchanged',
});
});

it('牌價有變化時刷新(reason=rate-changed)', () => {
const oldData = { updateTime: '2026/05/31 20:56:44', rates: baseRates };
const newData = {
updateTime: '2026/05/31 21:01:44',
rates: { TWD: { ...baseRates.TWD, sell: 46.2 } },
};

const decision = shouldRefreshLatestSnapshot(oldData, newData);
expect(decision.shouldUpdate).toBe(true);
expect(decision.reason).toBe('rate-changed');
expect(decision.rateChanges.length).toBeGreaterThan(0);
});
});
78 changes: 71 additions & 7 deletions scripts/fetch-moneybox-rates.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,28 +217,91 @@ function needsSchemaMigration() {
}
}

/**
* 從 snapshot 取得首爾掛牌日(YYYY-MM-DD)。
* 優先用 updateTime(首爾日曆字串),否則由 UTC timestamp 換算 Asia/Seoul 日期。
*/
function extractSeoulSnapshotDate(snapshot) {
const updateTime = snapshot?.updateTime;
if (typeof updateTime === 'string') {
const match = updateTime.match(/^(\d{4})\/(\d{2})\/(\d{2})/);
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`;
}
}

const timestamp = snapshot?.timestamp;
if (typeof timestamp === 'string') {
const parsed = new Date(timestamp);
if (!Number.isNaN(parsed.getTime())) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(parsed);
}
}

return null;
}

/**
* 判斷是否刷新 latest.json:匯率有變化,或首爾掛牌日跨日(牌價不變但日期前進仍需刷新,
* 讓 latest 與每日 history 對齊)。
*/
function shouldRefreshLatestSnapshot(oldData = {}, newData = {}) {
const rateChanges = listRateChanges(oldData.rates, newData.rates);
if (rateChanges.length > 0) {
return { shouldUpdate: true, reason: 'rate-changed', rateChanges };
}

const oldSnapshotDate = extractSeoulSnapshotDate(oldData);
const newSnapshotDate = extractSeoulSnapshotDate(newData);
if (oldSnapshotDate && newSnapshotDate && oldSnapshotDate !== newSnapshotDate) {
return {
shouldUpdate: true,
reason: 'date-rollover',
Comment thread
s123104 marked this conversation as resolved.
oldSnapshotDate,
newSnapshotDate,
rateChanges,
};
}

return {
shouldUpdate: false,
reason: 'unchanged',
rateChanges,
oldSnapshotDate,
newSnapshotDate,
};
}

function hasRateChanges(newData) {
try {
const oldData = JSON.parse(readFileSync(OUTPUT_FILE, 'utf8'));
const decision = shouldRefreshLatestSnapshot(oldData, newData);

const rateChanges = listRateChanges(oldData.rates, newData.rates);
const hasChanges = rateChanges.length > 0;

if (hasChanges) {
if (decision.shouldUpdate && decision.reason === 'rate-changed') {
console.log(
`🔄 Rate change detected: ${rateChanges.map(({ currency, field }) => `${currency}.${field}`).join(', ')}`,
`🔄 Rate change detected: ${decision.rateChanges.map(({ currency, field }) => `${currency}.${field}`).join(', ')}`,
);
for (const { currency, field, oldValue, newValue } of rateChanges) {
for (const { currency, field, oldValue, newValue } of decision.rateChanges) {
console.log(` ${currency}.${field}: ${oldValue} → ${newValue}`);
}
} else if (decision.shouldUpdate && decision.reason === 'date-rollover') {
console.log(
`📅 Snapshot date rolled over: ${decision.oldSnapshotDate} → ${decision.newSnapshotDate}`,
);
console.log(' Refreshing latest.json to keep current snapshot aligned with daily history');
} else {
const currentCurrencies = Object.keys(newData.rates ?? {}).length;
console.log('📊 Rates unchanged since last update');
console.log(` Last update: ${oldData.updateTime}`);
console.log(` Currencies checked: ${currentCurrencies}`);
}

return hasChanges;
return decision.shouldUpdate;
} catch {
console.log('📝 No previous data found, will create new file');
return true;
Expand Down Expand Up @@ -330,3 +393,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
export { fetchMoneyBoxRates };
export { listRateChanges };
export { needsSchemaMigration };
export { extractSeoulSnapshotDate, shouldRefreshLatestSnapshot };
Loading