updates
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-22 23:36:01 +02:00
parent dc66f75eef
commit a5cf110de2
7 changed files with 452 additions and 157 deletions

View File

@@ -0,0 +1,114 @@
(function () {
'use strict';
function toPartialUrl(input, fallbackView) {
const url = new URL(input, window.location.origin);
if (!url.searchParams.has('view') && fallbackView) {
url.searchParams.set('view', fallbackView);
}
url.searchParams.set('partial', '1');
return url;
}
async function fetchHtml(url, options) {
const response = await fetch(url.toString(), {
credentials: 'same-origin',
headers: {
Accept: 'text/html',
'X-Requested-With': 'XMLHttpRequest'
},
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
function enhanceHost(host, state) {
host.querySelectorAll('a[href]').forEach((link) => {
const href = link.getAttribute('href') || '';
if (!href.startsWith('/apps/boersenchecker')) {
return;
}
link.addEventListener('click', (event) => {
event.preventDefault();
state.load(href);
});
});
host.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const method = String(form.getAttribute('method') || 'get').toUpperCase();
const action = form.getAttribute('action') || state.currentUrl.toString();
const formData = new FormData(form);
const url = toPartialUrl(action, state.defaultView);
try {
if (method === 'GET') {
const next = new URL(url.toString());
next.search = '';
next.searchParams.set('partial', '1');
for (const [key, value] of formData.entries()) {
next.searchParams.append(key, String(value));
}
await state.load(next.toString());
return;
}
const html = await fetchHtml(url, {
method,
body: formData
});
state.render(html, url);
} catch (error) {
state.renderError(error);
}
});
});
}
window.initBoersencheckerApp = function initBoersencheckerApp(host, options) {
if (!(host instanceof HTMLElement)) {
return;
}
const entryRoute = String(options?.entryRoute || '/apps/boersenchecker');
const defaultView = String(options?.defaultView || 'overview');
const state = {
defaultView,
currentUrl: toPartialUrl(entryRoute, defaultView),
async load(targetUrl) {
const url = toPartialUrl(targetUrl, defaultView);
this.currentUrl = url;
host.innerHTML = '<div class="window-app-loading"><p>Börsenchecker wird geladen...</p></div>';
try {
const html = await fetchHtml(url);
this.render(html, url);
} catch (error) {
this.renderError(error);
}
},
render(html, url) {
this.currentUrl = url;
host.innerHTML = html;
enhanceHost(host, this);
if (typeof window.initBoersencheckerCharts === 'function') {
window.initBoersencheckerCharts(host);
}
},
renderError(error) {
const message = error instanceof Error ? error.message : 'Börsenchecker konnte nicht geladen werden.';
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
}
};
state.load(entryRoute);
};
})();

View File

