Files
nexus/modules/fx-rates/assets/fx-rates.js
Lars Gebhardt-Kusche 6ce45f6d23
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
ddfsdfdf
2026-05-01 02:49:22 +02:00

316 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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 = '<tr><td colspan="2">Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.</td></tr>';
return;
}
nodes.ratesBody.innerHTML = filteredEntries.map(([code, rate]) => {
const formatted = typeof rate === 'number'
? rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })
: 'n/a';
return `<tr><td>${code}</td><td>${formatted}</td></tr>`;
}).join('');
};
const renderFetches = (fetches) => {
if (!nodes.fetchesBody) {
return;
}
const entries = Array.isArray(fetches) ? fetches : [];
if (!entries.length) {
nodes.fetchesBody.innerHTML = '<tr><td colspan="4">Noch keine Abrufe vorhanden.</td></tr>';
return;
}
nodes.fetchesBody.innerHTML = entries.map((entry) => `
<tr>
<td>${entry?.fetched_at_display || entry?.fetched_at || ''}</td>
<td>${entry?.base_currency || ''}</td>
<td>${entry?.provider || ''}</td>
<td>${entry?.trigger_source_label || entry?.trigger_source || ''}</td>
</tr>
`).join('');
};
const renderHistory = (rows, currencies) => {
if (!nodes.historyHead || !nodes.historyBody) {
return;
}
const series = Array.isArray(currencies) ? currencies : [];
if (!series.length) {
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
return;
}
nodes.historyHead.innerHTML = `
<tr>
<th>Datum</th>
${series.map((currency) => `<th>${currency}</th>`).join('')}
</tr>
`;
const entries = Array.isArray(rows) ? rows : [];
if (!entries.length) {
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
return;
}
nodes.historyBody.innerHTML = entries.map((entry) => `
<tr>
<td>${entry.label}</td>
${series.map((currency) => {
const value = entry.rates?.[currency];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '<td></td>';
}
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
}).join('')}
</tr>
`).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 historyCurrencies = preferredCurrencies.filter((currency) => currency !== base);
if (!historyCurrencies.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: {},
});
}
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, historyCurrencies);
};
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.filter((currency) => currency !== String(
settings.display_base_currency || settings.default_base_currency || 'EUR'
).trim().toUpperCase()));
});
calculateConversion().catch(() => {});
})();