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
* 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 +
* 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]`.
@@ -33,14 +33,19 @@ export function useExchangeRates(base: string) {
if (cached) setRates(cached.rates)
if (cached && Date.now() - cached.ts < TTL_MS) return
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((d: { rates?: Record<string, number> }) => {
if (cancelled || !d?.rates) return
const entry = { rates: d.rates, ts: Date.now() }
.then((d: Array<{ quote?: string; rate?: number }>) => {
if (cancelled || !Array.isArray(d)) return
// 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)
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
setRates(d.rates)
setRates(rates)
})
.catch(() => { /* offline → keep cached/identity */ })
return () => { cancelled = true }
+5 -2
View File
@@ -20,8 +20,11 @@ beforeEach(() => {
} as any);
// Intercept CurrencyWidget's external fetch so it resolves before teardown
server.use(
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
http.get('https://api.frankfurter.dev/v2/rates', () => {
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 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(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))
}, [from])
+1 -1
View File
@@ -96,7 +96,7 @@ export function applyGlobalMiddleware(
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"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://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.
*
* 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
* 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]`.
@@ -17,10 +17,17 @@ const inflight = new Map<string, Promise<Record<string, number> | null>>();
async function fetchRates(base: string): Promise<Record<string, number> | null> {
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;
const data = (await res.json()) as { rates?: Record<string, number> };
return data.rates && typeof data.rates === 'object' ? data.rates : null;
// Frankfurter returns an array of { date, base, quote, rate } and omits the
// 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 {
return null;
}
+1 -1
View File
@@ -51,7 +51,7 @@
},
"scripts": {
"build": "tsdown",
"build:watch": "tsdown --watch",
"build:watch": "tsdown --watch --no-clean",
"test": "vitest run",
"test:watch": "vitest",
"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.
**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.