chore: move to Frankfurter API for exchange rate (#1214)

This commit is contained in:
jubnl
2026-06-16 20:59:08 +02:00
committed by GitHub
parent ac211d93c8
commit 79057ea603
7 changed files with 38 additions and 17 deletions
+11 -6
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
/** /**
* Live FX rates for the Costs panel, used to convert every amount into the user's * Live FX rates for the Costs panel, used to convert every amount into the user's
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted * display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
* for the dashboard widget) for the given base and caches per base in memory + * for the dashboard widget) for the given base and caches per base in memory +
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in * localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
* currency C converts to base as `amount / rates[C]`. * currency C converts to base as `amount / rates[C]`.
@@ -33,14 +33,19 @@ export function useExchangeRates(base: string) {
if (cached) setRates(cached.rates) if (cached) setRates(cached.rates)
if (cached && Date.now() - cached.ts < TTL_MS) return if (cached && Date.now() - cached.ts < TTL_MS) return
let cancelled = false let cancelled = false
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`) fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
.then(r => r.json()) .then(r => r.json())
.then((d: { rates?: Record<string, number> }) => { .then((d: Array<{ quote?: string; rate?: number }>) => {
if (cancelled || !d?.rates) return if (cancelled || !Array.isArray(d)) return
const entry = { rates: d.rates, ts: Date.now() } // Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
const rates: Record<string, number> = { [upper]: 1 }
for (const r of d) {
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
}
const entry = { rates, ts: Date.now() }
mem.set(upper, entry) mem.set(upper, entry)
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ } try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
setRates(d.rates) setRates(rates)
}) })
.catch(() => { /* offline → keep cached/identity */ }) .catch(() => { /* offline → keep cached/identity */ })
return () => { cancelled = true } return () => { cancelled = true }
+5 -2
View File
@@ -20,8 +20,11 @@ beforeEach(() => {
} as any); } as any);
// Intercept CurrencyWidget's external fetch so it resolves before teardown // Intercept CurrencyWidget's external fetch so it resolves before teardown
server.use( server.use(
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => { http.get('https://api.frankfurter.dev/v2/rates', () => {
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } }); return HttpResponse.json([
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
]);
}), }),
); );
}); });
+8 -2
View File
@@ -461,9 +461,15 @@ function CurrencyTool(): React.ReactElement {
const [rates, setRates] = useState<Record<string, number> | null>(null) const [rates, setRates] = useState<Record<string, number> | null>(null)
const fetchRate = React.useCallback(() => { const fetchRate = React.useCallback(() => {
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`) fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
.then(r => r.json()) .then(r => r.json())
.then(d => setRates(d.rates ?? null)) .then((d: Array<{ quote: string; rate: number }>) => {
if (!Array.isArray(d)) { setRates(null); return }
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
const map: Record<string, number> = { [from]: 1 }
for (const r of d) map[r.quote] = r.rate
setRates(map)
})
.catch(() => setRates(null)) .catch(() => setRates(null))
}, [from]) }, [from])
+1 -1
View File
@@ -96,7 +96,7 @@ export function applyGlobalMiddleware(
"https://en.wikipedia.org", "https://commons.wikimedia.org", "https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
], ],
+11 -4
View File
@@ -1,7 +1,7 @@
/** /**
* Live exchange rates for the Costs/Budget money conversion. * Live exchange rates for the Costs/Budget money conversion.
* *
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the * Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the
* dashboard widget) and caches per base currency in-memory for a few hours so a * dashboard widget) and caches per base currency in-memory for a few hours so a
* settlement request never hammers the upstream. Rates are "units of X per 1 * settlement request never hammers the upstream. Rates are "units of X per 1
* base", so an amount in currency C converts to base as `amount / rates[C]`. * base", so an amount in currency C converts to base as `amount / rates[C]`.
@@ -17,10 +17,17 @@ const inflight = new Map<string, Promise<Record<string, number> | null>>();
async function fetchRates(base: string): Promise<Record<string, number> | null> { async function fetchRates(base: string): Promise<Record<string, number> | null> {
try { try {
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`); const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`);
if (!res.ok) return null; if (!res.ok) return null;
const data = (await res.json()) as { rates?: Record<string, number> }; // Frankfurter returns an array of { date, base, quote, rate } and omits the
return data.rates && typeof data.rates === 'object' ? data.rates : null; // base's own self-rate, so seed the map with `base = 1` then index by quote.
const data = (await res.json()) as Array<{ quote?: string; rate?: number }>;
if (!Array.isArray(data)) return null;
const rates: Record<string, number> = { [base.toUpperCase()]: 1 };
for (const r of data) {
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate;
}
return Object.keys(rates).length > 1 ? rates : null;
} catch { } catch {
return null; return null;
} }
+1 -1
View File
@@ -51,7 +51,7 @@
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"build:watch": "tsdown --watch", "build:watch": "tsdown --watch --no-clean",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
+1 -1
View File
@@ -31,7 +31,7 @@ The currency converter lets you quickly convert an amount between two currencies
You can also click the swap arrow to reverse source and target. You can also click the swap arrow to reverse source and target.
**Exchange rates** are fetched from [exchangerate-api.com](https://www.exchangerate-api.com) using the `https://api.exchangerate-api.com/v4/latest/{from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon. **Exchange rates** are fetched from [Frankfurter](https://frankfurter.dev) using the `https://api.frankfurter.dev/v2/rates?base={from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon.
**Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones. **Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones.