diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 63011b2..8cd8816 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -790,6 +790,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
One Super Chat or Super Sticker event, as YouTube reports it.
+public sealed record SuperChatEvent(
+ string Id,
+ ChatEventKind Kind,
+ long AmountMicros,
+ string Currency,
+ string? DisplayAmount,
+ DateTime PublishedAt);
+
+///
+/// TASK 3·20: what this session's rewards added up to — in memory only.
+/// Derived on demand from superChatEvents.list rather than persisted: YouTube is the system
+/// of record for money, and keeping a local ledger would contradict the published promise that no
+/// viewer, member or donor information is retained after the session ends.
+/// Totals are kept per currency and never summed across currencies — a $5 and a €5 are not
+/// $10, and a single blended number would be a lie.
+///
+public sealed record SessionRewardSummary(
+ int SuperChatCount,
+ int SuperStickerCount,
+ IReadOnlyDictionary MicrosByCurrency,
+ DateTime? FirstEventAt,
+ DateTime? LastEventAt)
+{
+ public static readonly SessionRewardSummary Empty = new(
+ 0, 0, new Dictionary(), null, null);
+
+ public int TotalEvents => SuperChatCount + SuperStickerCount;
+
+ public bool HasEvents => TotalEvents > 0;
+
+ /// Filters to a session window and rolls the events up. Events outside
+ /// [, ] are ignored, which is what makes a
+ /// report "this session" rather than "this channel".
+ public static SessionRewardSummary From(
+ IEnumerable events, DateTime sinceUtc, DateTime? untilUtc = null)
+ {
+ var until = untilUtc ?? DateTime.MaxValue;
+ var inWindow = events
+ .Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
+ .OrderBy(e => e.PublishedAt)
+ .ToList();
+
+ if (inWindow.Count == 0) return Empty;
+
+ var byCurrency = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var e in inWindow)
+ {
+ var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
+ byCurrency.TryGetValue(currency, out var total);
+ byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
+ }
+
+ return new SessionRewardSummary(
+ inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
+ inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
+ byCurrency,
+ inWindow[0].PublishedAt,
+ inWindow[^1].PublishedAt);
+ }
+
+ /// Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
+ /// show — a creator who never monetized must never be shown a zero.
+ public IReadOnlyList MoneyLines()
+ => MicrosByCurrency
+ .Where(pair => pair.Value > 0)
+ .OrderByDescending(pair => pair.Value)
+ .Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
+ .ToList();
+
+ private static string Format(string currency, long micros)
+ {
+ var amount = micros / 1_000_000m;
+ var symbol = currency switch
+ {
+ "USD" => "$",
+ "EUR" => "€",
+ "GBP" => "£",
+ "CAD" => "CA$",
+ "AUD" => "A$",
+ _ => string.Empty,
+ };
+ return symbol.Length > 0
+ ? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
+ : amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
+ }
+}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 3397471..fcdaf09 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -57,6 +57,12 @@ public partial class LayoutStore : IDisposable
public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
=> UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
+ /// TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most
+ /// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05).
+ public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1";
+
+ public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
+
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
diff --git a/Services/SuperChatEventService.cs b/Services/SuperChatEventService.cs
new file mode 100644
index 0000000..c643ae4
--- /dev/null
+++ b/Services/SuperChatEventService.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ytLive.Helpers;
+using ytLive.Models;
+
+namespace ytLive.Services;
+
+///
+/// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be
+/// shown without storing anything.
+/// YouTube is the authority. This service only asks; it keeps no ledger, and the events it
+/// returns are the ones YouTube still considers valid (removed or refunded events do not come back).
+/// Scope: the endpoint is authorised by the app's existing auth/youtube scope, so no
+/// re-consent is needed — unlike the Analytics API, which does.
+///
+public interface ISuperChatEventSource
+{
+ /// The most recent events for a channel (YouTube caps the window at roughly 30 days).
+ Task> GetRecentAsync(
+ string channelId, int maxResults = 200, CancellationToken cancellationToken = default);
+}
+
+public sealed class SuperChatEventService : ISuperChatEventSource
+{
+ private const string ApiBase = "https://www.googleapis.com/youtube/v3";
+ private readonly YouTubeAuthService _auth;
+ private readonly HttpClient _http;
+
+ public SuperChatEventService(YouTubeAuthService auth, HttpClient? http = null)
+ {
+ _auth = auth;
+ _http = http ?? new HttpClient();
+ }
+
+ public async Task> GetRecentAsync(
+ string channelId, int maxResults = 200, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty();
+ if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty();
+
+ _http.DefaultRequestHeaders.Authorization = new("Bearer", token);
+ var url = $"{ApiBase}/superChatEvents?part=snippet&maxResults={Math.Clamp(maxResults, 1, 200)}" +
+ $"&channelId={Uri.EscapeDataString(channelId)}";
+ try
+ {
+ var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLog.Write($"superChatEvents.list failed: {(int)response.StatusCode}");
+ return Array.Empty();
+ }
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ return Parse(json);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"superChatEvents.list failed: {ex.Message}");
+ return Array.Empty();
+ }
+ }
+
+ /// Pure parse of a superChatEvents.list response. Both event types carry their money
+ /// in a different object, so a sticker must not be silently dropped.
+ internal static IReadOnlyList Parse(string json)
+ {
+ var events = new List();
+ using var document = JsonDocument.Parse(json);
+ if (!document.RootElement.TryGetProperty("items", out var items)) return events;
+
+ foreach (var item in items.EnumerateArray())
+ {
+ if (!item.TryGetProperty("snippet", out var snippet)) continue;
+
+ var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
+ var publishedAt = snippet.TryGetProperty("publishedAt", out var published)
+ && DateTime.TryParse(published.GetString(), out var parsed)
+ ? parsed.ToUniversalTime()
+ : DateTime.UtcNow;
+
+ var kind = ChatEventKind.SuperChat;
+ JsonElement details;
+ if (snippet.TryGetProperty("superChatDetails", out var chat))
+ details = chat;
+ else if (snippet.TryGetProperty("superStickerDetails", out var sticker))
+ {
+ details = sticker;
+ kind = ChatEventKind.SuperSticker;
+ }
+ else
+ {
+ continue;
+ }
+
+ var micros = details.TryGetProperty("amountMicros", out var amount)
+ && amount.ValueKind == JsonValueKind.Number
+ && amount.TryGetInt64(out var value)
+ ? value
+ : 0;
+ var currency = details.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String
+ ? cur.GetString() ?? string.Empty
+ : string.Empty;
+ var display = details.TryGetProperty("amountDisplayString", out var shown)
+ && shown.ValueKind == JsonValueKind.String
+ ? shown.GetString()
+ : null;
+
+ events.Add(new SuperChatEvent(id, kind, micros, currency, display, publishedAt));
+ }
+
+ return events;
+ }
+}
diff --git a/Services/YppTrackerViewModel.cs b/Services/YppTrackerViewModel.cs
index b86508b..efb7095 100644
--- a/Services/YppTrackerViewModel.cs
+++ b/Services/YppTrackerViewModel.cs
@@ -1,4 +1,7 @@
+using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Linq;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
@@ -25,6 +28,12 @@ public sealed class YppTrackerViewModel : ViewModelBase
private readonly Func _isConnected;
private readonly Func _displayName;
+ // TASK 3·20 — the session report is DERIVED, never stored: ask YouTube for the channel's recent
+ // reward events and roll them up for the window that starts when this session did.
+ private readonly Func _rewardEvents;
+ private readonly Func _channelId;
+ private readonly Func _sessionStartedAtUtc;
+
private bool _isDrawerOpen;
private bool _isLoading;
private bool _isRefreshing;
@@ -43,12 +52,18 @@ public sealed class YppTrackerViewModel : ViewModelBase
Func store,
Func stats,
Func isConnected,
- Func displayName)
+ Func displayName,
+ Func? rewardEvents = null,
+ Func? channelId = null,
+ Func? sessionStartedAtUtc = null)
{
_store = store;
_stats = stats;
_isConnected = isConnected;
_displayName = displayName;
+ _rewardEvents = rewardEvents ?? (() => null!);
+ _channelId = channelId ?? (() => null);
+ _sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
@@ -58,6 +73,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
+ _showMonetaryMetrics = _store().LoadShowMonetaryMetrics();
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
}
@@ -92,6 +108,63 @@ public sealed class YppTrackerViewModel : ViewModelBase
public bool IsSignedOut => !_isConnected();
+ private SessionRewardSummary _sessionRewards = SessionRewardSummary.Empty;
+
+ /// This session's reward events, derived on demand. Never persisted.
+ public SessionRewardSummary SessionRewards
+ {
+ get => _sessionRewards;
+ private set
+ {
+ _sessionRewards = value;
+ OnPropertyChanged(nameof(SessionRewards));
+ OnPropertyChanged(nameof(HasSessionRewards));
+ OnPropertyChanged(nameof(SessionCountsText));
+ OnPropertyChanged(nameof(SessionMoneyLines));
+ OnPropertyChanged(nameof(HasSessionMoney));
+ }
+ }
+
+ public bool HasSessionRewards => _sessionRewards.HasEvents;
+
+ /// Counts are always neutral — "3 Super Chats" says nothing about money, and most
+ /// creators have none.
+ public string SessionCountsText
+ {
+ get
+ {
+ if (!_sessionRewards.HasEvents) return string.Empty;
+ var parts = new List();
+ if (_sessionRewards.SuperChatCount > 0) parts.Add($"{_sessionRewards.SuperChatCount} Super Chat{(_sessionRewards.SuperChatCount == 1 ? "" : "s")}");
+ if (_sessionRewards.SuperStickerCount > 0) parts.Add($"{_sessionRewards.SuperStickerCount} Super Sticker{(_sessionRewards.SuperStickerCount == 1 ? "" : "s")}");
+ return string.Join(" · ", parts);
+ }
+ }
+
+ /// Money is shown only when the creator asked for it AND there is something to show.
+ /// A creator who never monetized never sees a zero (creator ruling 2026-11-05).
+ public IReadOnlyList SessionMoneyLines
+ => ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty();
+
+ public bool HasSessionMoney => SessionMoneyLines.Count > 0;
+
+ private bool _showMonetaryMetrics;
+
+ /// "Show monetary metrics" — lives in this drawer, persisted, OFF by default. Turning it on
+ /// is also what would justify requesting the monetary Analytics scope; while it is off the app never
+ /// asks Google for money data.
+ public bool ShowMonetaryMetrics
+ {
+ get => _showMonetaryMetrics;
+ set
+ {
+ if (!SetProperty(ref _showMonetaryMetrics, value)) return;
+ _store().SaveShowMonetaryMetrics(value);
+ OnPropertyChanged(nameof(SessionMoneyLines));
+ OnPropertyChanged(nameof(HasSessionMoney));
+ }
+ }
+
public string? LoadError
{
get => _loadError;
@@ -203,11 +276,38 @@ public sealed class YppTrackerViewModel : ViewModelBase
await RefreshAsync();
}
+ /// Asks YouTube for the channel's recent reward events and keeps only this session's.
+ /// Nothing is written down: the report is a view over YouTube's data, not a local ledger.
+ private async Task RefreshSessionRewardsAsync()
+ {
+ var started = _sessionStartedAtUtc();
+ var channel = _channelId();
+ var source = _rewardEvents();
+ if (started == null || string.IsNullOrWhiteSpace(channel) || source == null)
+ {
+ SessionRewards = SessionRewardSummary.Empty;
+ return;
+ }
+
+ try
+ {
+ var events = await source.GetRecentAsync(channel);
+ SessionRewards = SessionRewardSummary.From(events, started.Value);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Session rewards unavailable: {ex.Message}");
+ SessionRewards = SessionRewardSummary.Empty;
+ }
+ }
+
public async Task RefreshAsync()
{
if (_isRefreshing) return;
_isRefreshing = true;
IsLoading = true;
+
+ await RefreshSessionRewardsAsync();
LoadError = null;
try
{
diff --git a/TASKS.md b/TASKS.md
index 31000d0..2ba341b 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -47,7 +47,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
-| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
+| 3·20 | ✅ | Session report — **derived from `superChatEvents.list` on demand, not persisted** (YouTube is the authority; no ledger). Per-currency totals, money off by default (done 2026-11-05) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) |
| 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
diff --git a/TASKS/task-03-capture-pipeline.md b/TASKS/task-03-capture-pipeline.md
index cea924b..1e642bc 100644
--- a/TASKS/task-03-capture-pipeline.md
+++ b/TASKS/task-03-capture-pipeline.md
@@ -35,10 +35,14 @@
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
emits no public event for free subs), NO viewer count (vetoed), and every card draws the
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
- (still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
- (all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
- rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
- events into `ChatMessage` directly in memory; the report reads that table once it exists.**
+ ✅ **RESOLVED 2026-11-05 — derived, not persisted.** The creator's ruling: YouTube is the authority
+ for monetization, so the app asks it on demand instead of keeping a ledger. `Services/SuperChatEventService.cs`
+ reads `superChatEvents.list` (existing `auth/youtube` scope — no re-consent), `Models/SessionRewardSummary.cs`
+ rolls it up for the session window with **per-currency totals that are never summed together**, and the YPP
+ drawer shows "THIS SESSION" counts. No table, no migration, no backfill, and the published promise that no
+ viewer/member/donor information is retained stays literally true.
+ **Money is off by default** ("Show monetary metrics", in the drawer, persisted): counts are neutral, and a
+ creator who never monetized is never shown a zero. Tests: `SessionRewardReportTests`.**
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 6829ecd..1b988e8 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -120,6 +120,9 @@ public partial class MainViewModel : ViewModelBase
? "LlamaCasty"
: $"{StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
+ // TASK 3·20: the session report window starts here. Derived on demand from YouTube, never
+ // persisted, and reset when the session ends.
+ _sessionStartedAtUtc = DateTime.UtcNow;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
@@ -371,6 +374,7 @@ public partial class MainViewModel : ViewModelBase
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline;
+ _sessionStartedAtUtc = null;
IsRecording = false;
LiveScene = null;
EditingActive = true;
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index cdc3b75..0fb9c2d 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -416,6 +416,10 @@ public partial class MainViewModel : ViewModelBase
set => SetProperty(ref _previewGlowThickness, value);
}
+ /// When the current/last streaming session started — the window a session report covers.
+ /// Null when no session has run since launch.
+ private DateTime? _sessionStartedAtUtc;
+
public string LiveElapsedText
{
get => _liveElapsedText;
diff --git a/ViewModels/MainViewModel.Ypp.cs b/ViewModels/MainViewModel.Ypp.cs
index e880126..d1bf507 100644
--- a/ViewModels/MainViewModel.Ypp.cs
+++ b/ViewModels/MainViewModel.Ypp.cs
@@ -23,7 +23,10 @@ public partial class MainViewModel
() => _layoutStore,
() => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth),
() => IsConnected,
- () => _youtubeAuth.CurrentChannel?.DisplayName);
+ () => _youtubeAuth.CurrentChannel?.DisplayName,
+ rewardEvents: () => new SuperChatEventService(_youtubeAuth),
+ channelId: () => _youtubeAuth.CurrentChannel?.ChannelId,
+ sessionStartedAtUtc: () => _sessionStartedAtUtc);
TestSession = new TestSessionViewModel(
_youtubeStream,
diff --git a/docs/ai/product.md b/docs/ai/product.md
index a94d042..9cdcd46 100644
--- a/docs/ai/product.md
+++ b/docs/ai/product.md
@@ -298,6 +298,18 @@ comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No f
— chat history, moderation logs, analytics, crash payloads — may quietly break that
assumption.** If one is ever proposed, the 11.12 position must be re-argued first.
+### Monetization: derived, never a ledger (2026-11-05)
+
+YouTube is the **sole authority** for monetization progress and qualification; the API exposes **no**
+eligibility field (`channels.list` has no monetization part; `auditDetails` needs an MCN scope). So the app
+**asks YouTube on demand** (`superChatEvents.list` for the session report; Analytics for the YPP bars in
+TASK 39·2) and **stores nothing**. That keeps the promise above literally true — there is no reward ledger
+to retain — and it means the app never has to model YouTube's moving thresholds as our own data.
+
+**Money is off by default** ("Show monetary metrics", in the YPP drawer, persisted): counts are neutral,
+and a creator who never monetized is never shown a zero. Amounts are estimates that can be restated for
+weeks, and are labelled as such wherever they appear.
+
### Minors
The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube
diff --git a/ytLive.Tests/BackOnAirTests.cs b/ytLive.Tests/BackOnAirTests.cs
index b34bc2b..b719351 100644
--- a/ytLive.Tests/BackOnAirTests.cs
+++ b/ytLive.Tests/BackOnAirTests.cs
@@ -12,6 +12,10 @@ namespace ytLive.Tests;
/// route back is a NEW broadcast: one click, saved details, no dialog, and a title that says it is a
/// resume (otherwise the channel grows two identically named videos).
///
+// Constructs a MainViewModel directly, and `LayoutPathOverride` is static: it belongs in the serialised
+// collection with the other direct-construction tests, or a parallel sibling can swap the layout path
+// underneath it (observed as a one-off failure in a full run, green in isolation).
+[Collection("MainViewModelLayoutPath")]
public class BackOnAirTests
{
[Fact]
diff --git a/ytLive.Tests/SessionRewardReportTests.cs b/ytLive.Tests/SessionRewardReportTests.cs
new file mode 100644
index 0000000..c60f411
--- /dev/null
+++ b/ytLive.Tests/SessionRewardReportTests.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 3·20 — the session report is DERIVED from YouTube, never stored: no ledger, no migration, and the
+/// published promise ("no viewer, member or donor information is retained after the session ends") stays
+/// literally true.
+/// Two rules the creator set (2026-11-05): money is hidden unless asked for, and a creator who never
+/// monetized is never shown a zero. Counts stay neutral so a normal session still has something to read.
+///
+public class SessionRewardReportTests
+{
+ private static readonly DateTime SessionStart = new(2026, 11, 5, 20, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void ParsesBothRewardTypes_WithoutDroppingStickers()
+ {
+ const string json = """
+ {
+ "items": [
+ {"id":"sc1","snippet":{"publishedAt":"2026-11-05T20:05:00Z","superChatDetails":
+ {"amountMicros":5000000,"currency":"USD","amountDisplayString":"$5.00","userComment":"hi"}}},
+ {"id":"ss1","snippet":{"publishedAt":"2026-11-05T20:07:00Z","superStickerDetails":
+ {"amountMicros":2000000,"currency":"EUR","amountDisplayString":"€2.00"}}}
+ ]
+ }
+ """;
+
+ var events = SuperChatEventService.Parse(json);
+
+ Assert.Equal(2, events.Count);
+ Assert.Equal(ChatEventKind.SuperChat, events[0].Kind);
+ Assert.Equal(5_000_000, events[0].AmountMicros);
+ Assert.Equal("USD", events[0].Currency);
+ Assert.Equal(ChatEventKind.SuperSticker, events[1].Kind);
+ Assert.Equal("EUR", events[1].Currency);
+ }
+
+ [Fact]
+ public void TheWindowMakesItASessionReport_NotAChannelReport()
+ {
+ var events = new[]
+ {
+ Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(-30)), // before
+ Event(ChatEventKind.SuperChat, 3_000_000, "USD", SessionStart.AddMinutes(10)),
+ Event(ChatEventKind.SuperSticker, 2_000_000, "USD", SessionStart.AddMinutes(20)),
+ };
+
+ var summary = SessionRewardSummary.From(events, SessionStart);
+
+ Assert.Equal(1, summary.SuperChatCount);
+ Assert.Equal(1, summary.SuperStickerCount);
+ Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
+ }
+
+ [Fact]
+ public void CurrenciesAreNeverSummedTogether()
+ {
+ var events = new[]
+ {
+ Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(1)),
+ Event(ChatEventKind.SuperChat, 5_000_000, "EUR", SessionStart.AddMinutes(2)),
+ };
+
+ var summary = SessionRewardSummary.From(events, SessionStart);
+
+ Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
+ Assert.Equal(5_000_000, summary.MicrosByCurrency["EUR"]);
+ Assert.Equal(2, summary.MoneyLines().Count);
+ Assert.Contains(summary.MoneyLines(), line => line.StartsWith("USD $5.00"));
+ Assert.Contains(summary.MoneyLines(), line => line.StartsWith("EUR €5.00"));
+ }
+
+ [Fact]
+ public void AnEmptySession_HasNoCountsAndNoMoney()
+ {
+ var summary = SessionRewardSummary.From(Array.Empty(), SessionStart);
+
+ Assert.False(summary.HasEvents);
+ Assert.Empty(summary.MoneyLines());
+ Assert.Equal(0, summary.TotalEvents);
+ }
+
+ [Fact]
+ public void AStickerOnlySession_StillCounts()
+ {
+ var summary = SessionRewardSummary.From(
+ new[] { Event(ChatEventKind.SuperSticker, 1_000_000, "USD", SessionStart.AddMinutes(1)) },
+ SessionStart);
+
+ Assert.True(summary.HasEvents);
+ Assert.Equal(0, summary.SuperChatCount);
+ Assert.Equal(1, summary.SuperStickerCount);
+ }
+
+ private static SuperChatEvent Event(ChatEventKind kind, long micros, string currency, DateTime at)
+ => new($"id-{Guid.NewGuid():N}", kind, micros, currency, null, at);
+}