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 };