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:
2026-11-05 16:41:09 -08:00
parent 0fade9702b
commit a1d861608f
9 changed files with 418 additions and 50 deletions
+103
View File
@@ -0,0 +1,103 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// TASK 39·2: the two numbers YouTube only publishes through the **Analytics** API — long-form watch
/// hours (trailing 12 months) and Shorts-feed views (trailing 90 days).
/// <para>This is the one place the app needs a scope beyond <c>auth/youtube</c>: the Analytics API
/// requires <c>yt-analytics.readonly</c>, which means a one-time Google re-consent. Until that scope is
/// granted, the drawer says so instead of inventing numbers.</para>
/// <para>Values are estimates: Analytics lags, and YouTube's own validity filtering decides what counts
/// towards eligibility.</para>
/// </summary>
public interface IWatchMetricsSource
{
/// <summary>Estimated public long-form watch hours over the trailing 12 months, or null when the
/// Analytics scope is not granted / the read failed.</summary>
Task<long?> GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default);
/// <summary>Shorts-feed views over the trailing 90 days, or null.</summary>
Task<long?> GetShortsViewsAsync(CancellationToken cancellationToken = default);
}
public sealed class YouTubeAnalyticsService : IWatchMetricsSource
{
private const string ApiBase = "https://youtubeanalytics.googleapis.com/v2/reports";
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
private readonly Func<DateTime> _now;
public YouTubeAnalyticsService(YouTubeAuthService auth, HttpClient? http = null, Func<DateTime>? now = null)
{
_auth = auth;
_http = http ?? new HttpClient();
_now = now ?? (() => DateTime.UtcNow);
}
public async Task<long?> GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default)
{
// Minutes watched for long-form videos over the trailing year → hours.
var minutes = await QueryTotalAsync(
"estimatedMinutesWatched", "creatorContentType==video", 365, cancellationToken).ConfigureAwait(false);
return minutes is null ? null : (long)Math.Round(minutes.Value / 60.0);
}
public async Task<long?> GetShortsViewsAsync(CancellationToken cancellationToken = default)
=> await QueryTotalAsync("views", "creatorContentType==shorts", 90, cancellationToken)
.ConfigureAwait(false);
private async Task<long?> QueryTotalAsync(
string metric, string filter, int days, CancellationToken cancellationToken)
{
if (!_auth.HasAnalyticsScope) return null;
if (_auth.CurrentChannel?.AccessToken is not { } token) return null;
var end = _now().Date;
var start = end.AddDays(-days);
var url = $"{ApiBase}?ids=channel==MINE" +
$"&startDate={start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" +
$"&endDate={end.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" +
$"&metrics={Uri.EscapeDataString(metric)}" +
$"&filters={Uri.EscapeDataString(filter)}";
try
{
_http.DefaultRequestHeaders.Authorization = new("Bearer", token);
var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"Analytics query failed ({metric}): {(int)response.StatusCode}");
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return ParseTotal(json);
}
catch (Exception ex)
{
AppLog.Write($"Analytics query failed ({metric}): {ex.Message}");
return null;
}
}
/// <summary>Pure: the Analytics API answers with columnHeaders + rows; a channel with no data in the
/// window returns no rows at all, which must read as "no data" rather than zero.</summary>
internal static long? ParseTotal(string json)
{
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("rows", out var rows)) return null;
if (rows.ValueKind != JsonValueKind.Array || rows.GetArrayLength() == 0) return null;
var row = rows[0];
if (row.ValueKind != JsonValueKind.Array || row.GetArrayLength() == 0) return null;
var value = row[0];
return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var total) ? total : null;
}
}
+81 -1
View File
@@ -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
{