From 3795fc1de502e93e6cd540a413dded3a1a430ffe Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 4 May 2026 22:38:51 +0200 Subject: [PATCH] sddsf --- modules/mining-checker/assets/js/app.js | 171 +++++++++++++----- .../src/Domain/AnalyticsService.php | 60 +++++- 2 files changed, 182 insertions(+), 49 deletions(-) diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index e71d284..993e3e7 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -438,8 +438,29 @@ } function SimpleChart(props) { + const series = Array.isArray(props.series) + ? props.series + .map((item, index) => ({ + key: item && item.key ? String(item.key) : `series-${index}`, + label: item && item.label ? String(item.label) : `Serie ${index + 1}`, + color: item && item.color ? String(item.color) : ['#60a5fa', '#2dd4bf', '#f59e0b', '#f472b6'][index % 4], + data: Array.isArray(item && item.data) + ? item.data.filter((point) => point && point.y !== null && point.y !== undefined) + : [], + })) + .filter((item) => item.data.length > 0) + : []; const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : []; - if (!points.length) { + const isMultiSeries = series.length > 0; + const activeSeries = isMultiSeries ? series : [{ + key: 'default', + label: props.yLabel || 'Wert', + color: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + data: points, + }]; + const allPoints = activeSeries.flatMap((item) => item.data); + + if (!allPoints.length) { return h('div', { className: 'mc-empty' }, 'Keine Daten fuer diese Ansicht.'); } @@ -463,21 +484,28 @@ const width = 640; const height = 220; const padding = 24; - const values = points.map((point) => Number(point.y)); + const values = allPoints.map((point) => Number(point.y)); const minY = Math.min.apply(null, values); const maxY = Math.max.apply(null, values); const range = maxY - minY || 1; - const stepX = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0; - const coords = points.map((point, index) => { - const x = padding + stepX * index; - const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); - return [x, y]; + const seriesCoords = activeSeries.map((item) => { + const stepX = item.data.length > 1 ? (width - padding * 2) / (item.data.length - 1) : 0; + const coords = item.data.map((point, index) => { + const x = padding + stepX * index; + const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); + return [x, y]; + }); + + return { + ...item, + coords, + line: coords.map((coord) => coord.join(',')).join(' '), + area: coords.length + ? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]]) + .map((coord) => coord.join(',')).join(' ') + : '', + }; }); - const line = coords.map((coord) => coord.join(',')).join(' '); - const area = coords.length - ? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]]) - .map((coord) => coord.join(',')).join(' ') - : ''; return h('div', { className: 'mc-chart space-y-3' }, [ h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [ @@ -490,10 +518,9 @@ h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }), h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }), ]), - props.type === 'area' ? h('polygon', { key: 'area', points: area, fill: 'rgba(45, 212, 191, 0.18)' }) : null, - props.type === 'bar' - ? h('g', { key: 'bars' }, coords.map((coord, index) => { - const barWidth = Math.max(10, stepX * 0.6 || 24); + props.type === 'bar' && !isMultiSeries + ? h('g', { key: 'bars' }, seriesCoords[0].coords.map((coord, index) => { + const barWidth = Math.max(10, (((width - padding * 2) / Math.max(seriesCoords[0].coords.length - 1, 1)) * 0.6) || 24); return h('rect', { key: index, x: coord[0] - barWidth / 2, @@ -504,28 +531,49 @@ fill: 'rgba(59, 130, 246, 0.75)', }); })) - : h('polyline', { - key: 'line', - points: line, - fill: 'none', - stroke: props.type === 'area' ? '#2dd4bf' : '#60a5fa', - strokeWidth: 3, - strokeLinecap: 'round', - strokeLinejoin: 'round', - }), - h('g', { key: 'dots' }, coords.map((coord, index) => h('circle', { - key: index, - cx: coord[0], - cy: coord[1], - r: 4, - fill: '#f8fafc', - stroke: props.type === 'area' ? '#2dd4bf' : '#60a5fa', - strokeWidth: 2, - }))), + : h('g', { key: 'series' }, seriesCoords.flatMap((item, index) => { + const nodes = []; + if (props.type === 'area' && !isMultiSeries && item.area) { + nodes.push(h('polygon', { + key: `${item.key}-area`, + points: item.area, + fill: 'rgba(45, 212, 191, 0.18)', + })); + } + nodes.push(h('polyline', { + key: `${item.key}-line`, + points: item.line, + fill: 'none', + stroke: item.color, + strokeWidth: 3, + strokeLinecap: 'round', + strokeLinejoin: 'round', + })); + nodes.push(h('g', { key: `${item.key}-dots` }, item.coords.map((coord, pointIndex) => h('circle', { + key: `${item.key}-${pointIndex}`, + cx: coord[0], + cy: coord[1], + r: 4, + fill: '#f8fafc', + stroke: item.color, + strokeWidth: 2, + })))); + return nodes; + })), ]), - h('div', { key: 'labels', className: 'mc-mini-grid' }, - points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`)) - ), + isMultiSeries + ? h('div', { key: 'legend', className: 'mc-mini-grid' }, + seriesCoords.map((item) => { + const latestPoint = item.data[item.data.length - 1] || null; + return h('div', { key: item.key, className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-kicker', style: { color: item.color } }, item.label), + h('div', { key: 'value' }, latestPoint ? `${fmtNumber(latestPoint.y, 4)} ยท ${latestPoint.x}` : 'n/a'), + ]); + }) + ) + : h('div', { key: 'labels', className: 'mc-mini-grid' }, + points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`)) + ), ]); } @@ -1128,13 +1176,44 @@ loadSavedDashboards(); }, [payload, projectKey]); - const overviewCharts = useMemo(() => ({ - mining: measurements.map((row) => ({ x: row.measured_at.slice(5, 16), 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 })), - pricing: measurements.filter((row) => row.price_per_coin !== null) - .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.price_per_coin })), - }), [measurements]); + const overviewCharts = useMemo(() => { + const comparisonRows = measurements.filter((row) => { + const miningRate = Number(row.doge_per_hour_per_mh_interval); + const price = Number(row.effective_price_per_coin ?? row.price_per_coin); + return Number.isFinite(miningRate) && miningRate > 0 && Number.isFinite(price) && price > 0; + }); + const baseComparison = comparisonRows[0] || null; + const baseMining = baseComparison ? Number(baseComparison.doge_per_hour_per_mh_interval) : null; + 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 })), + 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 })), + pricing: measurements.filter((row) => row.price_per_coin !== null) + .map((row) => ({ x: row.measured_at.slice(5, 16), 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), + y: (Number(row.doge_per_hour_per_mh_interval) / baseMining) * 100, + })), + }, + { + key: 'doge-price', + label: 'DOGE-Kurs Index', + color: '#f59e0b', + data: comparisonRows.map((row) => ({ + x: row.measured_at.slice(5, 16), + y: (Number(row.effective_price_per_coin ?? row.price_per_coin) / basePrice) * 100, + })), + }, + ] : [], + }; + }, [measurements]); async function submitMeasurement(fromPreview) { const preview = normalizeOcrPreview(ocrPreview); @@ -2139,6 +2218,10 @@ panel('Mining-Verlauf', 'Coins total der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })), panel('Performance-Verlauf', 'DOGE-pro-Tag-Raten der letzten 15 Tage.', h(SimpleChart, { type: 'area', data: overviewCharts.performance })), panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), + panel('Mining vs. Kurs', 'Index-Vergleich der letzten 15 Tage. Mining wird auf DOGE pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.', h(SimpleChart, { + type: 'line', + series: overviewCharts.miningVsPrice, + })), ]), panel('Zielmonitor', 'Rest-DOGE und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.', h('div', { className: 'mc-target-grid' }, diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php index ee2ea5f..b0218e9 100644 --- a/modules/mining-checker/src/Domain/AnalyticsService.php +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -20,6 +20,7 @@ final class AnalyticsService $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; $preferredPriceCurrencies = array_values(array_unique(array_filter([ strtoupper(trim((string) ($settings['report_currency'] ?? ''))), 'USD', @@ -30,11 +31,11 @@ final class AnalyticsService $baselineTs = $this->utcTimestamp($baselineAt); $previous = null; $previousMeasuredTs = null; - $previousEffectiveCoinsTotal = null; $previousIntervalRate = null; $result = []; $payoutIndex = 0; $cumulativePayouts = 0.0; + $lastPayoutTs = null; $latestPriceByCurrency = []; foreach ($measurements as $row) { @@ -46,6 +47,7 @@ final class AnalyticsService } $cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); + $lastPayoutTs = $payoutTs; $payoutIndex++; } @@ -55,17 +57,32 @@ final class AnalyticsService $hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0; $perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null; $perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null; + $activeHashrateMh = $this->measurementHashrateMh($costPlans, $purchasedMiners, $measuredTs > 0 ? $measuredTs : null); $intervalHours = null; $intervalGrowth = null; $perHourInterval = null; $perDayInterval = null; + $perHourPerMhInterval = null; + $perDayPerMhInterval = null; - if (is_array($previous) && $previousMeasuredTs !== null && $previousEffectiveCoinsTotal !== null) { - $intervalHours = max(0.0, ($measuredTs - $previousMeasuredTs) / 3600); - $intervalGrowth = $effectiveCoinsTotal - $previousEffectiveCoinsTotal; + if (is_array($previous) && $previousMeasuredTs !== null) { + $intervalStartTs = $previousMeasuredTs; + $intervalStartCoins = (float) ($previous['coins_total'] ?? 0.0); + + if ($lastPayoutTs !== null && $lastPayoutTs > $previousMeasuredTs && $lastPayoutTs <= $measuredTs) { + $intervalStartTs = $lastPayoutTs; + $intervalStartCoins = 0.0; + } + + $intervalHours = max(0.0, ($measuredTs - $intervalStartTs) / 3600); + $intervalGrowth = $visibleCoinsTotal - $intervalStartCoins; $perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null; $perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null; + if ($perHourInterval !== null && $activeHashrateMh > 0) { + $perHourPerMhInterval = $perHourInterval / $activeHashrateMh; + $perDayPerMhInterval = $perDayInterval !== null ? $perDayInterval / $activeHashrateMh : null; + } } $trendLabel = 'stabil'; @@ -147,10 +164,13 @@ final class AnalyticsService 'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4), 'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6), 'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6), + 'active_hashrate_mh' => $this->roundOrNull($activeHashrateMh > 0 ? $activeHashrateMh : null, 4), 'interval_hours' => $this->roundOrNull($intervalHours, 4), 'interval_growth' => $this->roundOrNull($intervalGrowth, 6), 'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6), 'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6), + 'doge_per_hour_per_mh_interval' => $this->roundOrNull($perHourPerMhInterval, 8), + 'doge_per_day_per_mh_interval' => $this->roundOrNull($perDayPerMhInterval, 8), 'trend_label' => $trendLabel, 'effective_price_per_coin' => $this->roundOrNull($price, 8), 'effective_price_currency' => $priceCurrency, @@ -172,7 +192,6 @@ final class AnalyticsService } $previous = $row; $previousMeasuredTs = $measuredTs > 0 ? $measuredTs : null; - $previousEffectiveCoinsTotal = $effectiveCoinsTotal; } return $result; @@ -565,6 +584,37 @@ final class AnalyticsService return $total; } + private function measurementHashrateMh(array $costPlans, array $purchasedMiners, ?int $measurementTs = null): float + { + $total = 0.0; + + foreach ($costPlans as $plan) { + if (array_key_exists('is_active', $plan) && empty($plan['is_active'])) { + continue; + } + if (!$this->entryIsCovered($plan, $measurementTs)) { + continue; + } + + $total += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + if (!$this->entryIsCovered($miner, $measurementTs, 'purchased_at')) { + continue; + } + + $total += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + } + + return $total; + } + private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency));