diff --git a/build/dashboard/mining_dashboard/helper/utils.py b/build/dashboard/mining_dashboard/helper/utils.py
index bd0bc5d5..ab38244b 100644
--- a/build/dashboard/mining_dashboard/helper/utils.py
+++ b/build/dashboard/mining_dashboard/helper/utils.py
@@ -114,6 +114,24 @@ def format_xmr(amount):
return f"{val:.{dp}f} XMR"
+def format_xtm(amount):
+ """Format an XTM amount, the Tari sibling of :func:`format_xmr` — same magnitude-adaptive
+ precision, same "0 XTM" / em-dash edge cases.
+
+ Mirrors ``formatXtm`` in ``web/static/logic.mjs`` so a confirmed Tari total reads identically
+ in the bot and on the dashboard card (#387)."""
+ try:
+ val = float(amount)
+ except (ValueError, TypeError):
+ return "—"
+ if not math.isfinite(val):
+ return "—"
+ if val == 0:
+ return "0 XTM"
+ dp = 4 if val >= 1 else 6 if val >= 0.001 else 8
+ return f"{val:.{dp}f} XTM"
+
+
def format_duration(seconds):
"""
Formats a duration in seconds into a concise human-readable string.
diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py
index 15f51576..19b01df6 100644
--- a/build/dashboard/mining_dashboard/service/earnings.py
+++ b/build/dashboard/mining_dashboard/service/earnings.py
@@ -22,8 +22,15 @@
The XvB tier estimate is still deferred (per the Issue #12 discussion), and hashrate currently
donated to XvB isn't subtracted here — the estimate assumes the supplied hashrate mines via
P2Pool, which the dashboard states in its disclaimer.
+
+Alongside the model sits ``confirmed_payouts_summary`` (#787): the same domain layer, but over
+what the view-only wallets actually recorded (#381/#462) rather than what the model predicts.
+It lives here so the dashboard card and the Telegram bot roll the running windows up exactly
+once, from one implementation — the #61 principle again.
"""
+import time
+
# Monero amounts are reported in atomic units (piconero); 1 XMR = 1e12 atomic.
ATOMIC_PER_XMR = 1_000_000_000_000
# Tari amounts are reported in microTari (µT); 1 XTM = 1e6 µT (#462).
@@ -75,3 +82,86 @@ def tari_seconds_to_block_per_hs(network_difficulty):
if network_difficulty <= 0:
return 0.0
return float(network_difficulty)
+
+
+# Running-earnings windows (#787). The estimate above answers "what should this hashrate earn?";
+# these answer "what did it actually earn?" from the confirmed on-chain payouts the view-only
+# wallets record (#381/#462). Both the dashboard card and the Telegram bot read this one roll-up,
+# so the two surfaces cannot drift apart (#61/#387).
+RUNNING_WINDOWS = ("yesterday", "7d", "30d")
+
+
+def previous_local_day(now):
+ """``(start, end)`` unix seconds of the previous full **local** calendar day.
+
+ "Yesterday" means the calendar day, not a trailing 24 hours — that is the figure an operator
+ means by "what did I make yesterday". Local is the dashboard container's timezone
+ (``dashboard.timezone``), the same clock the daily summary fires on and the chart's payout
+ dates are stamped in, so all three agree on where a day ends.
+
+ Steps back through noon rather than subtracting 86 400 from today's midnight: a DST transition
+ makes a day 23 or 25 hours long, and the naive subtraction lands on the wrong date on exactly
+ those two days a year."""
+ lt = time.localtime(now)
+ today = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1))
+ prev = time.localtime(today - 43_200)
+ start = time.mktime((prev.tm_year, prev.tm_mon, prev.tm_mday, 0, 0, 0, 0, 0, -1))
+ return start, today
+
+
+def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="xmr"):
+ """Roll confirmed on-chain payouts into running totals + a count (#381/#462/#787).
+
+ ``payouts`` is the stored-payout list (``storage.get_payouts(chain)``): each carries ``ts``
+ (unix seconds) and ``amount_atomic``. Sums are converted atomic→whole-unit at this edge only,
+ via ``divisor`` (piconero 1e12 for Monero, microTari 1e6 for Tari) with the amount keys prefixed
+ by ``unit`` (``xmr_*`` / ``xtm_*``). ``enabled`` is False when the feature is off
+ (``payouts is None``) — the UI then shows only the estimate; an empty list means "on, nothing
+ 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.
+
+ ``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
+ says so rather than presenting it as a full one. It is deliberately a *may be incomplete*
+ signal, not a *is incomplete* one: a wallet that genuinely earned nothing for six weeks reads
+ as partial too, because the payouts table alone cannot tell "no payout arrived" apart from
+ "we weren't watching yet". Over-warning is the safe direction — the figure is never claimed to
+ be complete when it might not be."""
+ if payouts is None:
+ return {"enabled": False}
+ now = now if now is not None else time.time()
+ 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)
+ 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
+ if ts >= week:
+ atomic["7d"] += amt
+ if ts >= day:
+ atomic["24h"] += amt
+ # 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
+ # 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]
+ since_ts = min(stamps, default=0)
+ starts = {"yesterday": y_start, "7d": week, "30d": month}
+ summary = {
+ "enabled": True,
+ "count": len(payouts),
+ "last_ts": max(stamps, default=0),
+ # Oldest payout on record — where the history these windows are drawn from begins. 0 when
+ # nothing is confirmed yet, which marks every running window partial.
+ "since_ts": since_ts,
+ "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()})
+ return summary
diff --git a/build/dashboard/mining_dashboard/service/telegram_commands.py b/build/dashboard/mining_dashboard/service/telegram_commands.py
index c16c2276..9ae29a1f 100644
--- a/build/dashboard/mining_dashboard/service/telegram_commands.py
+++ b/build/dashboard/mining_dashboard/service/telegram_commands.py
@@ -5,6 +5,7 @@
import requests
+from mining_dashboard.config import config
from mining_dashboard.config.config import (
DASHBOARD_CONTROL_ENABLED,
HOST_IP,
@@ -24,9 +25,15 @@
format_duration,
format_hashrate,
format_xmr,
+ format_xtm,
)
from mining_dashboard.service import control_service
-from mining_dashboard.service.earnings import xmr_per_hs_day, xtm_per_hs_day
+from mining_dashboard.service.earnings import (
+ MICRO_PER_XTM,
+ confirmed_payouts_summary,
+ xmr_per_hs_day,
+ xtm_per_hs_day,
+)
from mining_dashboard.service.egress import egress_posture_from_config
from mining_dashboard.service.metrics import build_metrics
from mining_dashboard.service.telegram_notifier import TELEGRAM_API_BASE
@@ -72,7 +79,7 @@
"/system — host disk, RAM, CPU, HugePages\n"
"/pool — P2Pool sidechain + Monero network\n"
"/xvb — XvB mode, tier, and raffle eligibility\n"
- "/earnings — estimated P2Pool XMR per day\n"
+ "/earnings — estimated P2Pool XMR per day + confirmed yesterday/7d/30d\n"
"/luck — pool cadence: time-to-share, luck, PPLNS weight\n"
"/help — this message"
)
@@ -406,15 +413,56 @@ def format_xvb(metrics, host_label=""):
return "\n".join(lines)
-def format_earnings(metrics, network, host_label=""):
+def _running_lines(summary, unit_key, coin, fmt):
+ """Confirmed running-earnings lines for '/earnings' (#787) — what the wallet actually received
+ over yesterday / 7d / 30d, against the estimate above them.
+
+ ``summary`` is ``service.earnings.confirmed_payouts_summary``'s roll-up (the exact object the
+ dashboard's Confirmed on-chain block renders), so the bot re-derives nothing and the two
+ surfaces cannot disagree (#61/#387). Returns no lines when that chain's view-only wallet is off
+ — the estimate then stands alone, as it did before payout confirmation existed.
+
+ A window the server flagged partial gets a ``*`` and one footnote naming where the recorded
+ history starts, so a total summed over less than its labelled span never reads as a full one."""
+ if not summary or not summary.get("enabled"):
+ return []
+ partial = summary.get("partial") or {}
+ parts = [
+ f"{label} {fmt(summary.get(f'{unit_key}_{win}', 0) or 0)}"
+ + ("*" if partial.get(win) else "")
+ for win, label in (("yesterday", "yesterday"), ("7d", "7d"), ("30d", "30d"))
+ ]
+ lines = [f"\U0001f4e5 Confirmed {coin}: " + " · ".join(parts)]
+ if any(partial.get(w) for w in ("yesterday", "7d", "30d")):
+ since = summary.get("since_ts") or 0
+ where = (
+ f"starts {time.strftime('%Y-%m-%d', time.localtime(since))}"
+ if since
+ else "is empty — no payouts confirmed yet"
+ )
+ lines.append(f"* partial — recorded payout history {where}.")
+ return lines
+
+
+def format_earnings(metrics, network, host_label="", confirmed=None, tari_confirmed=None):
"""Estimated P2Pool XMR earnings — the answer to '/earnings'. Reuses the same rates the
dashboard calculator uses (``xmr_per_hs_day``/``xtm_per_hs_day``) applied to the displayed
P2Pool 1h-average hashrate. The Tari line appears only while merge-mining figures are live —
- the same hashrate earns the XTM alongside the XMR, in addition, not instead (#12, #117)."""
+ the same hashrate earns the XTM alongside the XMR, in addition, not instead (#12, #117).
+
+ ``confirmed`` / ``tari_confirmed`` (#787) are the confirmed-payout roll-ups from the view-only
+ wallets (#381/#462), appended as running yesterday / 7d / 30d totals — the estimate is a model,
+ these are what arrived. ``None`` (that chain's wallet feature off) omits them entirely."""
reward_atomic = (network or {}).get("reward", 0) or 0
coeff_day = xmr_per_hs_day(reward_atomic, metrics.network_difficulty)
+ # Confirmed totals come off the wallet, not the network figures, so they survive the estimate
+ # being uncomputable — a stack waiting on network data can still say what it was paid.
+ running = _running_lines(confirmed, "xmr", "XMR", format_xmr) + _running_lines(
+ tari_confirmed, "xtm", "XTM", format_xtm
+ )
if coeff_day <= 0:
- return f"{_prefix(host_label)}\U0001f4b0 Earnings estimate unavailable (waiting on network data)."
+ head = f"{_prefix(host_label)}\U0001f4b0 Earnings estimate unavailable (waiting on network data)."
+ return "\n".join([head, *running])
daily_1h = coeff_day * metrics.p2pool_1h
lines = [
f"{_prefix(host_label)}\U0001f4b0 Estimated P2Pool earnings",
@@ -437,6 +485,8 @@ def format_earnings(metrics, network, host_label=""):
if tari_daily > 0:
lines.append(f"Tari (merge-mined alongside): ~{tari_daily:.2f} XTM/day")
lines.append("Estimate only — excludes XvB-donated hashrate.")
+ # Estimate first, then what actually landed — the same order the dashboard card uses.
+ lines.extend(running)
return "\n".join(lines)
@@ -653,6 +703,28 @@ def __init__(
self.control_enabled = bool(self.enabled and control_enabled and self.allowed_ids)
self._gate = ControlGate(confirm_timeout)
+ def _payout_summary(self, chain):
+ """Confirmed-payout roll-up for ``chain`` (#787), or ``None`` when that chain's view-only
+ wallet is off — the same ``None`` the dashboard passes to mean "feature off, show only the
+ estimate".
+
+ Reads the stored payouts and rolls them up through the shared
+ :func:`~mining_dashboard.service.earnings.confirmed_payouts_summary`, so the bot's running
+ totals are the identical numbers the dashboard card renders (#61/#387). The config flags are
+ read at call time (module attribute, not a from-import) so a flipped setting takes effect
+ without a re-import — matching ``build_state``'s handling of the same two flags."""
+ enabled = (
+ config.PAYOUT_CONFIRM_ENABLED
+ if chain == "monero"
+ else config.TARI_PAYOUT_CONFIRM_ENABLED
+ )
+ if not enabled:
+ return None
+ payouts = self.data_service.state_manager.get_payouts(chain)
+ if chain == "tari":
+ return confirmed_payouts_summary(payouts, divisor=MICRO_PER_XTM, unit="xtm")
+ return confirmed_payouts_summary(payouts)
+
def reply_for(self, text):
"""Map an incoming message to a reply string, or ``None`` to stay silent.
@@ -708,7 +780,13 @@ def reply_for(self, text):
if cmd == "xvb":
return format_xvb(metrics, self.host_label)
if cmd == "earnings":
- return format_earnings(metrics, data.get("network", {}), self.host_label)
+ return format_earnings(
+ metrics,
+ data.get("network", {}),
+ self.host_label,
+ confirmed=self._payout_summary("monero"),
+ tari_confirmed=self._payout_summary("tari"),
+ )
if cmd == "luck":
return format_luck(metrics, self.host_label)
return None
diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index dfd5b870..0bc02ebd 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -595,23 +595,43 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
// holds their raw text.
// Confirmed on-chain payouts (#381), shown beside the estimate when the view-only wallet feature
// is on (`c.enabled`). Reads the unit-prefixed totals the server rolls up in
-// _confirmed_payouts_summary (`xmr_*` for Monero, `xtm_*` for Tari); `fmt` is the matching coin
+// confirmed_payouts_summary (`xmr_*` for Monero, `xtm_*` for Tari); `fmt` is the matching coin
// formatter and `unit` (XMR / XTM) picks the key prefix. Renders nothing when the feature is off,
// so the estimate stands alone.
+// The running windows (#787) — yesterday, 7d, 30d — are what an operator checks against the
+// estimate above; they carry a `*` and a footnote when the server flagged them partial, so a
+// window summed over less history than its label claims never reads as a complete one. The date
+// comes from `since_ts` (oldest payout on record) formatted in the VIEWER's locale, while the
+// day boundaries were cut in the dashboard container's timezone — close enough to place the
+// history, and the footnote says "starts", not a to-the-hour claim.
function confirmedBlock(c, fmt, unit) {
if (!c || !c.enabled) return null;
const k = unit.toLowerCase();
const n = c.count || 0;
+ const partial = c.partial || {};
+ const anyPartial = ["yesterday", "7d", "30d"].some((w) => partial[w]);
+ const since = c.since_ts ? new Date(c.since_ts * 1000).toLocaleDateString() : null;
+ const hint = since
+ ? `Partial — payout history starts ${since}`
+ : "Partial — no payouts on record yet";
+ const running = (key, label) => html`
+ <${StatCard} label=${partial[key] ? `${label} *` : label}
+ value=${fmt(c[`${k}_${key}`])}
+ title=${partial[key] ? hint : ""} />`;
+ const note = `* ${hint.replace("Partial — ", "")} — the window covers only the history on record, not its full span.`;
return html`
Confirmed on-chain
+ ${running("yesterday", "Yesterday")}
<${StatCard} label="Confirmed 24h" value=${fmt(c[`${k}_24h`])} />
- <${StatCard} label="Confirmed 7d" value=${fmt(c[`${k}_7d`])} />
+ ${running("7d", "Running 7d")}
+ ${running("30d", "Running 30d")}
<${StatCard} label="Confirmed all-time" value=${fmt(c[`${k}_all`])} />
<${StatCard} label="Last payout" value=${formatAgo(c.last_ts)}
title=${"Across " + n + " confirmed payout" + (n === 1 ? "" : "s")} />
+ ${anyPartial ? html`
${note}
` : null}
`;
}
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index 86edebea..4b8407a0 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,
+ confirmed_payouts_summary,
tari_seconds_to_block_per_hs,
xmr_per_hs_day,
xtm_per_hs_day,
@@ -1464,39 +1465,6 @@ def build_badges(data, metrics, mode_variant, db_healthy=True, wallet_change=Non
)
-def _confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="xmr"):
- """Roll confirmed on-chain payouts into 24h / 7d / all-time totals + a count (#381/#462).
-
- ``payouts`` is the stored-payout list (``storage.get_payouts(chain)``): each carries ``ts``
- (unix seconds) and ``amount_atomic``. Sums are converted atomic→whole-unit at this edge only,
- via ``divisor`` (piconero 1e12 for Monero, microTari 1e6 for Tari) with the amount keys prefixed
- by ``unit`` (``xmr_*`` / ``xtm_*``). ``enabled`` is False when the feature is off
- (``payouts is None``) — the UI then shows only the estimate; an empty list means "on, nothing
- confirmed yet" (shows 0.000000)."""
- if payouts is None:
- return {"enabled": False}
- now = now if now is not None else time.time()
- day, week = now - 86_400, now - 7 * 86_400
- atomic_24h = atomic_7d = atomic_all = 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 >= week:
- atomic_7d += amt
- if ts >= day:
- atomic_24h += amt
- last_ts = max((p.get("ts", 0) or 0 for p in payouts), default=0)
- return {
- "enabled": True,
- "count": len(payouts),
- f"{unit}_24h": atomic_24h / divisor,
- f"{unit}_7d": atomic_7d / divisor,
- f"{unit}_all": atomic_all / divisor,
- "last_ts": last_ts,
- }
-
-
def xvb_current_tier_reward_day(metrics, state_mgr):
"""XvB's published expected reward for the tier the fleet is CURRENTLY holding, as XMR/day (#712).
@@ -1537,10 +1505,10 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None)
"""Expected-XMR-from-P2Pool calculator inputs for the Advanced view (Issue #12).
``payouts`` (#381), when the view-only wallet feature is on, is the stored confirmed-payout
- list; it's rolled into a ``confirmed`` block (24h / 7d / all-time XMR) shown beside this
- estimate — the estimate is a model, the confirmed figure is ground truth from the wallet.
- ``tari_payouts`` (#462) is the same for the Tari side, rolled into ``tari_confirmed`` (XTM)
- beside the Tari time-to-block estimate.
+ list; it's rolled into a ``confirmed`` block (yesterday / 24h / 7d / 30d / all-time XMR, #787)
+ shown beside this estimate — the estimate is a model, the confirmed figure is ground truth from
+ the wallet. ``tari_payouts`` (#462) is the same for the Tari side, rolled into
+ ``tari_confirmed`` (XTM) beside the Tari time-to-block estimate.
This is a **P2Pool** mining calculator: it estimates the XMR earned by the hashrate that is
actually mining on your P2Pool node — *not* the rig's total output. The what-if default is
@@ -1593,10 +1561,10 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None)
"disclaimer": _EARNINGS_DISCLAIMER,
# Confirmed on-chain payouts (#381), beside the estimate above. {"enabled": False} when the
# view-only wallet feature is off — the UI then shows only the estimate.
- "confirmed": _confirmed_payouts_summary(payouts),
+ "confirmed": confirmed_payouts_summary(payouts),
# Confirmed Tari payouts (#462), beside the Tari time-to-block estimate. XTM (microTari),
# {"enabled": False} when the Tari view-only wallet feature is off.
- "tari_confirmed": _confirmed_payouts_summary(
+ "tari_confirmed": confirmed_payouts_summary(
tari_payouts, divisor=MICRO_PER_XTM, unit="xtm"
),
}
diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs
index 761edc2c..28742923 100644
--- a/build/dashboard/tests/frontend/components.test.mjs
+++ b/build/dashboard/tests/frontend/components.test.mjs
@@ -477,12 +477,18 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe
s.earnings.tari_available = true;
s.earnings.tari_coeff_day = 2e-3;
s.earnings.confirmed = {
- enabled: true, count: 3, xmr_24h: 0.25, xmr_7d: 0.75, xmr_all: 1.75,
+ enabled: true, count: 3, xmr_24h: 0.25, xmr_yesterday: 0.5, xmr_7d: 0.75,
+ xmr_30d: 1.25, xmr_all: 1.75,
last_ts: Math.floor(Date.now() / 1000) - 3600,
+ since_ts: Math.floor(Date.now() / 1000) - 90 * 86400,
+ partial: { yesterday: false, '7d': false, '30d': false },
};
s.earnings.tari_confirmed = {
- enabled: true, count: 1, xtm_24h: 0, xtm_7d: 4552.15, xtm_all: 4552.15,
+ enabled: true, count: 1, xtm_24h: 0, xtm_yesterday: 0, xtm_7d: 4552.15,
+ xtm_30d: 4552.15, xtm_all: 4552.15,
last_ts: Math.floor(Date.now() / 1000) - 7200,
+ since_ts: Math.floor(Date.now() / 1000) - 90 * 86400,
+ partial: { yesterday: false, '7d': false, '30d': false },
};
let html = renderApp({ state: s });
// One confirmed block per tab, populated from the summary keys through the coin formatters.
@@ -491,6 +497,14 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe
assert.match(html, /1\.7500 XMR/); // xmr_all
assert.match(html, /4552\.1500 XTM/); // xtm_all
assert.match(html, /Last payout/);
+ // Running windows (#787): yesterday / 7d / 30d beside the existing 24h and all-time figures.
+ assert.match(html, /Yesterday/);
+ assert.match(html, /Running 7d/);
+ assert.match(html, /Running 30d/);
+ assert.match(html, /0\.500000 XMR/); // xmr_yesterday
+ assert.match(html, /1\.2500 XMR/); // xmr_30d
+ // History predates every window here, so nothing is marked partial and no footnote appears.
+ assert.doesNotMatch(html, /Partial/);
// Estimates first, confirmed reality after — on the Tari tab the block follows the
// Long-run Average table, mirroring the Monero tab's order.
const tari = html.slice(html.indexOf('id="epanel-tari"'), html.indexOf('id="epanel-xvb"'));
@@ -502,6 +516,38 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe
assert.doesNotMatch(html, /Confirmed on-chain/);
});
+test('EarningsCard marks running windows the payout history does not fully cover (#787)', () => {
+ const s = clone();
+ s.earnings.available = true;
+ s.earnings.tari_confirmed = { enabled: false };
+ // History starts 3 days ago: yesterday is covered, 7d and 30d reach behind it.
+ s.earnings.confirmed = {
+ enabled: true, count: 1, xmr_24h: 0, xmr_yesterday: 0.5, xmr_7d: 0.5,
+ xmr_30d: 0.5, xmr_all: 0.5,
+ last_ts: Math.floor(Date.now() / 1000) - 3 * 86400,
+ since_ts: Math.floor(Date.now() / 1000) - 3 * 86400,
+ partial: { yesterday: false, '7d': true, '30d': true },
+ };
+ let html = renderApp({ state: s });
+ // Only the flagged windows carry the marker — a covered window must not be hedged.
+ assert.match(html, /Running 7d \*/);
+ assert.match(html, /Running 30d \*/);
+ assert.doesNotMatch(html, /Yesterday \*/);
+ // One footnote states where the recorded history starts, so a short window never reads as full.
+ assert.match(html, /payout history starts/);
+ assert.match(html, /covers only the history on record/);
+ // Nothing confirmed at all: every running window is flagged and the footnote says so instead
+ // of naming a date it doesn't have.
+ s.earnings.confirmed = {
+ enabled: true, count: 0, xmr_24h: 0, xmr_yesterday: 0, xmr_7d: 0, xmr_30d: 0, xmr_all: 0,
+ last_ts: 0, since_ts: 0,
+ partial: { yesterday: true, '7d': true, '30d': true },
+ };
+ html = renderApp({ state: s });
+ assert.match(html, /Yesterday \*/);
+ assert.match(html, /no payouts on record yet/);
+});
+
test('EarningsCard XvB tab shows the published current-tier reward as a day/month/year table', () => {
const s = clone();
s.earnings.available = true;
diff --git a/build/dashboard/tests/helper/test_utils.py b/build/dashboard/tests/helper/test_utils.py
index 8be0fbb8..2ec29ae1 100644
--- a/build/dashboard/tests/helper/test_utils.py
+++ b/build/dashboard/tests/helper/test_utils.py
@@ -11,6 +11,7 @@
format_hashrate,
format_time_abs,
format_xmr,
+ format_xtm,
get_tier_info,
is_ip_address,
parse_hashrate,
@@ -153,6 +154,22 @@ def test_zero_and_bad_data(self):
assert format_xmr(float("inf")) == "—"
+class TestFormatXtm:
+ """#387: the Tari sibling — mirrors formatXtm in web/static/logic.mjs, so a confirmed Tari
+ total (#787) reads identically in the bot and on the dashboard card."""
+
+ def test_precision_scales_with_magnitude(self):
+ assert format_xtm(4552.15) == "4552.1500 XTM" # >= 1 -> 4 dp
+ assert format_xtm(0.1234567) == "0.123457 XTM" # >= 0.001 -> 6 dp
+ assert format_xtm(0.00000123) == "0.00000123 XTM" # tiny -> 8 dp, not rounded to 0
+
+ def test_zero_and_bad_data(self):
+ assert format_xtm(0) == "0 XTM"
+ assert format_xtm(None) == "—"
+ assert format_xtm("invalid") == "—"
+ assert format_xtm(float("inf")) == "—"
+
+
class TestFormatDuration:
def test_branches(self):
assert format_duration(90000) == "1d 1h 0m"
diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py
index bdede8d3..a178cb2b 100644
--- a/build/dashboard/tests/service/test_earnings.py
+++ b/build/dashboard/tests/service/test_earnings.py
@@ -6,11 +6,16 @@
what-if hashrate; that scaling/formatting is tested in tests/frontend/logic.test.mjs.
"""
+import os
+import time
+
import pytest
from mining_dashboard.service.earnings import (
ATOMIC_PER_XMR,
SECONDS_PER_DAY,
+ confirmed_payouts_summary,
+ previous_local_day,
tari_seconds_to_block_per_hs,
xmr_per_hs_day,
xtm_per_hs_day,
@@ -100,3 +105,134 @@ def test_is_difficulty_per_hs(self):
def test_missing_or_bad_difficulty_is_zero(self, diff):
# Zero difficulty -> zero rate -> the card shows "—" (graceful degradation).
assert tari_seconds_to_block_per_hs(diff) == 0.0
+
+
+# --- Confirmed payouts summary (#381/#462, running windows #787) -----------------------
+
+
+def _local(year, month, day, hh=0, mm=0, ss=0):
+ """Unix seconds for a LOCAL wall-clock time. The summary cuts calendar days in the dashboard
+ container's timezone, so the fixtures are built the same way — the assertions then hold under
+ whatever TZ the suite happens to run in, instead of only under UTC."""
+ return time.mktime((year, month, day, hh, mm, ss, 0, 0, -1))
+
+
+class TestPreviousLocalDay:
+ def test_is_yesterdays_midnight_to_todays(self):
+ start, end = previous_local_day(_local(2026, 7, 28, 15, 30))
+ assert (start, end) == (_local(2026, 7, 27), _local(2026, 7, 28))
+
+ def test_is_stable_across_the_whole_day(self):
+ # Any hour of today must resolve to the same "yesterday" — including the two boundaries.
+ expected = (_local(2026, 7, 27), _local(2026, 7, 28))
+ assert previous_local_day(_local(2026, 7, 28, 0, 0, 0)) == expected
+ assert previous_local_day(_local(2026, 7, 28, 23, 59, 59)) == expected
+
+ def test_short_dst_day_still_lands_on_yesterday(self):
+ # Europe/London springs forward on 2026-03-29 (01:00 -> 02:00), making that day 23h long.
+ # Subtracting a flat 86_400 from 2026-03-30 midnight would land at 23:00 on the 28th — the
+ # WRONG date. Stepping back through noon keeps it on the 29th.
+ prev_tz = os.environ.get("TZ")
+ os.environ["TZ"] = "Europe/London"
+ time.tzset()
+ try:
+ start, end = previous_local_day(_local(2026, 3, 30, 12, 0))
+ assert (start, end) == (_local(2026, 3, 29), _local(2026, 3, 30))
+ assert end - start == 23 * 3_600 # the short day, proving it isn't a flat 24h subtract
+ finally:
+ if prev_tz is None:
+ os.environ.pop("TZ", None)
+ else:
+ os.environ["TZ"] = prev_tz
+ time.tzset()
+
+
+class TestConfirmedPayoutsSummary:
+ # Local noon, so "yesterday" is unambiguously the previous calendar day.
+ NOW = _local(2026, 7, 28, 12, 0)
+ ONE_XMR = ATOMIC_PER_XMR
+
+ def test_disabled_when_payouts_none(self):
+ # Feature off (storage returns None) -> only the enabled flag, no totals.
+ assert confirmed_payouts_summary(None) == {"enabled": False}
+
+ def test_empty_list_is_on_with_zeros(self):
+ # On, nothing confirmed yet: enabled, count 0, every window 0.0, no last payout — and every
+ # running window flagged partial, because no history means no proof the windows are covered.
+ s = confirmed_payouts_summary([], now=self.NOW)
+ assert s == {
+ "enabled": True,
+ "count": 0,
+ "xmr_24h": 0.0,
+ "xmr_yesterday": 0.0,
+ "xmr_7d": 0.0,
+ "xmr_30d": 0.0,
+ "xmr_all": 0.0,
+ "last_ts": 0,
+ "since_ts": 0,
+ "partial": {"yesterday": True, "7d": True, "30d": True},
+ }
+
+ def test_trailing_windows_bucket_by_age(self):
+ # One payout in each band; 1 XMR = 1e12 piconero. 24h ⊆ 7d ⊆ 30d ⊆ all-time.
+ payouts = [
+ {"ts": self.NOW - 3_600, "amount_atomic": self.ONE_XMR}, # 1h -> 24h, 7d, 30d, all
+ {"ts": self.NOW - 3 * 86_400, "amount_atomic": 2 * self.ONE_XMR}, # 3d -> 7d, 30d, all
+ {"ts": self.NOW - 10 * 86_400, "amount_atomic": 4 * self.ONE_XMR}, # 10d -> 30d, all
+ {"ts": self.NOW - 40 * 86_400, "amount_atomic": 8 * self.ONE_XMR}, # 40d -> all only
+ ]
+ s = confirmed_payouts_summary(payouts, now=self.NOW)
+ assert s["count"] == 4
+ assert s["xmr_24h"] == 1.0
+ assert s["xmr_7d"] == 3.0
+ assert s["xmr_30d"] == 7.0
+ assert s["xmr_all"] == 15.0
+ assert s["last_ts"] == self.NOW - 3_600
+ assert s["since_ts"] == self.NOW - 40 * 86_400
+
+ def test_yesterday_is_the_previous_calendar_day(self):
+ # A calendar day, NOT a trailing 24h: both edges of yesterday count, today's midnight and
+ # the day before's last second do not. Half-open [start, end) — midnight belongs to the day
+ # it opens, so a payout is never counted into two days.
+ payouts = [
+ {"ts": _local(2026, 7, 26, 23, 59, 59), "amount_atomic": self.ONE_XMR}, # day before
+ {"ts": _local(2026, 7, 27, 0, 0, 0), "amount_atomic": 2 * self.ONE_XMR}, # yesterday
+ {"ts": _local(2026, 7, 27, 23, 59, 59), "amount_atomic": 4 * self.ONE_XMR}, # yesterday
+ {"ts": _local(2026, 7, 28, 0, 0, 0), "amount_atomic": 8 * self.ONE_XMR}, # today
+ ]
+ s = confirmed_payouts_summary(payouts, now=self.NOW)
+ assert s["xmr_yesterday"] == 6.0
+ # 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
+
+ def test_partial_flags_track_recorded_history(self):
+ # History starts 10 days back: the 30d window reaches behind it (partial), 7d and yesterday
+ # sit inside it (complete).
+ payouts = [{"ts": self.NOW - 10 * 86_400, "amount_atomic": self.ONE_XMR}]
+ s = confirmed_payouts_summary(payouts, now=self.NOW)
+ assert s["partial"] == {"yesterday": False, "7d": False, "30d": True}
+
+ def test_history_starting_today_marks_every_running_window(self):
+ # First payout landed this morning — nothing on record covers yesterday, 7d or 30d.
+ payouts = [{"ts": _local(2026, 7, 28, 9, 0), "amount_atomic": self.ONE_XMR}]
+ s = confirmed_payouts_summary(payouts, now=self.NOW)
+ assert s["partial"] == {"yesterday": True, "7d": True, "30d": True}
+
+ def test_zero_timestamp_row_cannot_date_the_history(self):
+ # A row with no usable ts must not win the since_ts minimum and declare every window
+ # complete on the strength of a broken record.
+ payouts = [
+ {"ts": 0, "amount_atomic": self.ONE_XMR},
+ {"ts": self.NOW - 2 * 86_400, "amount_atomic": self.ONE_XMR},
+ ]
+ s = confirmed_payouts_summary(payouts, now=self.NOW)
+ assert s["since_ts"] == self.NOW - 2 * 86_400
+ assert s["partial"]["30d"] is True
+
+ def test_tari_unit_and_divisor(self):
+ # Tari reuses the helper with the microTari divisor (1e6) and xtm_* keys.
+ payouts = [{"ts": self.NOW, "amount_atomic": 2_500_000}]
+ s = confirmed_payouts_summary(payouts, now=self.NOW, divisor=1_000_000, unit="xtm")
+ assert s["xtm_all"] == 2.5
+ assert "xtm_30d" in s and "xtm_yesterday" in s and "xmr_all" not in s
diff --git a/build/dashboard/tests/service/test_telegram_commands.py b/build/dashboard/tests/service/test_telegram_commands.py
index 9c4c5122..5f3be419 100644
--- a/build/dashboard/tests/service/test_telegram_commands.py
+++ b/build/dashboard/tests/service/test_telegram_commands.py
@@ -6,12 +6,14 @@
"""
import asyncio
+import time
from dataclasses import replace
from types import SimpleNamespace
import pytest
from mining_dashboard.service import telegram_commands as tc
+from mining_dashboard.service.earnings import confirmed_payouts_summary, previous_local_day
from mining_dashboard.service.metrics import Metrics, SyncMetric
_SYNCED = SyncMetric(
@@ -382,6 +384,88 @@ def test_earnings_omits_tari_line_without_tari_figures():
assert "XMR/day" in out # the XMR estimate is unaffected
+# --- Confirmed running earnings on /earnings (#787) ---------------------------------------
+#
+# The window math itself is proven once, in tests/service/test_earnings.py — these build the
+# summaries through the real confirmed_payouts_summary so the bot is exercised against the exact
+# object the dashboard card renders (#61/#387), and assert only what the bot does with it.
+
+_NET = {"reward": 600_000_000_000}
+_ONE_XMR = 1_000_000_000_000
+
+
+def test_earnings_appends_confirmed_running_totals():
+ # A year of history with a payout in each window → yesterday / 7d / 30d land as actuals under
+ # the estimate, all complete (history predates every window), so no marker and no footnote.
+ now = time.time()
+ day_start, _ = previous_local_day(now)
+ payouts = [
+ {"ts": now - 365 * 86_400, "amount_atomic": _ONE_XMR}, # old — dates the history
+ {"ts": day_start + 3_600, "amount_atomic": 2 * _ONE_XMR}, # yesterday
+ {"ts": now - 3 * 86_400, "amount_atomic": 4 * _ONE_XMR}, # inside 7d
+ {"ts": now - 20 * 86_400, "amount_atomic": 8 * _ONE_XMR}, # inside 30d only
+ ]
+ out = tc.format_earnings(
+ _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary(payouts, now=now)
+ )
+ assert "Confirmed XMR: yesterday 2.0000 XMR · 7d 6.0000 XMR · 30d 14.0000 XMR" in out
+ assert "*" not in out.split("Confirmed XMR:")[1] # nothing partial → no marker, no footnote
+ assert out.index("1h avg") < out.index("Confirmed XMR:") # estimate leads, actuals follow
+
+
+def test_earnings_marks_partial_windows_with_history_start():
+ # History starts 3 days ago: yesterday is covered, 7d and 30d reach behind it and must say so
+ # rather than reading as full windows.
+ now = time.time()
+ payouts = [{"ts": now - 3 * 86_400, "amount_atomic": _ONE_XMR}]
+ out = tc.format_earnings(
+ _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary(payouts, now=now)
+ )
+ yesterday, seven, thirty = out.split("Confirmed XMR: ")[1].splitlines()[0].split(" · ")
+ assert not yesterday.endswith("*") and seven.endswith("*") and thirty.endswith("*")
+ assert "* partial — recorded payout history starts " in out
+
+
+def test_earnings_partial_footnote_names_an_empty_history():
+ # Wallet on, nothing confirmed yet: the zeros are honest, but every window is partial and the
+ # footnote says the history is empty rather than naming a date it doesn't have.
+ out = tc.format_earnings(
+ _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary([], now=time.time())
+ )
+ assert "Confirmed XMR: yesterday 0 XMR* · 7d 0 XMR* · 30d 0 XMR*" in out
+ assert "* partial — recorded payout history is empty — no payouts confirmed yet." in out
+
+
+def test_earnings_includes_confirmed_tari_totals():
+ # #462 side: the same roll-up over microTari, rendered with the XTM precision the card uses.
+ now = time.time()
+ payouts = [{"ts": now - 2 * 86_400, "amount_atomic": 4_552_150_000}] # 4552.15 XTM
+ out = tc.format_earnings(
+ _metrics(p2pool_1h=8000.0),
+ _NET,
+ tari_confirmed=confirmed_payouts_summary(payouts, now=now, divisor=1_000_000, unit="xtm"),
+ )
+ # History starts two days back, so yesterday is covered (no marker) while 7d/30d are not.
+ assert "Confirmed XTM: yesterday 0 XTM · 7d 4552.1500 XTM* · 30d 4552.1500 XTM*" in out
+
+
+def test_earnings_confirmed_survives_missing_network_data():
+ # The estimate needs live network figures; the confirmed totals come off the wallet and don't.
+ # A stack waiting on network data can still report what it was actually paid.
+ now = time.time()
+ payouts = [{"ts": now - 40 * 86_400, "amount_atomic": _ONE_XMR}]
+ out = tc.format_earnings(_metrics(), {}, confirmed=confirmed_payouts_summary(payouts, now=now))
+ assert "unavailable" in out
+ assert "Confirmed XMR: yesterday 0 XMR · 7d 0 XMR · 30d 0 XMR" in out
+
+
+def test_earnings_omits_confirmed_when_wallet_feature_is_off():
+ # None (the default — no view-only wallet configured) → the estimate stands alone, exactly as
+ # it read before payout confirmation existed.
+ out = tc.format_earnings(_metrics(p2pool_1h=8000.0), _NET, confirmed=None, tari_confirmed=None)
+ assert "Confirmed" not in out
+
+
def test_luck_reads_the_cadence_metrics():
# #84: the four figures come straight off Metrics — the same fields the dashboard card shows.
out = tc.format_luck(
@@ -540,7 +624,47 @@ def test_reply_for_pool_and_xvb(monkeypatch):
def test_reply_for_earnings(monkeypatch):
bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0)
- assert "XMR/day" in bot.reply_for("/earnings")
+ reply = bot.reply_for("/earnings")
+ assert "XMR/day" in reply
+ # Payout confirmation is off by default, so the estimate stands alone and storage is never read
+ # (the stub state_manager has no get_payouts — reaching for it would raise).
+ assert "Confirmed" not in reply
+
+
+def test_reply_for_earnings_rolls_up_stored_payouts(monkeypatch):
+ # Feature on: /earnings reads the stored payouts per chain and appends the running totals.
+ # The flags are read off the config MODULE at call time, so flipping them here takes effect
+ # without a re-import — the same handling build_state uses.
+ monkeypatch.setattr(tc.config, "PAYOUT_CONFIRM_ENABLED", True)
+ monkeypatch.setattr(tc.config, "TARI_PAYOUT_CONFIRM_ENABLED", True)
+ now = time.time()
+ yday = previous_local_day(now)[0] + 3_600
+ stored = {
+ "monero": [{"ts": yday, "amount_atomic": _ONE_XMR}],
+ "tari": [{"ts": yday, "amount_atomic": 2_000_000}], # 2 XTM
+ }
+ bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0)
+ bot.data_service.state_manager.get_payouts = stored.get
+ reply = bot.reply_for("/earnings")
+ assert "Confirmed XMR: yesterday 1.0000 XMR" in reply # piconero divisor
+ assert "Confirmed XTM: yesterday 2.0000 XTM" in reply # microTari divisor
+
+
+def test_reply_for_earnings_skips_the_chain_whose_wallet_is_off(monkeypatch):
+ # Monero confirmation on, Tari off → only the XMR totals appear, and Tari payouts are not read.
+ monkeypatch.setattr(tc.config, "PAYOUT_CONFIRM_ENABLED", True)
+ monkeypatch.setattr(tc.config, "TARI_PAYOUT_CONFIRM_ENABLED", False)
+ asked = []
+
+ def _payouts(chain):
+ asked.append(chain)
+ return []
+
+ bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0)
+ bot.data_service.state_manager.get_payouts = _payouts
+ reply = bot.reply_for("/earnings")
+ assert asked == ["monero"]
+ assert "Confirmed XMR" in reply and "Confirmed XTM" not in reply
def test_reply_for_hashrate_and_sync(monkeypatch):
diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py
index 3a17bdd8..6a8cedf0 100644
--- a/build/dashboard/tests/web/test_views.py
+++ b/build/dashboard/tests/web/test_views.py
@@ -24,7 +24,6 @@
from mining_dashboard.web.views import (
_MAX_CHART_POINTS,
_chart_tension,
- _confirmed_payouts_summary,
_mode_palette,
_reject_flag,
_rigforge_display,
@@ -1241,61 +1240,6 @@ def test_thermal_hold_wins_over_temp_chip(self):
assert not any("°C" in t for t in texts) # the hold chip replaces the temp chip
-# --- Confirmed payouts summary (#381) -------------------------------------------------
-
-
-class TestConfirmedPayoutsSummary:
- # A fixed "now" so the 24h/7d window boundaries are deterministic.
- NOW = 1_000_000_000
-
- def test_disabled_when_payouts_none(self):
- # Feature off (storage returns None) -> only the enabled flag, no totals.
- assert _confirmed_payouts_summary(None) == {"enabled": False}
-
- def test_empty_list_is_on_with_zeros(self):
- # On, nothing confirmed yet: enabled, count 0, all windows 0.0, no last payout.
- s = _confirmed_payouts_summary([], now=self.NOW)
- assert s == {
- "enabled": True,
- "count": 0,
- "xmr_24h": 0.0,
- "xmr_7d": 0.0,
- "xmr_all": 0.0,
- "last_ts": 0,
- }
-
- def test_windows_bucket_by_age(self):
- # One payout in each band; 1 XMR = 1e12 piconero. 24h ⊆ 7d ⊆ all-time.
- one_xmr = 1_000_000_000_000
- payouts = [
- {"ts": self.NOW - 3_600, "amount_atomic": one_xmr}, # 1h ago -> in 24h, 7d, all
- {"ts": self.NOW - 3 * 86_400, "amount_atomic": 2 * one_xmr}, # 3d -> 7d, all
- {"ts": self.NOW - 10 * 86_400, "amount_atomic": 4 * one_xmr}, # 10d -> all only
- ]
- s = _confirmed_payouts_summary(payouts, now=self.NOW)
- assert s["count"] == 3
- assert s["xmr_24h"] == 1.0
- assert s["xmr_7d"] == 3.0
- assert s["xmr_all"] == 7.0
- assert s["last_ts"] == self.NOW - 3_600
-
- def test_all_older_than_windows(self):
- # Every payout predates both windows: 24h and 7d are 0, all-time still sums.
- one_xmr = 1_000_000_000_000
- payouts = [{"ts": self.NOW - 30 * 86_400, "amount_atomic": 5 * one_xmr}]
- s = _confirmed_payouts_summary(payouts, now=self.NOW)
- assert s["xmr_24h"] == 0.0
- assert s["xmr_7d"] == 0.0
- assert s["xmr_all"] == 5.0
-
- def test_tari_unit_and_divisor(self):
- # Tari reuses the helper with the microTari divisor (1e6) and xtm_* keys.
- payouts = [{"ts": self.NOW, "amount_atomic": 2_500_000}]
- s = _confirmed_payouts_summary(payouts, now=self.NOW, divisor=1_000_000, unit="xtm")
- assert s["xtm_all"] == 2.5
- assert "xtm_24h" in s and "xmr_all" not in s
-
-
# --- Tari -----------------------------------------------------------------------------
@@ -1862,19 +1806,25 @@ def test_confirmed_disabled_by_default(self):
e = build_earnings(self._NET, _metrics())
assert e["confirmed"] == {"enabled": False}
- # ponytail: the 24h/7d/all windowing math is proven once, in TestConfirmedPayoutsSummary —
- # this class only asserts build_earnings passes payouts through (enabled/empty/disabled).
+ # ponytail: the yesterday/24h/7d/30d/all windowing math is proven once, in
+ # tests/service/test_earnings.py::TestConfirmedPayoutsSummary — this class only asserts
+ # build_earnings passes payouts through (enabled/empty/disabled).
def test_confirmed_enabled_but_empty(self):
# Feature on, nothing confirmed yet → enabled with zeroed totals (shows 0.000000, not "—").
+ # No history on record, so every running window is flagged partial (#787).
e = build_earnings(self._NET, _metrics(), payouts=[])
assert e["confirmed"] == {
"enabled": True,
"count": 0,
"xmr_24h": 0.0,
+ "xmr_yesterday": 0.0,
"xmr_7d": 0.0,
+ "xmr_30d": 0.0,
"xmr_all": 0.0,
"last_ts": 0,
+ "since_ts": 0,
+ "partial": {"yesterday": True, "7d": True, "30d": True},
}
def test_tari_confirmed_disabled_by_default(self):
@@ -1888,9 +1838,13 @@ def test_tari_confirmed_enabled_but_empty(self):
"enabled": True,
"count": 0,
"xtm_24h": 0.0,
+ "xtm_yesterday": 0.0,
"xtm_7d": 0.0,
+ "xtm_30d": 0.0,
"xtm_all": 0.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 f7674468..1755ebed 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -504,10 +504,26 @@ when you give the stack a way to check the chain. Set `monero.view_key` (the pri
for your payout address) and the stack runs a **view-only** `monero-wallet-rpc` against your local
node, scanning for confirmed incoming payouts. P2Pool pays each miner's share directly in a Monero
block's coinbase, so the wallet is the only ground truth that a payout arrived. The Monero tab of
-the earnings card then shows a **Confirmed on-chain** block under the estimate — 24-hour, 7-day, and
-all-time XMR totals plus the time since the **last payout** — and a `payout_confirmed` alert fires
-once per payout (Telegram and the other sinks). The Tari tab carries the same **Confirmed on-chain**
-block in XTM once Tari payout confirmation is on (see the Tari note below).
+the earnings card then shows a **Confirmed on-chain** block under the estimate — and a
+`payout_confirmed` alert fires once per payout (Telegram and the other sinks). The Tari tab carries
+the same **Confirmed on-chain** block in XTM once Tari payout confirmation is on (see the Tari note
+below).
+
+| Figure | What it sums |
+|---|---|
+| **Yesterday** | The previous full calendar day, midnight to midnight in the dashboard's timezone (`dashboard.timezone`) — the same clock the daily summary fires on. Not a trailing 24 hours. |
+| **Confirmed 24h** | The trailing 24 hours from now. Deliberately a different span from **Yesterday**, so the two disagree during the day. |
+| **Running 7d** | The trailing 7 days from now. |
+| **Running 30d** | The trailing 30 days from now. |
+| **Confirmed all-time** | Every payout recorded, however far back. |
+| **Last payout** | Time since the most recent confirmed payout, hover for the payout count. |
+
+A running window is marked with a `*` when it reaches back further than the oldest payout on
+record — the total then covers only the history the wallet gave the dashboard, not the full span its
+label names, and a footnote says where that history starts. Read the marker as *may be incomplete*:
+a wallet that genuinely earned nothing for six weeks is marked too, because the recorded payouts
+alone can't tell "no payout arrived" apart from "we weren't watching yet". A fresh install marks
+every running window until history builds up behind it.
Each confirmed Monero payout also drops a **Payouts** marker — a green coin at the block time it
landed — onto the hashrate chart, on the same marker row as the event diamonds and raffle stars.
diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md
index bce8c1cf..1423b99b 100644
--- a/docs/dev/testing-strategy.md
+++ b/docs/dev/testing-strategy.md
@@ -104,6 +104,7 @@ The deploy-time axes — each changes a real runtime path. Full table and assert
| dashboard DB writes failing → `db_healthy:false` (#131) | data dir read-only | 1 ✅ (flag logic) · 4 ▶ (`--fault-injection`, #202) |
| `/metrics` Prometheus exposition (#379), through Caddy + basic_auth | scrape | 1 ✅ (format) · 4 ▶ (`--check`) |
| `share_stats` series populated on a mining box (#116) | polls land | 1 ✅ (shape) · 4 ▶ (`--check`) |
+| Confirmed running earnings (#787): yesterday as a **calendar** day vs the trailing 24h/7d/30d spans, the DST-length day boundary, partial marking when a window outruns the recorded payout history, and one roll-up feeding both the dashboard card and `/earnings` | stored payouts | 1 ✅ (`test_earnings.py`, `test_telegram_commands.py`, `components.test.mjs`) |
| Dashboard reads correct live state on a real stack | real daemons | 4 ▶ |
### G. CLI lifecycle (`pithead`)
diff --git a/docs/telegram.md b/docs/telegram.md
index d510aeaf..85a528ef 100644
--- a/docs/telegram.md
+++ b/docs/telegram.md
@@ -222,7 +222,7 @@ Run `./pithead apply` after editing. The commands:
| `/system` | Host resources: disk, RAM, CPU + load, and HugePages. |
| `/pool` | P2Pool sidechain type, pool hashrate, Monero network height + difficulty, PPLNS shares in window, current **effort** (luck indicator), **sidechain blocks found**, **share acceptance** (accepted/rejected + reject %), and the **best share** difficulty found. |
| `/xvb` | XvB mode, current and target tier, the target tier's **threshold and cost** (holding a tier ≈ donating its threshold continuously, on both credited averages; a tier is **raffle status, not an XMR payout**), hashrate **routed** to XvB, the **credited** 1h/24h averages XvB measures (what sets your tier), raffle eligibility (PPLNS share), and a stale-data warning if the XvB feed is behind. |
-| `/earnings` | Estimated P2Pool XMR per day/month, from both your **1h** and (once available) steadier **24h** average hashrate, plus the XTM the same hashrate **merge-mines alongside** (shown only while merge-mining figures are live). Excludes XvB-donated hashrate. |
+| `/earnings` | Estimated P2Pool XMR per day/month, from both your **1h** and (once available) steadier **24h** average hashrate, plus the XTM the same hashrate **merge-mines alongside** (shown only while merge-mining figures are live). Excludes XvB-donated hashrate. With [payout confirmation](dashboard.md#payout-confirmation) on, the estimate is followed by what actually landed: **confirmed yesterday / 7d / 30d** totals per chain, the same figures and `*` partial marking the dashboard's Confirmed on-chain block shows. The confirmed totals come off the wallet, so they still report even when the estimate is waiting on network data. |
| `/luck` | Pool cadence: time since the pool's last block (pool-wide, not your payout), estimated **time-to-share** for your hashrate, **luck** (actual vs. expected shares in the PPLNS window — over 100 % = running lucky), and **your PPLNS weight** (the sum of your share difficulty in the window). The same figures as the dashboard's Pool Cadence & Luck card. |
| `/help` | The command list. |