@@ -1,152 +1,168 @@
(function () { (function () {
const app = document.querySelector('[data-bc-home]'); 'use strict';
if (!app) return;
const chartShell = app.querySelector('[data-bc-chart]'); function initBoersencheckerCharts(root) {
const instrumentSelect = app.querySelector('[data-bc-instrument]'); const scope = root instanceof Element ? root : document;
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]'); const app = scope.matches?.('[data-bc-home]') ? scope : scope.querySelector('[data-bc-home]');
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]'); if (!app || app.dataset.bcChartBound === '1') return;
const rangeButtons = Array.from(app.querySelectorAll('[data-range]')); app.dataset.bcChartBound = '1';
const statusNode = app.querySelector('[data-bc-chart-status]');
const summaryNode = app.querySelector('[data-bc-chart-summary]'); const chartShell = app.querySelector('[data-bc-chart]');
const endpoint = app.getAttribute('data-chart-endpoint') || ''; const instrumentSelect = app.querySelector('[data-bc-instrument]');
const instrumentsScript = app.querySelector('[data-bc-instruments-json]'); const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
const instrumentMap = new Map(); const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
if (instrumentsScript?.textContent) { const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
try { const statusNode = app.querySelector('[data-bc-chart-status]');
const items = JSON.parse(instrumentsScript.textContent); const summaryNode = app.querySelector('[data-bc-chart-summary]');
if (Array.isArray(items)) { const endpoint = app.getAttribute('data-bc-chart-endpoint') || app.getAttribute('data-chart-endpoint') || '';
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item)); const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
const instrumentMap = new Map();
if (instrumentsScript?.textContent) {
try {
const items = JSON.parse(instrumentsScript.textContent);
if (Array.isArray(items)) {
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
}
} catch (_error) {}
}
let activeRange = '1m';
let currentPayload = null;
function pointsForRange(payload, range) {
if (!payload) return [];
const daily = payload.daily || [];
const weekly = payload.weekly || [];
const monthly = payload.monthly || [];
switch (range) {
case '1d': return daily.slice(-2);
case '5d': return daily.slice(-5);
case '1m': return daily.slice(-22);
case '3m': return daily.slice(-66);
case '6m': return weekly.slice(-26);
case '1y': return weekly.slice(-52);
case '5y': return monthly.slice(-60);
default: return daily.slice(-22);
} }
} catch (_error) {}
}
let activeRange = '1m';
let currentPayload = null;
function pointsForRange(payload, range) {
if (!payload) return [];
const daily = payload.daily || [];
const weekly = payload.weekly || [];
const monthly = payload.monthly || [];
switch (range) {
case '1d': return daily.slice(-2);
case '5d': return daily.slice(-5);
case '1m': return daily.slice(-22);
case '3m': return daily.slice(-66);
case '6m': return weekly.slice(-26);
case '1y': return weekly.slice(-52);
case '5y': return monthly.slice(-60);
default: return daily.slice(-22);
}
}
function renderChart(points) {
if (!chartShell) return;
chartShell.classList.remove('bc-panel-fade');
void chartShell.offsetWidth;
chartShell.classList.add('bc-panel-fade');
if (!points || points.length === 0) {
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
} }
const values = points.map((point) => Number(point.close || 0)); function renderChart(points) {
const min = Math.min(...values); if (!chartShell) return;
const max = Math.max(...values); chartShell.classList.remove('bc-panel-fade');
const width = 920; void chartShell.offsetWidth;
const height = 340; chartShell.classList.add('bc-panel-fade');
const paddingX = 24;
const paddingY = 28;
const usableWidth = width - paddingX * 2;
const usableHeight = height - paddingY * 2;
const spread = max - min || 1;
const coords = points.map((point, index) => { if (!points || points.length === 0) {
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1)); chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight; return;
return { x, y }; }
const values = points.map((point) => Number(point.close || 0));
const min = Math.min(...values);
const max = Math.max(...values);
const width = 920;
const height = 340;
const paddingX = 24;
const paddingY = 28;
const usableWidth = width - paddingX * 2;
const usableHeight = height - paddingY * 2;
const spread = max - min || 1;
const coords = points.map((point, index) => {
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
return { x, y };
});
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
const grid = [0, 1, 2, 3].map((step) => {
const y = paddingY + (usableHeight * step / 3);
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
}).join('');
chartShell.innerHTML = `
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs>
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
</linearGradient>
</defs>
<g class="bc-chart-grid">${grid}</g>
<path class="bc-chart-area" d="${area}"></path>
<path class="bc-chart-path" d="${path}"></path>
</svg>
`;
const first = values[0];
const last = values[values.length - 1];
const delta = last - first;
const percent = first !== 0 ? (delta / first) * 100 : 0;
if (summaryNode) {
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
}
}
async function loadChart() {
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
if (!instrumentId || !endpoint) {
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
if (summaryNode) summaryNode.textContent = '-';
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
try {
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
});
const payload = await response.json();
if (!payload.ok) {
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
}
currentPayload = payload;
renderChart(pointsForRange(payload, activeRange));
if (statusNode) {
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
const instrumentRef = payload.symbol || payload.isin || '';
statusNode.textContent = instrumentRef
? `Quelle: ${sourceLabel} | ${instrumentRef}`
: `Quelle: ${sourceLabel}`;
}
} catch (error) {
currentPayload = null;
if (chartShell) {
chartShell.innerHTML = `<div class="muted">${error instanceof Error ? error.message : 'Chartdaten konnten nicht geladen werden.'}</div>`;
}
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
}
}
rangeButtons.forEach((button) => {
button.addEventListener('click', () => {
activeRange = button.getAttribute('data-range') || '1m';
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
renderChart(pointsForRange(currentPayload, activeRange));
});
}); });
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' '); if (instrumentSelect) {
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`; instrumentSelect.addEventListener('change', () => {
const grid = [0, 1, 2, 3].map((step) => { const meta = instrumentMap.get(String(instrumentSelect.value));
const y = paddingY + (usableHeight * step / 3); if (meta) {
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`; if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
}).join(''); if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
}
chartShell.innerHTML = ` loadChart();
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"> });
<defs>
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
</linearGradient>
</defs>
<g class="bc-chart-grid">${grid}</g>
<path class="bc-chart-area" d="${area}"></path>
<path class="bc-chart-path" d="${path}"></path>
</svg>
`;
const first = values[0];
const last = values[values.length - 1];
const delta = last - first;
const percent = first !== 0 ? (delta / first) * 100 : 0;
if (summaryNode) {
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
} }
loadChart();
} }
async function loadChart() { window.initBoersencheckerCharts = initBoersencheckerCharts;
const instrumentId = instrumentSelect ? instrumentSelect.value : ''; initBoersencheckerCharts(document);
if (!instrumentId || !endpoint) {
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
if (summaryNode) summaryNode.textContent = '-';
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
try {
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } });
const payload = await response.json();
if (!payload.ok) {
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
}
currentPayload = payload;
renderChart(pointsForRange(payload, activeRange));
if (statusNode) {
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
const instrumentRef = payload.symbol || payload.isin || '';
statusNode.textContent = instrumentRef
? `Quelle: ${sourceLabel} | ${instrumentRef}`
: `Quelle: ${sourceLabel}`;
}
} catch (error) {
currentPayload = null;
chartShell.innerHTML = `<div class="muted">${error.message}</div>`;
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
}
}
rangeButtons.forEach((button) => {
button.addEventListener('click', () => {
activeRange = button.getAttribute('data-range') || '1m';
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
renderChart(pointsForRange(currentPayload, activeRange));
});
});
if (instrumentSelect) {
instrumentSelect.addEventListener('change', () => {
const meta = instrumentMap.get(String(instrumentSelect.value));
if (meta) {
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
}
loadChart();
});
}
loadChart();
})(); })();

