feat(ypp): the three eligibility bars, with dated thresholds (TASK 39·2)
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against a threshold table that carries the date it was read. - `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread. - `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours (trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows means NO DATA, not zero. - **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h, 10M → 20M Shorts views) resolves from the date, not from an edit. - **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle. - Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path. Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data), and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
This commit is contained in:
@@ -34,6 +34,10 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
private readonly Func<string?> _channelId;
|
||||
private readonly Func<DateTime?> _sessionStartedAtUtc;
|
||||
|
||||
// TASK 39·2 — the two numbers only the Analytics API publishes. Null source (or a missing scope)
|
||||
// means the drawer says so rather than inventing values.
|
||||
private readonly Func<IWatchMetricsSource?> _watchMetrics;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isLoading;
|
||||
private bool _isRefreshing;
|
||||
@@ -55,7 +59,8 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
Func<string?> displayName,
|
||||
Func<ISuperChatEventSource>? rewardEvents = null,
|
||||
Func<string?>? channelId = null,
|
||||
Func<DateTime?>? sessionStartedAtUtc = null)
|
||||
Func<DateTime?>? sessionStartedAtUtc = null,
|
||||
Func<IWatchMetricsSource?>? watchMetrics = null)
|
||||
{
|
||||
_store = store;
|
||||
_stats = stats;
|
||||
@@ -64,6 +69,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
_rewardEvents = rewardEvents ?? (() => null!);
|
||||
_channelId = channelId ?? (() => null);
|
||||
_sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
|
||||
_watchMetrics = watchMetrics ?? (() => null);
|
||||
|
||||
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
|
||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||
@@ -212,6 +218,38 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
|
||||
public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
|
||||
|
||||
private long? _watchHours;
|
||||
private long? _shortsViews;
|
||||
|
||||
/// <summary>The three eligibility metrics, with the threshold that applies today (the long-form bar
|
||||
/// doubles on 1 Feb 2027 — the date is data, not a code change).</summary>
|
||||
public YppProgress Eligibility => new(
|
||||
SubscriberCount,
|
||||
YppThresholds.Tier2Now.Subscribers,
|
||||
_watchHours ?? 0,
|
||||
YppThresholds.Tier2Now.WatchHours,
|
||||
_shortsViews ?? 0,
|
||||
YppThresholds.Tier2Now.ShortsViews);
|
||||
|
||||
/// <summary>True once the Analytics scope is granted and both numbers came back. Without it the two
|
||||
/// view bars cannot be drawn honestly, so the drawer says what is missing instead.</summary>
|
||||
public bool HasWatchMetrics => _watchHours.HasValue && _shortsViews.HasValue;
|
||||
|
||||
public string WatchMetricsMissingText =>
|
||||
"Watch hours and Shorts views need YouTube Analytics access — one-time consent, granted from the "
|
||||
+ "button below. Until then those two bars stay empty.";
|
||||
|
||||
public double SubscribersBarWidth => Eligibility.SubscribersFraction * BarMaxWidth;
|
||||
public double WatchHoursBarWidth => Eligibility.WatchHoursFraction * BarMaxWidth;
|
||||
public double ShortsViewsBarWidth => Eligibility.ShortsViewsFraction * BarMaxWidth;
|
||||
|
||||
public string SubscribersText => Eligibility.SubscribersText;
|
||||
public string WatchHoursText => Eligibility.WatchHoursText;
|
||||
public string ShortsViewsText => Eligibility.ShortsViewsText;
|
||||
public string CriteriaMetText => Eligibility.CriteriaMetText;
|
||||
public string ThresholdsAsOfText =>
|
||||
$"Thresholds as published {YppThresholds.AsOfUtc:d MMMM yyyy} · estimates, can lag.";
|
||||
|
||||
public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth;
|
||||
public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth;
|
||||
public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth;
|
||||
@@ -276,6 +314,47 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
/// <summary>Asks Analytics for the two view metrics. Missing scope → nulls, and the drawer explains
|
||||
/// what to do; nothing is guessed.</summary>
|
||||
private async Task RefreshWatchMetricsAsync()
|
||||
{
|
||||
var source = _watchMetrics();
|
||||
if (source == null)
|
||||
{
|
||||
_watchHours = null;
|
||||
_shortsViews = null;
|
||||
RaiseProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_watchHours = await source.GetLongFormWatchHoursAsync();
|
||||
_shortsViews = await source.GetShortsViewsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Watch metrics unavailable: {ex.Message}");
|
||||
_watchHours = null;
|
||||
_shortsViews = null;
|
||||
}
|
||||
RaiseProgress();
|
||||
}
|
||||
|
||||
private void RaiseProgress()
|
||||
{
|
||||
OnPropertyChanged(nameof(Eligibility));
|
||||
OnPropertyChanged(nameof(HasWatchMetrics));
|
||||
OnPropertyChanged(nameof(SubscribersBarWidth));
|
||||
OnPropertyChanged(nameof(WatchHoursBarWidth));
|
||||
OnPropertyChanged(nameof(ShortsViewsBarWidth));
|
||||
OnPropertyChanged(nameof(SubscribersText));
|
||||
OnPropertyChanged(nameof(WatchHoursText));
|
||||
OnPropertyChanged(nameof(ShortsViewsText));
|
||||
OnPropertyChanged(nameof(CriteriaMetText));
|
||||
OnPropertyChanged(nameof(ThresholdsAsOfText));
|
||||
}
|
||||
|
||||
/// <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()
|
||||
@@ -308,6 +387,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
IsLoading = true;
|
||||
|
||||
await RefreshSessionRewardsAsync();
|
||||
await RefreshWatchMetricsAsync();
|
||||
LoadError = null;
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user