diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py
index 19b01df6..e09151c8 100644
--- a/build/dashboard/mining_dashboard/service/earnings.py
+++ b/build/dashboard/mining_dashboard/service/earnings.py
@@ -120,7 +120,10 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
confirmed yet" (shows 0.000000).
Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous
- full local day (:func:`previous_local_day`), ``all`` is everything stored.
+ full local day (:func:`previous_local_day`), ``all`` is everything stored. Each window also
+ carries its payout **count** (``n_24h`` … ``n_30d``, #808): for solo-merge-mined Tari a payout
+ IS a found block, so the count is the honest actual to hold against the expected block count —
+ the all-time count stays ``count``.
``partial`` marks each running window whose span begins before the oldest payout on record
(``since_ts``) — the sum then covers only part of the window it is labelled with, so the UI
@@ -135,20 +138,25 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400
y_start, y_end = previous_local_day(now)
atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0)
+ counts = dict.fromkeys(("24h", "yesterday", "7d", "30d"), 0)
for p in payouts:
amt = p.get("amount_atomic", 0) or 0
ts = p.get("ts", 0) or 0
atomic["all"] += amt
if ts >= month:
atomic["30d"] += amt
+ counts["30d"] += 1
if ts >= week:
atomic["7d"] += amt
+ counts["7d"] += 1
if ts >= day:
atomic["24h"] += amt
+ counts["24h"] += 1
# Half-open [start, end) so a payout landing exactly at midnight belongs to the day it
# starts, never to both days.
if y_start <= ts < y_end:
atomic["yesterday"] += amt
+ counts["yesterday"] += 1
# Only positive stamps: a row with a missing/zero ts can't date the history, and letting it
# win the min would mark every window complete on the strength of a broken row.
stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0]
@@ -164,4 +172,5 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
"partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS},
}
summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()})
+ summary.update({f"n_{w}": v for w, v in counts.items()})
return summary
diff --git a/build/dashboard/mining_dashboard/service/metrics.py b/build/dashboard/mining_dashboard/service/metrics.py
index 3973c937..11cb5381 100644
--- a/build/dashboard/mining_dashboard/service/metrics.py
+++ b/build/dashboard/mining_dashboard/service/metrics.py
@@ -109,6 +109,13 @@ class Metrics:
# Defaulted so direct Metrics(...) constructors needn't set them.
tari_difficulty: float = 0.0 # Tari AUX-chain difficulty (not P2Pool sidechain, not Monero)
tari_reward: float = 0.0 # Tari block reward, XTM (collector converts p2pool's µT figure)
+ # Window-matched routed averages for the expected-vs-actual card (#808): the expectation over
+ # a trailing 7d/30d window must use the hashrate that actually ran THAT window, not the
+ # current 1h figure — a fleet that grew or shrank mid-window would otherwise be judged
+ # against the wrong baseline. History retention (30 days) covers both windows. Defaulted so
+ # direct Metrics(...) constructors needn't set them.
+ p2pool_7d: float = 0.0
+ p2pool_30d: float = 0.0
def build_metrics(latest_data, state_mgr, history=None):
@@ -143,6 +150,8 @@ def build_metrics(latest_data, state_mgr, history=None):
# stratum estimate or a total-minus-XvB subtraction (Issue #27).
p2pool_1h = _avg_p2pool_over_window(history, 3600)
p2pool_24h = _avg_p2pool_over_window(history, 86400)
+ p2pool_7d = _avg_p2pool_over_window(history, 7 * 86400)
+ p2pool_30d = _avg_p2pool_over_window(history, 30 * 86400)
# The XvB raffle qualifies a tier on BOTH the 1h and 24h credited average, and terminates a win
# if the 1h average drops below the round minimum — so Current Tier is the one cleared by the
@@ -200,6 +209,8 @@ def build_metrics(latest_data, state_mgr, history=None):
total_h15=total_h15,
p2pool_1h=p2pool_1h,
p2pool_24h=p2pool_24h,
+ p2pool_7d=p2pool_7d,
+ p2pool_30d=p2pool_30d,
xvb_1h=xvb_1h,
xvb_24h=xvb_24h,
xvb_routed_1h=xvb_routed_1h,
diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index 0bc02ebd..d7c865a1 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -585,6 +585,100 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
`;
}
+// Expected vs actual (#808): the comparison the operator otherwise assembles by hand across the
+// Earnings tabs, compact enough to be the Simple view's one earnings card. Deliberately carries
+// NEITHER view class — card-simple and card-advanced are disjoint (each hides in the other's
+// mode), and this is the one earnings surface both views share.
+// The server rolls the rows up (build_earnings_vs_actual — window-matched
+// hashrate, same confirmed roll-up as the Earnings card); this renders them. Per-stream windows
+// match cadence: Monero XMR over 7d with a percent-of-expected, Tari BLOCKS over 30d (solo
+// merge-mining pays whole blocks — a count, not a percent), XvB wins over 30d beside XvB's
+// published estimate with deliberately NO ratio (a win pays out through ordinary small payouts
+// the payout table cannot attribute). A stream with payout confirmation off shows the config key
+// to set instead of a zero that would read as "earned nothing"; the card yields to nothing when
+// no stream has anything to compare.
+function ExpectedVsActualCard({ summary }) {
+ if (!summary) return null;
+ const { xmr, tari, xvb } = summary;
+ if (!xmr.available && !tari.available && !xvb.enabled) return null;
+ const partialMark = (row, text) => (row.partial ? text + " *" : text);
+ const anyPartial = (xmr.enabled && xmr.partial) || (tari.enabled && tari.partial);
+ const rows = [];
+ rows.push({
+ label: "Monero (7d)",
+ expected: xmr.available ? formatXmr(xmr.expected_7d) : "—",
+ actual: !xmr.enabled
+ ? "set monero.view_key"
+ : partialMark(xmr, formatXmr(xmr.actual_7d) + (xmr.pct !== null ? ` (${xmr.pct}%)` : "")),
+ dim: !xmr.enabled,
+ title:
+ "Confirmed on-chain payouts over the trailing 7 days vs the linear expectation at your " +
+ "7-day average P2Pool hashrate. P2Pool pays when the pool finds blocks, so a single week " +
+ "swings with luck — a sustained gap is the signal worth checking, not one short window.",
+ });
+ rows.push({
+ label: "Tari (30d)",
+ // Two significant digits, not a fixed decimal: at real Tari difficulty the expectation is a
+ // FRACTION of a block per month, and "0.0" would erase exactly the number this row exists
+ // to show (≈ 0.0052 blocks is the honest, legible form).
+ expected: tari.available ? `≈ ${Number(tari.expected_blocks_30d.toPrecision(2))} blocks` : "—",
+ actual: !tari.enabled
+ ? "set tari.view_key"
+ : partialMark(
+ tari,
+ `${tari.blocks_30d} block${tari.blocks_30d === 1 ? "" : "s"} · ${formatXtm(tari.xtm_30d)}`,
+ ),
+ dim: !tari.enabled,
+ title:
+ "Tari is merge-mined SOLO: a confirmed payout IS a found block, all at once. Expected is " +
+ "your 30-day average hashrate × 30 days ÷ the Tari difficulty — at fractions of a block " +
+ "per month, zero found is the normal case, not a fault.",
+ });
+ if (xvb.enabled) {
+ rows.push({
+ label: "XvB wins (30d)",
+ expected: xvb.published_day !== null ? formatXmr(xvb.published_day) + "/day (XvB est.)" : "—",
+ actual:
+ `${xvb.wins_30d} win${xvb.wins_30d === 1 ? "" : "s"}` +
+ (xvb.last_win_ts ? ` · last ${formatAgo(xvb.last_win_ts)}` : ""),
+ dim: false,
+ title:
+ "Raffle wins recorded in the last 30 days. A win pays out through ordinary small " +
+ "payouts that cannot be told apart from P2Pool payouts, so no XvB XMR figure is shown. " +
+ "The published estimate is XvB's own raffle-wide expectation for your current tier — " +
+ "not a promise.",
+ });
+ }
+ return html`
+
+
Earnings — Expected vs Actual
+
+
+
+
+
Expected
+
Actual
+
+
+ ${rows.map(
+ (r) => html`
+
+
${r.label}
+
${r.expected}
+
${r.actual}
+
`,
+ )}
+
+
+
+ ${
+ anyPartial
+ ? html`
* covers only the payout history on record, not the window's full span.
`
+ : null
+ }
+
`;
+}
+
// P2Pool earnings calculator (Issue #12). A power-user card (Advanced view) over the metrics
// layer that estimates XMR from *P2Pool mining only* — explicitly not XvB — plus the Tari the
// same hashrate merge-mines alongside it (#117; "—" while merge-mining is inactive). The server
@@ -1129,6 +1223,7 @@ function DashboardView({
Your Stack
<${Overview} state=${state} />
+ <${ExpectedVsActualCard} summary=${state.earnings_summary} />
<${NodeStats} state=${state} />
<${XvBStats} state=${state} />
<${EarningsCard} earnings=${state.earnings} xvb=${state.xvb_calc} energy=${state.energy} />
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index 4b8407a0..fb233231 100644
--- a/build/dashboard/mining_dashboard/web/views.py
+++ b/build/dashboard/mining_dashboard/web/views.py
@@ -42,6 +42,7 @@
from mining_dashboard.service.earnings import (
ATOMIC_PER_XMR,
MICRO_PER_XTM,
+ SECONDS_PER_DAY,
confirmed_payouts_summary,
tari_seconds_to_block_per_hs,
xmr_per_hs_day,
@@ -1570,6 +1571,70 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None)
}
+def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None):
+ """Expected-vs-actual summary — one row per income stream, for both views (#808).
+
+ The comparison the operator otherwise assembles by hand across the Earnings tabs: the linear
+ expectation over a trailing window beside what the view-only wallets confirmed over the SAME
+ window (#381/#462). Expected uses the time-weighted routed P2Pool average over the window
+ itself (``metrics.p2pool_7d``/``p2pool_30d``), not the current 1h figure — a fleet that grew
+ or shrank mid-window would otherwise be judged against the wrong baseline.
+
+ Per-stream windows match each stream's cadence. **Monero** compares XMR over 7d — P2Pool pays
+ whenever the pool finds blocks, so a week is long enough to mean something and short enough to
+ act on; ``pct`` is the actual as a percent of expected, and ``partial`` carries the confirmed
+ window's may-be-incomplete flag so a short history is never presented as a full week.
+ **Tari** compares BLOCK COUNTS over 30d: solo merge-mining pays whole blocks, so the honest
+ unit is blocks (expected = hashrate × window ÷ aux difficulty; actual = confirmed payout
+ count, each payout being a found block), with the XTM sum alongside — no percent, a count that
+ small is luck either way. **XvB** shows wins in the trailing 30d beside XvB's published
+ per-day estimate for the current tier — deliberately NO ratio: a win pays out through ordinary
+ small payouts the payout table cannot attribute, so actual XvB XMR is not separable from
+ P2Pool XMR, and the published figure is XvB's own raffle-wide expectation, not a promise.
+
+ Raw numbers out; the client formats. ``actual``/``blocks``/``xtm`` are None while the matching
+ payout-confirmation feature is off — the card then hints at the view key instead of showing a
+ zero that would read as "earned nothing"."""
+ now = now if now is not None else time.time()
+ conf = earnings["confirmed"]
+ tari_conf = earnings["tari_confirmed"]
+ expected_xmr = earnings["coeff_day"] * metrics.p2pool_7d * 7
+ xmr = {
+ "available": expected_xmr > 0,
+ "expected_7d": expected_xmr,
+ "enabled": bool(conf.get("enabled")),
+ "actual_7d": conf.get("xmr_7d") if conf.get("enabled") else None,
+ "partial": bool((conf.get("partial") or {}).get("7d")),
+ "pct": None,
+ }
+ if xmr["available"] and xmr["enabled"]:
+ xmr["pct"] = round((xmr["actual_7d"] or 0.0) / expected_xmr * 100)
+ # Expected Tari blocks over the window: hashrate × seconds ÷ difficulty (hashes-per-block).
+ # Gated on tari_mining like the calculator, so a dead merge-mine channel shows "—", not 0.
+ expected_blocks = (
+ metrics.p2pool_30d * 30 * SECONDS_PER_DAY / metrics.tari_difficulty
+ if metrics.tari_mining and metrics.tari_difficulty > 0
+ else 0.0
+ )
+ tari = {
+ "available": expected_blocks > 0,
+ "expected_blocks_30d": expected_blocks,
+ "enabled": bool(tari_conf.get("enabled")),
+ "blocks_30d": tari_conf.get("n_30d") if tari_conf.get("enabled") else None,
+ "xtm_30d": tari_conf.get("xtm_30d") if tari_conf.get("enabled") else None,
+ "partial": bool((tari_conf.get("partial") or {}).get("30d")),
+ }
+ stamps = [w.get("ts", 0) or 0 for w in (raffle_wins or [])]
+ xvb = {
+ "enabled": metrics.xvb_enabled,
+ "wins_30d": sum(1 for t in stamps if t >= now - 30 * SECONDS_PER_DAY),
+ "last_win_ts": max(stamps, default=0),
+ # XvB's published per-day estimate for the current tier — None unless fresh (#712).
+ "published_day": earnings["xvb_day"],
+ }
+ return {"xmr": xmr, "tari": tari, "xvb": xvb}
+
+
# XvB tier calculator copy (#118). A tier is RAFFLE status, never an XMR payout, and the winner is
# drawn at random among qualifiers — donating above the threshold buys zero extra win chance, so
# the honest framing is cost, not reward.
@@ -1762,6 +1827,18 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
mode_tok, p2p_tok, xvb_tok = _mode_palette(metrics.mode)
pool_net = build_pool_network(data, metrics)
+ # Built once, consumed twice: the Earnings card reads the full payload, the expected-vs-actual
+ # summary (#808) rolls the same figures up — one build, so the two can't disagree.
+ earnings = build_earnings(
+ data,
+ metrics,
+ payouts=monero_payouts,
+ tari_payouts=(
+ state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None
+ ),
+ xvb_day=xvb_current_tier_reward_day(metrics, state_mgr),
+ )
+
egress = egress_posture_from_config() # per-component egress route + privacy roll-up (#170)
topology = (
topology_from_config()
@@ -1809,17 +1886,11 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
"raffle_eligible": build_raffle_eligibility(metrics),
"raffle_wins": build_raffle_log(raffle_wins),
"proxy_workers": metrics.workers_online,
- # Confirmed payouts (#381): pass the stored list when the feature is on, else None (feature
- # off → earnings shows only the estimate). config read at call time so tests can flip it.
- "earnings": build_earnings(
- data,
- metrics,
- payouts=monero_payouts,
- tari_payouts=(
- state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None
- ),
- xvb_day=xvb_current_tier_reward_day(metrics, state_mgr),
- ),
+ # Confirmed payouts (#381): the stored list rides in when the feature is on, else None
+ # (feature off → earnings shows only the estimate). Built above, before the payload.
+ "earnings": earnings,
+ # Expected vs actual, one row per stream (#808) — the Simple view's earnings figure.
+ "earnings_summary": build_earnings_vs_actual(metrics, earnings, raffle_wins),
"xvb_calc": build_xvb_calc(metrics, state_mgr),
# On a backup stack, the XvB controller state last pulled from the primary (#249) — held as
# standby, adopted only at failover. None on a single stack (nothing pulls). Inspectable so
diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py
index a178cb2b..b301e721 100644
--- a/build/dashboard/tests/service/test_earnings.py
+++ b/build/dashboard/tests/service/test_earnings.py
@@ -168,6 +168,10 @@ def test_empty_list_is_on_with_zeros(self):
"xmr_7d": 0.0,
"xmr_30d": 0.0,
"xmr_all": 0.0,
+ "n_24h": 0,
+ "n_yesterday": 0,
+ "n_7d": 0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
@@ -187,6 +191,11 @@ def test_trailing_windows_bucket_by_age(self):
assert s["xmr_7d"] == 3.0
assert s["xmr_30d"] == 7.0
assert s["xmr_all"] == 15.0
+ # Per-window counts mirror the sums (#808) — for Tari a payout IS a found block, so the
+ # count is the actual the expected-vs-actual card holds against the expected block count.
+ assert s["n_24h"] == 1
+ assert s["n_7d"] == 2
+ assert s["n_30d"] == 3
assert s["last_ts"] == self.NOW - 3_600
assert s["since_ts"] == self.NOW - 40 * 86_400
@@ -202,6 +211,7 @@ def test_yesterday_is_the_previous_calendar_day(self):
]
s = confirmed_payouts_summary(payouts, now=self.NOW)
assert s["xmr_yesterday"] == 6.0
+ assert s["n_yesterday"] == 2
# The trailing 24h is a different span and deliberately disagrees: it reaches back into
# yesterday noon and includes today's midnight payout.
assert s["xmr_24h"] == 12.0
diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py
index 6a8cedf0..31d099c0 100644
--- a/build/dashboard/tests/web/test_views.py
+++ b/build/dashboard/tests/web/test_views.py
@@ -35,6 +35,7 @@
build_chart,
build_disk_growth,
build_earnings,
+ build_earnings_vs_actual,
build_energy,
build_hashrate,
build_pool_network,
@@ -1686,6 +1687,123 @@ def test_dash_fallbacks_when_unavailable(self):
assert c["last_block"] == "Never"
+# --- Expected vs actual earnings summary (#808) ---------------------------------------
+
+
+def _summary_earnings(**over):
+ """A minimal build_earnings-shaped dict — only the keys build_earnings_vs_actual reads."""
+ e = {
+ "coeff_day": 0.0,
+ "confirmed": {"enabled": False},
+ "tari_confirmed": {"enabled": False},
+ "xvb_day": None,
+ }
+ e.update(over)
+ return e
+
+
+class TestEarningsVsActual:
+ NOW = 1_760_000_000
+
+ def test_xmr_expected_uses_the_window_average_and_pct_rounds(self):
+ # Expected = coeff_day × 7d-average hashrate × 7 — the WINDOW's average (p2pool_7d), not
+ # the current 1h figure, so a fleet that changed mid-window is judged against what ran.
+ e = _summary_earnings(
+ coeff_day=1e-8,
+ confirmed={"enabled": True, "xmr_7d": 0.28, "partial": {"7d": False}},
+ )
+ s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW)
+ assert s["xmr"]["available"] is True
+ assert s["xmr"]["expected_7d"] == pytest.approx(1e-8 * 8000.0 * 7) # 0.00056·1000=0.56e-3
+ assert s["xmr"]["actual_7d"] == 0.28
+ assert s["xmr"]["pct"] == round(0.28 / (1e-8 * 8000.0 * 7) * 100)
+ assert s["xmr"]["partial"] is False
+
+ def test_xmr_row_degrades_honestly(self):
+ # Estimate unavailable (no network figures) -> not available, and no pct even with
+ # confirmed payouts on; confirmation off -> actual/pct None, never a zero that would
+ # read as "earned nothing".
+ on = _summary_earnings(confirmed={"enabled": True, "xmr_7d": 0.5, "partial": {}})
+ s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), on, [], now=self.NOW)
+ assert s["xmr"]["available"] is False and s["xmr"]["pct"] is None
+ off = _summary_earnings(coeff_day=1e-8)
+ s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), off, [], now=self.NOW)
+ assert s["xmr"]["enabled"] is False
+ assert s["xmr"]["actual_7d"] is None and s["xmr"]["pct"] is None
+
+ def test_xmr_partial_flag_rides_the_confirmed_window(self):
+ e = _summary_earnings(
+ coeff_day=1e-8,
+ confirmed={"enabled": True, "xmr_7d": 0.1, "partial": {"7d": True}},
+ )
+ s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW)
+ assert s["xmr"]["partial"] is True
+
+ def test_tari_compares_block_counts_over_30d(self):
+ # Expected blocks = 30d-average hashrate × 30 days ÷ aux difficulty; actual = the
+ # confirmed payout count (solo merge-mining: a payout IS a found block), XTM alongside.
+ e = _summary_earnings(
+ tari_confirmed={
+ "enabled": True,
+ "n_30d": 1,
+ "xtm_30d": 12_345.0,
+ "partial": {"30d": True},
+ }
+ )
+ m = _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=True)
+ s = build_earnings_vs_actual(m, e, [], now=self.NOW)
+ assert s["tari"]["available"] is True
+ assert s["tari"]["expected_blocks_30d"] == pytest.approx(10_000.0 * 30 * 86_400 / 4.0e12)
+ assert s["tari"]["blocks_30d"] == 1
+ assert s["tari"]["xtm_30d"] == 12_345.0
+ assert s["tari"]["partial"] is True
+
+ def test_tari_gates_on_mining_and_difficulty(self):
+ # A dead merge-mine channel (tari_mining False) or missing difficulty -> unavailable,
+ # mirroring the calculator's gate, so no phantom expectation is shown.
+ e = _summary_earnings()
+ off = _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=False)
+ assert build_earnings_vs_actual(off, e, [], now=self.NOW)["tari"]["available"] is False
+ nodiff = _metrics(p2pool_30d=10_000.0, tari_difficulty=0.0, tari_mining=True)
+ assert build_earnings_vs_actual(nodiff, e, [], now=self.NOW)["tari"]["available"] is False
+ # Confirmation off -> counts None, not 0.
+ s = build_earnings_vs_actual(
+ _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=True),
+ e,
+ [],
+ now=self.NOW,
+ )
+ assert s["tari"]["blocks_30d"] is None and s["tari"]["xtm_30d"] is None
+
+ def test_xvb_counts_wins_in_the_trailing_30d_only(self):
+ wins = [
+ {"ts": self.NOW - 40 * 86_400}, # outside the window
+ {"ts": self.NOW - 10 * 86_400},
+ {"ts": self.NOW - 86_400},
+ ]
+ s = build_earnings_vs_actual(
+ _metrics(), _summary_earnings(xvb_day=0.004), wins, now=self.NOW
+ )
+ assert s["xvb"]["enabled"] is True
+ assert s["xvb"]["wins_30d"] == 2
+ assert s["xvb"]["last_win_ts"] == self.NOW - 86_400
+ # XvB's published figure passes through untouched — and stays None when not fresh (#712).
+ assert s["xvb"]["published_day"] == 0.004
+ s = build_earnings_vs_actual(
+ _metrics(xvb_enabled=False), _summary_earnings(), [], now=self.NOW
+ )
+ assert s["xvb"]["enabled"] is False and s["xvb"]["wins_30d"] == 0
+
+ def test_rides_build_state_end_to_end(self, monkeypatch):
+ # The summary must reach the top-level payload the client polls, built from the SAME
+ # earnings dict the Earnings card receives — one build, so the two cannot disagree.
+ monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", False)
+ monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", False)
+ st = build_state(_data(), _state_mgr(), "all")
+ assert set(st["earnings_summary"]) == {"xmr", "tari", "xvb"}
+ assert st["earnings_summary"]["xmr"]["enabled"] is False
+
+
# --- Host address beside the hostname (Issue #119) ------------------------------------
@@ -1822,6 +1940,10 @@ def test_confirmed_enabled_but_empty(self):
"xmr_7d": 0.0,
"xmr_30d": 0.0,
"xmr_all": 0.0,
+ "n_24h": 0,
+ "n_yesterday": 0,
+ "n_7d": 0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
@@ -1842,6 +1964,10 @@ def test_tari_confirmed_enabled_but_empty(self):
"xtm_7d": 0.0,
"xtm_30d": 0.0,
"xtm_all": 0.0,
+ "n_24h": 0,
+ "n_yesterday": 0,
+ "n_7d": 0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 1755ebed..a6684d32 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -259,6 +259,29 @@ The summary panel pulls the key numbers together:
| **Tari Mining** | Whether merge-mining of Tari is active and healthy. |
| **Wallet XMR / Wallet TARI** | Your configured Monero and Tari payout addresses, one card each. |
+### Earnings — Expected vs Actual
+
+One compact table, shown in **both** views, that answers "am I earning what this hashrate should?"
+— the comparison you'd otherwise assemble by hand from the Earnings tabs. One row per income
+stream, each over the window that suits how that stream pays:
+
+| Row | Expected | Actual |
+|---|---|---|
+| **Monero (7d)** | The linear estimate over the trailing 7 days, at your **7-day average** routed P2Pool hashrate — the hashrate that actually ran the window, so a fleet that grew or shrank mid-week is judged against what really ran. | Confirmed on-chain payouts over the same 7 days ([payout confirmation](#payout-confirmation)), with a percent-of-expected. |
+| **Tari (30d)** | Expected **blocks** over the trailing 30 days (hashrate × window ÷ Tari difficulty). Tari is merge-mined solo, so blocks are the honest unit — at fractions of a block per month, zero found is the normal case, not a fault. | Blocks found (each confirmed Tari payout is one solo-found block) and the XTM they paid. |
+| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the last one landed. |
+
+Rows degrade honestly rather than guess: a stream with [payout confirmation](#payout-confirmation)
+off shows the config key to set instead of a zero that would read as "earned nothing"; the XvB row
+disappears when XvB is off; a `*` marks a window that reaches back past the oldest recorded payout.
+The XvB row deliberately shows **no XMR figure and no percent**: a win pays out through ordinary
+small payouts that can't be told apart from P2Pool payouts, so any "XvB XMR earned" number would be
+an invention.
+
+Short windows swing with mining luck — P2Pool pays when the pool finds blocks, and solo Tari blocks
+are rarer still. A sustained gap between expected and actual is the signal worth checking (workers
+offline, a misconfigured payout address); a single quiet week is not.
+
### Workers Alive
A live table of every connected rig: worker name, IP, uptime, and per-worker hashrate over the 1m
@@ -374,14 +397,15 @@ sync gaps, or other noise the average doesn't separate out.
### Simple vs. Advanced view
A **Simple / Advanced** toggle sits above the chart. **Simple** (the default) shows the chart, the
-Overview summary, and the worker table. **Advanced** swaps the Overview for cards that break out the
-same data in more detail: **My P2Pool Node Stats**, **Global P2Pool Stats**, **XvB Donation Stats**,
-**XMR Network**, **Tari Merge-Mining**, and the **P2Pool Earnings (estimated)** calculator below. The
-choice is remembered across reloads.
-
-The earnings estimates and the XvB tier calculator live only in Advanced view. Simple view shows a
-one-time banner pointing there; it goes away once you dismiss it or open Advanced view, and stays
-away across reloads.
+Overview summary, the [Earnings — Expected vs Actual](#earnings--expected-vs-actual) table, and the
+worker table. **Advanced** swaps the Overview for cards that break out the same data in more
+detail: **My P2Pool Node Stats**, **Global P2Pool Stats**, **XvB Donation Stats**, **XMR Network**,
+**Tari Merge-Mining**, and the **P2Pool Earnings (estimated)** calculator below. The
+expected-vs-actual table stays in both views. The choice is remembered across reloads.
+
+The what-if earnings calculator and the XvB tier calculator live only in Advanced view. Simple view
+shows a one-time banner pointing there; it goes away once you dismiss it or open Advanced view, and
+stays away across reloads.