// Real-time FX rates service with caching + fallback. // Default fetch: exchangerate.host (no key, mid-market). Fallback: static seed data. // Cached in-memory + sessionStorage with 10-minute TTL. const CACHE_TTL_MS = 10 * 60 * 1000; const CACHE_KEY = "atlas.rates.cache.v1"; const listeners = new Set(); const state = { status: "idle", // idle | loading | ready | error | stale base: "USD", // the base the rates are expressed against rates: null, // { USD: 1, EUR: 0.91, ... } updatedAt: 0, source: "seed", // 'live' | 'cache' | 'seed' error: null, }; function seedRates() { // Rates relative to USD, derived from CURRENCIES[].rate (USD = 1) const out = {}; window.CURRENCIES.forEach(c => { out[c.code] = c.rate; }); return out; } function loadCache() { try { const raw = sessionStorage.getItem(CACHE_KEY); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || !obj.rates || !obj.updatedAt) return null; return obj; } catch { return null; } } function saveCache(obj) { try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(obj)); } catch {} } function notify() { listeners.forEach(fn => fn(state)); } async function fetchLive() { // Try multiple providers; accept first success const providers = [ { name: "exchangerate.host", url: "https://api.exchangerate.host/latest?base=USD", map: (d) => d.rates }, { name: "open.er-api.com", url: "https://open.er-api.com/v6/latest/USD", map: (d) => d.rates }, { name: "frankfurter.app", url: "https://api.frankfurter.app/latest?from=USD", map: (d) => d.rates }, ]; for (const p of providers) { try { const res = await fetch(p.url, { cache: "no-store" }); if (!res.ok) continue; const json = await res.json(); const rates = p.map(json); if (rates && rates.EUR && rates.JPY) { return { rates: { USD: 1, ...rates }, provider: p.name }; } } catch (e) { /* try next */ } } throw new Error("All providers failed"); } async function refresh({ force = false } = {}) { // Use cache if fresh if (!force) { const cached = loadCache(); if (cached && Date.now() - cached.updatedAt < CACHE_TTL_MS) { Object.assign(state, { status: "ready", rates: cached.rates, updatedAt: cached.updatedAt, source: "cache", error: null }); notify(); return; } } state.status = "loading"; notify(); try { const { rates, provider } = await fetchLive(); Object.assign(state, { status: "ready", rates, updatedAt: Date.now(), source: "live", error: null }); saveCache({ rates, updatedAt: state.updatedAt, provider }); notify(); } catch (err) { // Fallback chain: cache (even stale) → seed const cached = loadCache(); if (cached) { Object.assign(state, { status: "stale", rates: cached.rates, updatedAt: cached.updatedAt, source: "cache", error: err.message }); } else { Object.assign(state, { status: "error", rates: seedRates(), updatedAt: Date.now(), source: "seed", error: err.message }); } notify(); } } function convert(baseCode, targetCode) { const r = state.rates || seedRates(); const b = r[baseCode], t = r[targetCode]; if (!b || !t) return 0; return (1 / b) * t; } function subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); } function init() { // Seed immediately so UI has something state.rates = seedRates(); state.updatedAt = Date.now(); state.status = "idle"; notify(); refresh(); // Auto-refresh every 10 minutes (polite polling) setInterval(() => refresh({ force: true }), CACHE_TTL_MS); } window.RatesService = { init, refresh, convert, subscribe, get state() { return state; } };