New desktop
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-currencies');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
|
||||
const selected = new Set(
|
||||
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
|
||||
.map((code) => String(code || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
|
||||
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
|
||||
|
||||
const nodes = {
|
||||
tokenList: root.querySelector('[data-fx-token-list]'),
|
||||
searchInput: root.querySelector('[data-fx-search-input]'),
|
||||
suggestions: root.querySelector('[data-fx-suggestions]'),
|
||||
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
|
||||
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
|
||||
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const currencyByCode = new Map(
|
||||
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
|
||||
);
|
||||
|
||||
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const ensureDisplayBase = () => {
|
||||
const available = sortedSelectedCodes();
|
||||
if (available.length === 0) {
|
||||
displayBase = '';
|
||||
return;
|
||||
}
|
||||
if (!displayBase || !selected.has(displayBase)) {
|
||||
displayBase = available[0];
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
if (!nodes.hiddenPreferred) {
|
||||
return;
|
||||
}
|
||||
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
|
||||
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
|
||||
.join('');
|
||||
if (nodes.displayBaseHidden) {
|
||||
nodes.displayBaseHidden.value = displayBase;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDisplayBase = () => {
|
||||
if (!nodes.displayBaseSelect) {
|
||||
return;
|
||||
}
|
||||
ensureDisplayBase();
|
||||
const available = sortedSelectedCodes();
|
||||
nodes.displayBaseSelect.innerHTML = available.length
|
||||
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
|
||||
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
|
||||
nodes.displayBaseSelect.disabled = available.length === 0;
|
||||
};
|
||||
|
||||
const removeCode = (code) => {
|
||||
selected.delete(code);
|
||||
renderAll();
|
||||
};
|
||||
|
||||
const renderTokens = () => {
|
||||
if (!nodes.tokenList) {
|
||||
return;
|
||||
}
|
||||
const selectedCodes = sortedSelectedCodes();
|
||||
if (selectedCodes.length === 0) {
|
||||
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
|
||||
return;
|
||||
}
|
||||
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
|
||||
const currency = currencyByCode.get(code) || { code, name: code };
|
||||
return `
|
||||
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
|
||||
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
|
||||
<span class="fx-token-close">x</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderSuggestions = () => {
|
||||
if (!nodes.suggestions || !nodes.searchInput) {
|
||||
return;
|
||||
}
|
||||
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
|
||||
if (!needle) {
|
||||
nodes.suggestions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const matches = currencies
|
||||
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
|
||||
.filter((currency) => {
|
||||
const code = String(currency.code || '').toLowerCase();
|
||||
const name = String(currency.name || '').toLowerCase();
|
||||
return code.includes(needle) || name.includes(needle);
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
nodes.suggestions.innerHTML = matches.map((currency) => `
|
||||
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
|
||||
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
|
||||
<span>${escapeHtml(String(currency.name || ''))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
|
||||
if (code) {
|
||||
selected.add(code);
|
||||
if (nodes.searchInput) {
|
||||
nodes.searchInput.value = '';
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
ensureDisplayBase();
|
||||
renderTokens();
|
||||
renderDisplayBase();
|
||||
renderHiddenInputs();
|
||||
renderSuggestions();
|
||||
};
|
||||
|
||||
nodes.searchInput?.addEventListener('input', renderSuggestions);
|
||||
nodes.displayBaseSelect?.addEventListener('change', () => {
|
||||
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
|
||||
renderHiddenInputs();
|
||||
const url = new URL(window.location.href);
|
||||
if (displayBase) {
|
||||
url.searchParams.set('base', displayBase);
|
||||
} else if (savedDisplayBase) {
|
||||
url.searchParams.set('base', savedDisplayBase);
|
||||
} else {
|
||||
url.searchParams.delete('base');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
287
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css
Normal file
287
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css
Normal file
@@ -0,0 +1,287 @@
|
||||
#fx-rates-app,
|
||||
#fx-rates-currencies {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.fx-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-section-head h2,
|
||||
.section-box h2,
|
||||
.card-box h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.fx-section-head p,
|
||||
.section-box p,
|
||||
.card-box p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-form-grid label,
|
||||
.fx-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fx-form-grid span,
|
||||
.fx-block span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-form-grid input,
|
||||
.fx-form-grid select,
|
||||
.fx-block input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message.is-error {
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.fx-message.is-success {
|
||||
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
|
||||
}
|
||||
|
||||
.fx-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fx-table th,
|
||||
.fx-table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.4rem;
|
||||
}
|
||||
|
||||
.fx-api-note {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fx-convert-result {
|
||||
margin-top: 1rem;
|
||||
min-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-card-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fx-action-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-action-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-save-row {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-save-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-mini-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-mini-label,
|
||||
.fx-field-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-card-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fx-currency-selection-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-token-list--inline {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 0 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.fx-input,
|
||||
.fx-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-token,
|
||||
.fx-suggestion {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-token {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-token:hover,
|
||||
.fx-suggestion:hover {
|
||||
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
|
||||
}
|
||||
|
||||
.fx-token-close {
|
||||
color: var(--brand-accent-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-suggestion-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.fx-suggestion {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-suggestion strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-text {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-history-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.fx-info-button {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.fx-currency-selection-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
286
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js
Normal file
286
temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js
Normal 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('<', '<')
|
||||
.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 = '<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(() => {});
|
||||
})();
|
||||
Reference in New Issue
Block a user