New desktop
Some checks failed
Deploy / deploy-staging (push) Failing after 6s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-06 03:20:50 +02:00
parent 756b4e119b
commit 888981c782
127 changed files with 31275 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
(() => {
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
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>
<div class="fx-history-date">
<span>${entry.label}</span>
${entry.fetch ? `
<button
type="button"
class="fx-info-button"
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
>i</button>
` : ''}
</div>
</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 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(() => {});
})();