feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published promise that no viewer, member or donor information is retained after the session ends, and it would have meant owning a ledger that YouTube already keeps. So the report is DERIVED on demand instead: - `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent. - `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals that are never summed together** (a $5 and a €5 are not $10). Nothing is written down. - The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle, persisted, **off by default** — a creator who never monetized is never shown a $0.00. Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated for weeks, not days). Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds a MainViewModel directly and was flaking against parallel tests that swap the static layout path. FAST gate: 557 passed, 0 failed, 0 warnings.
This commit is contained in:
@@ -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<bool> _isConnected;
|
||||
private readonly Func<string?> _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<ISuperChatEventSource> _rewardEvents;
|
||||
private readonly Func<string?> _channelId;
|
||||
private readonly Func<DateTime?> _sessionStartedAtUtc;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isLoading;
|
||||
private bool _isRefreshing;
|
||||
@@ -43,12 +52,18 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
Func<LayoutStore> store,
|
||||
Func<ChannelStatsService> stats,
|
||||
Func<bool> isConnected,
|
||||
Func<string?> displayName)
|
||||
Func<string?> displayName,
|
||||
Func<ISuperChatEventSource>? rewardEvents = null,
|
||||
Func<string?>? channelId = null,
|
||||
Func<DateTime?>? 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;
|
||||
|
||||
/// <summary>This session's reward events, derived on demand. Never persisted.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Counts are always neutral — "3 Super Chats" says nothing about money, and most
|
||||
/// creators have none.</summary>
|
||||
public string SessionCountsText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_sessionRewards.HasEvents) return string.Empty;
|
||||
var parts = new List<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public IReadOnlyList<string> SessionMoneyLines
|
||||
=> ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty<string>();
|
||||
|
||||
public bool HasSessionMoney => SessionMoneyLines.Count > 0;
|
||||
|
||||
private bool _showMonetaryMetrics;
|
||||
|
||||
/// <summary>"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.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user