From 156840133858e3d9efd24ecc8409c198ecc093ce Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 30 Jun 2026 02:41:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ratewise):=20RW-6=20moneybox=20?= =?UTF-8?q?=E9=A6=96=E7=88=BE=E6=8E=9B=E7=89=8C=E6=97=A5=E8=B7=A8=E6=97=A5?= =?UTF-8?q?=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加 extractSeoulSnapshotDate/shouldRefreshLatestSnapshot:rate-changed 或 date-rollover 才刷新 - 整合進 hasRateChanges;保留 main 的 needsSchemaMigration/enrichExchangeShop(紅線不取代) - 補 3 個 vitest 案例並納入 test:root(date-rollover/unchanged/rate-changed) 測試:moneybox vitest 3 passed、腳本 import 5 exports 正常、needsSchemaMigration 保留 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../rw6-moneybox-seoul-date-rollover.md | 5 ++ .../dev/002_development_reward_penalty_log.md | 7 +- package.json | 2 +- .../__tests__/fetch-moneybox-rates.test.ts | 60 ++++++++++++++ scripts/fetch-moneybox-rates.js | 78 +++++++++++++++++-- 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 .changeset/rw6-moneybox-seoul-date-rollover.md create mode 100644 scripts/__tests__/fetch-moneybox-rates.test.ts diff --git a/.changeset/rw6-moneybox-seoul-date-rollover.md b/.changeset/rw6-moneybox-seoul-date-rollover.md new file mode 100644 index 000000000..ebaddb857 --- /dev/null +++ b/.changeset/rw6-moneybox-seoul-date-rollover.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正換錢所匯率資料:首爾掛牌日跨日時即使牌價未變也刷新快照,讓趨勢與歷史日期正確對齊。 diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index e5bfa3b81..fd97099d9 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、neutral 0)|累計總分:+89 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+90 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期: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 漂移 diff --git a/package.json b/package.json index 39af557fe..de50d1fba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/__tests__/fetch-moneybox-rates.test.ts b/scripts/__tests__/fetch-moneybox-rates.test.ts new file mode 100644 index 000000000..481c72805 --- /dev/null +++ b/scripts/__tests__/fetch-moneybox-rates.test.ts @@ -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); + }); +}); diff --git a/scripts/fetch-moneybox-rates.js b/scripts/fetch-moneybox-rates.js index 12102ecdb..29b835ac2 100644 --- a/scripts/fetch-moneybox-rates.js +++ b/scripts/fetch-moneybox-rates.js @@ -217,20 +217,83 @@ 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', + 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'); @@ -238,7 +301,7 @@ function hasRateChanges(newData) { console.log(` Currencies checked: ${currentCurrencies}`); } - return hasChanges; + return decision.shouldUpdate; } catch { console.log('📝 No previous data found, will create new file'); return true; @@ -330,3 +393,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { export { fetchMoneyBoxRates }; export { listRateChanges }; export { needsSchemaMigration }; +export { extractSeoulSnapshotDate, shouldRefreshLatestSnapshot }; From b43012454af9d868f195fa7ab8e53ef3294b46df Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 30 Jun 2026 03:08:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ratewise):=20moneybox=20history=20?= =?UTF-8?q?=E6=AA=94=E5=90=8D=E9=A6=96=E7=88=BE=E5=8C=96=E8=88=87=20sw=20?= =?UTF-8?q?=E9=9B=A2=E7=B7=9A=E8=B7=AF=E7=94=B1=EF=BC=88rw-6b=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow history 檔名改用 extractSeoulSnapshotDate(資料首爾掛牌日)+ fallback 首爾 wall-clock - aggregate 首次缺失補 changed=true,避免 history-30d.json 遺失卻不開 PR - sw.ts history-30d 路由加 providers/moneybox 路徑,離線可服務換錢所趨勢圖 測試:typecheck、lint、test 2516 passed、build:ratewise(sw 編進 worker) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../rw6-moneybox-seoul-date-rollover.md | 2 +- .github/workflows/update-moneybox-rates.yml | 19 ++++++++++++++++++- apps/ratewise/src/sw.ts | 7 +++++-- .../dev/002_development_reward_penalty_log.md | 7 ++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.changeset/rw6-moneybox-seoul-date-rollover.md b/.changeset/rw6-moneybox-seoul-date-rollover.md index ebaddb857..6d34ade6f 100644 --- a/.changeset/rw6-moneybox-seoul-date-rollover.md +++ b/.changeset/rw6-moneybox-seoul-date-rollover.md @@ -2,4 +2,4 @@ '@app/ratewise': patch --- -修正換錢所匯率資料:首爾掛牌日跨日時即使牌價未變也刷新快照,讓趨勢與歷史日期正確對齊。 +修正換錢所匯率資料:首爾掛牌日跨日時即使牌價未變也刷新快照,讓趨勢與歷史日期正確對齊,並讓離線時也能服務換錢所趨勢圖。 diff --git a/.github/workflows/update-moneybox-rates.yml b/.github/workflows/update-moneybox-rates.yml index 082bcd18e..8d94ab562 100644 --- a/.github/workflows/update-moneybox-rates.yml +++ b/.github/workflows/update-moneybox-rates.yml @@ -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 @@ -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 diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 3eb0eb31c..6ef39d733 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -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') || (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: [ diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index fd97099d9..f8aaf2fcc 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、neutral 0)|累計總分:+90 +> 本次分數變化:+2(reward 2、penalty 0、neutral 0)|累計總分:+91 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期: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 日期不對齊