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:
+56
-46
@@ -768,56 +768,66 @@
|
||||
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
|
||||
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
|
||||
FontWeight="SemiBold" Margin="0,0,0,10"/>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
|
||||
<!-- TASK 39·2 — the three metrics that count, with the threshold
|
||||
that applies TODAY (the long-form bar doubles 2027-02-01; that is
|
||||
data, not a code change). YouTube decides eligibility; these are
|
||||
estimates and the drawer says so. No red/green scoring. -->
|
||||
<TextBlock Text="SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.SubscribersText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Ad revenue tier" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,10">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
|
||||
Width="{Binding Ypp.SubscribersBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,6">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.UploadsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
|
||||
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"/>
|
||||
|
||||
<!-- TASK 3·20 — THIS SESSION. Counts are neutral and always
|
||||
shown when there were events; money appears only if the
|
||||
creator turned it on AND there is something to show, so a
|
||||
creator who never monetized never sees a zero. Derived from
|
||||
YouTube on demand; nothing is stored. -->
|
||||
<StackPanel Visibility="{Binding Ypp.HasSessionRewards, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="THIS SESSION" Style="{StaticResource YtLabel}" Margin="0,14,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.SessionCountsText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<ItemsControl ItemsSource="{Binding Ypp.SessionMoneyLines}"
|
||||
Visibility="{Binding Ypp.HasSessionMoney, Converter={StaticResource BoolToVis}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" FontSize="12" Margin="0,2,0,0"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Estimates from YouTube — removed or refunded events are excluded and totals can change."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Visibility="{Binding Ypp.HasSessionMoney, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="LONG-FORM WATCH HOURS · 12 MONTHS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.WatchHoursText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Public videos" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,4">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.WatchHoursBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="Bar rises to 8,000 h on 1 Feb 2027."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,10"/>
|
||||
|
||||
<CheckBox Content="Show monetary metrics" Style="{StaticResource YtCheckBox}"
|
||||
FontSize="12" Margin="0,10,0,0"
|
||||
IsChecked="{Binding Ypp.ShowMonetaryMetrics, Mode=TwoWay}"
|
||||
ToolTip="Off by default. Counts still appear; amounts stay hidden until you ask for them."/>
|
||||
<TextBlock Text="SHORTS VIEWS · 90 DAYS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.ShortsViewsText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Shorts feed" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,8">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.ShortsViewsBarWidth}"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="{Binding Ypp.CriteriaMetText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="Either view route qualifies — subscribers plus watch hours, or subscribers plus Shorts views."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="{Binding Ypp.ThresholdsAsOfText}"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBlock Text="{Binding Ypp.WatchMetricsMissingText}"
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="0,0,0,8"
|
||||
Visibility="{Binding Ypp.HasWatchMetrics, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
|
||||
<TextBlock Text="FAN FUNDING TIER · 500 SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
|
||||
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 39·2: the three metrics that count towards YouTube Partner Program eligibility, as progress
|
||||
/// towards the ad-revenue thresholds.
|
||||
/// <para><b>YouTube decides.</b> This only reports what the published numbers say; it never claims
|
||||
/// eligibility, and it cannot see strikes, AdSense state, 2FA or the review decision.</para>
|
||||
/// <para><b>The view criteria are either/or</b> — 4,000 public watch hours <i>or</i> 10M Shorts views —
|
||||
/// so "2 of 3" is reported with that qualifier rather than implying all three must pass.</para>
|
||||
/// </summary>
|
||||
public sealed record YppProgress(
|
||||
long Subscribers,
|
||||
long SubscriberTarget,
|
||||
long WatchHours,
|
||||
long WatchHoursTarget,
|
||||
long ShortsViews,
|
||||
long ShortsViewsTarget)
|
||||
{
|
||||
public bool SubscribersMet => Subscribers >= SubscriberTarget;
|
||||
public bool WatchHoursMet => WatchHours >= WatchHoursTarget;
|
||||
public bool ShortsViewsMet => ShortsViews >= ShortsViewsTarget;
|
||||
|
||||
/// <summary>Criteria currently met (0–3). Two of three can mean eligible: subs plus EITHER view route.</summary>
|
||||
public int CriteriaMet => (SubscribersMet ? 1 : 0) + (WatchHoursMet ? 1 : 0) + (ShortsViewsMet ? 1 : 0);
|
||||
|
||||
/// <summary>True when the subscriber bar AND at least one view route are satisfied — the shape of the
|
||||
/// published rule. Still not a qualification claim: YouTube reviews it.</summary>
|
||||
public bool MeetsPublishedRule => SubscribersMet && (WatchHoursMet || ShortsViewsMet);
|
||||
|
||||
public double SubscribersFraction => Fraction(Subscribers, SubscriberTarget);
|
||||
public double WatchHoursFraction => Fraction(WatchHours, WatchHoursTarget);
|
||||
public double ShortsViewsFraction => Fraction(ShortsViews, ShortsViewsTarget);
|
||||
|
||||
public string SubscribersText => $"{Compact(Subscribers)} / {Compact(SubscriberTarget)}";
|
||||
public string WatchHoursText => $"{Compact(WatchHours)} / {Compact(WatchHoursTarget)} h";
|
||||
public string ShortsViewsText => $"{Compact(ShortsViews)} / {Compact(ShortsViewsTarget)}";
|
||||
|
||||
public string CriteriaMetText => CriteriaMet == 1
|
||||
? "1 of 3 criteria met — YouTube reviews eligibility."
|
||||
: $"{CriteriaMet} of 3 criteria met — YouTube reviews eligibility.";
|
||||
|
||||
private static double Fraction(long value, long target)
|
||||
=> target <= 0 ? 0 : Math.Clamp((double)value / target, 0, 1);
|
||||
|
||||
/// <summary>1,234 → "1,234"; 12,345 → "12.3k"; 1,234,567 → "1.23M". Mono-face friendly and stable
|
||||
/// in width, so the three bars do not jitter as values move.</summary>
|
||||
internal static string Compact(long value) => value switch
|
||||
{
|
||||
>= 1_000_000 => (value / 1_000_000d).ToString("0.##", CultureInfo.InvariantCulture) + "M",
|
||||
>= 10_000 => (value / 1_000d).ToString("0.#", CultureInfo.InvariantCulture) + "k",
|
||||
_ => value.ToString("N0", CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ public static class YppThresholds
|
||||
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
|
||||
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
|
||||
|
||||
/// <summary>When these thresholds were last read from YouTube's published requirements. Shown in the
|
||||
/// drawer and asserted by a test, because YouTube moves the bar and a stale bar must not look current.</summary>
|
||||
public static readonly DateTime AsOfUtc = new(2026, 11, 5);
|
||||
|
||||
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
|
||||
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
|
||||
| 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
|
||||
| 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) |
|
||||
| — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) |
|
||||
| 39·2 | ✅ | YPP slice 2 — three eligibility bars (subs / watch hours / Shorts views) from Analytics, dated thresholds with a staleness test, "x of 3 criteria met" (done 2026-11-05) | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) |
|
||||
| 49 | ❌ | Chat profanity filter — **retired 2026-11-05**: YouTube owns moderation (blocked words + hold for review run before the API). Replaced by not rendering YouTube's moderation markers (tombstones / no-display events) | [`task-49`](TASKS/task-49-chat-profanity-filter.md) |
|
||||
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
|
||||
| 54 | 🔶 | Freemium: EULA + privacy shipped; one codebase for free Demo + paid app. **Blocked on Valve**: identity verification pending, tax review up to 30 business days; app IDs, store page and build review all follow | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
|
||||
|
||||
@@ -91,7 +91,22 @@ MyMistakes.
|
||||
- 2FA: `https://myaccount.google.com/signinoptions/two-step-verification`
|
||||
- AdSense: `https://support.google.com/youtube/answer/72851`
|
||||
|
||||
## Slice 2 (outline, queued)
|
||||
## Slice 2 — ✅ SHIPPED 2026-11-05 (the three bars)
|
||||
|
||||
Shipped as designed: `yt-analytics.readonly` behind `IWatchMetricsSource`, three bars (subscribers,
|
||||
long-form watch hours, Shorts views) against `YppThresholds.Tier2Now`, and one line —
|
||||
"x of 3 criteria met — YouTube reviews eligibility." Notes recorded with it:
|
||||
|
||||
- **The view routes are either/or**, so the line is qualified in the drawer ("subscribers plus watch
|
||||
hours, or subscribers plus Shorts views"); `YppProgress.MeetsPublishedRule` encodes that shape.
|
||||
- **Thresholds carry a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a test
|
||||
fails once that reading is older than 180 days — YouTube moves the bar and a stale bar must not look
|
||||
current. The 2027-02-01 doubling is data, not a code change.
|
||||
- **No analytics scope → no invented numbers**: the drawer says what is missing instead of drawing
|
||||
empty bars as zero.
|
||||
- Money stays behind the drawer's "Show monetary metrics" toggle (TASK 3·20).
|
||||
|
||||
## Slice 2 (original outline, for reference)
|
||||
|
||||
`yt-analytics.readonly` (+ re-consent) → `YouTubeAnalyticsService` behind `IChannelStatsProvider`
|
||||
(`ai.md`): long-form `estimatedMinutesWatched` (12-mo), Shorts-feed views (90-d), then velocity /
|
||||
|
||||
@@ -26,7 +26,8 @@ public partial class MainViewModel
|
||||
() => _youtubeAuth.CurrentChannel?.DisplayName,
|
||||
rewardEvents: () => new SuperChatEventService(_youtubeAuth),
|
||||
channelId: () => _youtubeAuth.CurrentChannel?.ChannelId,
|
||||
sessionStartedAtUtc: () => _sessionStartedAtUtc);
|
||||
sessionStartedAtUtc: () => _sessionStartedAtUtc,
|
||||
watchMetrics: () => new YouTubeAnalyticsService(_youtubeAuth));
|
||||
|
||||
TestSession = new TestSessionViewModel(
|
||||
_youtubeStream,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 39·2 — the three eligibility bars. The rules worth pinning: the view criteria are EITHER/OR (so
|
||||
/// "2 of 3" can mean eligible), the long-form bar doubles on 2027-02-01, and the threshold table carries a
|
||||
/// publication date that fails a test when it goes stale — YouTube moves the goalposts and a stale bar
|
||||
/// must not look current.
|
||||
/// </summary>
|
||||
public class YppProgressTests
|
||||
{
|
||||
[Fact]
|
||||
public void TwoOfThreeCanBeEligible_BecauseTheViewRoutesAreEitherOr()
|
||||
{
|
||||
var progress = new YppProgress(
|
||||
Subscribers: 1_200, SubscriberTarget: 1_000,
|
||||
WatchHours: 4_500, WatchHoursTarget: 4_000,
|
||||
ShortsViews: 10_000, ShortsViewsTarget: 10_000_000);
|
||||
|
||||
Assert.Equal(2, progress.CriteriaMet);
|
||||
Assert.True(progress.MeetsPublishedRule);
|
||||
Assert.Equal("2 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribersWithoutEitherViewRoute_IsNotEnough()
|
||||
{
|
||||
var progress = new YppProgress(2_000, 1_000, 100, 4_000, 5_000, 10_000_000);
|
||||
|
||||
Assert.Equal(1, progress.CriteriaMet);
|
||||
Assert.False(progress.MeetsPublishedRule);
|
||||
Assert.Equal("1 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortsAlone_IsAQualifyingRoute()
|
||||
{
|
||||
var progress = new YppProgress(1_000, 1_000, 0, 4_000, 12_000_000, 10_000_000);
|
||||
|
||||
Assert.Equal(2, progress.CriteriaMet);
|
||||
Assert.True(progress.MeetsPublishedRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BarsClampAndNeverOverflow()
|
||||
{
|
||||
var progress = new YppProgress(5_000, 1_000, 20_000, 4_000, 50_000_000, 10_000_000);
|
||||
|
||||
Assert.Equal(1.0, progress.SubscribersFraction);
|
||||
Assert.Equal(1.0, progress.WatchHoursFraction);
|
||||
Assert.Equal(1.0, progress.ShortsViewsFraction);
|
||||
Assert.Equal(3, progress.CriteriaMet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLongFormBarDoublesOnTheAnnouncedDate()
|
||||
{
|
||||
var before = YppThresholds.Tier2For(new DateTime(2027, 1, 31));
|
||||
var after = YppThresholds.Tier2For(new DateTime(2027, 2, 1));
|
||||
|
||||
Assert.Equal(4_000, before.WatchHours);
|
||||
Assert.Equal(8_000, after.WatchHours);
|
||||
Assert.Equal(10_000_000, before.ShortsViews);
|
||||
Assert.Equal(20_000_000, after.ShortsViews);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThresholdsCarryAPublicationDate_ThatFailsWhenStale()
|
||||
{
|
||||
// YouTube changes these. If the reading is over 180 days old, the drawer would be presenting a
|
||||
// stale bar as current — so the suite refuses to pass until someone re-checks the numbers.
|
||||
var age = DateTime.UtcNow - YppThresholds.AsOfUtc;
|
||||
Assert.True(age < TimeSpan.FromDays(180),
|
||||
$"YPP thresholds were last read on {YppThresholds.AsOfUtc:yyyy-MM-dd} — re-check YouTube's published requirements.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyticsResponse_ParsesTheTotal_AndTreatsNoRowsAsNoData()
|
||||
{
|
||||
Assert.Equal(123_456, YouTubeAnalyticsService.ParseTotal(
|
||||
"""{"columnHeaders":[{"name":"estimatedMinutesWatched"}],"rows":[[123456]]}"""));
|
||||
|
||||
// A channel with nothing in the window returns no rows: that is "no data", not zero.
|
||||
Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"columnHeaders":[]}"""));
|
||||
Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"rows":[]}"""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(640, "640")]
|
||||
[InlineData(12_345, "12.3k")]
|
||||
[InlineData(1_234_567, "1.23M")]
|
||||
public void ValuesStayCompactAndStableInWidth(long value, string expected)
|
||||
=> Assert.Equal(expected, YppProgress.Compact(value));
|
||||
}
|
||||
Reference in New Issue
Block a user