View File

@@ -15,11 +15,27 @@ return [
'icon' => 'BC', 'icon' => 'BC',
'entry_route' => '/apps/boersenchecker', 'entry_route' => '/apps/boersenchecker',
'window_mode' => 'window', 'window_mode' => 'window',
'content_mode' => 'iframe', 'content_mode' => 'native-module',
'default_width' => 1220, 'default_width' => 1220,
'default_height' => 860, 'default_height' => 860,
'supports_widget' => false, 'supports_widget' => false,
'supports_tray' => false, 'supports_tray' => false,
'module_name' => 'finance', 'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.'), 'summary' => (string) ($manifest['description'] ?? 'Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.'),
'native_module' => [
'initializer' => 'initBoersencheckerApp',
'options' => [
'entryRoute' => '/apps/boersenchecker',
'defaultView' => 'overview',
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.css') ? filemtime(__DIR__ . '/assets/boersenchecker.css') : '0'))],
],
'scripts' => [
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.js') ? filemtime(__DIR__ . '/assets/boersenchecker.js') : '0'))],
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker-native.js') ? filemtime(__DIR__ . '/assets/boersenchecker-native.js') : '0'))],
],
],
],
]; ];

View File

@@ -11,6 +11,7 @@ session_start();
$projectRoot = dirname(__DIR__, 3); $projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot); ModuleHttp::requireDesktopAccess($projectRoot);
$isPartial = !empty($_GET['partial']);
$view = trim((string) ($_GET['view'] ?? 'overview')); $view = trim((string) ($_GET['view'] ?? 'overview'));
$allowedViews = ['overview', 'depotverwaltung', 'aktienverwaltung', 'setup']; $allowedViews = ['overview', 'depotverwaltung', 'aktienverwaltung', 'setup'];
@@ -149,6 +150,36 @@ $renderSetup = static function (): void {
<?= module_shell_footer() ?> <?= module_shell_footer() ?>
<?php <?php
}; };
$renderContent = static function () use ($view, $renderSetup): void {
?>
<div class="boersenchecker-module-shell window-app-shell">
<?php
if ($view === 'setup') {
$renderSetup();
} elseif ($view === 'depotverwaltung') {
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->handle());
} elseif ($view === 'aktienverwaltung') {
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
module_tpl('boersenchecker', 'instruments', $page->handle());
} else {
$page = new \Modules\Boersenchecker\Support\HomePage();
module_tpl('boersenchecker', 'home', $page->handle());
}
?>
</div>
<?php
};
ob_start();
$renderContent();
$pageContent = (string) ob_get_clean();
if ($isPartial) {
echo $pageContent;
return;
}
?><!DOCTYPE html> ?><!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -250,22 +281,7 @@ $renderSetup = static function (): void {
<link rel="stylesheet" href="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.css&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.css'), ENT_QUOTES) ?>"> <link rel="stylesheet" href="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.css&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.css'), ENT_QUOTES) ?>">
</head> </head>
<body> <body>
<div class="boersenchecker-module-shell"> <?= $pageContent ?>
<?php
if ($view === 'setup') {
$renderSetup();
} elseif ($view === 'depotverwaltung') {
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->handle());
} elseif ($view === 'aktienverwaltung') {
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
module_tpl('boersenchecker', 'instruments', $page->handle());
} else {
$page = new \Modules\Boersenchecker\Support\HomePage();
module_tpl('boersenchecker', 'home', $page->handle());
}
?>
</div>
<script src="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.js&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.js'), ENT_QUOTES) ?>"></script> <script src="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.js&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.js'), ENT_QUOTES) ?>"></script>
</body> </body>

