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
+56 -46
View File
@@ -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"/>