(() => { const root = document.getElementById('fx-rates-app'); if (!root) { return; } const page = JSON.parse(root.dataset.page || '{}'); const settings = page.settings || {}; const nodes = { ratesBody: root.querySelector('[data-bind="rates-body"]'), historyHead: root.querySelector('[data-bind="history-head"]'), historyBody: root.querySelector('[data-bind="history-body"]'), fetchesBody: root.querySelector('[data-bind="fetches-body"]'), convertResult: root.querySelector('[data-bind="convert-result"]'), convertFrom: root.querySelector('select[name="convert_from"]'), convertTo: root.querySelector('select[name="convert_to"]'), convertAmount: root.querySelector('input[name="convert_amount"]'), }; const apiBase = '/api/fx-rates/v1'; const preferredCurrencies = Array.isArray(page.preferred_currencies) ? page.preferred_currencies .map((item) => String(item || '').trim().toUpperCase()) .filter(Boolean) : []; const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60)); const parseDateValue = (value) => { const raw = String(value || '').trim(); if (!raw) { return null; } let normalized = raw.replace(' ', 'T'); normalized = normalized.replace(/([+-]\d{2})$/, '$1:00'); const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed; }; const latestFetchedAt = () => { const latest = page.latest && typeof page.latest === 'object' ? page.latest : null; const direct = parseDateValue(latest?.fetched_at); if (direct) { return direct; } const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : []; for (const entry of recentFetches) { const parsed = parseDateValue(entry?.fetched_at); if (parsed) { return parsed; } } return null; }; const bindManualRefreshAction = () => { const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => { try { const url = new URL(link.href, window.location.origin); return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1'; } catch (_error) { return false; } }); if (!refreshLink) { return; } refreshLink.addEventListener('click', (event) => { const url = new URL(refreshLink.href, window.location.origin); if (url.searchParams.get('force') === '1') { return; } const lastFetch = latestFetchedAt(); if (!lastFetch) { return; } const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000; if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) { return; } event.preventDefault(); const confirmed = window.confirm( `Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` + 'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?' ); if (!confirmed) { return; } url.searchParams.set('force', '1'); window.location.href = url.toString(); }); }; const renderSnapshot = (snapshot) => { const rates = snapshot && snapshot.rates ? snapshot.rates : null; const entries = rates ? Object.entries(rates) : []; const visibleCurrencies = preferredCurrencies.length ? new Set(preferredCurrencies) : null; if (!nodes.ratesBody) { return; } const filteredEntries = visibleCurrencies ? entries.filter(([code]) => visibleCurrencies.has(String(code || '').trim().toUpperCase())) : entries; if (!filteredEntries.length) { nodes.ratesBody.innerHTML = 'Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.'; return; } nodes.ratesBody.innerHTML = filteredEntries.map(([code, rate]) => { const formatted = typeof rate === 'number' ? rate.toLocaleString('de-DE', { maximumFractionDigits: 8 }) : 'n/a'; return `${code}${formatted}`; }).join(''); }; const renderFetches = (fetches) => { if (!nodes.fetchesBody) { return; } const entries = Array.isArray(fetches) ? fetches : []; if (!entries.length) { nodes.fetchesBody.innerHTML = 'Noch keine Abrufe vorhanden.'; return; } nodes.fetchesBody.innerHTML = entries.map((entry) => ` ${entry?.fetched_at_display || entry?.fetched_at || ''} ${entry?.base_currency || ''} ${entry?.provider || ''} ${entry?.trigger_source_label || entry?.trigger_source || ''} `).join(''); }; const renderHistory = (rows, currencies) => { if (!nodes.historyHead || !nodes.historyBody) { return; } const series = Array.isArray(currencies) ? currencies : []; if (!series.length) { nodes.historyHead.innerHTML = 'DatumKurse'; nodes.historyBody.innerHTML = 'Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.'; return; } nodes.historyHead.innerHTML = ` Datum ${series.map((currency) => `${currency}`).join('')} `; const entries = Array.isArray(rows) ? rows : []; if (!entries.length) { nodes.historyBody.innerHTML = `Noch keine Verlaufsdaten vorhanden.`; return; } nodes.historyBody.innerHTML = entries.map((entry) => ` ${entry.label} ${series.map((currency) => { const value = entry.rates?.[currency]; if (typeof value !== 'number' || !Number.isFinite(value)) { return '–'; } return `${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`; }).join('')} `).join(''); }; const request = async (path, options = {}) => { const response = await fetch(`${apiBase}${path}`, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}), }, ...options, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`); } return payload.data; }; const loadLatest = async () => { const base = String( settings.display_base_currency || settings.default_base_currency || 'EUR' ).trim().toUpperCase(); const query = new URLSearchParams(); query.set('base', base); if (preferredCurrencies.length) { query.set('symbols', preferredCurrencies.join(',')); } const data = await request(`/latest?${query.toString()}`); renderSnapshot(data); return data; }; const loadHistory = async () => { const base = String( settings.display_base_currency || settings.default_base_currency || 'EUR' ).trim().toUpperCase(); const selectedCurrencies = preferredCurrencies.length ? preferredCurrencies : [base]; const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base); if (!selectedCurrencies.length) { renderHistory([], []); return; } const histories = await Promise.all(historyCurrencies.map(async (currency) => { const query = new URLSearchParams({ from: base, to: currency, limit: '20' }); const rows = await request(`/history?${query.toString()}`); return { currency, rows: Array.isArray(rows) ? rows : [] }; })); const byDate = new Map(); histories.forEach(({ currency, rows }) => { rows.forEach((row) => { const key = String(row?.fetched_at || row?.rate_date || '').trim(); if (!key) { return; } if (!byDate.has(key)) { byDate.set(key, { sortKey: key, label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key, rates: base !== '' ? { [base]: 1 } : {}, }); } const entry = byDate.get(key); if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) { entry.rates[currency] = row.rate; } }); }); const mergedRows = Array.from(byDate.values()) .sort((left, right) => String(left.sortKey).localeCompare(String(right.sortKey))); renderHistory(mergedRows, selectedCurrencies); }; const calculateConversion = async () => { if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) { return; } const from = String(nodes.convertFrom.value || '').trim().toUpperCase(); const to = String(nodes.convertTo.value || '').trim().toUpperCase(); const amount = Number(nodes.convertAmount.value || '0'); if (!from || !to || !Number.isFinite(amount)) { nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.'; return; } if (from === to) { nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`; return; } try { const query = new URLSearchParams({ from, to }); const data = await request(`/rate?${query.toString()}`); const rate = Number(data?.rate || 0); if (!Number.isFinite(rate) || rate <= 0) { throw new Error('Kein Kurs verfuegbar.'); } const converted = amount * rate; nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`; } catch (error) { nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.'; } }; [nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => { node?.addEventListener('change', () => { calculateConversion().catch(() => {}); }); node?.addEventListener('input', () => { calculateConversion().catch(() => {}); }); }); renderFetches(page.recent_fetches || []); bindManualRefreshAction(); loadLatest().catch(() => {}); loadHistory().catch(() => { renderHistory([], preferredCurrencies); }); calculateConversion().catch(() => {}); })();