ydassdsa
This commit is contained in:
@@ -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) !== '') {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user