diff --git a/.changeset/rw-offline-manifest-theme-ssot.md b/.changeset/rw-offline-manifest-theme-ssot.md
new file mode 100644
index 000000000..71fc75a42
--- /dev/null
+++ b/.changeset/rw-offline-manifest-theme-ssot.md
@@ -0,0 +1,5 @@
+---
+'@app/ratewise': patch
+---
+
+對齊離線頁、manifest 與 index.html 的 zen 主題色(violet-600),避免 PWA 安裝列與離線殼色不一致。
diff --git a/.changeset/rw3-manifest-theme-color-ssot.md b/.changeset/rw3-manifest-theme-color-ssot.md
new file mode 100644
index 000000000..c823fb834
--- /dev/null
+++ b/.changeset/rw3-manifest-theme-color-ssot.md
@@ -0,0 +1,5 @@
+---
+'@app/ratewise': patch
+---
+
+PWA 安裝/狀態列顏色改用實際主題色(移除硬編 hex),並修正 zen 主色 themes.ts 與 CSS 漂移。
diff --git a/.changeset/rw3b-offline-template-theme-aware.md b/.changeset/rw3b-offline-template-theme-aware.md
new file mode 100644
index 000000000..1d46f1707
--- /dev/null
+++ b/.changeset/rw3b-offline-template-theme-aware.md
@@ -0,0 +1,5 @@
+---
+'@app/ratewise': patch
+---
+
+離線頁改為主題感知:依使用者主題顯示對應底色(深色主題不再閃淺紫),並保留斷網自我修復;同步狀態列顏色與 iOS 安全區。
diff --git a/.changeset/rw5-seo-truthfulness-update-frequency.md b/.changeset/rw5-seo-truthfulness-update-frequency.md
new file mode 100644
index 000000000..f890c3c53
--- /dev/null
+++ b/.changeset/rw5-seo-truthfulness-update-frequency.md
@@ -0,0 +1,5 @@
+---
+'@app/ratewise': patch
+---
+
+SEO 文案真實性:將「每 5 分鐘自動同步」軟化為「約每 5 分鐘檢查更新」並加新鮮度免責,與實際資料管線一致。
diff --git a/.changeset/rw6-moneybox-seoul-date-rollover.md b/.changeset/rw6-moneybox-seoul-date-rollover.md
new file mode 100644
index 000000000..6d34ade6f
--- /dev/null
+++ b/.changeset/rw6-moneybox-seoul-date-rollover.md
@@ -0,0 +1,5 @@
+---
+'@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/.prettierignore b/.prettierignore
index 194e97624..23dd34c6e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,6 +2,9 @@
node_modules/
.pnpm-store/
+# 由 generate-offline-html.mjs 從模板生成(prebuild 禁 prettier,避免格式漂移)
+apps/ratewise/public/offline.html
+
# Build output
dist/
build/
diff --git a/apps/ratewise/index.html b/apps/ratewise/index.html
index c5cf65faf..9097b0130 100644
--- a/apps/ratewise/index.html
+++ b/apps/ratewise/index.html
@@ -4,7 +4,7 @@
-
+
diff --git a/apps/ratewise/public/about.md b/apps/ratewise/public/about.md
index 8e8eedf7e..43b4c210a 100644
--- a/apps/ratewise/public/about.md
+++ b/apps/ratewise/public/about.md
@@ -12,7 +12,7 @@ HaoRate 是以臺灣銀行牌告匯率為基礎的換匯工具,重點是幫台
## 資料方法與範圍
- 資料來源為臺灣銀行官方牌告匯率,涵蓋 18 種貨幣。
-- 每 5 分鐘自動同步最新報價,涵蓋現金買入、現金賣出、即期買入、即期賣出四種。
+- 約每 5 分鐘檢查更新最新報價,涵蓋現金買入、現金賣出、即期買入、即期賣出四種。
- 資料管線:GitHub Actions 每日抓取 + 雙重驗證(台銀牌告 vs open.er-api.com 中間價,誤差 ≤ 2%)+ Pull Request 自動審核後合併至 data branch。
- 匯差範例數字透過 SSG(vite-react-ssg)於 build 期嵌入靜態 HTML,搜尋引擎無需執行 JavaScript 即可讀取。
@@ -34,7 +34,7 @@ HaoRate 是以臺灣銀行牌告匯率為基礎的換匯工具,重點是幫台
### 1. 匯率數據來源是什麼?
-資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。
+資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。
### 2. 免費使用嗎?需要帳號或有廣告嗎?
diff --git a/apps/ratewise/public/api/latest.json b/apps/ratewise/public/api/latest.json
index e4f818eb1..1212b0a97 100644
--- a/apps/ratewise/public/api/latest.json
+++ b/apps/ratewise/public/api/latest.json
@@ -44,7 +44,7 @@
"moneyboxTwdToKrw": "amount * rates.TWD.sell"
}
},
- "description": "臺灣銀行牌告匯率靜態 API — 資料每 5 分鐘自動同步,並提供 App 匯率模式欄位對照",
+ "description": "臺灣銀行牌告匯率靜態 API — 資料約每 5 分鐘檢查更新,並提供 App 匯率模式欄位對照",
"source": "臺灣銀行牌告匯率",
"sourceUrl": "https://rate.bot.com.tw/xrt",
"updateFrequency": "every 5 minutes",
diff --git a/apps/ratewise/public/faq.md b/apps/ratewise/public/faq.md
index 2bc836590..55a7f4692 100644
--- a/apps/ratewise/public/faq.md
+++ b/apps/ratewise/public/faq.md
@@ -19,7 +19,7 @@ HaoRate 是基於臺灣銀行牌告匯率的即時匯率 PWA 應用,支援 18
### 2. 匯率數據來源是什麼?
-HaoRate 的匯率資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步一次,涵蓋現金與即期買入賣出價四種報價。
+HaoRate 的匯率資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新一次,涵蓋現金與即期買入賣出價四種報價。
### 3. 現金匯率和即期匯率有什麼差別?
@@ -51,7 +51,7 @@ HaoRate 依據各國旅遊與消費習慣提供常用金額按鈕。例如台幣
### 10. 匯率更新頻率如何?
-匯率數據每 5 分鐘自動更新一次,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理(Pull to Refresh)以手動同步最新牌告資料。
+匯率數據約每 5 分鐘檢查更新一次,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理(Pull to Refresh)以手動同步最新牌告資料;實際新鮮度依資料來源與 CDN 快取而定。
### 11. 如何安裝 HaoRate 到手機桌面?
diff --git a/apps/ratewise/public/index.md b/apps/ratewise/public/index.md
index c70d676b7..fe84e2685 100644
--- a/apps/ratewise/public/index.md
+++ b/apps/ratewise/public/index.md
@@ -33,7 +33,7 @@
### 4. 匯率多久更新一次?
-匯率數據每 5 分鐘自動同步臺灣銀行最新牌告匯率,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理以手動同步。
+匯率數據約每 5 分鐘檢查更新臺灣銀行最新牌告匯率,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理以手動同步。
### 5. 單幣別和多幣別模式有什麼差別?
diff --git a/apps/ratewise/public/llms-full.txt b/apps/ratewise/public/llms-full.txt
index 95fe8864c..c3c534167 100644
--- a/apps/ratewise/public/llms-full.txt
+++ b/apps/ratewise/public/llms-full.txt
@@ -270,7 +270,7 @@ GET https://app.haotool.org/ratewise/openapi.json
## Answer Capsule (Q&A for AI Citation)
-- Q: HaoRate 提供什麼服務? A: HaoRate 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 18 種貨幣,每 5 分鐘自動同步。
+- Q: HaoRate 提供什麼服務? A: HaoRate 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 18 種貨幣,約每 5 分鐘檢查更新。
- Q: 如何取得即時台銀匯率(適合開發者/LLM)? A: 免費 CDN API:GET https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/latest.json。回傳欄位 details.{幣別}.cash.sell(現金賣出)、details.{幣別}.cash.buy(現金買入)、details.{幣別}.spot.sell(即期賣出)、details.{幣別}.spot.buy(即期買入)。無需 API Key,CORS 啟用,每 5 分鐘更新。
- Q: 現金匯率和即期匯率的差別? A: 現金匯率適用臨櫃換鈔(到銀行換現鈔),即期匯率適用銀行電匯(匯款)。現鈔通常比即期差 1~3%,因為銀行有保管與運送成本。
- Q: 買入和賣出怎麼看? A: 買入/賣出是銀行角度。您拿外幣換台幣 → 看「買入」(銀行買你的外幣);您拿台幣換外幣 → 看「賣出」(銀行賣外幣給你)。
@@ -294,7 +294,7 @@ GET https://app.haotool.org/ratewise/openapi.json
- Source: 臺灣銀行牌告匯率(Bank of Taiwan)
- Source URL: https://rate.bot.com.tw/xrt
-- Update mechanism: GitHub Actions 自動排程,每 5 分鐘同步
+- Update mechanism: GitHub Actions 自動排程,約每 5 分鐘檢查更新
- CDN: jsDelivr(全球 CDN,99.9% uptime)
- Rate Types: 現金買入(cash_buy)、現金賣出(cash_sell)、即期買入(spot_buy)、即期賣出(spot_sell)
- Disclaimer: 匯率僅供參考,實際交易請以金融機構公告為準。
diff --git a/apps/ratewise/public/llms.txt b/apps/ratewise/public/llms.txt
index 093dbcd4e..5c5804321 100644
--- a/apps/ratewise/public/llms.txt
+++ b/apps/ratewise/public/llms.txt
@@ -9,7 +9,7 @@ Version: v2.25.12
- Q: HaoRate 提供什麼? A: 顯示臺灣銀行牌告的實際買入賣出價(非中間價)的即時匯率換算工具。內建計算機鍵盤(支援四則運算)、快速金額按鈕、收藏管理、拖曳排序、換算歷史紀錄、7~30 天匯率趨勢圖、現金/即期匯率切換、6 種主題風格、4 語言介面與 PWA 離線使用。
- Q: 為什麼 HaoRate 比其他匯率工具更精準? A: 多數匯率工具只顯示中間價(mid-rate),而 HaoRate 顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),直接對應你在銀行換匯的真實金額。
- Q: 匯率資料來源? A: 臺灣銀行牌告匯率(現金買入/賣出、即期買入/賣出四種報價)。
-- Q: 更新頻率? A: 每 5 分鐘自動同步。
+- Q: 更新頻率? A: 約每 5 分鐘檢查更新。
- Q: 建議用途? A: 出國旅遊換匯、跨境購物匯率比較、日常外幣查詢。
- Q: 現金匯率和即期匯率的差別? A: 現金匯率用於臨櫃換外幣紙鈔,即期匯率用於外幣帳戶轉帳或匯款。因銀行持有實鈔有保管、運送、偽鈔鑑定成本,現金匯率通常比即期差約 0.5~2%,換 1,000 美元現金比即期多付約 150~600 元台幣。
- Q: 買入和賣出怎麼看? A: 買入/賣出是銀行視角:出國換外幣(你支付台幣)看「賣出」價;回國換台幣(你交出外幣)看「買入」價。台銀買賣價差通常為即期匯率 0.3~1%、現金匯率 1~2%。
@@ -19,14 +19,14 @@ Version: v2.25.12
## E-E-A-T Signals
- 專業性:匯率計算邏輯與格式化策略具完整測試覆蓋。
-- 權威性:資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步。
+- 權威性:資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新。
- 可信度:開源 GPL-3.0,透明可驗證;提供聯絡方式。
- 經驗:專為台灣用戶設計,依各國旅遊消費習慣提供常用金額按鈕。
## Key Metrics
- 支援貨幣:18 種
-- 更新頻率:每 5 分鐘自動同步
+- 更新頻率:約每 5 分鐘檢查更新
- 匯率類型:現金買入、現金賣出、即期買入、即期賣出
- 6 種主題風格(Zen/Nitro/Kawaii/Classic/Ocean/Forest)
- i18n 4 語言支援(English/繁體中文/日本語/한국어)
@@ -136,7 +136,7 @@ Googlebot 與 AI agent 可直接讀取靜態 HTML:
- Source: 臺灣銀行牌告匯率(Bank of Taiwan)
- Source URL: https://rate.bot.com.tw/xrt
-- Update: 每 5 分鐘自動同步(GitHub Actions)
+- Update: 約每 5 分鐘檢查更新(GitHub Actions)
- Rate Types: 現金買入、現金賣出、即期買入、即期賣出
- Currencies: 18 種(TWD, USD, JPY, EUR, GBP, HKD, CNY, KRW, AUD, CAD, SGD, THB, NZD, CHF, VND, PHP, IDR, MYR)
- Disclaimer: 匯率僅供參考,實際交易請以金融機構公告為準。
diff --git a/apps/ratewise/public/manifest.webmanifest b/apps/ratewise/public/manifest.webmanifest
index 129fdd942..603974dee 100644
--- a/apps/ratewise/public/manifest.webmanifest
+++ b/apps/ratewise/public/manifest.webmanifest
@@ -2,8 +2,8 @@
"name": "HaoRate 匯率好工具",
"short_name": "HaoRate",
"description": "HaoRate 匯率好工具顯示臺灣銀行牌告實際買賣價(非中間價),支援 18 種貨幣換算,每 5 分鐘同步,離線可用的 PWA 匯率工具。",
- "theme_color": "#8B5CF6",
- "background_color": "#E8ECF4",
+ "theme_color": "#7C3AED",
+ "background_color": "#F8FAFC",
"display": "standalone",
"scope": "https://app.haotool.org/ratewise/",
"start_url": "https://app.haotool.org/ratewise/",
diff --git a/apps/ratewise/public/offline.html b/apps/ratewise/public/offline.html
index 6c6ff95c8..649b1e7fe 100644
--- a/apps/ratewise/public/offline.html
+++ b/apps/ratewise/public/offline.html
@@ -2,13 +2,176 @@
-
-
+
+
+
+
+
離線模式 - HaoRate
+
diff --git a/apps/ratewise/public/open-data.md b/apps/ratewise/public/open-data.md
index a8f456fca..88e5b4ce1 100644
--- a/apps/ratewise/public/open-data.md
+++ b/apps/ratewise/public/open-data.md
@@ -15,7 +15,7 @@
| OpenAPI 規格 | https://app.haotool.org/ratewise/openapi.json |
- **免 API Key**、**免費使用**、**CORS 已啟用**。
-- 更新頻率:每 5 分鐘自動同步臺灣銀行牌告。
+- 更新頻率:約每 5 分鐘檢查更新臺灣銀行牌告。
- 涵蓋 18 種貨幣的現金買/賣、即期買/賣四種報價。
## 呼叫範例
diff --git a/apps/ratewise/scripts/generate-api-json.mjs b/apps/ratewise/scripts/generate-api-json.mjs
index 9b274c6b6..9fe178538 100644
--- a/apps/ratewise/scripts/generate-api-json.mjs
+++ b/apps/ratewise/scripts/generate-api-json.mjs
@@ -51,7 +51,7 @@ const latestJson = {
schemaVersion: API_SEMANTICS_SCHEMA_VERSION,
semanticsDoc: API_SEMANTICS_DOC.publicUrl,
semanticFieldMapping: buildSemanticFieldMapping(),
- description: '臺灣銀行牌告匯率靜態 API — 資料每 5 分鐘自動同步,並提供 App 匯率模式欄位對照',
+ description: '臺灣銀行牌告匯率靜態 API — 資料約每 5 分鐘檢查更新,並提供 App 匯率模式欄位對照',
source: '臺灣銀行牌告匯率',
sourceUrl: 'https://rate.bot.com.tw/xrt',
updateFrequency: 'every 5 minutes',
diff --git a/apps/ratewise/scripts/generate-llms-txt.mjs b/apps/ratewise/scripts/generate-llms-txt.mjs
index 2bbac613e..c0260ad40 100644
--- a/apps/ratewise/scripts/generate-llms-txt.mjs
+++ b/apps/ratewise/scripts/generate-llms-txt.mjs
@@ -106,7 +106,7 @@ Version: v${VERSION}
- Q: ${BRAND_SHORT} 提供什麼? A: 顯示臺灣銀行牌告的實際買入賣出價(非中間價)的即時匯率換算工具。內建計算機鍵盤(支援四則運算)、快速金額按鈕、收藏管理、拖曳排序、換算歷史紀錄、7~30 天匯率趨勢圖、現金/即期匯率切換、6 種主題風格、${SUPPORTED_LANGUAGE_COUNT} 語言介面與 PWA 離線使用。
- Q: 為什麼 ${BRAND_SHORT} 比其他匯率工具更精準? A: 多數匯率工具只顯示中間價(mid-rate),而 ${BRAND_SHORT} 顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),直接對應你在銀行換匯的真實金額。
- Q: 匯率資料來源? A: 臺灣銀行牌告匯率(現金買入/賣出、即期買入/賣出四種報價)。
-- Q: 更新頻率? A: 每 5 分鐘自動同步。
+- Q: 更新頻率? A: 約每 5 分鐘檢查更新。
- Q: 建議用途? A: 出國旅遊換匯、跨境購物匯率比較、日常外幣查詢。
- Q: 現金匯率和即期匯率的差別? A: 現金匯率用於臨櫃換外幣紙鈔,即期匯率用於外幣帳戶轉帳或匯款。因銀行持有實鈔有保管、運送、偽鈔鑑定成本,現金匯率通常比即期差約 0.5~2%,換 1,000 美元現金比即期多付約 150~600 元台幣。
- Q: 買入和賣出怎麼看? A: 買入/賣出是銀行視角:出國換外幣(你支付台幣)看「賣出」價;回國換台幣(你交出外幣)看「買入」價。台銀買賣價差通常為即期匯率 0.3~1%、現金匯率 1~2%。
@@ -116,14 +116,14 @@ Version: v${VERSION}
## E-E-A-T Signals
- 專業性:匯率計算邏輯與格式化策略具完整測試覆蓋。
-- 權威性:資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步。
+- 權威性:資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新。
- 可信度:開源 GPL-3.0,透明可驗證;提供聯絡方式。
- 經驗:專為台灣用戶設計,依各國旅遊消費習慣提供常用金額按鈕。
## Key Metrics
- 支援貨幣:${SUPPORTED_CURRENCY_COUNT} 種
-- 更新頻率:每 5 分鐘自動同步
+- 更新頻率:約每 5 分鐘檢查更新
- 匯率類型:現金買入、現金賣出、即期買入、即期賣出
- 6 種主題風格(Zen/Nitro/Kawaii/Classic/Ocean/Forest)
- i18n ${SUPPORTED_LANGUAGE_COUNT} 語言支援(${SUPPORTED_LANGUAGE_LABEL_TEXT})
@@ -190,7 +190,7 @@ ${buildReverseRates()}
- Source: 臺灣銀行牌告匯率(Bank of Taiwan)
- Source URL: https://rate.bot.com.tw/xrt
-- Update: 每 5 分鐘自動同步(GitHub Actions)
+- Update: 約每 5 分鐘檢查更新(GitHub Actions)
- Rate Types: 現金買入、現金賣出、即期買入、即期賣出
- Currencies: ${SUPPORTED_CURRENCY_COUNT} 種(TWD, USD, JPY, EUR, GBP, HKD, CNY, KRW, AUD, CAD, SGD, THB, NZD, CHF, VND, PHP, IDR, MYR)
- Disclaimer: 匯率僅供參考,實際交易請以金融機構公告為準。
@@ -520,7 +520,7 @@ GET ${BASE_URL}openapi.json
## Answer Capsule (Q&A for AI Citation)
-- Q: ${BRAND_SHORT} 提供什麼服務? A: ${BRAND_SHORT} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,每 5 分鐘自動同步。
+- Q: ${BRAND_SHORT} 提供什麼服務? A: ${BRAND_SHORT} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,約每 5 分鐘檢查更新。
- Q: 如何取得即時台銀匯率(適合開發者/LLM)? A: 免費 CDN API:GET https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/latest.json。回傳欄位 details.{幣別}.cash.sell(現金賣出)、details.{幣別}.cash.buy(現金買入)、details.{幣別}.spot.sell(即期賣出)、details.{幣別}.spot.buy(即期買入)。無需 API Key,CORS 啟用,每 5 分鐘更新。
- Q: 現金匯率和即期匯率的差別? A: 現金匯率適用臨櫃換鈔(到銀行換現鈔),即期匯率適用銀行電匯(匯款)。現鈔通常比即期差 1~3%,因為銀行有保管與運送成本。
- Q: 買入和賣出怎麼看? A: 買入/賣出是銀行角度。您拿外幣換台幣 → 看「買入」(銀行買你的外幣);您拿台幣換外幣 → 看「賣出」(銀行賣外幣給你)。
@@ -544,7 +544,7 @@ GET ${BASE_URL}openapi.json
- Source: 臺灣銀行牌告匯率(Bank of Taiwan)
- Source URL: https://rate.bot.com.tw/xrt
-- Update mechanism: GitHub Actions 自動排程,每 5 分鐘同步
+- Update mechanism: GitHub Actions 自動排程,約每 5 分鐘檢查更新
- CDN: jsDelivr(全球 CDN,99.9% uptime)
- Rate Types: 現金買入(cash_buy)、現金賣出(cash_sell)、即期買入(spot_buy)、即期賣出(spot_sell)
- Disclaimer: 匯率僅供參考,實際交易請以金融機構公告為準。
diff --git a/apps/ratewise/scripts/generate-manifest.mjs b/apps/ratewise/scripts/generate-manifest.mjs
index 6fe2fe52f..53ef7636a 100644
--- a/apps/ratewise/scripts/generate-manifest.mjs
+++ b/apps/ratewise/scripts/generate-manifest.mjs
@@ -2,6 +2,19 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { APP_INFO, APP_MANIFEST } from '../src/config/app-info.ts';
+import { STYLE_DEFINITIONS } from '../src/config/themes.ts';
+
+// manifest 為靜態單一色,取預設 zen 主題的主色/背景作 SSOT,避免硬編 hex 漂移
+const zenColors = STYLE_DEFINITIONS.zen.colors;
+
+function rgbTripletToHex(rgbTriplet) {
+ return `#${rgbTriplet
+ .trim()
+ .split(/\s+/)
+ .map((value) => Number.parseInt(value, 10).toString(16).padStart(2, '0'))
+ .join('')
+ .toUpperCase()}`;
+}
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
@@ -21,8 +34,8 @@ const manifest = {
name: APP_INFO.name,
short_name: APP_MANIFEST.shortName,
description: `${APP_INFO.name}顯示臺灣銀行牌告實際買賣價(非中間價),支援 ${currencyCount} 種貨幣換算,每 5 分鐘同步,離線可用的 PWA 匯率工具。`,
- theme_color: '#8B5CF6',
- background_color: '#E8ECF4',
+ theme_color: rgbTripletToHex(zenColors.primary),
+ background_color: rgbTripletToHex(zenColors.background),
display: 'standalone',
// 絕對 HTTPS scope/start_url:避免獨立 PWA partition + Chrome HTTPS-First 在啟動時以 http 語意解析。
// id 維持相對(id 變更會被視為新 PWA 身分,破壞既有安裝更新連續性)。
diff --git a/apps/ratewise/scripts/generate-markdown-mirrors.mjs b/apps/ratewise/scripts/generate-markdown-mirrors.mjs
index 8347862b4..eddaa79f6 100644
--- a/apps/ratewise/scripts/generate-markdown-mirrors.mjs
+++ b/apps/ratewise/scripts/generate-markdown-mirrors.mjs
@@ -258,7 +258,7 @@ ${APP_INFO.shortName} 是以臺灣銀行牌告匯率為基礎的換匯工具,
## 資料方法與範圍
- 資料來源為臺灣銀行官方牌告匯率,涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣。
-- 每 5 分鐘自動同步最新報價,涵蓋現金買入、現金賣出、即期買入、即期賣出四種。
+- 約每 5 分鐘檢查更新最新報價,涵蓋現金買入、現金賣出、即期買入、即期賣出四種。
- 資料管線:GitHub Actions 每日抓取 + 雙重驗證(台銀牌告 vs open.er-api.com 中間價,誤差 ≤ 2%)+ Pull Request 自動審核後合併至 data branch。
- 匯差範例數字透過 SSG(vite-react-ssg)於 build 期嵌入靜態 HTML,搜尋引擎無需執行 JavaScript 即可讀取。
@@ -392,7 +392,7 @@ function buildOpenDataMd() {
| OpenAPI 規格 | ${BASE_URL}openapi.json |
- **免 API Key**、**免費使用**、**CORS 已啟用**。
-- 更新頻率:每 5 分鐘自動同步臺灣銀行牌告。
+- 更新頻率:約每 5 分鐘檢查更新臺灣銀行牌告。
- 涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣的現金買/賣、即期買/賣四種報價。
## 呼叫範例
diff --git a/apps/ratewise/scripts/generate-offline-html.mjs b/apps/ratewise/scripts/generate-offline-html.mjs
index fc2a9d2fa..b2e33a6a3 100644
--- a/apps/ratewise/scripts/generate-offline-html.mjs
+++ b/apps/ratewise/scripts/generate-offline-html.mjs
@@ -2,23 +2,124 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { APP_INFO } from '../src/config/app-info.ts';
+import { STYLE_DEFINITIONS } from '../src/config/themes.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
+const zenColors = STYLE_DEFINITIONS.zen.colors;
+const DARK_TEXT_COLOR = '2 6 23';
+const LIGHT_TEXT_COLOR = '255 255 255';
+
+function rgbTripletToHex(rgbTriplet) {
+ return `#${rgbTriplet
+ .trim()
+ .split(/\s+/)
+ .map((value) => Number.parseInt(value, 10).toString(16).padStart(2, '0'))
+ .join('')
+ .toUpperCase()}`;
+}
+
+function rgbTripletToRgba(rgbTriplet, alpha) {
+ const rgb = rgbTriplet
+ .trim()
+ .split(/\s+/)
+ .map((value) => Number.parseInt(value, 10))
+ .join(', ');
+
+ return `rgba(${rgb}, ${alpha})`;
+}
+
+function rgbTripletToNumbers(rgbTriplet) {
+ return rgbTriplet
+ .trim()
+ .split(/\s+/)
+ .map((value) => Number.parseInt(value, 10));
+}
+
+function relativeLuminance(rgbTriplet) {
+ const [red, green, blue] = rgbTripletToNumbers(rgbTriplet).map((channel) => {
+ const value = channel / 255;
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+ });
+
+ return red * 0.2126 + green * 0.7152 + blue * 0.0722;
+}
+
+function contrastRatio(foreground, background) {
+ const foregroundLuminosity = relativeLuminance(foreground);
+ const backgroundLuminosity = relativeLuminance(background);
+ const lighter = Math.max(foregroundLuminosity, backgroundLuminosity);
+ const darker = Math.min(foregroundLuminosity, backgroundLuminosity);
+
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+function choosePrimaryForeground(primary) {
+ return contrastRatio(LIGHT_TEXT_COLOR, primary) >= contrastRatio(DARK_TEXT_COLOR, primary)
+ ? LIGHT_TEXT_COLOR
+ : DARK_TEXT_COLOR;
+}
+
+function buildOfflineStyleBlock([styleName, definition]) {
+ const { colors } = definition;
+ const primaryForeground = choosePrimaryForeground(colors.primary);
+
+ return `html[data-style='${styleName}'] {
+ --offline-theme-color: ${rgbTripletToHex(colors.primary)};
+ --offline-background: ${rgbTripletToHex(colors.background)};
+ --offline-surface: ${rgbTripletToHex(colors.surface)};
+ --offline-border: ${rgbTripletToHex(colors.border)};
+ --offline-text: ${rgbTripletToHex(colors.text)};
+ --offline-text-muted: ${rgbTripletToHex(colors.textMuted)};
+ --offline-primary: ${rgbTripletToHex(colors.primary)};
+ --offline-primary-foreground: ${rgbTripletToHex(primaryForeground)};
+ --offline-secondary: ${rgbTripletToHex(colors.secondary)};
+ --offline-accent: ${rgbTripletToHex(colors.accent)};
+ --offline-primary-tint: ${rgbTripletToRgba(colors.primary, 0.08)};
+ --offline-primary-border-tint: ${rgbTripletToRgba(colors.primary, 0.18)};
+ --offline-primary-shadow-soft: ${rgbTripletToRgba(colors.primary, 0.18)};
+ --offline-primary-shadow-strong: ${rgbTripletToRgba(colors.primary, 0.26)};
+ --offline-primary-shadow-hover: ${rgbTripletToRgba(colors.primary, 0.34)};
+ --offline-warning-tint: ${rgbTripletToRgba(colors.warning, 0.16)};
+ --offline-warning: ${rgbTripletToHex(colors.warning)};
+ --offline-success-tint: ${rgbTripletToRgba(colors.success, 0.12)};
+ --offline-success: ${rgbTripletToHex(colors.success)};
+ }`;
+}
+
+const themeColorMap = Object.fromEntries(
+ Object.entries(STYLE_DEFINITIONS).map(([styleName, definition]) => [
+ styleName,
+ rgbTripletToHex(definition.colors.primary),
+ ]),
+);
+
+const offlineThemeTokens = {
+ __THEME_COLOR__: rgbTripletToHex(zenColors.primary),
+ __OFFLINE_STYLE_BLOCKS__: Object.entries(STYLE_DEFINITIONS)
+ .map(buildOfflineStyleBlock)
+ .join('\n\n '),
+ __OFFLINE_THEME_COLOR_MAP__: JSON.stringify(themeColorMap),
+};
function substitute(template) {
- return template
- .replace(/__BRAND_SHORT__/g, APP_INFO.shortName)
- .replace(/__BRAND_FULL__/g, APP_INFO.name);
+ return Object.entries(offlineThemeTokens).reduce(
+ (content, [token, value]) => content.replace(new RegExp(token, 'g'), value),
+ template
+ .replace(/__BRAND_SHORT__/g, APP_INFO.shortName)
+ .replace(/__BRAND_FULL__/g, APP_INFO.name),
+ );
}
+// 直接寫出模板替換結果;不經 prettier(遵守 prebuild 禁 prettier、防格式漂移)。
function generate(templatePath, outPath) {
const template = readFileSync(resolve(ROOT, templatePath), 'utf-8');
- writeFileSync(resolve(ROOT, outPath), substitute(template));
+ const content = substitute(template);
+ writeFileSync(resolve(ROOT, outPath), content);
console.log(` ✅ ${outPath}`);
}
console.log('🧾 生成靜態資源(從品牌模板)...');
-generate('scripts/templates/offline.template.html', 'public/offline.html');
-generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt');
+await generate('scripts/templates/offline.template.html', 'public/offline.html');
+await generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt');
console.log('✅ 靜態品牌資源生成完成');
diff --git a/apps/ratewise/scripts/templates/offline.template.html b/apps/ratewise/scripts/templates/offline.template.html
index b361cfe2d..693c36aa8 100644
--- a/apps/ratewise/scripts/templates/offline.template.html
+++ b/apps/ratewise/scripts/templates/offline.template.html
@@ -2,13 +2,46 @@
-
-
+
+
+
+
+
離線模式 - __BRAND_SHORT__
+
diff --git a/apps/ratewise/src/config/seo-metadata/core.ts b/apps/ratewise/src/config/seo-metadata/core.ts
index 16966ab5e..274713db5 100644
--- a/apps/ratewise/src/config/seo-metadata/core.ts
+++ b/apps/ratewise/src/config/seo-metadata/core.ts
@@ -149,7 +149,7 @@ const ASSET_VERSION = `v=${BUILD_TIME.replace(/[-T:Z.]/g, '').slice(0, 8) || 'de
export const DEFAULT_LOCALE = 'zh-TW' as const;
export const SEO_INDEXABLE_LOCALES = [DEFAULT_LOCALE] as const;
export const OG_IMAGE_ALT = `${APP_INFO.name} 匯率轉換器分享圖片` as const;
-export const DEFAULT_DESCRIPTION = `${APP_INFO.shortName} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出價(非中間價),讓你換匯前清楚知道要付多少台幣。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣即時換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,每 5 分鐘自動同步,免費無廣告無註冊。`;
+export const DEFAULT_DESCRIPTION = `${APP_INFO.shortName} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出價(非中間價),讓你換匯前清楚知道要付多少台幣。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣即時換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,約每 5 分鐘檢查更新,免費無廣告無註冊。`;
export const DEFAULT_KEYWORDS = [
APP_INFO.subtitle,
APP_INFO.shortName,
@@ -496,7 +496,7 @@ export function buildCurrencyConversionServiceJsonLd(): JsonLdBlock {
featureList: [
'台灣銀行牌告匯率(現金/即期四種報價)',
`${SUPPORTED_CURRENCY_COUNT} 種貨幣即時換算`,
- '每 5 分鐘自動同步',
+ '約每 5 分鐘檢查更新',
'PWA 離線使用',
'匯率歷史趨勢圖(7-30 天)',
],
@@ -845,7 +845,7 @@ export const HOMEPAGE_FAQ_CONTENT = [
{
question: '匯率多久更新一次?',
answer:
- '匯率數據每 5 分鐘自動同步臺灣銀行最新牌告匯率,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理以手動同步。',
+ '匯率數據約每 5 分鐘檢查更新臺灣銀行最新牌告匯率,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理以手動同步。',
},
{
question: '單幣別和多幣別模式有什麼差別?',
@@ -905,7 +905,7 @@ export const HOMEPAGE_SEO = {
answerCapsule: [
{
question: `${APP_INFO.shortName} 顯示的是台銀哪種匯率?`,
- answer: `${APP_INFO.shortName} 顯示臺灣銀行牌告的實際買入與賣出價(現金與即期各兩種),不是銀行間中間價。你拿台幣換外幣時看「銀行賣出價」,把外幣換回台幣時看「銀行買入價」,每 5 分鐘自動同步。`,
+ answer: `${APP_INFO.shortName} 顯示臺灣銀行牌告的實際買入與賣出價(現金與即期各兩種),不是銀行間中間價。你拿台幣換外幣時看「銀行賣出價」,把外幣換回台幣時看「銀行買入價」,約每 5 分鐘檢查更新。`,
},
{
question: '換匯前為什麼要看賣出價,不看中間價?',
@@ -928,9 +928,9 @@ export const HOMEPAGE_SEO = {
buildCurrencyConversionServiceJsonLd(),
],
content: {
- eyebrow: '臺灣銀行牌告匯率 · 每 5 分鐘同步 · 顯示實際買賣價',
+ eyebrow: '臺灣銀行牌告匯率 · 約每 5 分鐘檢查更新 · 顯示實際買賣價',
heading: `${APP_INFO.name} 即時匯率換算`,
- intro: `顯示臺灣銀行牌告的實際買入賣出價(不是中間價),讓你換匯前就知道真正要付多少台幣。支援台幣、美元、日圓、韓元、歐元等 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,每 5 分鐘自動同步,適合出國旅遊、海外付款與跨境報價前快速比價。`,
+ intro: `顯示臺灣銀行牌告的實際買入賣出價(不是中間價),讓你換匯前就知道真正要付多少台幣。支援台幣、美元、日圓、韓元、歐元等 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,約每 5 分鐘檢查更新,適合出國旅遊、海外付款與跨境報價前快速比價。`,
highlights: [
'顯示實際買賣價:臺灣銀行牌告匯率的現金與即期買入賣出四種報價,不是中間價——換匯金額更精準。',
`支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,提供計算機快速輸入、收藏管理、拖曳排序與換算歷史。`,
@@ -971,7 +971,7 @@ export const FAQ_PAGE_ENTRIES = [
},
{
question: '匯率數據來源是什麼?',
- answer: `${APP_INFO.shortName} 的匯率資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步一次,涵蓋現金與即期買入賣出價四種報價。`,
+ answer: `${APP_INFO.shortName} 的匯率資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新一次,涵蓋現金與即期買入賣出價四種報價。`,
},
{
question: '現金匯率和即期匯率有什麼差別?',
@@ -1008,7 +1008,7 @@ export const FAQ_PAGE_ENTRIES = [
{
question: '匯率更新頻率如何?',
answer:
- '匯率數據每 5 分鐘自動更新一次,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理(Pull to Refresh)以手動同步最新牌告資料。',
+ '匯率數據約每 5 分鐘檢查更新一次,畫面會顯示最近更新時間。您也可以在首頁下拉重新整理(Pull to Refresh)以手動同步最新牌告資料;實際新鮮度依資料來源與 CDN 快取而定。',
},
{
question: `如何安裝 ${APP_INFO.shortName} 到手機桌面?`,
@@ -1302,7 +1302,7 @@ export const OPEN_DATA_PAGE_SEO = {
'curl',
'fetch',
],
- articleBody: `${APP_INFO.shortName} 提供台灣銀行牌告匯率的開放 JSON 資料,無需 API Key,免費使用。主要端點透過 jsDelivr CDN 加速,備援端點透過 GitHub Raw。支援最新匯率(每 5 分鐘更新)與歷史匯率查詢,涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣的現金與即期四種報價。`,
+ articleBody: `${APP_INFO.shortName} 提供台灣銀行牌告匯率的開放 JSON 資料,無需 API Key,免費使用。主要端點透過 jsDelivr CDN 加速,備援端點透過 GitHub Raw。支援最新匯率(約每 5 分鐘檢查更新)與歷史匯率查詢,涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣的現金與即期四種報價。`,
speakableCssSelectors: ['h1', 'h3'],
proficiencyLevel: 'Beginner',
dependencies: ['HTTP', 'JSON', 'curl 或 fetch'],
@@ -1315,7 +1315,7 @@ export const ABOUT_PAGE_FAQ = [
{
question: '匯率數據來源是什麼?',
answer:
- '資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。',
+ '資料來源為臺灣銀行官方牌告匯率,約每 5 分鐘檢查更新,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。',
},
{
question: '免費使用嗎?需要帳號或有廣告嗎?',
@@ -1389,7 +1389,7 @@ export const ABOUT_PAGE_SEO = {
'匯差計算',
'LLM 引用',
],
- articleBody: `${APP_INFO.name}是專為台灣用戶設計的即時匯率 PWA 工具,資料來源為臺灣銀行官方牌告匯率,支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣換算與離線使用。完全免費、無廣告,資料每 5 分鐘自動同步,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。各頁面部署 schema.org JSON-LD 結構化標記,採用 SSG 靜態預渲染確保爬蟲可讀性,匯差數據每日自動雙重驗證更新。`,
+ articleBody: `${APP_INFO.name}是專為台灣用戶設計的即時匯率 PWA 工具,資料來源為臺灣銀行官方牌告匯率,支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣換算與離線使用。完全免費、無廣告,資料約每 5 分鐘檢查更新,涵蓋現金買入、現金賣出、即期買入、即期賣出四種報價。各頁面部署 schema.org JSON-LD 結構化標記,採用 SSG 靜態預渲染確保爬蟲可讀性,匯差數據每日自動雙重驗證更新。`,
speakableCssSelectors: ['h1', 'h3'],
},
),
diff --git a/apps/ratewise/src/config/seo-metadata/currency-landing.ts b/apps/ratewise/src/config/seo-metadata/currency-landing.ts
index d722fbee6..a6846999b 100644
--- a/apps/ratewise/src/config/seo-metadata/currency-landing.ts
+++ b/apps/ratewise/src/config/seo-metadata/currency-landing.ts
@@ -666,13 +666,13 @@ export function buildPairAmountSeo(
if (direction === 'twd-to-foreign') {
return {
title: `${formatted} 台幣換${currencyName}(TWD/${currencyCode})— 台銀實際賣出價 | ${APP_INFO.shortName}`,
- description: `${formatted} 台幣今日可換多少${currencyName}?${APP_INFO.shortName} 直接顯示台銀牌告現金賣出價(非中間價),資料每 5 分鐘自動更新,幫你出國換匯前精確估算可兌換的外幣金額,避免被中間價誤導。`,
+ description: `${formatted} 台幣今日可換多少${currencyName}?${APP_INFO.shortName} 直接顯示台銀牌告現金賣出價(非中間價),資料約每 5 分鐘檢查更新,幫你出國換匯前精確估算可兌換的外幣金額,避免被中間價誤導。`,
};
}
return {
title: `買 ${formatted} ${currencyName}要多少新台幣(${currencyCode}/TWD)— 台銀實際賣出價 | ${APP_INFO.shortName}`,
- description: `買 ${formatted} ${currencyName}今日要多少新台幣?${APP_INFO.shortName} 直接顯示台銀牌告現金賣出價(非中間價),資料每 5 分鐘自動更新,幫你出國換匯前精確估算所需台幣金額,避免被中間價誤導。`,
+ description: `買 ${formatted} ${currencyName}今日要多少新台幣?${APP_INFO.shortName} 直接顯示台銀牌告現金賣出價(非中間價),資料約每 5 分鐘檢查更新,幫你出國換匯前精確估算所需台幣金額,避免被中間價誤導。`,
};
}
@@ -810,8 +810,8 @@ export function getCurrencyLandingPageContent(
currencyName: displayName,
title: `即時${displayName}匯率 — 台銀實際賣出價 | ${code}/TWD`,
description: spotAvailable
- ? `即時查看台銀${displayName}現金賣出價(非中間價),換匯前確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,每 5 分鐘自動同步,支援現金與即期匯率切換,附快速金額按鈕與 7~30 天歷史趨勢圖。適合${override.region}費用估算使用。`
- : `即時查看台銀${displayName}現金賣出價(非中間價),換匯前確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,每 5 分鐘自動同步,附快速金額按鈕與 7~30 天歷史趨勢圖。適合${override.region}費用估算使用。`,
+ ? `即時查看台銀${displayName}現金賣出價(非中間價),換匯前確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,約每 5 分鐘檢查更新,支援現金與即期匯率切換,附快速金額按鈕與 7~30 天歷史趨勢圖。適合${override.region}費用估算使用。`
+ : `即時查看台銀${displayName}現金賣出價(非中間價),換匯前確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,約每 5 分鐘檢查更新,附快速金額按鈕與 7~30 天歷史趨勢圖。適合${override.region}費用估算使用。`,
pathname,
canonical: canonicalUrl,
keywords: [
@@ -872,7 +872,7 @@ export function getCurrencyLandingPageContent(
spotAvailable
? `資料來源:臺灣銀行牌告匯率,現金與即期買入賣出四種報價完整呈現。`
: `資料來源:臺灣銀行牌告匯率,頁面以該幣別可實際查得的現金買入賣出報價為準。`,
- `更新頻率:每 5 分鐘自動同步,首頁顯示最近更新時間,亦可下拉手動重新整理。`,
+ `更新頻率:約每 5 分鐘檢查更新,首頁顯示最近更新時間,亦可下拉手動重新整理。`,
`適用情境:${override.region}前快速查看 ${code}/TWD 即時換算與歷史趨勢。`,
`${override.travelTip}`,
`工具功能:計算機鍵盤快速輸入、快速金額按鈕、收藏管理與拖曳排序、換算歷史紀錄。`,
@@ -1075,8 +1075,8 @@ export function getReverseCurrencyLandingPageContent(
currencyName: displayName,
title: `台幣換${displayName}匯率 — 出國換匯實際費率 | TWD/${code}`,
description: spotAvailable
- ? `出國換${displayName}前,先用台銀實際現金賣出價(非中間價)確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,每 5 分鐘自動同步,支援現金與即期匯率切換,附快速金額按鈕與 7~30 天歷史趨勢圖,幫助你合理規劃換匯預算。`
- : `出國換${displayName}前,先用台銀實際現金賣出價(非中間價)確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,每 5 分鐘自動同步,附快速金額按鈕與 7~30 天歷史趨勢圖,幫助你合理規劃換匯預算。`,
+ ? `出國換${displayName}前,先用台銀實際現金賣出價(非中間價)確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,約每 5 分鐘檢查更新,支援現金與即期匯率切換,附快速金額按鈕與 7~30 天歷史趨勢圖,幫助你合理規劃換匯預算。`
+ : `出國換${displayName}前,先用台銀實際現金賣出價(非中間價)確認你真正要付多少台幣。資料來源臺灣銀行官方牌告,約每 5 分鐘檢查更新,附快速金額按鈕與 7~30 天歷史趨勢圖,幫助你合理規劃換匯預算。`,
pathname,
canonical: canonicalUrl,
keywords: [
@@ -1136,7 +1136,7 @@ export function getReverseCurrencyLandingPageContent(
spotAvailable
? `資料來源:臺灣銀行牌告匯率,現金與即期買入賣出四種報價完整呈現。`
: `資料來源:臺灣銀行牌告匯率,頁面以該幣別可實際查得的現金買入賣出報價為準。`,
- `更新頻率:每 5 分鐘自動同步,顯示最近更新時間,可手動重新整理。`,
+ `更新頻率:約每 5 分鐘檢查更新,顯示最近更新時間,可手動重新整理。`,
`換匯估算:輸入台幣金額即時計算可換到的${displayName},並附 7~30 天趨勢。`,
`${override.travelTip}`,
`工具功能:計算機鍵盤快速輸入、快速金額按鈕、收藏管理、換算歷史紀錄。`,
diff --git a/apps/ratewise/src/config/themes.ts b/apps/ratewise/src/config/themes.ts
index 05dfb0f13..2fd30728e 100644
--- a/apps/ratewise/src/config/themes.ts
+++ b/apps/ratewise/src/config/themes.ts
@@ -119,7 +119,7 @@ const zenStyle: StyleDefinition = {
surface: '255 255 255',
text: '15 23 42', // slate-900
textMuted: '100 116 139', // slate-500
- primary: '99 102 241', // indigo-500 - 專業紫藍
+ primary: '124 58 237', // violet-600(與 index.css 渲染值同步,修 TS↔CSS 漂移)
secondary: '71 85 105', // slate-600
accent: '59 130 246', // blue-500
border: '226 232 240', // slate-200
diff --git a/apps/ratewise/src/index.html.test.ts b/apps/ratewise/src/index.html.test.ts
index c14823b57..fa9672832 100644
--- a/apps/ratewise/src/index.html.test.ts
+++ b/apps/ratewise/src/index.html.test.ts
@@ -19,7 +19,7 @@ describe('index.html - Static Template (SEOHelmet Architecture)', () => {
});
it('should have theme-color meta tag', () => {
- expect(indexHtmlContent).toContain(' {
@@ -114,7 +114,7 @@ describe('index.html - Static Template (SEOHelmet Architecture)', () => {
describe('🟢 PWA Meta Tags(保留)', () => {
it('should retain PWA essential tags', () => {
- expect(indexHtmlContent).toContain(' {
expect(content).toContain('cached-info');
expect(content).toContain("localStorage.getItem('exchangeRates')");
});
+
+ it('should keep offline.html zen theme-color aligned with manifest theme_color SSOT', () => {
+ const offlinePath = resolve(ROOT_PATH, 'public/offline.html');
+ const manifestPath = resolve(ROOT_PATH, 'public/manifest.webmanifest');
+ const offlineContent = readFileSync(offlinePath, 'utf-8');
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { theme_color: string };
+
+ const themeColorMatch = / {
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/apps/ratewise/tests/e2e/pwa.spec.ts b/apps/ratewise/tests/e2e/pwa.spec.ts
index 8ef5036a8..0be4543b6 100644
--- a/apps/ratewise/tests/e2e/pwa.spec.ts
+++ b/apps/ratewise/tests/e2e/pwa.spec.ts
@@ -123,7 +123,7 @@ test.describe('PWA Features', () => {
test('should have theme color meta tag', async ({ rateWisePage: page }) => {
const themeColor = await page.locator('meta[name="theme-color"]').getAttribute('content');
- expect(themeColor).toBe('#8B5CF6');
+ expect(themeColor).toBe('#7C3AED');
});
test('should have viewport meta tag', async ({ rateWisePage: page }) => {
diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md
index e5bfa3b81..478cde071 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)|累計總分:+95
## 新增模板(4 行)
@@ -13,6 +13,36 @@
## 條目(新→舊)
+- 日期:2026-06-30
+- ID:reward-rw-offline-manifest-theme-ssot
+- 原因:RW-3b 離線頁 committed zen 仍為 #6366F1(未 regenerate),與 manifest/index SSOT #7C3AED 不一致,deploy gate 失敗
+- 解法:generate:deterministic 重生成 offline.html、index.html theme-color 對齊 violet-600、更新 E2E/unit 期望與 artifact smoke test
+
+- 日期: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-rw5-seo-truthfulness-update-frequency
+- 原因:SEO 文案過度承諾「每 5 分鐘自動同步/更新」(實際依資料來源與 CDN 快取而定),有 E-E-A-T 不實風險
+- 解法:seo-metadata core/currency-landing 與內容 generator 一致軟化為「約每 5 分鐘檢查更新」+ OpenData FAQ 加新鮮度免責;regenerate mirrors/llms/api 同步;「最精準」品牌定位詞屬產品決策,本支不擅改
+
+- 日期:2026-06-30
+- ID:reward-rw3b-offline-template-theme-aware
+- 原因:離線頁背景/容器/圖示硬編紫色,nitro/深色主題使用者斷網時閃錯誤的淺紫,與主題不一致
+- 解法:合併 #433 主題感知模板(per-theme CSS + theme-color + safe-area)但植回 main 的 #508 自我修復腳本與 retry-btn(非整檔取避免回退死亡迴圈);generator 移除 prettier(紅線);驗證 nitro 離線頁深色 #020617 且 #508 導回 app 正常
+
+- 日期:2026-06-30
+- ID:reward-rw3-manifest-theme-color-ssot
+- 原因:PWA manifest theme_color/background 硬編 #8B5CF6/#E8ECF4,與實際 zen 主題色不符;themes.ts zen primary(99 102 241) 與 index.css(124 58 237) 漂移
+- 解法:manifest 改讀 STYLE_DEFINITIONS.zen 經 rgbTripletToHex 轉換;同步 themes.ts zen primary 至 124 58 237 修漂移(離線模板主題化屬高風險 #508 合併,列 RW-3b)
+
- 日期: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 };