diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php index 84639e1..1f87a10 100644 --- a/modules/fx-rates/src/Domain/FxRatesService.php +++ b/modules/fx-rates/src/Domain/FxRatesService.php @@ -197,7 +197,8 @@ final class FxRatesService public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array { $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); - $payload = $this->fetchLatestPayload($requestedBase, null); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase); + $payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies); $base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase)); if ($base === '') { $base = $requestedBase !== '' ? $requestedBase : 'USD'; @@ -227,11 +228,16 @@ final class FxRatesService public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array { $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base); $latest = $this->repository->getLatestFetch($base); $maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600); - $fetchedAt = is_array($latest) ? strtotime((string) ($latest['fetched_at'] ?? '')) : false; + $fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null; - if ($fetchedAt !== false && (time() - $fetchedAt) <= $maxAgeSeconds) { + if ( + $fetchedAt !== null + && (time() - $fetchedAt) <= $maxAgeSeconds + && $this->latestFetchCoversCurrencies($latest, $requestedCurrencies) + ) { return [ 'base' => $base, 'rate_date' => $latest['rate_date'] ?? null, @@ -243,7 +249,7 @@ final class FxRatesService ]; } - $result = $this->refreshLatestRates($currencies, $base, $triggerSource); + $result = $this->refreshLatestRates($requestedCurrencies, $base, $triggerSource); $result['reused'] = false; return $result; } @@ -855,6 +861,38 @@ final class FxRatesService return strtoupper(trim((string) $currency)); } + private function normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array + { + if (!is_array($currencies)) { + return null; + } + + $base = $this->normalizeCurrency($baseCurrency); + $normalized = array_values(array_unique(array_filter(array_map( + fn (mixed $currency): string => $this->normalizeCurrency((string) $currency), + $currencies + ), fn (string $currency): bool => $currency !== '' && $currency !== $base))); + + return $normalized === [] ? null : $normalized; + } + + private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool + { + if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) { + return true; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach ($currencies as $currency) { + if (!array_key_exists($currency, $rates)) { + return false; + } + } + + return true; + } + private function normalizeTimestamp(?string $value): ?string { $value = trim((string) $value); @@ -863,13 +901,31 @@ final class FxRatesService } try { - $date = new DateTimeImmutable($value); + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) { + $date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC')); + } else { + $date = new DateTimeImmutable($value); + } return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); } catch (\Throwable) { return null; } } + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = $this->normalizeTimestamp($value); + if ($normalized === null) { + return null; + } + + try { + return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + private function normalizeRateDate(mixed $value): string { if (is_string($value) && trim($value) !== '') { diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index c3d4571..aef8e73 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -45,6 +45,13 @@ sequence: 0, }; window.__nexusDebugBus = debugBus; + const browserTimezone = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'; + } catch (_error) { + return 'Europe/Berlin'; + } + })(); function emitDebug(entry) { debugBus.sequence += 1; @@ -124,19 +131,96 @@ }).format(Number(value)); } + function parseStoredUtcDate(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + let normalized = raw.replace(' ', 'T'); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + normalized = `${normalized}T00:00:00Z`; + } else if (!/[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized)) { + normalized = `${normalized}Z`; + } + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + function formatDateByParts(value, includeTime) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return value ? String(value).replace('T', ' ').slice(0, includeTime ? 16 : 10) : 'n/a'; + } + const parts = new Intl.DateTimeFormat('de-DE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {}), + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return includeTime + ? `${map.day}.${map.month}.${map.year} ${map.hour}:${map.minute}` + : `${map.day}.${map.month}.${map.year}`; + } + + function toDateTimeLocalValue(value) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return ''; + } + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } + + function nowDateTimeLocalValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } + + function todayLocalDateValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; + } + function fmtDate(value) { if (!value) { return 'n/a'; } - return value.replace('T', ' ').slice(0, 16); + return formatDateByParts(value, true); } function fmtDateTime(value) { if (!value) { return 'n/a'; } - const normalized = String(value).replace('T', ' '); - return normalized.slice(0, 16); + return formatDateByParts(value, true); } async function request(path, options) { @@ -579,7 +663,7 @@ const [importHelpOpen, setImportHelpOpen] = useState(false); const [ocrForm, setOcrForm] = useState({ image: null, - date_context: new Date().toISOString().slice(0, 10), + date_context: todayLocalDateValue(), ocr_hint_text: '', }); const [ocrPreview, setOcrPreview] = useState(null); @@ -1093,18 +1177,18 @@ const basePrice = baseComparison ? Number(baseComparison.effective_price_per_coin ?? baseComparison.price_per_coin) : null; return { - mining: measurements.map((row) => ({ x: row.measured_at.slice(5, 16), y: row.coins_total })), + mining: measurements.map((row) => ({ x: fmtDate(row.measured_at), y: row.coins_total })), performance: measurements.filter((row) => row.doge_per_day_interval !== null) - .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.doge_per_day_interval })), + .map((row) => ({ x: fmtDate(row.measured_at), y: row.doge_per_day_interval })), pricing: measurements.filter((row) => row.price_per_coin !== null) - .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.price_per_coin })), + .map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })), miningVsPrice: baseMining && basePrice ? [ { key: 'mining-rate', label: 'Mining/h je MH/s Index', color: '#2dd4bf', data: comparisonRows.map((row) => ({ - x: row.measured_at.slice(5, 16), + x: fmtDate(row.measured_at), y: (Number(row.doge_per_hour_per_mh_interval) / baseMining) * 100, })), }, @@ -1113,7 +1197,7 @@ label: 'DOGE-Kurs Index', color: '#f59e0b', data: comparisonRows.map((row) => ({ - x: row.measured_at.slice(5, 16), + x: fmtDate(row.measured_at), y: (Number(row.effective_price_per_coin ?? row.price_per_coin) / basePrice) * 100, })), }, @@ -1501,7 +1585,7 @@ await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers/${offerId}/purchase`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(overrides || { purchased_at: new Date().toISOString().slice(0, 19).replace('T', ' ') }), + body: JSON.stringify(overrides || { purchased_at: nowDateTimeLocalValue() }), }); setMessage('Miner als gemietet erfasst.'); setPurchaseMinerModalOpen(false); @@ -1521,7 +1605,7 @@ } await purchaseMinerOffer(purchaseMinerForm.offer_id, { - purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 19).replace('T', ' '), + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), total_cost_amount: purchaseMinerForm.total_cost_amount || null, currency: purchaseMinerForm.currency || null, reference_price_amount: purchaseMinerForm.reference_price_amount || null, @@ -2210,7 +2294,7 @@ onClick: () => { setPurchaseMinerForm({ offer_id: String(offer.id), - purchased_at: new Date().toISOString().slice(0, 16), + purchased_at: nowDateTimeLocalValue(), total_cost_amount: offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', currency: offer.effective_price_currency || offer.base_price_currency || 'USD', reference_price_amount: offer.base_price_amount !== null && offer.base_price_amount !== undefined ? String(offer.base_price_amount) : '', @@ -2404,7 +2488,7 @@ const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); setPurchaseMinerForm({ offer_id: value, - purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 16), + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), total_cost_amount: offer && offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', currency: offer?.effective_price_currency || offer?.base_price_currency || 'USD', reference_price_amount: offer && offer.reference_price_amount !== null && offer.reference_price_amount !== undefined ? String(offer.reference_price_amount) : '', @@ -2552,7 +2636,7 @@ className: 'mc-form', onSubmit: submitSettings, }, [ - inputField('Baseline Zeitpunkt', 'datetime-local', settingsForm.baseline_measured_at ? settingsForm.baseline_measured_at.replace(' ', 'T').slice(0, 16) : '', (value) => setSettingsForm({ ...settingsForm, baseline_measured_at: value })), + inputField('Baseline Zeitpunkt', 'datetime-local', toDateTimeLocalValue(settingsForm.baseline_measured_at), (value) => setSettingsForm({ ...settingsForm, baseline_measured_at: value })), inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'), selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })), selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })), diff --git a/modules/mining-checker/src/Domain/FxService.php b/modules/mining-checker/src/Domain/FxService.php index a270697..089cc53 100644 --- a/modules/mining-checker/src/Domain/FxService.php +++ b/modules/mining-checker/src/Domain/FxService.php @@ -258,8 +258,8 @@ final class FxService } $latestFetch = $this->repository->getLatestFxFetch($normalizedBase); - $latestFetchedAt = is_array($latestFetch) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false; - $ageSeconds = $latestFetchedAt ? (time() - $latestFetchedAt) : null; + $latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null; + $ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null; $maxAgeSeconds = (int) round($maxAgeHours * 3600); if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) { @@ -813,6 +813,25 @@ final class FxService return date('Y-m-d'); } + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) { + $date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC')); + } else { + $date = new \DateTimeImmutable($normalized); + } + return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + private function catalogSortOrder(string $code, int $fallback): int { return match (strtoupper($code)) {