View File

@@ -178,6 +178,11 @@ $renderStartIcon = static function (array $profile): string {
<button class="tray-menu-entry" type="button" data-account-action="login">Login</button> <button class="tray-menu-entry" type="button" data-account-action="login">Login</button>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="tray-menu" id="tray-fx-menu" hidden>
<p class="tray-menu-copy" id="tray-fx-status">Kursstatus wird geladen ...</p>
<button class="tray-menu-entry" type="button" data-fx-action="refresh">Refresh</button>
<button class="tray-menu-entry" type="button" data-fx-action="force-refresh">Force-Refresh</button>
</div>
</footer> </footer>
</main> </main>
</div> </div>

View File

@@ -1405,6 +1405,14 @@ h1 {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
} }
.tray-menu-copy {
margin: 0;
padding: 6px 4px 2px;
color: #cbd5e1;
font-size: 12px;
line-height: 1.45;
}
.window-content p:last-child { .window-content p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@@ -17,6 +17,8 @@ if (payloadNode) {
const startMenuUserAvatar = document.getElementById('start-menu-user-avatar'); const startMenuUserAvatar = document.getElementById('start-menu-user-avatar');
const startMenuSessionAction = document.getElementById('start-menu-session-action'); const startMenuSessionAction = document.getElementById('start-menu-session-action');
const accountMenu = document.getElementById('tray-account-menu'); const accountMenu = document.getElementById('tray-account-menu');
const fxTrayMenu = document.getElementById('tray-fx-menu');
const fxTrayStatusNode = document.getElementById('tray-fx-status');
const clockNode = document.getElementById('clock'); const clockNode = document.getElementById('clock');
const clockTopNode = document.getElementById('clock-top'); const clockTopNode = document.getElementById('clock-top');
const debugToggleNode = document.getElementById('debug-toggle'); const debugToggleNode = document.getElementById('debug-toggle');
@@ -389,6 +391,88 @@ if (payloadNode) {
accountButton.setAttribute('aria-expanded', String(isOpen)); accountButton.setAttribute('aria-expanded', String(isOpen));
}; };
const setFxTrayMenuOpen = (isOpen) => {
const fxButton = document.querySelector('.tray-pill[data-tray-app-id="fx-rates"]');
if (!(fxTrayMenu instanceof HTMLElement) || !(fxButton instanceof HTMLElement)) {
return;
}
fxTrayMenu.hidden = !isOpen;
fxButton.setAttribute('aria-expanded', String(isOpen));
};
const loadFxTrayStatus = async () => {
const fxButton = document.querySelector('.tray-pill[data-tray-app-id="fx-rates"]');
if (!(fxTrayStatusNode instanceof HTMLElement) || !(fxButton instanceof HTMLElement)) {
return null;
}
fxTrayStatusNode.textContent = 'Kursstatus wird geladen ...';
fxButton.setAttribute('title', 'Kursstatus wird geladen ...');
try {
const response = await fetch('/api/fx-rates/index.php?path=v1/status', {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
const rows = Array.isArray(payload?.data) ? payload.data : [];
const latest = rows.slice().sort((left, right) => {
const leftTs = new Date(left?.fetched_at || 0).getTime();
const rightTs = new Date(right?.fetched_at || 0).getTime();
return rightTs - leftTs;
})[0] || null;
const statusText = latest
? `Letzter Abruf: ${latest.fetched_at_display || latest.fetched_at} · ${latest.base_currency} · ${latest.provider}`
: 'Noch keine gespeicherten Kurse vorhanden.';
fxTrayStatusNode.textContent = statusText;
fxButton.setAttribute('title', statusText);
return latest;
} catch (_error) {
const errorText = 'Kursstatus konnte nicht geladen werden.';
fxTrayStatusNode.textContent = errorText;
fxButton.setAttribute('title', errorText);
return null;
}
};
const runFxTrayRefresh = async (force) => {
if (!(fxTrayStatusNode instanceof HTMLElement)) {
return;
}
fxTrayStatusNode.textContent = 'Aktualisierung laeuft ...';
try {
const response = await fetch('/api/fx-rates/index.php?path=v1/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
force: force === true,
trigger_source: 'tray',
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
}
const result = payload?.data || {};
fxTrayStatusNode.textContent = result.reused
? 'Kein neuer API-Abruf. Snapshot ist noch jung genug.'
: `Kurse aktualisiert. ${result.updated_count || 0} Werte gespeichert.`;
await loadFxTrayStatus();
} catch (error) {
fxTrayStatusNode.textContent = error instanceof Error ? error.message : 'Aktualisierung fehlgeschlagen.';
}
};
const findAppById = (appId) => payload.apps.find((app) => app.app_id === appId) || null; const findAppById = (appId) => payload.apps.find((app) => app.app_id === appId) || null;
const dispatchUserSettingsUpdate = (detail) => { const dispatchUserSettingsUpdate = (detail) => {
@@ -1711,12 +1795,17 @@ if (payloadNode) {
if (!(event.target instanceof Element) || !event.target.closest('.tray-pill[data-tray-id="account"], #tray-account-menu')) { if (!(event.target instanceof Element) || !event.target.closest('.tray-pill[data-tray-id="account"], #tray-account-menu')) {
setAccountMenuOpen(false); setAccountMenuOpen(false);
} }
if (!(event.target instanceof Element) || !event.target.closest('.tray-pill[data-tray-app-id="fx-rates"], #tray-fx-menu')) {
setFxTrayMenuOpen(false);
}
}); });
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
closeDesktopContextMenu(); closeDesktopContextMenu();
setAccountMenuOpen(false); setAccountMenuOpen(false);
setFxTrayMenuOpen(false);
} }
}); });
@@ -1729,6 +1818,7 @@ if (payloadNode) {
const isClosed = startMenu.hasAttribute('hidden'); const isClosed = startMenu.hasAttribute('hidden');
setStartMenuOpen(isClosed); setStartMenuOpen(isClosed);
setAccountMenuOpen(false); setAccountMenuOpen(false);
setFxTrayMenuOpen(false);
}); });
document.querySelector('.tray-pill[data-tray-id="account"]')?.addEventListener('click', () => { document.querySelector('.tray-pill[data-tray-id="account"]')?.addEventListener('click', () => {
@@ -1738,20 +1828,50 @@ if (payloadNode) {
setStartMenuOpen(false); setStartMenuOpen(false);
setAccountMenuOpen(accountMenu.hidden); setAccountMenuOpen(accountMenu.hidden);
setFxTrayMenuOpen(false);
}); });
document.querySelectorAll('.tray-pill[data-tray-app-id]').forEach((button) => { document.querySelectorAll('.tray-pill[data-tray-app-id]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const appId = button.getAttribute('data-tray-app-id') || ''; const appId = button.getAttribute('data-tray-app-id') || '';
if (appId === 'fx-rates') {
setStartMenuOpen(false);
setAccountMenuOpen(false);
setFxTrayMenuOpen(fxTrayMenu instanceof HTMLElement ? fxTrayMenu.hidden : false);
void loadFxTrayStatus();
return;
}
const app = findAppById(appId); const app = findAppById(appId);
if (app) { if (app) {
openApp(app); openApp(app);
setStartMenuOpen(false); setStartMenuOpen(false);
setAccountMenuOpen(false); setAccountMenuOpen(false);
setFxTrayMenuOpen(false);
} }
}); });
}); });
fxTrayMenu?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('[data-fx-action]') : null;
if (!target) {
return;
}
const action = target.getAttribute('data-fx-action');
if (action === 'refresh') {
void runFxTrayRefresh(false);
return;
}
if (action === 'force-refresh') {
void runFxTrayRefresh(true);
}
});
void loadFxTrayStatus();
debugToggleNode?.addEventListener('click', () => { debugToggleNode?.addEventListener('click', () => {
toggleDebugWindow(); toggleDebugWindow();
}); });