(() => { const root = document.getElementById('fx-rates-app'); if (!root) { return; } const page = JSON.parse(root.dataset.page || '{}'); const settings = page.settings || {}; const nodes = { historyHead: root.querySelector('[data-bind="history-head"]'), historyBody: root.querySelector('[data-bind="history-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 escapeHtml = (value) => String(value || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); 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} ${entry.fetch ? ` ` : ''}
${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 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 recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : []; const byDate = new Map(); recentFetches.forEach((fetch) => { const key = String(fetch?.fetched_at || '').trim(); if (!key || byDate.has(key)) { return; } byDate.set(key, { sortKey: key, label: fetch?.fetched_at_display || fetch?.fetched_at || key, fetch, rates: base !== '' ? { [base]: 1 } : {}, }); }); 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, fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null, 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(right.sortKey).localeCompare(String(left.sortKey))) .slice(0, 15); 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(() => {}); }); }); bindManualRefreshAction(); loadHistory().catch(() => { renderHistory([], preferredCurrencies); }); calculateConversion().catch(() => {}); })();